Spring Boot + MyBatis + PostgreSQL 整合实战:配置、事务、分页与连接池调优

发布时间:2026/10/14 2:22:30
Spring Boot + MyBatis + PostgreSQL 整合实战:配置、事务、分页与连接池调优 从零开始把 Spring Boot、MyBatis 和 PostgreSQL 这条链路跑通到底要踩多少个坑我最近刚把一个老项目从 H2 内存库迁移到 PostgreSQL过程中把整合配置、连接池调优、Mapper 写法、事务边界这些环节重新捋了一遍。这篇就把整个落地过程写清楚从选版本到写 CRUD再到排查问题全部是基于我实际操作的记录。如果你正准备用这个组合搭新项目或者想把现有项目迁过去可以直接照着做。1. 项目整体设计与技术选型思路1.1 为什么是这三件套Spring Boot 作为应用框架负责把组件粘合起来MyBatis 负责数据库访问层的 SQL 映射PostgreSQL 作为关系型数据库提供事务、索引、JSON 支持等能力。这个组合在中小型后端项目里非常常见尤其适合业务逻辑复杂、SQL 需要精细控制的场景。有人可能会问为什么不直接用 JPA我的看法是JPA 适合表结构简单、CRUD 为主的系统一旦查询逻辑变复杂比如多表联查、动态排序、分页带过滤条件JPA 的 Specification 写起来非常别扭。MyBatis 的 SQL 完全掌握在开发者手里优化起来更直接。PostgreSQL 则比 MySQL 多了一些实用特性比如数组类型、JSONB、窗口函数、部分索引这些在统计报表场景里非常有用。1.2 版本选型的考量Spring Boot 和 MyBatis 的版本兼容性是个老生常谈但必须重视的问题。以我这次使用的组合为例Spring Boot 2.7.x对应 Spring Framework 5.3.xMyBatis Spring Boot Starter 2.3.xPostgreSQL JDBC Driver 42.5.xPostgreSQL 数据库版本 13.x 及以上为什么要这样挑Spring Boot 2.7 是 2.x 系列的最后一个稳定分支很多公司还在用这个系列。MyBatis Starter 2.3.x 对 Spring Boot 2.x 的自动配置机制做了完整适配不会出现 Mapper 扫描失效的问题。JDBC Driver 42.x 系列里42.5 以上的版本对 PostgreSQL 13 的支持已经非常稳定。如果你用的是 Spring Boot 3.x那必须搭配 MyBatis Starter 3.0因为 Spring Boot 3 基于 Jakarta EE 9包名从 javax 变成了 jakarta旧的驱动和 Starter 会直接启动失败。这个坑我在帮别人排查问题时遇到过好几次报错信息还不明显通常是一个奇怪的 BeanCreationException很多人会往配置上找原因实际上是版本不兼容。提示如果你是新项目直接上 Spring Boot 3.x MyBatis Starter 3.0.4 PostgreSQL Driver 42.7.x 也可以。如果是老项目升级先确认所有依赖的 Jakarta 迁移情况再动手否则会陷入无穷无尽的编译错误。2. 核心细节解析与实操要点2.1 依赖引入的完整清单在 pom.xml 里核心依赖就四个但容易漏的是分页插件和连接池的显式声明。Spring Boot 2.x 默认使用 HikariCP 连接池这个不用额外引入只需要配置参数。但如果想换用 Druid需要单独加依赖并做配置。我更推荐直接使用 HikariCP因为它轻量、性能好Spring Boot 默认就是它少一个依赖少一份烦恼。依赖配置如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency !-- 分页插件PageHelper 只对 MySQL 友好PostgreSQL 建议用 MyBatis 官方分页或手写 limit -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency这里有个关键点要说明PageHelper 对 PostgreSQL 的方言支持虽然存在但我在实际使用中发现某些复杂 SQL比如子查询、with 语句在配合 PageHelper 时会出现 count 查询错误。所以后来我干脆手写 limit 分页反而更可控。如果你也是 PostgreSQL建议先测试你项目里的核心查询语句再决定是否依赖 PageHelper。2.2 application.yml 配置详解配置文件是整个整合过程的核心。我见过的很多启动失败问题都出在配置细节上。先看一份我实际使用的配置spring: datasource: url: jdbc:postgresql://localhost:5432/mydb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: postgres password: your_password driver-class-name: org.postgresql.Driver hikari: 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 cache-enabled: true逐个参数说driver-class-name必须是org.postgresql.Driver不要写成org.postgresql.Driver以外的任何形式。之前有人误写为com.postgresql.Driver启动直接报 ClassNotFound。url 中的 serverTimezonePostgreSQL JDBC 驱动并不强制要求传这个参数但如果你代码里用了java.time.LocalDateTime并且想要避免时区转换问题建议加上。更推荐的做法是在 PostgreSQL 连接串里用currentSchema指定 schema比如jdbc:postgresql://localhost:5432/mydb?currentSchemapublic。map-underscore-to-camel-case这个必须设为 true否则数据库里的create_time字段映射到 Java 的createTime属性时会直接为 null而且不会报错查出来的数据看起来一切正常就是关键字段是空的非常坑。mapper-locations如果你的 Mapper 接口和 XML 文件放在同一个包下且包名在MapperScan扫描范围内这个配置可以省略。但为了规范和可维护性我建议保留 xml 路径映射并将 XML 放在resources/mapper目录下。2.3 为什么要单独说 schema 的问题PostgreSQL 和 MySQL 有一个很大的不同schema 的概念。MySQL 里库就是 schema而 PostgreSQL 允许一个数据库里有多个 schema默认是public。如果你的数据库账号不是超级用户或者你的表建在非 public schema 下连接串里不加currentSchema就会找不到表报错信息是relation xxx does not exist。这个报错非常容易误判新手会以为是表没建或者 Mapper 的 SQL 写错了。实际上只是连接串没指对 schema。如果表在business这个 schema 下连接串应该这样写jdbc:postgresql://localhost:5432/mydb?currentSchemabusiness还要注意MyBatis 的 SQL 语句里如果写了不带 schema 的表名而连接串里指定了currentSchema是可以在当前 schema 下找到表的。但如果同一个表在多个 schema 下都存在就必须显式写schema.table的形式否则会用到默认的搜索路径很可能会出问题。3. 实操过程与核心环节实现3.1 建表和实体类的准备工作不管用什么框架建表 SQL 都得先理清楚。我以一个简单的用户表为例这个表我尽量包含了常见的字段类型方便演示 MyBatis 的字段映射CREATE TABLE IF NOT EXISTS app_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, email VARCHAR(128), age INT, score NUMERIC(10, 2) DEFAULT 0, create_time TIMESTAMP WITH TIME ZONE DEFAULT now(), update_time TIMESTAMP WITH TIME ZONE DEFAULT now(), is_deleted SMALLINT DEFAULT 0 );这里几个 PostgreSQL 特有的点BIGSERIAL是自增主键的写法对应 MySQL 的AUTO_INCREMENT。TIMESTAMP WITH TIME ZONE建议存储 UTC 时间或带时区的时间Java 侧用OffsetDateTime或Instant来对应。如果你习惯用LocalDateTime也可以用TIMESTAMP WITHOUT TIME ZONE但要注意写入时的时区问题。我个人的习惯是统一用TIMESTAMP WITH TIME ZONE在 Java 侧用OffsetDateTime接收可以彻底避免时区错乱。实体类对应如下public class AppUser { private Long id; private String username; private String email; private Integer age; private BigDecimal score; private OffsetDateTime createTime; private OffsetDateTime updateTime; private Short isDeleted; // getters and setters 省略 }3.2 Mapper 接口与 XML 的书写规范MyBatis 支持全注解方式和 XML 方式。我的建议是简单 SQL 用注解复杂 SQL 用 XML。但为了保持统一我在这个项目里全部使用了 XML。关键原因是当你需要动态 SQL比如if、foreach时XML 的可读性远高于注解里的字符串拼接。Mapper 接口定义如下Mapper public interface AppUserMapper { AppUser selectById(Param(id) Long id); ListAppUser listByCondition(Param(username) String username, Param(ageGt) Integer ageGt); int insert(AppUser user); int updateSelective(AppUser user); int deleteById(Param(id) Long id); ListAppUser pageList(Param(offset) int offset, Param(limit) int limit, Param(username) String username); long countByCondition(Param(username) String username); }对应的 XML 文件AppUserMapper.xml放在resources/mapper目录下?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.AppUserMapper resultMap idBaseResultMap typecom.example.demo.entity.AppUser id propertyid columnid/ result propertyusername columnusername/ result propertyemail columnemail/ result propertyage columnage/ result propertyscore columnscore/ result propertycreateTime columncreate_time/ result propertyupdateTime columnupdate_time/ result propertyisDeleted columnis_deleted/ /resultMap select idselectById resultMapBaseResultMap select id, username, email, age, score, create_time, update_time, is_deleted from app_user where id #{id} /select select idlistByCondition resultMapBaseResultMap select id, username, email, age, score, create_time, update_time, is_deleted from app_user where 1 1 if testusername ! null and username ! and username like concat(%, #{username}, %) /if if testageGt ! null and age gt; #{ageGt} /if /select insert idinsert useGeneratedKeystrue keyPropertyid insert into app_user(username, email, age, score, create_time, update_time, is_deleted) values (#{username}, #{email}, #{age}, #{score}, #{createTime}, #{updateTime}, #{isDeleted}) /insert update idupdateSelective update app_user set if testusername ! nullusername #{username},/if if testemail ! nullemail #{email},/if if testage ! nullage #{age},/if if testscore ! nullscore #{score},/if if testupdateTime ! nullupdate_time #{updateTime},/if /set where id #{id} /update delete iddeleteById delete from app_user where id #{id} /delete select idpageList resultMapBaseResultMap select id, username, email, age, score, create_time, update_time, is_deleted from app_user where if testusername ! null and username ! username like concat(%, #{username}, %) /if /where order by id desc limit #{limit} offset #{offset} /select /mapper这里几个操作上的细节值得展开讲第一#{username}是预编译参数占位符MyBatis 会将它翻译成 JDBC 的?安全性上是没有注入风险的。而${}是字符串拼接虽然有时候用于动态表名、动态排序字段但绝对不要把用户输入直接拼进去。第二like查询在 PostgreSQL 里推荐用concat(%, #{username}, %)不要写% || #{username} || %虽然两者等价但 concat 函数在参数为 null 时返回空字符串而不是 null行为上更安全。第三gt;是 XML 中对的转义。在if标签里写age #{ageGt}会导致 XML 解析错误必须写成gt;。同理要写成lt;。想避免转义也可以把这类比较条件放在CDATA区里但不建议滥用。第四分页这里我用了手写limit #{limit} offset #{offset}没有用 PageHelper。原因前面说过PostgreSQL 的复杂 SQL 在 PageHelper 的 count 处理上偶尔会翻车手写虽然看起来略显笨拙但稳定性和可控性强得多。3.3 Service 层与事务边界Service 层的写法很大程度上决定了代码的好坏。我见过很多项目把所有 SQL 都写在 Controller 里这种做法在数据量小、业务简单的时候没什么问题一旦逻辑复杂起来事务边界完全不可控。一个标准的 Service 实现类如下Service public class AppUserService { private final AppUserMapper appUserMapper; public AppUserService(AppUserMapper appUserMapper) { this.appUserMapper appUserMapper; } Transactional(rollbackFor Exception.class) public void createUserWithInitialLog(AppUser user) { appUserMapper.insert(user); // 这里可以写日志记录的插入逻辑和用户插入保持同一事务 } public PageResultAppUser searchUsers(String username, int pageNum, int pageSize) { int offset (pageNum - 1) * pageSize; ListAppUser records appUserMapper.pageList(offset, pageSize, username); long total appUserMapper.countByCondition(username); return new PageResult(records, total); } }关于事务有几点实践经验Transactional只对 public 方法生效并且要通过 Spring 代理调用。同一个类内部调用另一个方法事务是不会起作用的因为代理没有介入。如果你在同一个类里调用this.createUserWithInitialLog()事务就失灵了。rollbackFor Exception.class很重要。Spring 默认只对RuntimeException和Error回滚对受检异常Exception则不会回滚。如果你在事务方法里抛了 IOException 这类受检异常默认情况下数据不会回滚这个问题非常隐蔽。PostgreSQL 的事务隔离级别默认是READ COMMITTED如果你的业务要求更强的隔离性可以在事务注解里指定isolation Isolation.REPEATABLE_READ。但要注意隔离级别越高并发越低别满屏乱配。3.4 分页查询的 count 优化思路分页接口需要返回总条数才能算出总页数。最常见的写法是 count 查询和列表查询分开执行。但问题是count 查询不应该使用和列表查询一样的条件否则性能会很差。我的做法是列表查询的 SQL 可以带order by和limit/offset但 count 查询的 SQL 只保留 where 条件把 order 和 limit 去掉。同时如果列表查询里关联了多张表才需要的字段而 count 只需要主表的记录数那 count 可以只查主表减少 join 的开销。分页 SQL 之外PostgreSQL 还支持WITH TIES和游标分页。数据量很大的场景比如超过百万行建议用基于游标的分页方式即 where id 上次查询的最小 id limit 20而不是 offset 分页。offset 越深性能越差这是关系型数据库的通病PostgreSQL 也不例外。3.5 批量插入的正确姿势批量插入是很多项目里被低估的性能杀手。逐条 insert 在数据量大时性能很差。PostgreSQL 支持两种批量插入方式第一种MyBatis 的foreach拼接多条 valuesinsert idbatchInsert insert into app_user(username, email, age) values foreach collectionlist itemitem separator, (#{item.username}, #{item.email}, #{item.age}) /foreach /insert这个方式简单直观但要注意 SQL 的长度。PostgreSQL 对单条 SQL 的长度没有硬限制但如果一次性插入几千条SQL 文本会非常大网络传输和解析都会变慢。一般建议每次批量 200~500 条比较合适。第二种使用 PostgreSQL JDBC 驱动的rewriteBatchedInsertstrue参数。在连接串里加上这个参数后JDBC 会将多条 insert 合并为一条多值的插入语句发送到数据库性能提升明显。对应的 JDBC URL 如下jdbc:postgresql://localhost:5432/mydb?rewriteBatchedInsertstrue这两种方式的效果类似但第二种改动更小只需要改连接串代码逻辑不变。如果你的批量插入场景很多建议直接开启rewriteBatchedInserts。4. 常见问题与排查技巧实录4.1 启动时报 invalid bean definition 或 mapper not found这类问题的根源一般是 Mapper 接口扫描失败。处理方式分两步第一在 Spring Boot 启动类上添加MapperScan(com.example.demo.mapper)确保所有 Mapper 接口被注册。第二确认每个 Mapper 接口上都标注了Mapper注解。这两个方法用哪个都可以但建议不要混用。我个人习惯在启动类上用MapperScan因为可以一次性扫描整个包不用在接口上一个个加注解。如果加了MapperScan还是报 mapper not found下一步检查 XML 文件的 namespace 是否和接口全限定名一致。这是非常常见的低级错误namespace 写错一个字MyBatis 就会提示找不到对应的 statement。4.2 查询结果字段为 null这是排在第一位的隐蔽问题。现象是查询能查到数据但 Java 对象里的一些属性是 null尤其是createTime、updateTime这种下划线命名字段。绝大多数情况下是因为map-underscore-to-camel-case没有开启或者你的 application.yml 里这个配置放在了错误的位置。配置项必须放在mybatis.configuration下面而不是mybatis下直接写mybatis: configuration: map-underscore-to-camel-case: true如果配置没问题还是 null再检查实体类的字段类型和数据库列类型是否匹配。PostgreSQL 的NUMERIC类型映射到 Java 的BigDecimal如果你用Double接收MyBatis 也能转但会丢精度提前埋雷。还有一种情况是查询 SQL 用了select *而结果集里某些列名因为大小写问题和你想的对不上。PostgreSQL 对未加引号的标识符会自动转成小写如果你的表名或列名在创建时加了双引号比如APP_USER那查询时就必须要加双引号才能匹配。这个坑很多人踩过建议建表时一律使用小写命名不要加引号。4.3 时区错误导致时间字段偏差PostgreSQL 的TIMESTAMP WITH TIME ZONE类型存储的是 UTC 时间JDBC 驱动读出来时会转换成 JVM 默认时区的时间。如果你的 JVM 时区不是东八区那么你看到的时间就会差 8 小时。处理这个问题的标准做法是在启动参数里显式指定时区。-Duser.timezoneAsia/Shanghai或者在 application.yml 里spring: jackson: time-zone: Asia/Shanghai如果上述配置都做了还是有问题就要检查数据库会话时区。可以通过下面的 SQL 查询当前时区SHOW timezone;如果这个值是UTC而你希望它是东八区可以在连接串里加options-c timezoneAsia/Shanghai或者直接修改数据库配置。我的建议是不要在应用层反复做时区转换而是统一在 JVM 启动参数和数据库连接串里各设置一次保证整条链路一致。4.4 连接池参数配置不当导致连接耗尽HikariCP 是 Spring Boot 默认的数据库连接池但它默认的maximum-pool-size是 10。如果你的应用并发较高或者某个接口执行了慢查询连接池很容易被打满表现为接口突然变慢错误日志里出现connection is not available, request timed out after 30000ms。从实际经验来看maximum-pool-size不是越大越好。数据库连接数是和数据库实例的能力挂钩的。如果应用是微服务部署了 10 个实例每个实例配置 30 个连接那数据库要承受 300 个连接很多 PostgreSQL 默认配置可能撑不住。一个理性的做法是单个实例的maximum-pool-size设为 10~20再配合慢查询优化和连接超时设置。以下几个参数是我在项目里常用的minimum-idle: 5保持最少 5 个空闲连接应对低峰的突发流量。maximum-pool-size: 20最大连接数超过这个会被拒绝或者等待。connection-timeout: 30000等待连接的最大毫秒数超过则报错。max-lifetime: 1800000连接的最大存活时间PostgreSQL 服务器一般不会主动断开连接但网络设备可能会所以主动回收更稳妥。4.5 N1 查询问题MyBatis 的一对多、多对一关联查询如果不加注意很容易出现 N1 问题。比如先查 100 个用户再在循环里逐个查每个用户的订单会产生 101 条 SQL。虽然 PostgreSQL 性能不错但 101 条 SQL 的网络往返时间和数据库解析开销仍然是不可忽视的。解决方式有三种一是直接使用 SQL 联表查询把需要的数据一次查出。这是最推荐的方式也是选择 MyBatis 而不是 ORM 框架的重要原因。二是使用 MyBatis 的嵌套结果映射collection通过一条 SQL 查询带出关联列表配合resultMap里的columnPrefix和autoMapping处理列名冲突。这个适合分页时要展示列表项里包含若干关联数据的场景。三是开启 MyBatis 的多查询懒加载fetchTypelazy。但我不推荐这个方式除非你非常清楚懒加载的范围否则调试时会发现 session 关闭导致懒加载失败或者不经意间反而触发了更多查询。如果你用了 PageHelper 且 SQL 里有collection映射要注意分页插件对count的计算可能不正确。因为 PageHelper 默认会在外层套一层 count 查询如果 SQL 里有复杂的关联关系count 出来的数往往不是你想的那个数这也是我坚持手写分页的另一个原因。4.6 PostgreSQL 的大对象和 JSONB 字段在 MyBatis 中的处理PostgreSQL 的JSONB类型在 MyBatis 里默认是没法直接映射到 Java 对象的。你需要实现一个 TypeHandler或者把实体字段定义成 String然后自己在业务层做序列化反序列化。我提供一个简单的 TypeHandler 示例MappedTypes(JsonNode.class) MappedJdbcTypes(JdbcType.JAVA_OBJECT) public class JsonNodeTypeHandler extends BaseTypeHandlerJsonNode { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter.toString()); } Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private JsonNode parse(String content) { try { return MAPPER.readTree(content); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } }XML 里的使用方式result columnmetadata propertymetadata typeHandlercom.example.demo.handler.JsonNodeTypeHandler/如果你不想搞 TypeHandler还有一个取巧的办法SQL 里用metadata::text把 JSONB 转成文本Java 实体字段定义成 String。查询和插入的时候自己处理 JSON。这种方式适合 JSON 字段不是主查询条件、仅仅用于存储和展示的场景。但如果你要在 JSONB 上做条件过滤比如metadata {status: active}那还是建议用 TypeHandler 把字段类型映射到位。最后再分享一个实用小技巧如果项目里有多套环境比如开发、测试、生产数据库连接配置一定不要写在同一个 application.yml 里反复切换。用 Spring Boot 的多环境配置application.yml application-dev.yml application-prod.yml启动时通过spring.profiles.activedev指定。这样每个环境的配置独立数据库地址、账号密码互不干扰可以避免上线前临时改配置导致连错库的悲剧。另外数据库连接加一句ssltrue要慎重。PostgreSQL 默认不走 SSL如果你的数据库和服务器都在内网就不用开启。一旦开了而数据库端没配证书连接直接失败。只有当客户端和服务端的 SSL 都配置完成后才建议开启。这次整合做完之后我最大的感受是Spring Boot 帮我们省了很多配置的时间但数据库相关的细节还是得靠积累。PostgreSQL 和 MySQL 在用 MyBatis 时有很多微妙的差异平时开发可能感觉不到一旦遇到线上问题这些细节就是排查的方向。希望这次记录对你有所帮助。