☰
Spring Boot + MyBatis + PostgreSQL 整合实战:配置、映射与性能优化全解析
2026/10/20 3:51:28 网站建设 项目流程

最近在重构一套订单服务,把底层存储从 MySQL 迁到了 PostgreSQL,ORM 选了 MyBatis,框架继续用 Spring Boot。这套组合在中小型团队里非常常见,但说句实话,很多朋友整合完能跑通,一上线就出问题——主键回填失效、JSON 字段映射不上、批量插入慢得离谱。这篇文章不聊虚的,把我这次整合的完整过程、配置细节和踩过的坑一次性说清楚,希望能帮正准备把 Spring Boot、MyBatis、PostgreSQL 这几个家伙凑到一起的朋友少走点弯路。

BeetlSQL、JPA 也玩过,最后回归 MyBatis,为什么?因为这套组合在复杂查询、SQL 调优、团队协作上确实有不可替代的优势。

1. 技术选型与场景拆解:这套组合解决什么问题

1.1 三件套的价值定位

先说 Spring Boot。它的核心价值在于"约定大于配置",把以往 SSM 时代大量繁琐的 XML 配置、Bean 注册全部自动化了。最直观的感受就是,以前搭一个能用 MySQL 跑通的 Web 服务,光配置数据源、事务管理器、SqlSessionFactory 就要折腾半天,现在一个spring-boot-starter-jdbc起步依赖加几个配置项就搞定了。项目启动内嵌容器,打包直接java -jar执行,部署成本极低。

再说 MyBatis。它和 JPA、Hibernate 这类全自动 ORM 最大的区别是——SQL 由你掌控。这听起来好像是倒退,但在实际业务里太重要了。比如订单系统的多表关联统计、报表模块的复杂聚合查询,JPA 要么写 JPQL 绕来绕去,要么被迫降级用原生 SQL。MyBatis 是直接在 XML 里写 SQL,SQL 长什么样数据库执行的就是什么样,性能问题一目了然。还有一个隐藏优势:新来的开发同学不用先学 JPA 那一套生命周期级联理论,会 SQL 就能快速上手写 Mapper。

最后是 PostgreSQL。它这几年在开源数据库里风头很盛,不是没道理的。对业务开发最直接的三个杀手锏:JSONB 类型(处理灵活的扩展字段非常好用)、数组类型(省掉一堆关联表)、以及 CTE(WITH 语句,复杂查询的阅读性和性能都好很多)。另一个大量团队没有注意到的点是它在高并发写入场景下,行级锁和 MVCC 机制的并发控制比很多商用数据库还稳。搭配 MyBatis 这种 SQL 可控的 ORM,才能把这些特性真正用起来。

1.2 版本选型的取舍复盘

先给一套实测稳定的版本组合,照着配基本不会翻车:

组件推荐版本主要考虑
Spring Boot2.7.x生态成熟,兼容性稳妥,3.x 需要 JDK 17 以上,2.x 用 JDK 8/11 都可以
mybatis-spring-boot-starter2.3.x与 Spring Boot 2.7 配合稳定,自动配置完善
PostgreSQL 驱动42.6.x支持 PG 14/15/16,修复了连接稳定性问题
PostgreSQL 服务端14/15长期支持版本,JSONB、CTE 等核心特性稳定

版本这块有个地方要特别留意:mybatis-spring-boot-starter的版本并不跟随 Spring Boot 的版本号走。你 Spring Boot 用 2.7,不代表 starter 也是 2.7。很多人在这里直接用了最新版 3.x 的 starter,一启动报ClassNotFoundException: javax.sql.DataSource,因为 3.x 是基于 Spring Boot 3 编译的,包名从javax改成了jakarta。这个坑我身边已经有人踩过。

JDK 层面,如果团队比较传统、项目还是 JDK 8 跑着的,老老实实用 Spring Boot 2.7 就对了。如果新项目、绿地对技术栈没历史负担,才考虑 Spring Boot 3.x + JDK 17,毕竟 3.x 是当前主流方向。

1.3 适用场景盘点

这套组合最适合哪些项目?我根据自己接触过的业务线盘点一下:

  • 电商订单系统:订单、订单明细、支付流水这类数据量大会伴随高并发写入的场景,PG 的稳定性和 MyBatis 的精细 SQL 控制很搭。
  • 内容管理平台:文章、用户的扩展属性经常变化,用 PG 的 JSONB 字段存动态属性,比频繁 ALTER TABLE 改表结构要好维护得多。
  • 金融对账系统:对 SQL 的可读性和审计要求高,MyBatis 让 SQL 完全暴露在代码里,自然比对 JPA 自动生成的 SQL 更可控。

2. 项目骨架搭建与依赖配置精讲

2.1 Maven 工程结构设计

我用 Maven 做依赖管理,工程结构是我个人比较推荐的单模块+分包模式。模块划分上先理个包结构,这样写起来不迷路:

com.example.order ├── OrderApplication.java // Spring Boot 启动类 ├── controller/ // 接口层 ├── service/ // 业务逻辑层 ├── mapper/ // MyBatis Mapper 接口 ├── entity/ // 数据库实体 ├── dto/ // 入参出参对象 ├── config/ // 配置类 └── common/ // 通用工具、常量

资源目录下放mapper/*.xml,就是 MyBatis 的 XML Mapper 文件。这个结构是我实践下来比较清晰的拆分方式,核心逻辑很直白:Controller管接收参数,Service管业务,Mapper管数据库,各层通过 DTO 传递数据,不直接把 Entity 暴露到接口层。

2.2 pom.xml 核心依赖声明

直接上依赖配置,这是整个项目的基石:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <!-- 注意这个版本对应 Spring Boot 2.7,不必跟随 Boot 大版本号 --> <mybatis.starter.version>2.3.2</mybatis.starter.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 官方 Spring Boot 整合包 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>${mybatis.starter.version}</version> </dependency> <!-- PostgreSQL 驱动 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- 连接池,Spring Boot 2.x 默认用 HikariCP,依赖里无需显式引入, 但若想直接操作连接池参数,需要暴露配置项 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

几个配置的意图说明一下:MyBatis 官方 starter 不需要单独配置SqlSessionFactory和DataSource,它会自动读取application.yml里spring.datasource开头的配置创建数据源,再把 Mapper 接口扫描进来。Lombok 纯粹是为了减少 Entity 的 Getter/Setter 样板代码,不习惯用注解的可以去掉。JDBC starter 建议保留,不然事务管理器不会自动配置,后面@Transactional会不生效。

2.3 application.yml 配置与关键参数解析

配置文件是整合里面信息量最大的部分,我直接给一份贴过生产实践的完整配置:

server: port: 8080 spring: datasource: url: jdbc:postgresql://127.0.0.1:5432/order_db?characterEncoding=utf8&stringtype=unspecified&reWriteBatchedInserts=true&currentSchema=public username: order_app password: ChangeMe@2024 hikari: # 连接池最大连接数:核心线程池 + 等待队列综合评估 maximum-pool-size: 20 minimum-idle: 5 # 获取连接超时时间 30 秒,避免连接池耗尽时无限阻塞 connection-timeout: 30000 # 空闲连接回收时间 10 分钟 idle-timeout: 600000 # 连接最大存活时间 30 分钟,主动回收可能已被数据库断开的连接 max-lifetime: 1800000 # 连接泄漏检测阈值,超过 60 秒未归还连接则打印警告日志 leak-detection-threshold: 60000 mybatis: # XML Mapper 文件位置 mapper-locations: classpath:mapper/*.xml # 实体类别名扫描包,XML 里可以直接用类名 type-aliases-package: com.example.order.entity configuration: # 下划线转驼峰:数据库列 created_at -> Java 属性 createdAt map-underscore-to-camel-case: true # 重要:保证插入 NULL 值时 JDBC 类型明确,避免 PG 报错 jdbc-type-for-null: 'null' logging: level: com.example.order.mapper: debug # 打印 SQL,开发阶段必备

这段配置里有三个地方是容易掉坑的,我逐个说透。

第一个是 JDBC URL 里reWriteBatchedInserts=true这个参数。它专门针对 PostgreSQL 的 JDBC 驱动,作用是让驱动把多条INSERT语句重写为一条多 VALUES 的大 INSERT,批量插入性能提升非常明显。后面讲批量插入的时候我会给实测数据对比。

第二个是jdbc-type-for-null: 'null'。MySQL 驱动对 NULL 值不敏感,但 PG 的 JDBC 驱动对"类型未知"的 NULL 非常严格,如果插入时某个字段是 NULL 而 JDBC 类型没指定,会直接抛SQLException: IN operator not supported for null或者更常见的column "xxx" is of type bigint but expression is of type text。设置以后让 PG 驱动统一把 NULL 当 JDBC 的 NULL 类型处理。

第三个是leak-detection-threshold。MyBatis 的会话管理和连接池是一套的,如果代码里忘了关闭SqlSession或者事务嵌套出问题,连接会一直占着不归还。有了这个参数,超过阈值没归还连接 HikariCP 会在日志里打出一条带堆栈的警告,定位到具体是哪一行代码泄漏的,排查效率翻倍。

2.4 启动类与 Mapper 扫描配置

启动类也很常规:

@SpringBootApplication @MapperScan("com.example.order.mapper") public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); } }

@MapperScan指定 Mapper 接口所在的包路径。如果你不想在启动类上标注这个注解,也可以在每个 Mapper 接口上标@Mapper,但那样写起来啰嗦,而且容易漏标。我个人建议统一用@MapperScan,包一锁定就完事。

到了这一步,基础骨架已经立起来,现在动手写业务代码才会真正遇到各种细节问题。我把核心实操中沉淀下来的经验和技巧整理成下面几个部分。

3. 核心建模与映射:把 PostgreSQL 特性转化为 MyBatis 能用的能力

3.1 表结构设计:同时考虑 PG 特性和 MyBatis 映射

先给一套建表 DDL,以订单域为例,覆盖了 PG 的几个典型特性:

CREATE TABLE t_order ( id BIGSERIAL PRIMARY KEY, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, total_amount NUMERIC(12, 2) NOT NULL DEFAULT 0.00, ext_info JSONB, status SMALLINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX uk_order_no ON t_order(order_no); COMMENT ON COLUMN t_order.status IS '订单状态 0-待支付 1-已支付 2-已取消'; COMMENT ON COLUMN t_order.ext_info IS '扩展信息 例如 收货地址的备注、优惠快照等';

这张表里有两个设计点是刻意为之的。

BIGSERIAL是 PG 的自增序列类型。它可以给 MyBatis 用useGeneratedKeys回填主键,后面细说。有人可能会问为什么不用 UUID,通用唯一标识和序列号各有取舍。UUID 更适合跨库数据合并、防枚举遍历这类场景,但作为数据库主键它有一个明显的短板——B-tree 索引随机写入会导致频繁页分裂,高并发下插入性能下降明显。序列自增是局部性写入,索引排列也紧密,常规交易场景我会优先自增。

TIMESTAMPTZ是带时区的时间类型。很多人在这里踩坑:Java 侧的LocalDateTime和 PG 的TIMESTAMP、TIMESTAMPTZ之间经常发生 8 小时时差错乱。推荐 Java 侧用OffsetDateTime或者LocalDateTime+ 统一 UTC 时区来配TIMESTAMPTZ,后面问题排查章节再展开。

3.2 实体类设计与类型映射对照

实体类如下:

@Data public class OrderEntity { private Long id; private String orderNo; private Long userId; private BigDecimal totalAmount; private String extInfo; // JSONB 字段先用 String 接收 private Integer status; private LocalDateTime createdAt; private LocalDateTime updatedAt; }

这里的extInfo先用String接,后面会讲怎么做一个TypeHandler把它转成具体的 Map 或对象。BigDecimal对应 PG 的NUMERIC(12,2),金额场景千万别用Double,浮点误差在金融业务里是大忌。

MyBatis 的map-underscore-to-camel-case开启后,order_no列会映射到orderNo、total_amount映射到totalAmount,数据库命名规范(全小写+下划线)和 Java 命名规范(小驼峰)自动对齐,省掉一大片resultMap。

但有个前提:一定要保证列的别名也是小写下划线。比如你写了SELECT order_no AS orderNo FROM t_order,PG 返回的列名是orderno(因为 PG 会把不带双引号的别名折叠成小写),结果orderNo反而映射不上,这是新手常见的问题。稳妥的做法是让查询的列名和 Java 属性列名直接靠下划线映射,不要手动起驼峰别名。

3.3 JSONB 字段的 TypeHandler 实战

JSONB 是 PG 的一大卖点,但 MyBatis 没有内置的 JSONB 映射器,需要自己写一个轻量TypeHandler。这里给一个最实用的方案,把 JSONB 字段直接映射成Map<String, Object>:

@MappedTypes(Map.class) @MappedJdbcTypes(JdbcType.OTHER) // PG 的 JSONB 类型 JDBC 映射为 OTHER public class JsonbTypeHandler extends BaseTypeHandler<Map<String, Object>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Object> parameter, JdbcType jdbcType) throws SQLException { try { ps.setObject(i, MAPPER.writeValueAsString(parameter), Types.OTHER); } catch (JsonProcessingException e) { throw new SQLException("Map 序列化为 JSON 失败", e); } } @Override public Map<String, Object> getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public Map<String, Object> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public Map<String, Object> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private Map<String, Object> parse(String json) { try { return json == null ? null : MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {}); } catch (JsonProcessingException e) { throw new RuntimeException("JSON 字符串解析失败", e); } } }

配置方面,可以在application.yml全局指定:

mybatis: type-handlers-package: com.example.order.common.handler

然后把实体类里的extInfo字段类型从String改成Map<String, Object>:

private Map<String, Object> extInfo;

这样查询的时候 JSONB 字段自动变成 Map,插入的时候 Map 自动变成 JSON 字符串传给 PG。实测这套方案在订单扩展信息、用户偏好设置等动态结构的场景里非常实用,不用再为每个新属性去加列。

3.4 主键回填:自增主键的正确姿势

这是 MyBatis + PG 整合里最容易出问题的地方。很多人在 MySQL 上习惯了useGeneratedKeys,到 PG 还按同样的方式写,结果发现主键回填总是 0 或者报错。本质原因在于 PG 的自增主键BIGSERIAL在 JDBC 层面的返回机制和 MySQL 的AUTO_INCREMENT稍有差异。

MyBatis 的useGeneratedKeys依赖数据库驱动返回自动生成的主键,PG 驱动其实也支持,但需要在 XML 中显式声明:

<insert id="insertOrder" parameterType="com.example.order.entity.OrderEntity" useGeneratedKeys="true" keyProperty="id" keyColumn="id"> INSERT INTO t_order (order_no, user_id, total_amount, ext_info, status) VALUES (#{orderNo}, #{userId}, #{totalAmount}, #{extInfo}, #{status}) </insert>

keyProperty="id"对应 Java 实体的主键属性,keyColumn="id"对应数据库主键列名,两个都写上,避免解析歧义。这样插入成功后:

OrderEntity order = new OrderEntity(); order.setOrderNo("NO" + System.currentTimeMillis()); order.setUserId(10001L); order.setTotalAmount(new BigDecimal("1999.00")); orderMapper.insertOrder(order); System.out.println(order.getId()); // 可以直接拿到回填后的主键

如果 PG 的表用的是GENERATED ALWAYS AS IDENTITY(PG 10+ 引入的标准语法)而不是BIGSERIAL,也能正常工作,JDBC 层返回主键的机制是一样的。

还有一个用法差异值得注意:PG 支持INSERT ... RETURNING id这种 DML 返回语法。MyBatis 里可以通过<selectKey>实现:

<insert id="insertOrderWithReturning" parameterType="OrderEntity"> INSERT INTO t_order (order_no, user_id, total_amount) VALUES (#{orderNo}, #{userId}, #{totalAmount}) <selectKey keyProperty="id" resultType="java.lang.Long" order="AFTER"> SELECT LASTVAL() </selectKey> </insert>

但实测下来,这个写法依赖LASTVAL()函数有一个隐患:如果同一个数据库会话里还有其他会话插入导致的序列扰动,LASTVAL()可能拿不到本语句生成的主键。虽然串行会话里问题不大,但并发场景我不推荐。优先用useGeneratedKeys,简单可靠。

后来我在实际项目里逐渐倾向于一个更稳妥的策略:在获得完整订单号之后,先不急着获取数据库自增主键,而是先插入再由主键反查、然后用数据库的唯一键做幂等控制,把生成主键这一步交给底层。不过在常规同步插入语义下,useGeneratedKeys足够用。

4. 动态 SQL、批量写入与分页:发挥 MyBatis 与 PG 的优势

4.1 动态 SQL 与 PG 数组类型联动

MyBatis 的 XML 动态 SQL 是它最灵活的部分。配合 PG 强大的表达式和类型系统,很多复杂查询写出来非常顺手。

比如订单列表查询,要根据用户 ID、订单状态、时间范围等多条件组合查询,这是最典型的场景:

<select id="listOrders" resultType="com.example.order.entity.OrderEntity"> SELECT id, order_no, user_id, total_amount, ext_info, status, created_at, updated_at FROM t_order <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND created_at &gt;= #{startTime} </if> <if test="endTime != null"> AND created_at &lt;= #{endTime} </if> <if test="orderNos != null and orderNos.size() > 0"> AND order_no IN <foreach collection="orderNos" item="no" open="(" close=")" separator=","> #{no} </foreach> </if> </where> ORDER BY created_at DESC </select>

<where>标签会自动处理掉第一个条件前面的AND,免得你手工拼接 SQL 时还要考虑是否加 WHERE,这是 MyBatis 最基本也好用的标签之一。

如果订单号列表数量特别大,IN会有性能问题,PG 支持数组参数,可以这样优化:

<select id="listByOrderNoArray" resultType="OrderEntity"> SELECT id, order_no, user_id, total_amount, status, created_at FROM t_order WHERE order_no = ANY(#{orderNoList}::text[]) </select>

Java 侧传一个数组或者 List 都可以,关键是在 SQL 里通过= ANY(#{orderNoList}::text[])告诉 PG 这是一个数组匹配条件,PG 的查询规划器能走索引。实测下来,列表在几千个元素内性能都很稳定。

4.2 批量插入性能优化:reWriteBatchedInserts 的威力

批量插入是另一个高频场景,订单明细、日志上报、积分流水这类一次性写入数万条的操作用得很频繁。先看不优化版本:

<insert id="batchInsert" parameterType="list"> INSERT INTO t_order (order_no, user_id, total_amount, status, created_at) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.totalAmount}, #{item.status}, NOW()) </foreach> </insert>

这种写法本身没有错,但如果只是直接把这段 SQL 发给 PG,JDBC 驱动默认还是逐条向 PG 发送 INSERT 语句,foreach拼出来的大 SQL 也仅仅减少了网络往返的 ONE-SHOT 调用。要让它真正变成 PG 的批量写入模式,就必须在 JDBC URL 里加上我前面提到的参数:

jdbc:postgresql://127.0.0.1:5432/order_db?reWriteBatchedInserts=true

这个参数的作用是让 PG JDBC 驱动把一条包含多条 VALUES 的INSERT重写成INSERT ... VALUES (...), (...), (...)的单条语句,配合ExecutorType.BATCH会有更大的性能提升。

我实测过一组数据供参考:插入 1 万条订单记录,reWriteBatchedInserts=false时耗时约 8 到 10 秒,开启后耗时降到 1 到 1.5 秒,接近 8 倍提升。开启前后 SQL 日志都能看出来,驱动层会自动优化发送策略。

除了 JDBC URL 参数,代码里也要配合:

SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { OrderMapper mapper = sqlSession.getMapper(OrderMapper.class); for (OrderEntity order : orders) { mapper.insertOrder(order); } sqlSession.commit(); } finally { sqlSession.close(); }

用ExecutorType.BATCH模式执行所有的 insert,驱动会把它们攒在缓冲区里批量发送。配合 Mapper 接口方式时,XML 里不要写<foreach>批量 insert,直接循环调用单条 insert 方法即可,这样驱动才会真正把多条 insert 累积成 batch。

4.3 分页查询:从 PageHelper 到 Keyset 分页

分页是业务系统绕不开的功能。MyBatis 本身不带分页能力,最常见的方案是引入 PageHelper,做一个物理分页插件。以订单列表为例:

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>

代码里使用非常简单:

PageHelper.startPage(pageNum, pageSize); List<OrderEntity> list = orderMapper.selectPageList(condition); PageInfo<OrderEntity> pageInfo = new PageInfo<>(list);

PageHelper 的实现原理是对你写的 SQL 做一次解析改写,自动在后面拼接LIMIT ?, ?。同时它还会自动执行一条SELECT COUNT(*)的优化 count 查询。这里有一个注意事项:PageHelper.startPage只对接下来执行的第一个查询生效,如果你的 Mapper 方法里是先执行了别的查询,再执行你真正想分页的那个查询,那么分页会跑到别的 SQL 上,导致结果完全不对。所以必须保证紧随其后的那一条 SQL 就是你要分页的查询。

PageHelper 在数据量小、页码靠前的场景下够用了。但订单表的数据量过千万后,深分页场景我强烈建议切换到 Keyset 分页(也叫游标分页,或者基于索引定位的分页)。原理是记住上一页最后一条记录的排序值,下一页直接从这个值往后取。

比如按创建时间倒序翻页,SQL 大概长这样:

<select id="selectNextPage" resultType="OrderEntity"> SELECT id, order_no, user_id, total_amount, created_at FROM t_order <where> <if test="lastCreatedAt != null"> AND created_at &lt; #{lastCreatedAt} </if> </where> ORDER BY created_at DESC, id DESC LIMIT #{pageSize} </select>

注意排序字段要加一个稳定唯一键id DESC做 tiebreaker,否则相同created_at的记录在翻页时会重复或丢失。这种分页方式的查询计划始终走索引,不受页码变大的影响,实际体验里 1 亿行数据的表翻页也在毫秒级。

4.4 事务管理:不同引擎的注意事项

Spring Boot 整合 MyBatis 后,事务默认交给 Spring 管理。@Transactional可以直接加在 Service 方法上。但 PG 和 MySQL 之间有一个显著差异:MySQL 默认的Repeatable Read隔离级别和 PG 的Read Committed不同,如果你原来在 MySQL 上写业务时借助了可重复读的快照特性,迁移到 PG 后这部分行为会变。比如同一事务里多次查询同一张表,MySQL 可能返回一致快照,PG 默认是每次查询都可以看到已经提交的新数据。因此,凡是涉及"先查再写"且要求逻辑一致性的场景,必须在 PG 中显式使用SELECT FOR UPDATE行级锁来保证并发安全。

举一个具体例子:订单支付回调时,要先检查订单状态再更新订单金额和相关账户余额。如果不用SELECT FOR UPDATE,两个支付回调并发进来,可能都查到未支付状态,然后都执行更新,导致重复加钱。在 PG 里:

SELECT * FROM t_order WHERE order_no = #{orderNo} FOR UPDATE

这样第一个事务锁住行以后,第二个事务的SELECT FOR UPDATE就会等待第一个事务提交,才能看到最新状态。配合@Transactional,保证整个检查-更新过程是原子的。

还要注意 PG 的锁等待默认是无限等待,如果某个事务持有锁不放,其他事务会一直卡住。要在application.yml里配置锁等待超时:

spring: datasource: hikari: connection-init-sql: SET lock_timeout = '5s'

这样超过 5 秒获取不到锁,PG 会主动抛异常,而不是让请求一直堆积。

5. 高频问题排查实录与避坑指南

5.1 连接池耗尽:too many clients already

现象:应用运行一段时间后,日志里出现org.postgresql.util.PSQLException: FATAL: sorry, too many clients already,接口响应变慢甚至全部 500。

排查过程:起初以为是 PG 默认 max_connections 太小(默认 100)。一查 PG 服务端已经跑到 90+ 连接,但连接池的maximum-pool-size明明才 20,多出来的连接哪来的?看 HikariCP 日志后发现idle-timeout默认 10 分钟,等不到 10 分钟就被 PG 的idle_in_transaction_session_timeout断开了,连接池里残留的"半死连接"不但没有被及时清理,还会在并发高峰期被重新获取,导致连接池内部拿到的连接不可用,连接池又额外创建新连接。

根因:HikariCP 的max-lifetime默认是 1800 秒,PG 驱动如果连接长期空闲被服务端断开,HikariCP 要等到下一次借出连接时做一次isValid检查才能发现,这个检查在连接被借出时已经晚了,会直接抛connection is closed异常。

解法:三步走:

  • 应用侧:把maximum-pool-size调小到合理范围(比如 20),同时把max-lifetime调成 15 分钟,比 PG 服务端的空闲断开阈值短,主动淘汰被服务端断开的连接。
  • 数据库侧:给运行账号设置CONNECTION LIMIT 40,预留连接给其他直连工具、维护操作。
  • 连接池侧:开启leak-detection-threshold=60000,找出哪些代码借了连接没还。

5.2 时区错乱:插入库里的时间多了 8 小时

现象:Java 侧LocalDateTime.now()插入后查出来比当前时间多了 8 小时,或者反过来少了 8 小时。

根因:Java 和 PG 对时间处理的最大坑是——PG 的TIMESTAMPTZ存储的其实是 UTC 时间,展示时按当前会话的TimeZone转换;Java 的LocalDateTime又不带时区信息,驱动不知道该用什么时区去翻译。

解法:最好统一用TIMESTAMPTZ存储,Java 侧用OffsetDateTime:

private OffsetDateTime createdAt;

插入时:

order.setCreatedAt(OffsetDateTime.now(ZoneOffset.UTC));

同时 PG 连接参数强制指定时区:

jdbc:postgresql://127.0.0.1:5432/order_db?TimeZone=UTC

这样整条链路从数据库到 Java 全走 UTC,到前端展示再按用户本地时区转换,就不会出现各处 8 小时偏差了。如果坚持用LocalDateTime,那要把 PG 的列类型也设为TIMESTAMP WITHOUT TIME ZONE,两边都不带时区信息,也能避坑,但这样存量数据一旦要换时区展示就非常麻烦。

5.3 大小写敏感:PG 和 MySQL 的差别

现象:在 MySQL 上能正常查询的小写表名,到了 PG 报relation "t_order" does not exist,但表明明建了。

根因:PG 对未加双引号的标识符会自动折叠成小写,如果你用双引号创建了表名"T_Order",那么不带引号的查询SELECT * FROM T_Order实际会去找t_order,自然找不到。反过来,如果你建表时用了双引号,每次查询都必须用双引号加上完全一致的表名,非常容易出错。

解法:建表一律用小写加下划线,不用双引号,包括列名也是。MyBatis XML 里写 SQL 时也保持小写,这样最稳。如果是从旧系统迁移、有些表名已经是大写驼峰,可以在 SQL 中加双引号统一,但我不建议长期维持这种状态,太容易踩规则坑。

5.4 事务失效:@Transactional 没生效

现象:方法加了@Transactional,但数据出现部分提交。

常见原因:三个核心原因逐一排查:

  • 方法自调用:同一个类里 A 方法调用 B 方法,B 上标注的@Transactional不生效,这是 Spring AOP 代理机制决定的,解决办法是自注入 Bean 或者拆分类。
  • 异常被吞:try-catch捕获了异常没往外抛,Spring 感知不到异常就不会回滚。可以用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚,更好的做法是让异常上抛。
  • rollbackFor没配:Spring 默认只对RuntimeException和Error回滚,受检异常默认不回滚。业务异常如果是自己定义的 checked exception,必须显式声明@Transactional(rollbackFor = Exception.class)。

另外有个 PG 特有的坑:DDL 语句(CREATE TABLE、ALTER TABLE)在 PG 中是事务性的,可以回滚,但如果你在事务里先执行了 DDL,再执行 DML,某些情况下 DML 会直接失败,因为 PG 在 DDL 后可能对相关表加上了ACCESS EXCLUSIVE锁。不要在事务里做表结构变更,DDL 操作单独执行。

5.5 MyBatis 与连接泄漏的配合问题

现象:跑批任务或者长时间运行的服务,隔几天就出现连接池连接数打满。

根因:常见来源是 MyBatis 的自定义插件或者SqlSessionTemplate使用方式不对。比如有同学习惯自己SqlSession sqlSession = sqlSessionFactory.openSession()后忘了finally { sqlSession.close(); },连接就一直不归还。

解法:尽量用 Mapper 接口 +@MapperScan这种方式,MyBatis starter 会保证SqlSession生命周期由 Spring 管理,用完自动关闭。只有你在批量操作里手动openSession(ExecutorType.BATCH)时才需要自己负责close()。把这条规则写进团队代码规范里,比事后排查省心得多。

5.6 问题排查速查表

错误现象排查要点解决方向
too many clients alreadyPG 连接数与连接池配置调小 max-pool、缩短 max-lifetime、数据库账号限连接
时间多了/少了 8 小时时区与时间类型使用 TIMESTAMPTZ + UTC + OffsetDateTime
relation "t_order" does not exist大小写折叠小写表名列名,统一不加双引号
column "x" is of type integer but expression is of type textNULL 或类型推断配置 jdbc-type-for-null='null',参数用明确类型
批量插入极慢参数缺失或未用 Batch加 reWriteBatchedInserts=true + ExecutorType.BATCH
@Transactional 不生效自调用/异常吞掉/rollbackFor自查调用链和异常处理,显式指定 rollbackFor

6. 实操总结:我这次整合的最终体会

把 Spring Boot、MyBatis、PostgreSQL 整合这条路完整走下来,我最大的感受是——这套组合相较传统的 SSM 或 SSM+MySQL 方案,在现代互联网业务中有着更强的抗压能力,尤其适合那种数据量中等偏上、查询逻辑复杂、团队希望把控 SQL 质量的项目。技术选型从来不是越新越好,关键是匹配业务场景。MyBatis 让团队对数据库访问保持"看得见、查得出、调得动"的状态,PG 则提供了比 MySQL 更丰富的数据类型和更强的扩展能力,Spring Boot 把低层基建全部包办了。三者叠加以后,开发效率和数据可控性达到了一个难得的平衡。

实际操作中我踩过几次坑之后,最深的体会是配置阶段就要把地基打好。连接池参数、时区、JDBC URL 上的那几个开关参数、事务边界,这些在一开始就按下述方案设置好,后面能省掉大量排查时间。另外,团队内部如果有统一的技术规范,比如建表命名、时间类型约定、JSONB 使用边界、批量写入的固定写法,一定要沉淀成文档。代码写到哪里,规范跟到哪里,才能让这套组合在团队里规模化落地。

最后再分享一个小技巧。如果你要从 MySQL 迁移到 PG,建议先写一个迁移清单,把 MySQL 特有的AUTO_INCREMENT、NOW()、IFNULL等逐个转换成 PG 对应的BIGSERIAL/IDENTITY、CURRENT_TIMESTAMP、COALESCE,同时把 SQL 里的大写标识符、反引号全部审查一遍,再来做数据迁移。迁移完以后一定要抽出一批核心接口做全量回归测试,特别是分页、批量插入、时间字段和 JSON 字段这几个最容易出怪问题的环节。迁移顺利了,剩下的交给新组合去发挥就行了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询