最近重构一个内部资产管理系统时,我把技术栈从 MySQL + JPA 换成了 PostgreSQL + MyBatis。这套组合看起来不算新,但真正落地的时候,坑比想象中多:驱动版本、JSONB 映射、动态 SQL、批量插入、参数大小写、时区问题,每一个都能卡你半天。这篇博文不是官方文档复读,而是把我这一路踩过的坑、权衡过的设计、最终能跑通的代码都整理出来,完全围绕 Spring Boot 整合 MyBatis 与 PostgreSQL 这条主线展开,从装库到复杂查询逐层过一遍。
这篇文章适合下面几类人看:正在把老项目从 MySQL 迁到 PostgreSQL 的开发者;刚接手 Spring Boot 3 + MyBatis 组合、想避开常见配置坑的新手;以及准备 MyBatis 面试想顺带把缓存、TypeHandler 工作流程搞明白的人。内容里所有 SQL 都基于 PostgreSQL 16 验证过,Spring Boot 版本以 3.2.x 为主,但文末问题排查部分也会提到 Boot 2.x 的差异。
1. 为什么是 Spring Boot + MyBatis + PostgreSQL
先说结论:这套组合最适合“SQL 需要精细控制、数据表结构复杂、业务查询里既有 CRUD 又有一堆统计报表”的项目。如果你只是做一个简单的 CRUD Demo,用 JPA 更省事;但如果数据模型有一百多张表、查询条件经常变、DBA 还要求 SQL 能手工调优,MyBatis 的 XML 映射会给你最大的掌控力。
1.1 三个组件各自解决什么问题
Spring Boot 在这套组合里负责“组装”。自动配置把数据源、事务、Mapper 扫描、日志这些基础设施全部接好,你不需要写 ApplicationContext 的手动装配代码。尤其到了 Spring Boot 3,注解驱动和自动配置的边界比 Boot 2 更清晰,一个 starter 就能把 MyBatis 接进来。
MyBatis 负责“SQL 与对象的转换”。它不像 JPA 那样帮你生成查询,而是把你自己写的 SQL 原样发给数据库,再把结果集映射成对象。这带来两个直接好处:第一,SQL 不会被框架重新改写,线上慢查询可以直接拿到 explain 分析;第二,PostgreSQL 特有的语法,比如 JSONB 操作符、窗口函数、ON CONFLICT,MyBatis 都能原样执行,不会被 ORM 拦截。
PostgreSQL 负责“数据能力”。我这次迁移感受最深的是它的 JSONB、数组、数值精度和时区处理。业务里有个字段是扩展属性,内容不固定,MySQL 里我可能得建一张 EAV 表,或者用 TEXT 硬存 JSON 再在代码里解析;PostgreSQL 直接用 JSONB 列,配合查询条件甚至能在数据库层过滤,省掉不少 Java 代码。
1.2 相比 JPA 和 MyBatis-Plus,我为什么仍选原生 MyBatis
这个选择当时也被同事质疑过:MyBatis-Plus 不是更好用吗?确实,如果项目全是单表 CRUD、分页、逻辑删除,MyBatis-Plus 的 BaseMapper 能省一半工作量。但我们这套系统有大量多表关联和报表统计,MyBatis-Plus 的 LambdaQueryWrapper 写复杂查询反而比 XML 更绕。原生 MyBatis 的 XML 虽然啰嗦,但胜在“查询怎么写、结果怎么映射”完全透明,团队里任何人打开一个 mapper XML 就能看懂。
另外,PostgreSQL 的很多高级类型,MyBatis-Plus 的通用 TypeHandler 覆盖得并不全。原生 MyBatis 允许你针对单列指定 typeHandler,JSONB、数组这种特殊类型自己写处理器就行,不依赖框架内置支持。这也是我坚持用原生 MyBatis 的原因。
2. 环境准备:PostgreSQL 安装、服务启动与连接检查
很多人第一步就卡在 PostgreSQL 装不上、服务起不来。这部分我分别说 Windows、Linux,以及装完之后必须做的连接验证。
2.1 Windows 下安装与常见启动问题
Windows 上最省事的方式是下载 EDB 官方安装包。安装过程中有三个地方容易被忽略:一是安装目录不要带中文和空格;二是端口一般保持默认 5432,如果本机已装过旧版 PostgreSQL,端口冲突会直接导致服务启动失败;三是设置 postgres 超级用户密码时,别用太简单的纯数字,后面客户端连接、主从配置都会用到。
安装完成后,很多人打开“服务”面板发现 PostgreSQL 服务没启动。先不要直接点启动,去安装目录看日志,通常在C:\Program Files\PostgreSQL\16\data\pg_log下。我遇到过最典型的两个原因:
- 端口被占用:
netstat -ano | findstr 5432看一下监听进程,如果是其它程序占用,改 PostgreSQL 的postgresql.conf里port = 5433再启动。 - 数据目录权限不对:Windows 用户对
data目录没有完全控制权,右键目录属性,给当前用户授予完全控制权限。
启动后,用命令行验证一下:
psql -U postgres -h localhost -p 5432 -d postgres能进入 psql 提示符,说明服务正常。如果提示connection refused,优先检查服务是否在跑;如果提示password authentication failed,检查密码有没有记错,或者pg_hba.conf里 local 连接的认证方式是不是scram-sha-256。
2.2 Linux 下安装与 systemd 管理
Linux 分两类发行版。Debian/Ubuntu 直接:
sudo apt update sudo apt install postgresql postgresql-contrib装完系统会自动创建postgres用户和一个名为postgres的默认数据库。切到该用户再进 psql:
sudo -u postgres psqlCentOS/RHEL 系列:
sudo yum install postgresql-server postgresql-contrib sudo postgresql-setup initdb sudo systemctl enable --now postgresqlCentOS 上特别容易漏掉postgresql-setup initdb这一步,不做初始化的话,数据目录是空的,服务起了也会退出。初始化完成后再设置密码:
ALTER USER postgres WITH PASSWORD 'your_password';2.3 连接前必做的数据库和用户准备
实际项目不要直接用 postgres 超级用户连业务库,这是我在生产环境吃过亏的教训。建议为项目单独建用户和库:
CREATE USER app_user WITH PASSWORD 'app_pass_2024'; CREATE DATABASE asset_db OWNER app_user; GRANT ALL PRIVILEGES ON DATABASE asset_db TO app_user;把database的连接权限、schema的建表权限都收拢到 app_user 上。这样即使连接串泄露,也只是业务库权限,不会波及整个实例。
3. Spring Boot 工程初始化与核心配置
工程层面最核心的三件事:依赖选对版本、数据源配置写对、MyBatis 的全局配置和日志输出调通。很多项目起不来,看似是代码问题,其实都在配置阶段。
3.1 依赖版本怎么选:Spring Boot 3 与 Boot 2 的区别
如果你用 Spring Boot 3.x,Java 版本至少是 17,对应的 MyBatis 集成 starter 应该是mybatis-spring-boot-starter的 3.x 系列;如果项目还在 Boot 2.x,则用 2.x 系列。这个版本对应关系是 MyBatis 官方在维护的,混用会报各种奇怪的 NoClassDefFoundError。
以 Spring Boot 3.2.5 为例,pom 里关键依赖:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.3</version> <scope>runtime</scope> </dependency>PostgreSQL JDBC 驱动版本建议比数据库小版本新一点。比如数据库是 16.x,驱动用 42.7.x 完全没问题。驱动升级到 42.7 之后,sslmode、connectTimeout等参数的行为有变化,细节我放到排查部分讲。
3.2 application.yml 完整配置与逐行解释
这是我实际使用的配置模板:
spring: datasource: url: jdbc:postgresql://localhost:5432/asset_db username: app_user password: app_pass_2024 driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 sql: init: mode: never mybatis: mapper-locations: classpath:mapper/**/*.xml type-aliases-package: com.example.asset.entity configuration: map-underscore-to-camel-case: true jdbc-type-for-null: 'null' log-impl: org.apache.ibatis.logging.stdout.StdOutImpl几个容易被忽略的点:
driver-class-name在 Spring Boot 3 里其实可以不写,数据库驱动会通过url自动推断。但建议显式写,避免多个数据库驱动同时在 classpath 时选错。hikari.maximum-pool-size不要拍脑袋设成 50。PostgreSQL 每个连接都是一个后端进程,连接数越大越糟。一般业务系统 10~20 足够。jdbc-type-for-null: 'null'是 MyBatis 的经典设置。某些 PostgreSQL 的 SQL 语句里,如果 Java 参数为 null,MyBatis 默认会传JdbcType.OTHER,PostgreSQL 驱动可能报无法推断类型,设置成NULL可避免这类问题。log-impl改为 StdOutImpl 是为了开发时看 SQL;生产环境建议删掉这行,改用 Slf4j 配合日志框架,避免 SQL 打满控制台。
3.3 启动类与 Mapper 扫描
启动类上必须加@MapperScan,或者给每个 Mapper 接口加@Mapper。我推荐前者,统一扫描包路径,新增 Mapper 接口时不用重复加注解:
@SpringBootApplication @MapperScan("com.example.asset.mapper") public class AssetApplication { public static void main(String[] args) { SpringApplication.run(AssetApplication.class, args); } }这一步如果漏掉,最常见的报错是Field assetMapper in ... required a bean of type ... that could not be found。不是 MyBatis 没生效,而是 Mapper 接口没有被扫描注册成 Bean。
4. 表结构设计与实体层映射
PostgreSQL 建表和 MySQL 建表有很不一样的习惯。建表时如果只想着“能用”,后面写 XML 映射会非常别扭。我建议从一开始就把“实体字段”和“表字段”的对应关系理顺。
4.1 PostgreSQL 建表时的几个关键习惯
以资产表为例:
CREATE TABLE assets ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, asset_code VARCHAR(32) NOT NULL, asset_name VARCHAR(128) NOT NULL, category VARCHAR(64), purchase_price NUMERIC(12,2), extra_info JSONB NOT NULL DEFAULT '{}'::jsonb, status SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_assets_status ON assets(status); CREATE INDEX idx_assets_category ON assets(category);两个点我着重说明一下。
第一,主键不要用BIGSERIAL,用GENERATED ALWAYS AS IDENTITY是更现代的标准写法。BIGSERIAL本质是序列加默认值,它允许显式插入 id,容易在数据迁移时产生序列不同步问题;identity 列更规范,但在 MyBatis 中要配合useGeneratedKeys处理返回主键。
第二,时间字段统一用TIMESTAMPTZ,不要用TIMESTAMP WITHOUT TIME ZONE。业务系统迟早会遇到跨时区协作,TIMESTAMPTZ存储的是带时区的时间点,JDBC 驱动读出来后能正确对应LocalDateTime,不会出现“入库早一小时、显示晚一小时”的灵异事件。
4.2 实体类设计:LocalDateTime 与 JSONB 的处理
实体类我用的是基础 POJO,字段与表字段用驼峰命名对应:
public class Asset { private Long id; private String assetCode; private String assetName; private String category; private BigDecimal purchasePrice; private Map<String, Object> extraInfo; private Integer status; private LocalDateTime createdAt; private LocalDateTime updatedAt; }extraInfo对应 JSONB 列,Java 类型直接用Map<String, Object>。这里必须配合自定义 TypeHandler,否则 MyBatis 会拿到PGobject,类型转换直接报错。TypeHandler 的工作流程其实很简单:写库时 Java 对象 -> JSON 字符串 ->PGobject-> PostgreSQL JSONB 列;读库时反过来,JSONB 列 -> 字符串 -> Java 对象。我自己实现的JsonbTypeHandler在后面映射章节给出完整代码。
4.3 Mapper 接口与 XML 文件组织方式
Mapper 接口只写方法签名和参数注解:
public interface AssetMapper { Asset findById(@Param("id") Long id); List<Asset> search(@Param("category") String category, @Param("status") Integer status, @Param("keyword") String keyword); int insert(Asset asset); int batchInsert(@Param("list") List<Asset> assets); int update(Asset asset); int deleteById(@Param("id") Long id); }XML 文件放在src/main/resources/mapper目录下,文件名与 Mapper 接口名保持一致,例如AssetMapper.xml。这样做的好处是 IDE 插件(比如 MyBatisX)可以自动跳转,团队协作时找文件也快。
5. MyBatis XML 映射与动态 SQL 实战
这一章是整篇的干货核心。我把日常写 SQL 时最高频的几个场景拆开讲:resultMap、动态查询、批量插入、分页。每个场景不仅给出可运行的代码,还会说明为什么这么写。
5.1 resultMap 与自定义 TypeHandler 的完整用法
XML 里先定义一个 resultMap,把数据库列名和 Java 属性映射起来,重点看extraInfo这一列:
<resultMap id="assetMap" type="com.example.asset.entity.Asset"> <id property="id" column="id"/> <result property="assetCode" column="asset_code"/> <result property="assetName" column="asset_name"/> <result property="category" column="category"/> <result property="purchasePrice" column="purchase_price"/> <result property="extraInfo" column="extra_info" typeHandler="com.example.asset.handler.JsonbTypeHandler"/> <result property="status" column="status"/> <result property="createdAt" column="created_at"/> <result property="updatedAt" column="updated_at"/> </resultMap>JsonbTypeHandler完整代码如下:
package com.example.asset.handler; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.postgresql.util.PGobject; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Map; 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 { PGobject jsonObject = new PGobject(); jsonObject.setType("jsonb"); jsonObject.setValue(MAPPER.writeValueAsString(parameter)); ps.setObject(i, jsonObject); } catch (Exception e) { throw new SQLException("Cannot convert Map to JSONB", e); } } @Override public Map<String, Object> getNullableResult(ResultSet rs, String columnName) throws SQLException { return readValue(rs.getString(columnName)); } @Override public Map<String, Object> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return readValue(rs.getString(columnIndex)); } @Override public Map<String, Object> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return readValue(cs.getString(columnIndex)); } private Map<String, Object> readValue(String json) { if (json == null) { return null; } try { return MAPPER.readValue(json, Map.class); } catch (Exception e) { throw new RuntimeException("Cannot parse JSONB to Map", e); } } }这个处理器的关键点有两个:写库时一定要setType("jsonb"),否则数据库可能把它当成json类型,部分操作会有差异;读库时直接从 ResultSet 取字符串再解析,不要尝试让 MyBatis 自动转换PGobject。
5.2 动态 SQL:where、set、foreach 的实战写法
PostgreSQL 的查询条件经常是“可选 + 组合”,用<where>标签最合适。<where>会自动去掉子句开头的AND或OR,比在每个条件里手写WHERE 1=1干净得多。
<select id="search" resultMap="assetMap"> SELECT * FROM assets <where> <if test="category != null and category != ''"> AND category = #{category} </if> <if test="status != null"> AND status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (asset_code ILIKE CONCAT('%', #{keyword}, '%') OR asset_name ILIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY created_at DESC </select>这里我用了ILIKE而不是LIKE,是 PostgreSQL 特有的不区分大小写模糊匹配。如果项目需要走索引,这种写法在数据量大的时候可能要改造成pg_trgm的 GIN 索引,但小规模业务这样写已经够了。
更新语句用<set>标签,同样能自动处理尾逗号:
<update id="update"> UPDATE assets <set> <if test="assetName != null">asset_name = #{assetName},</if> <if test="category != null">category = #{category},</if> <if test="status != null">status = #{status},</if> updated_at = NOW() </set> WHERE id = #{id} </update>5.3 批量插入:PostgreSQL 的参数上限与分批策略
批量插入我建议直接拼单条 INSERT 的多值语句,而不是用 JDBC 的executeBatch()循环。PostgreSQL 对多值 INSERT 的解析效率很高,而且能减少客户端与数据库的往返次数。
<insert id="batchInsert"> INSERT INTO assets (asset_code, asset_name, category, purchase_price, extra_info, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.assetCode}, #{item.assetName}, #{item.category}, #{item.purchasePrice}, #{item.extraInfo, typeHandler=com.example.asset.handler.JsonbTypeHandler}, #{item.status}) </foreach> </insert>注意 PostgreSQL 对单条语句的占位符数量有上限,虽然数值很大,但实际单批次行数不要盲目拉高。我通常控制在 500 到 1000 行一批。比如每行 6 个字段,1000 行就是 6000 个参数,很安全;如果一次塞 5000 行,参数数量逼近上限,可能报PreparedStatement参数过多或数据库内存飙升。写入后再用一个for循环分页调batchInsert,性能实测比 MyBatis 默认的ExecutorType.BATCH更稳定。
5.4 分页查询:用分页插件还是手工 limit/offset
如果你的业务分页条件不复杂,我建议先用 PostgreSQL 原生的LIMIT/OFFSET写一个公共的分页查询,不要一上来就引 PageHelper。理由很简单:PageHelper 的物理分页逻辑会改写你的 SQL,遇到WITH子句、窗口函数这种复杂查询时,偶尔会生成错误的 count 语句。
简单分页长这样:
<select id="searchPage" resultMap="assetMap"> SELECT * FROM assets <where> <if test="asset.status != null"> AND status = #{asset.status} </if> </where> ORDER BY created_at DESC LIMIT #{pageSize} OFFSET #{offset} </select>配套的 count 查询单独写一个<select>,返回long。这样可以保证 count 和 page 的查询条件完全一致,也方便在 count 里删除不必要的ORDER BY。
6. PostgreSQL 专属特性在 MyBatis 中的落地
这一章是很多从 MySQL 转过来的团队最想看的。PostgreSQL 的高级类型和 SQL 特性用好了,确实能简化应用层代码。但如果 MyBatis 映射没配对,反而会比 MySQL 更痛苦。
6.1 JSONB 的读写条件过滤与常见坑
除了用 TypeHandler 完成映射,有时还需要在 SQL 层过滤 JSONB 字段。比如按扩展属性里的brand过滤:
<select id="searchByBrand" resultMap="assetMap"> SELECT * FROM assets WHERE extra_info ->> 'brand' = #{brand} </select>->>返回的是文本,->返回的是 JSON 值。PostgreSQL 的 JSONB 查询很灵活,但有一个坑是:MyBatis 的#{}参数在 SQL 里是?占位符,而 JSONB 的空值判断如果写成extra_info -> 'brand' = ?,参数类型很难推断。最好用->>取出文本再和字符串比较,或者显式加::text转换。
对 JSONB 字段建索引时也别用普通 B-tree,应该用 GIN 索引,比如:
CREATE INDEX idx_assets_extra ON assets USING GIN (extra_info);这一个索引能加速很多 JSONB 包含查询。
6.2 返回主键与 UPSERT:ON CONFLICT 的实现
新增记录时,如果不想先查一遍再决定 insert 还是 update,PostgreSQL 的ON CONFLICT是最好用的。配合 MyBatis 的useGeneratedKeys可以拿到新生成的 id:
<insert id="insertWithUpsert" parameterType="com.example.asset.entity.Asset" useGeneratedKeys="true" keyProperty="id"> INSERT INTO assets (asset_code, asset_name, category, purchase_price, extra_info, status) VALUES (#{assetCode}, #{assetName}, #{category}, #{purchasePrice}, #{extraInfo, typeHandler=com.example.asset.handler.JsonbTypeHandler}, #{status}) ON CONFLICT (asset_code) DO UPDATE SET asset_name = EXCLUDED.asset_name, category = EXCLUDED.category, purchase_price = EXCLUDED.purchase_price, extra_info = EXCLUDED.extra_info, status = EXCLUDED.status, updated_at = NOW() </insert>这段 SQL 里有个小细节:asset_code是唯一键,冲突时用EXCLUDED引用插入但被拒绝的新值。这种方式天然适合“相同业务编码只保留一条最新记录”的场景,比先 select 再 insert 的竞态控制可靠得多。
6.3 窗口函数与分组取最新一条
报表系统里经常要按分类分组取每条分组最新的一条记录。在 MySQL 8 之前,你得写子查询加 join;PostgreSQL 的窗口函数就很直接:
<select id="findLatestByCategory" resultMap="assetMap"> SELECT id, asset_code, asset_name, category, purchase_price, extra_info, status, created_at, updated_at FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY created_at DESC) AS rn FROM assets ) t WHERE rn = 1 </select>注意这种 SQL 里,extra_info仍要用resultMap映射,否则 MyBatis 拿到的是PGobject。窗口函数本身不改变列名和类型,所以唯一要多做的就是保持 resultMap 正确。
记住一个原则:PostgreSQL 擅长的事情尽量在 SQL 里做,不要全部拉回 Java 再 stream 处理。因为数据库端基于索引的排序和聚合,通常比 JVM 内存里的全量计算高效得多。
7. 事务、缓存与连接管理:防止线上问题
代码能跑只是第一步,真正上线后出问题的大多是事务边界、缓存一致性和连接池管理。这几个点也是 MyBatis 面试题里的高频区。
7.1 @Transactional 的正确使用范围
Spring 声明式事务最常见的问题是“事务被一个自己调自己的方法绕过”。比如:
@Service public class AssetService { public void process(Asset asset) { updateAsset(asset); } @Transactional(rollbackFor = Exception.class) public void updateAsset(Asset asset) { assetMapper.update(asset); } }process方法通过this.updateAsset调用,事务注解根本不会生效,因为 Spring 事务是通过代理对象实现的,类内部调用走的是原始对象。解决办法是把事务方法放到另一个 Service,或者注入自身代理:
@Transactional(rollbackFor = Exception.class) public void process(Asset asset) { assetMapper.update(asset); doOtherThing(); // 如果此处抛异常,上面 update 会回滚 }另外,rollbackFor = Exception.class在 Spring Boot 中默认已经对所有 RuntimeException 生效,但受检异常默认不回滚。如果你有自定义的受检业务异常,必须显式加rollbackFor,否则数据就会半提交。
7.2 MyBatis 一级缓存与二级缓存:面试考点与工程取舍
MyBatis 一级缓存是 SqlSession 级别的。在 Spring Boot 集成下,SqlSession 的生命周期通常绑定到一次数据库操作,甚至一个事务方法,所以一级缓存能缓存同一个 SqlSession 内的相同查询。实际作用没有想象中大,因为大多数业务每个 Service 方法都有自己的 SqlSession。
二级缓存是 Mapper namespace 级别的,跨 SqlSession 共享。开启很简单,在 XML 里加一行:
<cache/>但实话实说,我在业务系统里基本不开启二级缓存。原因有三个:第一,多表更新时非常容易命中过期缓存;第二,分布式环境下默认缓存只在单机内有效,一致性很难保证;第三,PostgreSQL 本身的查询性能已经不差,很多查询完全可以直接走数据库缓存。如果真有高频只读数据,我宁可引入独立的缓存组件,而不是把 MyBatis 二级缓存作为一个长期方案。
7.3 HikariCP 连接池参数调整与连接泄漏
Spring Boot 默认连接池是 HikariCP,它对单条 SQL 的执行效率很好,但连接数配置不合理会有两种典型现象:
- 连接池被打满,报
Connection is not available, request timed out。 - 数据库端出现大量
idle in transaction连接,物理连接数暴增。
前者通常是因为业务并发高而maximum-pool-size太小;后者更常见,是代码里的事务没有正确提交或回滚,连接一直被事务占着。排查方法很简单,在 PostgreSQL 里执行:
SELECT pid, state, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state = 'idle in transaction';看到长时间idle in transaction的连接,就去对应代码里查@Transactional方法是不是抛了异常没回滚,或者有没有在事务里做了耗时很长的外部调用。
高并发场景下,Hikari 的max-lifetime建议小于数据库端的连接超时时间。比如 PostgreSQL 默认没有强制 kill 空闲连接,但中间代理层可能限制连接最大存活时间,所以要把max-lifetime调成比代理超时短一点,避免“连接被服务端关闭后客户端还在复用”的报错。
8. 高频问题排查速查表
这部分是我压箱底的排查记录。很多问题看起来五花八门,归根结底就那么几类。我整理成表格,方便你遇到问题时直接对照。
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
No suitable driver found for jdbc:postgresql://... | 缺少 PostgreSQL JDBC 依赖 | 确认 pom 里引入org.postgresql:postgresql且 scope 不是provided |
relation "assets" does not exist | 表名大小写问题或 schema 不对 | 检查表实际名称,加 schema 前缀如public.assets |
column "assetcode" does not exist | 下划线转驼峰没生效 | 确认map-underscore-to-camel-case: true;或显式在 resultMap 配 column |
Cannot infer type for parameter | Java 参数为 null 时 JdbcType 推断失败 | 在 MyBatis 全局配置设置jdbc-type-for-null: 'null' |
connection refused | PostgreSQL 服务没启动、端口不对、防火墙拦截 | 服务先启动;测试psql -h localhost -p 5432 -U postgres |
PSQLException: FATAL: password authentication failed | 密码错误或 pg_hba.conf 认证策略不对 | 检查连接用户名密码,确认pg_hba.conf中 host 认证方式 |
class java.lang.String cannot be cast to ... PGobject | JSONB 列没有使用自定义 TypeHandler | resultMap 和 insert 参数里都加JsonbTypeHandler |
arguments cannot be null | 批量插入集合为空或元素属性为 null | foreach 前先判断集合非空,必填字段加校验 |
Spring Boot 启动报 Invalid value type for property 'sslMode' | 驱动版本与连接串参数不匹配 | 检查连接串中的sslmode参数,确认驱动是 42.x 新版本 |
8.1 时区问题:为什么查出来老是差 8 小时
PG 的TIMESTAMPTZ存储的是 UTC 时间,JDBC 读出来转成LocalDateTime时,是否带时区转换取决于驱动参数。最简单的规避方式是连接串里明确指定时区:
url: jdbc:postgresql://localhost:5432/asset_db?serverTimezone=Asia/Shanghai准确说 PostgreSQL 的 JDBC 参数名不是serverTimezone,那套参数是 MySQL 的习惯。PostgreSQL 中更常见的是用TimeZone参数,但 JDBC 读取行为主要取决于 JVM 默认时区和 PG 服务端设置。我的做法是:数据库统一用timestamptz,Java 实体全部用LocalDateTime,JVM 启动参数加-Duser.timezone=Asia/Shanghai,这样基本不会出现偏差。
8.2 MyBatis 打印 SQL 与日志脱敏
开发时想看到真实 SQL 和参数,把log-impl设为 StdOutImpl 即可。但这样会把完整的参数都打出来,生产环境不适合。如果只想看 mapper 里某个慢查询,可以把日志级别调整到只对某个 mapper 包生效:
logging: level: com.example.asset.mapper: debug同时注意,日志里如果打印了 SQL 参数,要确保purchase_price、extra_info这类字段不是敏感数据。PostgreSQL 的 SQL 日志本身也有log_statement参数,默认none,线上别随便开成ddl或mod,否则大查询日志量会爆炸。
8.3 Spring Boot 3 与旧版 MyBatis starter 的兼容性问题
如果你从 Boot 2 升级 Boot 3,一定要同步升级mybatis-spring-boot-starter到 3.x。很多升级编译不过的报错都出在org.mybatis.spring.SqlSessionTemplate或者org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration的变更上。Boot 3 里@ConfigurationProperties、DataSource 自动配置的类名和位置都变了,靠百度旧代码硬调,越调越乱。
升级后的配置项大多是兼容的,mybatis.mapper-locations、mybatis.configuration.map-underscore-to-camel-case这些还是同样写法。真正要检查的是有没有在代码里直接new SqlSessionFactoryBean手动配置数据源,如果有,那就要适配 Boot 3 的DataSourceBuilder。
8.4 批量操作遇到PreparedStatement相关报错
Batch update returned unexpected row count是我见过的经典报错。这个报错在 PostgreSQL 上往往不是 SQL 错,而是 MyBatis 的ExecutorType.BATCH和表里某些触发器、外键约束的预期行数不一致,导致 JDBC 驱动认为执行失败。遇到这种别死磕 XML,换个思路,回到多值 INSERT 的写法,问题通常会消失。这也是我在批量插入一节坚持用foreach拼多值语句的原因之一。
写在最后的一点经验
这套 Spring Boot + MyBatis + PostgreSQL 的组合,真正跑顺之后,给我最大的感受是“稳定且透明”。MyBatis 让我对每一条 SQL 都有掌控,PostgreSQL 又把这些 SQL 的潜力发挥到最大,Spring Boot 则负责把这些粘合起来。
如果只给你一个建议,我会说:从项目第一天就按 PostgreSQL 的习惯来设计表结构和 SQL,不要带着 MySQL 的旧习惯硬套。见过太多团队迁库之后,SQL 还是老写法,JSONB 不用、数组不用、窗口函数也不用,结果迁完性能反而更差。先花半天把 PostgreSQL 的常用运算符和类型体系过一遍,后面能省你数倍的时间。
最后再分享一个小技巧:开发环境连接 PostgreSQL 时,尽量用 DBeaver 或 Navicat 这类图形工具看执行计划,但线上问题排查一定学会用pg_stat_statements和EXPLAIN ANALYZE。MyBatis 打出来的 SQL 复制进 psql 里执行,再EXPLAIN ANALYZE一遍,你会发现大多数慢查询问题根本不在框架层,而在 SQL 本身。把排查精力放到 SQL 上,比折腾框架配置有意义得多。