
这个组合在国内 Java 项目里有多常见不用我多说。Spring Boot 负责把整个工程串起来MyBatis 管持久层操作PostgreSQL 作为开源关系型数据库里功能最全的那个选手三者凑在一起基本是中小型项目的“标准答案”之一。但标准答案不代表没坑尤其 PostgreSQL 跟 MySQL 在细节上的差异足以让不少习惯 MySQL 写法的人栽跟头。这篇文章我把从环境搭建到参数映射、从动态 SQL 到 JSONB 类型处理的完整过程走一遍标注出哪些地方容易踩坑希望能帮正准备做整合的同学省掉半天排查时间。1. 这个组合到底强在哪技术选型前的思考1.1 项目场景与需求拆解假设你现在要接手一个业务系统需求大概是这样的需要处理用户注册、商品管理、订单查询这类常规业务数据量不大但要求稳定可能会存一些半结构化的数据比如用户扩展信息、商品标签等。前端由 Vue 或者别的框架调用后端接口后端需要提供 REST API。乍一看任何语言都能做但放到 Java 体系里“Spring Boot MyBatis PostgreSQL”几乎是最顺手的一套方案。这种组合能解决的问题很明确Spring Boot 帮你把繁琐的配置自动搞定MyBatis 让你以接近手写 SQL 的方式精确控制数据库操作PostgreSQL 则提供了比 MySQL 更丰富的数据类型和更强的查询能力。对于团队里有 DBA 或强迫症型后端开发来说能写标准 SQL 本身就是一种安全感。1.2 为什么是 Spring Boot MyBatis PostgreSQL先说 Spring Boot。它解决的问题是“集成地狱”。不用它的时候你要手动配置 Spring 容器、配置数据源、配置事务管理器、配置 MyBatis 的 SqlSessionFactory还要处理 jar 包冲突。用了它之后大部分事情靠 starter 和自动配置就能搞定。你只需要关注业务代码本身。再说 MyBatis。市面上 Java 持久层框架很多有 JPA/Hibernate也有 MyBatis-Plus。为什么还有大量项目坚持用原生 MyBatis核心原因是 SQL 可控。业务稍微复杂一点比如多表联查、嵌套子查询、动态条件组合JPA 要么写 JPQL要么写原生 SQL要么靠 Specification 拼条件写起来既绕又难调优。而 MyBatis 的 XML 文件允许你写标准的 SQL完全按照数据库方言来组织性能优化空间一目了然。最后说 PostgreSQL。这是三者里国内开发者相对陌生的一块。PostgreSQL 在很多方面比 MySQL 更“标准”比如它对窗口函数、CTE、JSONB 的原生支持就很成熟。举个例子MySQL 5.7 之前连 JSON 类型都没有像样的索引方案而 PostgreSQL 的 JSONB 可以直接建 GIN 索引做高效的包含查询。这个特性对业务上需要存储扩展字段、动态属性的场景非常适用。一个常见疑问用 Spring Boot JPA 不也能连 PostgreSQL 吗能但如果你的 SQL 是团队里沉淀多年的复杂查询迁移到 JPQL 的成本远大于直接保留 SQL。MyBatis 的学习曲线也比起 JPA 更平滑新成员上手一个 XML 映射文件比理解 Entity 关联生命周期容易得多。1.3 版本选型与兼容性对照版本选型是整合时最容易出问题的第一关。我见过不少人在 Spring Boot 3.x 项目里硬塞 mybatis-spring-boot-starter 2.x结果启动直接报找不到 SqlSessionFactory 自动配置的错。先列一张当前主流版本的对应表方便直接对照Spring Boot 版本JDK 要求mybatis-spring-boot-starter 版本PostgreSQL JDBC 驱动2.3.xJDK 82.1.x42.2.x2.6.xJDK 82.2.x42.3.x2.7.xJDK 82.3.x42.5.x3.0.xJDK 173.0.x42.6.x3.2.xJDK 173.0.x42.7.x我的建议如果是新项目直接上 Spring Boot 3.2 JDK 17 mybatis-spring-boot-starter 3.0.3这个版本组合比较稳。如果公司老项目还锁在 JDK 8那就用 Spring Boot 2.7.x mybatis-spring-boot-starter 2.3.2不要强行升级。PostgreSQL 本身的版本也有讲究。单机开发用 16 或者 17 都可以如果说要跟生产环境保持一致那就看生产是什么版本。这里有个细节pgjdbc 驱动向后兼容性做得不错新驱动连接旧数据库一般没问题但反过来就不好说了所以驱动版本别太旧。2. 环境准备:PostgreSQL安装与初始配置的几道坎2.1 Windows下PostgreSQL安装与服务启动很多人第一步就卡在装数据库上。PostgreSQL 的安装包在官网下载选择对应你操作系统的最新稳定版即可。我这边以 Windows 平台为例说说安装过程中容易被忽略的细节。安装向导本身没什么难度一路 Next。但有三个地方要注意一是安装目录和数据存放目录默认是C:\Program Files\PostgreSQL\16\data。这个 data 目录是数据库的真正家底别放在系统盘最好单独设到 D 盘之类的地方否则以后磁盘满了麻烦。二是端口号默认 5432。如果电脑上装了 MySQL3306那跟 PostgreSQL 不冲突但如果之前装过别的 PostgreSQL 或者某些软件占用了 5432安装向导会提醒你修改最好记下你改成的端口。三是超级用户 postgres 的密码。这个密码一定要记住丢失之后重置非常麻烦需要在本地用单用户模式操作。装完以后服务一般会自动注册为 Windows 服务名字形如postgresql-x64-16。如果启动不了最常见的原因是 data 目录权限不对或者是之前拆过数据库文件导致数据损坏。这时候去安装目录/data/log下看日志比如postgresql-xxx.log比瞎猜强得多。排查技巧Windows 上查看 PostgreSQL 服务是否正常可以在命令行执行pg_isready -h 127.0.0.1 -p 5432如果输出表示接受连接说明服务没问题。这比去“服务”面板里看状态更直观。2.2 创建专属数据库与用户安装完成之后默认只有一个 postgres 超级用户。实际项目开发里我不建议直接用超级用户连接业务库原因很简单万一代码里 SQL 写错或者被注入攻击影响面太大。一个项目对应一个普通用户一个专属数据库这是最基础的权限隔离。创建操作在 psql 命令行或者图形客户端里都能做。我用 psql 举个例子CREATE USER myapp WITH PASSWORD MyApp2024; CREATE DATABASE myapp_db OWNER myapp ENCODING UTF8; GRANT ALL PRIVILEGES ON DATABASE myapp_db TO myapp;这里有一个隐藏结论如果在数据库创建之后才去改默认字符集麻烦程度很高。所以创建时最好显式指定ENCODING UTF8。PostgreSQL 默认模板库可能不是 UTF8不指定的话后面插入中文很可能出现invalid byte sequence for encoding的报错。还有一个初始化时就要考虑的点扩展。如果你打算用 JSONB 存扩展字段、用 UUID 做主键最好在业务库里提前把扩展建好CREATE EXTENSION IF NOT EXISTS uuid-ossp; CREATE EXTENSION IF NOT EXISTS pgcrypto;uuid-ossp提供uuid_generate_v4()pgcrypto提供gen_random_uuid()。两者都能生成随机 UUID按喜好选一个就行。2.3 连接串与JDBC驱动要点JDBC 连接串是整合时一个很容易被忽视但影响巨大的参数。PostgreSQL 的连接串基本格式是这样的jdbc:postgresql://127.0.0.1:5432/myapp_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaistringtypeunspecifiedcurrentSchemapublic几个参数逐一说一下useUnicode和characterEncodingutf8保证中文不乱码其实 PostgreSQL 驱动经常能靠服务端编码自己判断但显式写上没有坏处。serverTimezoneAsia/Shanghai处理timestamp与时区相关的坑。如果你的 PostgreSQL 服务端时区不是东八区不带这个参数可能查出来的时间差 8 小时。stringtypeunspecified这是一个容易让人忽略但有奇效的参数。它允许字符串动态绑定到jsonb、uuid等类型列时自动做隐式类型转换。currentSchemapublic明确指定默认 schema避免多个 schema 时relation does not exist这类困扰。JDBC 驱动的 Maven 依赖坐标也很关键dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency如果你用的 Spring Boot 是 BOM 统一管理依赖那么这里可以不用写 version交给 Spring Boot 的 dependencies 管理。3. Spring Boot工程搭建与配置文件逐行拆解3.1 Maven依赖引入与版本对应新建 Spring Boot 项目的方式很多用 IDEA 的 Spring Initializr 最省事。关键是在pom.xml里加对依赖。一个可用的最小依赖集合如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies注意mybatis-spring-boot-starter的版本最好按前面提到的对照表走。Spring Boot 3.0 之后它从org.mybatis.spring.boot这个 groupId 下发的版本号直接跳到 3.x如果你在 Spring Boot 3 项目里用 2.x 版本启动时会提示ClassNotFoundException: org.springframework.boot.autoconfigure.condition.ConditionalOnClass之类的异常。这不是代码的问题纯粹是版本错位。3.2 application.yml配置详解Spring Boot 的配置文件建议用 YAML 格式可读性比 properties 好太多。核心配置如下server: port: 8080 spring: datasource: url: jdbc:postgresql://127.0.0.1:5432/myapp_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghaistringtypeunspecified username: myapp password: MyApp2024 driver-class-name: org.postgresql.Driver hikari: pool-name: MyAppHikariPool minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐行说明一下比较容易困惑的点spring.datasource.driver-class-name写org.postgresql.Driver官网叫 org 开头这个类名。很多教程里写的com.postgresql.Driver是错的那是 Oracle 驱动的写法混入了记忆。mybatis.mapper-locations指定 XML 文件的扫包路径。如果你的 mapper XML 放在了src/main/java/com/example/demo/mapper下而不是 resources 目录那是扫不到的编译后 Java 目录下只有 class 文件并不会把 XML 复制到 target。建议把 XML 文件统一放在src/main/resources/mapper/下。map-underscore-to-camel-case: true这个配置很关键。PostgreSQL 里我前面建议列名用下划线命名比如user_nameJava 实体属性用驼峰userName开启这个配置就不用每个字段都手动写映射了。log-impl设为StdOutImpl可以在控制台直接看到 MyBatis 执行的 SQL 和参数开发阶段强烈建议开启。线上环境记得关掉或换成日志框架。3.3 用一段代码验证连接成功配置写完之后最重要的事情是验证数据库连接是否真的通。我遇到过不少情况配置文件看起来没问题但数据库密码后多了个空格或者防火墙没放行端口导致应用启动时一直报Connection refused。可以写一个最简单的 Mapper 来做通断测试。建表CREATE TABLE app_user ( id BIGSERIAL PRIMARY KEY, user_name VARCHAR(50) NOT NULL, email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );实体类Data public class AppUser { private Long id; private String userName; private String email; private LocalDateTime createdAt; }Mapper 接口Mapper public interface AppUserMapper { AppUser findById(Param(id) Long id); }XMLmapper namespacecom.example.demo.mapper.AppUserMapper select idfindById resultTypecom.example.demo.entity.AppUser SELECT id, user_name, email, created_at FROM app_user WHERE id #{id} /select /mapper然后写一个接口用浏览器访问http://localhost:8080/user/1能返回 JSON 数据说明整个链路是通的连不上的话再看 Tomcat 的报错日志比一次性写完所有功能再调 bug 高效得多。4. MyBatis核心实战:Mapper、XML、动态SQL与特殊类型4.1 表结构、实体类、Mapper接口的映射关系很多人刚开始用 MyBatis 时最困惑的问题是实体类、Mapper 接口、XML 文件之间到底是怎么联系起来的。用一个生活化的类比来解释Mapper 接口相当于“菜单”XML 文件是“后厨配方”实体类是“上桌的菜品”。菜单上写的是菜名方法名和参数后厨配方告诉你具体怎么做SQL 语句菜品就是最终端给你的 Java 对象。它们的联系规则是这样的Mapper 接口全限定名等于 XML 的namespace。接口中的方法名等于 XML 中select/insert/update/delete标签的id。方法参数通过#{param}方式引用多个参数时用Param标注。查询结果通过resultType或resultMap映射到实体类。上面这四点里最容易出问题的就是resultType和resultMap的选择。如果查询结果是单表并且字段名和实体属性名能靠驼峰转换对应上用resultType最简单。一旦涉及多表联查、聚合函数、或者数据库字段命名跟实体差别很大就要老老实实用resultMap。比如resultMap idUserWithOrderMap typecom.example.demo.dto.UserWithOrder id propertyuserId columnid/ result propertyuserName columnuser_name/ collection propertyorders ofTypecom.example.demo.entity.Order id propertyorderId columnorder_id/ result propertyorderNo columnorder_no/ /collection /resultMap使用collection时如果查询结果里 orders 对应的列名跟 user 的列名冲突一定要给 SQL 里的列起别名。这算是一个典型的细节坑两张表都有id列不设置别名的话后面的列会覆盖前面的最终查出来的 user_id 和 order_id 可能全是同一个值。4.2 返回自增主键:PostgreSQL的RETURNINGMySQL 里插入数据后获取自增主键MyBatis 的写法通常是useGeneratedKeystrue keyPropertyid。这个写法在 PostgreSQL 下也能用但 PostgreSQL 有更优雅的方言解法RETURNING。两者的对比场景MySQL 方式PostgreSQL 方式插入单条并回填主键useGeneratedKeystrueuseGeneratedKeystrue或RETURNING id插入多条并回填主键useGeneratedKeys配合 batch 可能有兼容问题RETURNING id可以直接把数据库生成的序列值取回来实际开发中我更推荐 PostgreSQL 的RETURNING写法因为它不仅支持返回主键还能返回任意字段比如创建时间、默认值等insert idinsertUser parameterTypecom.example.demo.entity.AppUser INSERT INTO app_user (user_name, email) VALUES (#{userName}, #{email}) RETURNING id, created_at /insert对应的 Mapper 接口方法int insertUser(AppUser user);执行完之后user.getId()和user.getCreatedAt()会被 MyBatis 自动填充不需要再额外查询一次。这比两条 SQL 的方式性能好不少在高并发插入场景下尤其明显。4.3 批量插入的高效写法批量插入是实际项目里最常见的高频操作。PostgreSQL 和 MySQL 在批量插入上的语法支持有区别MySQL 可以写INSERT INTO t (a,b) VALUES (1,2),(3,4)PostgreSQL 同样支持这种多行 VALUES 写法但官方更推荐的还是INSERT ... SELECT * FROM unnest(...)或者直接用多值 VALUES。MyBatis 里的foreach标签是批量操作的主力insert idbatchInsertUsers INSERT INTO app_user (user_name, email, created_at) VALUES foreach collectionlist itemitem separator, (#{item.userName}, #{item.email}, NOW()) /foreach /insert小数据量几百条内这种方式完全够用执行效率很好。但要注意批量插入的 SQL 字符串长度不能无限制增长PostgreSQL 默认的 max 语句长度没有 MySQL 那么敏感但网络包大小、数据库端的解析成本还是要考虑的。几千条、几万条一次性拼进去不如分批执行。比如每 500 条一批用循环处理。还有一个需要特别留意的地方如果批量插入的数据里包含 PostgreSQL 的 jsonb 字段直接传字符串往往会报错。解决办法是使用#{item.jsonStr,typeHandlercom.example.handler.JsonbTypeHandler}这样的显式 TypeHandler或者干脆在 SQL 里写CAST(#{item.jsonStr} AS jsonb)。前面的stringtypeunspecified参数就是在这种场景下能救你一命的关键配置。4.4 动态SQL:if/where/foreach/choose组合动态 SQL 是 MyBatis 的杀手级功能它让“一个方法适配多个查询条件”成为可能。最常见的是条件查询接口前端传来的筛选条件可能有多个但未必全填。如果为每个组合写一个 SQL组合爆炸根本写不完。whereif的组合是最常用的select idsearchUsers resultTypecom.example.demo.entity.AppUser SELECT id, user_name, email, created_at FROM app_user where if testuserName ! null and userName ! AND user_name ILIKE CONCAT(%, #{userName}, %) /if if testemail ! null and email ! AND email #{email} /if if testcreateTimeStart ! null AND created_at gt; #{createTimeStart} /if /where ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset} /select几个细节解释一下where标签会自动把第一个多余的AND去掉。如果你写成WHERE 11再拼动态条件虽然也能跑但不够优雅而且没法避免某些场景下索引失效。ILIKE是 PostgreSQL 特有的“不区分大小写的模糊匹配”MySQL 对应LIKE。真要说性能前模糊%xxx一般走不上索引数据量大时要考虑使用pg_trgm索引或者换方案。gt;是 XML 里对的转义。如果你在 XML 里直接写created_at 2024-01-01XML 解析器会把当作标签结束符号所以必须转义。把整段比较式放进CDATA是另一种写法我个人的代码风格是能转义就转义保持上下文可读性。choose标签适合“多选一”的场景类似于 Java 里的 switch-case。比如按创建时间排序可选最新、最旧、按更新时间排序choose when testsortType newest ORDER BY created_at DESC /when when testsortType oldest ORDER BY created_at ASC /when otherwise ORDER BY id DESC /otherwise /choose这种写法把 SQL 分支控制在 XML 里比起 Java 里拼 SQL 字符串安全和清晰程度都高几个量级。4.5 PostgreSQL特色类型:JSONB、数组与自定义TypeHandlerPostgreSQL 最有吸引力的地方就是类型系统。jsonb、数组、hstore、tsvector这些都是它的招牌。整合 MyBatis 时这些类型不能被 JDBC 默认映射直接处理需要借助 TypeHandler 或者 SQL 里的类型转换。先举一个 JSONB 的例子。假设业务表里有一个ext_json字段类型是jsonbCREATE TABLE app_user ( id BIGSERIAL PRIMARY KEY, user_name VARCHAR(50) NOT NULL, ext_json JSONB );如果直接在 Java 实体类里声明private String extJson;MyBatis 默认用StringTypeHandler去 set 和 get而且 PG JDBC 驱动接收字符串时不知道 JSONB 类型很容易报ERROR: column ext_json is of type jsonb but expression is of type character varying。三种解决办法第一种SQL 里写强制转换insert idinsertUser INSERT INTO app_user (user_name, ext_json) VALUES (#{userName}, CAST(#{extJson} AS jsonb)) /insert第二种给参数指定 TypeHandler#{extJson, jdbcTypeOTHER, typeHandlercom.example.handler.JsonbTypeHandler}第三种连接串里加stringtypeunspecified前面讲过这样驱动不再提前揣测参数类型而是把类型判断交给数据库VALUES (#{extJson})也能正确插入。自定义 TypeHandler 也不算复杂核心是继承BaseTypeHandlerT重写四个方法MappedTypes(String.class) public class JsonbTypeHandler extends BaseTypeHandlerString { Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return rs.getString(columnName); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return rs.getString(columnIndex); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return cs.getString(columnIndex); } }这个 Handler 只是个示例实际项目中完全可以优化插入时用PGobject直接设置 jsonb 类型查询时把PGobject的 value 取出来转成 JSON 字符串。更规范的写法是让 Handler 直接返回一个 Jackson 反序列化后的 Map 或对象但那样会让 Handler 变重我更倾向于让 Handler 负责“字符串和 jsonb 的转换”再由 Service 层用 Jackson 做反序列化。数组类型也是一样常见。PostgreSQL 的TEXT[]、BIGINT[]用的很多但 MyBatis 默认映射同样处理不了。如果只是简单查询并在 Java 里转成 List可以在 XML 里这样写select idgetTagsById resultTypecom.example.demo.entity.UserTag SELECT user_id, tags::text AS tags FROM user_tag WHERE user_id #{userId} /select数据库数组通过::text转成字符串例如{a,b,c}再到 Java 里拆开处理。如果要支持插入数组可以同上用CAST(#{tags} AS text[])。5. 排查实录:整合过程中的高频问题速查5.1 密码认证失败:scram-sha-256与md5这个报错出现的频率相当高错误信息一般类似FATAL: password authentication failed for user myapp第一反应通常是密码写错了。我排过很多次发现真正的原因往往没那么简单PostgreSQL 从 14 版本开始默认密码加密方式是scram-sha-256而某些旧客户端、旧驱动、或者初始化时人工改过pg_hba.conf的机器可能要求的是md5认证方式。两边算法对不上密码哪怕完全正确也会被拒绝。处理思路是这样的第一步确认密码确实没写错可以在 psql 里手工用这个用户连接试试。第二步看pg_hba.conf文件里的认证方式。Windows 下一般位于C:\Program Files\PostgreSQL\16\data\pg_hba.conf找到host all all 127.0.0.1/32 scram-sha-256这一行确认客户端 IP 段和认证方法。第三步如果确定是加密方式不兼容再考虑改配置。注意修改 pg_hba.conf 之后需要重新加载配置执行pg_ctl reload或者通过SELECT pg_reload_conf();重启服务也可以不过影响面大一点。5.2 relation does not exist:大小写与schema的坑ERROR: relation app_user does not exist这个报错仅次于密码问题出现在明明表已经建好了的情况下。PostgreSQL 对标识符的处理是不加双引号的标识符会被折叠成小写。也就是说你在 psql 里执行CREATE TABLE AppUser (...)表名实际存的是appuser因为你没加双引号。然后 Java 代码里写SELECT * FROM AppUserPostgreSQL 把AppUser按规则转成小写appuser看起来似乎没问题。但如果你建表时用了双引号比如CREATE TABLE AppUser (...)那么表名就是区分大小写的Java 那边必须每次写双引号或者取别名才能查到。我的建议是统一小写命名表名、列名全部用下划线连接的小写形式Java 实体属性用驼峰靠map-underscore-to-camel-case双向转换。从头到尾不要用双引号这样最不容易出问题。另外还有一个常见原因多个 schema。PostgreSQL 默认搜索路径是$user, public。如果你的表建在了my_schema下而连接串里没有指定currentSchemamy_schema那 JDBC 默认去 public 里找表自然报 relation does not exist。解决方式是连接串带上参数或者把搜索路径改好。5.3 参数映射不生效:Param的Value必须一致MyBatis 的多参数方法有一个明显要求XML 里#{xxx}引用的名字必须跟接口方法上Param(xxx)里写的名字一致。这两边是一一对应的关系写错任何一边都是运行时才报错错误信息往往还不太直接。ListAppUser findUser(Param(uname) String userName, Param(em) String email);select idfindUser SELECT * FROM app_user WHERE user_name #{uname} AND email #{em} /select比如上面的例子#{uname}在 XML 里写成了#{userName}那 MyBatis 会抛BindingException说找不到参数。这个问题靠编译器发现不了因为参数名在运行时才绑定。尽早用单元测试覆盖 Mapper 方法比事后翻日志效率高得多。另一种情况是单参数场景。如果 Mapper 方法只有一个参数XML 里写#{任意值}都能取到此时不需要Param。但一旦有两个或以上的参数必须全部标注Param并且保证 XML 引用一致。5.4 分页写法:PostgreSQL的LIMIT/OFFSET这个坑更多是习惯问题。MySQL 的写法是LIMIT 10, 20意思是跳过 10 条取 20 条。PostgreSQL 的写法是LIMIT 20 OFFSET 10参数顺序完全不同。如果你照着 MySQL 的习惯写SELECT * FROM app_user LIMIT 10, 20;PostgreSQL 会直接报语法错误因为LIMIT不接受逗号分隔的两个参数。正确的 PostgreSQL 分页SELECT * FROM app_user ORDER BY id DESC LIMIT #{pageSize} OFFSET #{offset};其中offset (pageNum - 1) * pageSize这个换算逻辑写在 Service 层比较清楚。如果项目里还是想用 PageHelper 这类插件需要确认插件版本跟 Spring Boot 版本兼容。PageHelper 的 spring boot starter 在 Spring Boot 3 下发生过不少兼容问题需要额外配置拦截器我不是特别推荐简单业务不用插件手写 LIMIT/OFFSET 完全够用。5.5 mybatis-spring-boot-starter版本导致自动配置失败这个问题的典型表现是应用启动时报SqlSessionFactory或者SqlSessionTemplate找不到 bean。根本原因我在前面第 1.3 节已经提过Spring Boot 主版本跟 mybatis-spring-boot-starter 的大版本必须对应。Spring Boot 3.x 对应 mybatis starter 3.xSpring Boot 2.x 对应 2.x这个对应关系很多人没意识到。让我再补充一个很少被注意的细节如果你在 Spring Boot 3 项目里用 2.3.x 的 mybatis starter它内部的mybatis-spring版本也比较旧而mybatis-spring对 Spring 6 的新 API 有兼容性问题最终表现是抛各种奇怪的NoSuchMethodError。排查这类问题别在代码层面死磕先去 pom.xml 里看版本八成能揪出元凶。检查依赖树可以用 Maven 命令mvn dependency:tree -Dincludesorg.mybatis.spring.boot输出的结果里如果 starter 的版本跟你的 Spring Boot 版本对不上直接改版本号再试。一般来说用 BOM 管理的 Spring Boot 项目只有 mybatis starter 需要手动指定版本PG 驱动可以交给 Spring Boot 管理。6. 进阶优化:连接池、事务与缓存6.1 HikariCP连接池参数调优Spring Boot 2.0 以后默认的数据库连接池就是 HikariCP这其实是它自身宣传的最大优势之一性能极好口碑极佳。用 HikariCP 一般不推荐像 Druid 那样配一长串监控 SQL、黑名单、白名单的规则它追求的是简洁高效。关键参数就四个第一个maximum-pool-size单个数据库实例的连接数上限。默认值是 10业务量不大时完全够用。如果你有并发压测需求可以按核心数 * 2 磁盘数这种经验公式估算但实际最佳值取决于数据库配置和查询复杂度最好压测后用数据说话。第二个minimum-idle连接池维护的最小空闲连接数。默认跟 maximum-pool-size 一样就是常驻连接池里有很多连接这样做可以减少启动峰值时的建连时间。但如果是低峰期很长的应用把 minimum-idle 设小点能节省数据库资源。第三个connection-timeout获取连接的超时时间默认 30 秒。含义是线程拿不到连接时最多等多久。如果超过这个时间还拿不到直接抛SQLTransientConnectionException。调成 5 到 10 秒更合理因为 30 秒对大多数请求来说用户早就等崩溃了。第四个max-lifetime连接在池里的最大存活时间。默认 30 分钟。设置的原因跟数据库端的wait_timeout有关连接被数据库主动断开之后池里还留着这些“死连接”就会导致偶发异常。HikariCP 建议这个值略小于数据库的wait_timeoutPostgreSQL 的idle_in_transaction_session_timeout也要留余地。配置示例前面给过了不再重复。一句话总结连接池参数不是越大越好过大反而引发数据库端连接风暴。6.2 Transactional与事务回滚Spring Boot MyBatis 的事务管理极为简单核心就是Transactional注解。用法上有一个容易忽略的细节这个注解推荐加在 Service 实现类的方法上而不是 Mapper 方法上。原因是事务的边界应该在“业务操作”这个粒度比如一个下单逻辑里要同时更新库存、插入订单流水、扣减余额这三步要么全部成功要么全部回滚必须在 Service 方法上开一个事务包住它们。Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { orderMapper.insert(request.getOrder()); stockMapper.deductStock(request.getSkuId(), request.getQuantity()); accountMapper.deductBalance(request.getUserId(), request.getAmount()); }rollbackFor Exception.class这个写法也很重要。Spring 默认只在RuntimeException时回滚如果你代码里抛出的是受检异常比如自定义的BusinessException extends Exception不加rollbackFor的话事务不会回滚数据就半更新了。所以写事务注解时我几乎总是带上rollbackFor Exception.class。还有一点事务方法内部调用的所有 Mapper 操作会共享同一个数据库连接这个连接是从同一个事务上下文里获取的。如果你在事务方法里调用了别的 Service 方法而这个方法也有Transactional则默认参与同一个事务除非显式指定传播行为。常见的propagation有REQUIRED、REQUIRES_NEW、NESTED。默认是REQUIRED跟随外层事务。如果要在原事务内部开启独立事务比如记录审计日志日志出错不影响主流程可以设REQUIRES_NEW。这种场景不多但了解后不至于用到时才去翻文档。6.3 缓存策略:MyBatis一级缓存、二级缓存与Caffeine缓存是提升查询性能最直接的手段MyBatis 在这块自带两种缓存一级缓存与二级缓存。一级缓存是 SqlSession 级别的默认开启。也就是说同一个 SqlSession 里连续执行两次完全相同的查询第二次会直接命中缓存不再打数据库。但注意Spring 管理的 Mapper bean 每次操作都可能是新的 SqlSession一级缓存实际作用域很小别指望靠它扛住大量重复查询。二级缓存是 Mapper 级别的默认关闭。开启方式是在 XML 里加cache/效果是同一个 namespace 下的查询结果会被缓存但缓存的对象必须实现Serializable接口否则序列化时会报错。这里我多提醒一句MyBatis 二级缓存命中后返回的是缓存对象的克隆对象副本不是同一个引用避免直接修改缓存里的对象污染数据。在实际项目中组合使用 Caffeine 这种本地缓存更常见。Spring Cache Caffeine 的做法是Cacheable(cacheNames userCache, key #id) public AppUser getUserById(Long id) { return appUserMapper.findById(id); }配合启动类或配置类Configuration EnableCaching public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(30))); return manager; } }用 Caffeine 的好处是访问速度极快完全在 JVM 内存里不依赖数据库。坏处是集群环境下缓存数据在多个节点之间不一致需要配合 Redis 或者消息通知做失效。如果是单机小应用Caffeine 是性价比很高的选择如果是集群环境老老实实上 Redis 更稳妥。经验教训不要迷信“缓存一定快”这句话。如果查询本身有索引覆盖几毫秒就能出结果加上缓存反而带来缓存一致性维护的成本。我在实际项目里只对两类数据加缓存读多写少的字典数据、以及查询开销确实高且不需要秒级一致性的统计结果。7. 一些个人体会这个整合方案我前前后后做过不下十个项目从最早的 Spring Boot 1.x 一直用到现在 Spring Boot 3.x踩过的坑基本都在这篇文章里了。要说最想强调的一点不是某个排序写法或者某个参数名而是“让数据库发挥它该有的作用”。PostgreSQL 本身功能强大JSONB、数组、窗口函数、CTE 都很值得学习。但前提是先确保基础整合链路稳定连接串参数正确、版本匹配、SQL 命名规范。地基打好了后面的优化才有意义。最后再奉送一个小技巧开发时把 MyBatis 的log-impl设为StdOutImpl测试接口时注意看日志里打印的 SQL尤其是Preparing:和Parameters:两行。很多问题在 SQL 打印出来的瞬间就暴露了比自己对着控制台猜半天高效得多。整合是没有一劳永逸的随着数据库版本、Spring Boot 版本和驱动版本的变化总会冒出新的组合问题但掌握排查思路比记住某一条具体答案值钱得多。