做后端这几年,Spring Boot + MyBatis + PostgreSQL 这套组合我碰到的频率越来越高。一部分是接手老项目时它已经跑了好几年,另一部分是新项目在评审阶段就被团队定为默认技术栈——Spring Boot 负责应用骨架,MyBatis 管 SQL,PostgreSQL 做数据底座。有人觉得 MyBatis 是“过气”的 ORM,有人觉得 PostgreSQL 只是“高级一点的 MySQL”,但真要把三件事串起来落地,中间有不少细节值得认真过一遍。这篇内容就是给打算用这套组合、或者已经在用但经常被诡异问题卡住的人准备的,我不会只贴“能跑通的代码片段”,而是把每段配置、每个 SQL、每个报错背后的原因都讲透,尽量让你读完可以直接在自己的项目里复现,也能解释清楚为什么这么写。
文章会用一个“内容平台的文章标签系统”作为贯穿案例,从零建库建表,到写 Mapper、XML,再到 PostgreSQL 特有类型和上线前容易踩的坑,按正常项目的推进顺序走一遍。
1. 先交代清楚:为什么这套组合到现在还值得写
1.1 我的选型判断:什么项目适合这套组合
每隔一段时间就有人争论 JPA 和 MyBatis 谁更好。我的观点很直白:如果你的业务里有大量复杂查询、报表统计、多表联查,并且 SQL 需要由人精确控制,那 MyBatis 比 JPA 合适得多。JPA 适合数据模型规整、对查询性能要求不极致、希望少写 CRUD 的管理系统;但内容平台、订单系统、统计后台这类场景,SQL 往往非常长且经常要调优,MyBatis 的 XML 可以把 SQL 和 Java 代码隔离,DBA 也能直接拿走 SQL 去分析,协作成本低不少。
PostgreSQL 这边,除了大家常说的“开源界的 heavyweight”,一个很重要的吸引力是它同时具备了关系型数据库的严谨和部分 NoSQL 的能力。比如可以直接在表里用 JSONB 存非结构化数据,用数组存轻量级集合,用 tsvector 做全文检索,这样很多场景不需要额外引入 Elasticsearch 或 MongoDB 就能撑住初期业务。而且它对 SQL 标准的支持非常彻底,窗口函数、WITH 语法、约束机制都做得比老牌 MySQL 细致,数据完整性好控制。Spring Boot 提供了自动配置能力,生产环境跑这套组合是完全可靠的。
1.2 版本组合怎么定:不要无脑追新
版本坑我踩过一次之后,现在每次写整合都会先把版本组合钉死。Spring Boot 2.7.x 是目前很多企业存量项目的主流版本,对应的 mybatis-spring-boot-starter 用 2.3.x 这一代;如果项目直接上 Spring Boot 3.x,则必须把 mybatis-spring-boot-starter 换成 3.x 版本,因为它内部依赖的 mybatis-spring 需要适配 Spring 6 和 JDK 17。我下面的示例基于 Spring Boot 2.7.18 + mybatis-spring-boot-starter 2.3.2 + PostgreSQL JDBC 42.x,这也是我建议大多数业务项目使用的组合。
为方便对照,我把版本选择的逻辑整理如下:
| 项目情况 | 推荐版本组合 | 主要考虑 |
|---|---|---|
| 存量业务,JDK 8/11 | Spring Boot 2.7 + mybatis-spring-boot-starter 2.3 | 升级成本低,依赖生态成熟 |
| 全新项目,JDK 17/21 | Spring Boot 3.x + mybatis-spring-boot-starter 3.x | 拥抱新特性,注意驱动和配置差异 |
| 特殊合规改造 | 按公司内部基线锁定 | 用不到新特性就不要乱动版本 |
PostgreSQL 驱动不需要显式写版本,Spring Boot 的 dependency management 已经帮你管理好了。只有当你有特殊需求,比如要修复某个驱动 bug,才需要手动覆盖。版本选型是第一道关,选错后面会出现一堆“看起来跟代码无关”的报错,排查成本极高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初始化工程:用一个标签表把最小闭环跑通
2.1 引入依赖时需要避开的坑
最基础的一步是建 Spring Boot 工程,引入 web、mybatis starter 和 PostgreSQL 驱动。pom.xml 里长这样:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
这里有两个容易被忽略的点。第一,postgresql 依赖 scope 设置为 runtime 没问题,但如果你在单元测试里想手动加载 Class.forName("org.postgresql.Driver"),那就要把 scope 改成 compile 或用 test 作用域另引。第二,不要同时引入 mybatis-spring-boot-starter 和 mybatis-plus-boot-starter,这两套在 mapper 扫描、SqlSessionFactory 初始化时会互相打架。如果你用了 MyBatis-Plus 的代码生成器生成了一套 Mapper,又手写了一套原生 MyBatis,运行时会因为 namespace 冲突或者 Bean 重复定义报出一堆莫名其妙的错误。
2.2 application.yml:每个参数都值得看懂
数据源配置是整合里的头号难点,很多人从 MySQL 迁移过来后只改了驱动类名和 URL,结果各种服务启动失败或数据查不出来。我先给一份我常用的最小配置:
yaml复制spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/blog?currentSchema=public&stringtype=unspecified
username: postgres
password: postgres
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1800000
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.blog.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
逐段说明一下。driver-class-name 不能从 MySQL 的 com.mysql.cj.jdbc.Driver 平替成 postgresql 就完事,PostgreSQL 的驱动类名是 org.postgresql.Driver,拼错后启动时会说找不到 Driver。然后是 URL,currentSchema=public 是 PostgreSQL 特有参数,它的作用是切换 schema 搜索路径。如果你不在 URL 里指定当前 schema,后面每张表都要带 schema 前缀,比如 public.article_tag,否则 MyBatis 生成的 SQL 会去找默认 schema 里的表。第三是 stringtype=unspecified,这个是针对 PostgreSQL 比较隐蔽的参数。PostgreSQL 对类型的隐式转换非常严格,尤其 JSONB 字段,后面章节会详细讲,这里先记住:加上它之后,JDBC 驱动在设置字符串参数时会尽量让数据库自行推断类型,很多因为类型不匹配导致的报错都能缓解。
type-aliases-package 是用来给实体类起别名的,配置后 XML 的 resultType 可以直接写类名小写或大写,不用写全限定名。map-underscore-to-camel-case 设置为 true 后,数据库列名 tag_name 能自动映射到 Java 字段 tagName,省去写大量 resultMap。不过一旦你在 XML 里显式定义了 resultMap,这个驼峰映射就不会作用于该 resultMap 的配置,你需要自己在 resultMap 中写清楚。
2.3 建表、实体、Mapper、XML:跑通第一句查询
表结构我设计得稍微复杂一点,可以把后面的高级特性都带出来:
sql复制CREATE TABLE article_tag (
id BIGSERIAL PRIMARY KEY,
tag_name VARCHAR(64) NOT NULL UNIQUE,
article_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
related_tags BIGINT[] NOT NULL DEFAULT ARRAY[]::bigint[],
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
对应的实体类:
java复制public class ArticleTag {
private Long id;
private String tagName;
private Integer articleCount;
private String metadata;
private Long[] relatedTags;
private OffsetDateTime createdAt;
// 省略 getter/setter
}
Mapper 接口:
java复制@Mapper
public interface ArticleTagMapper {
ArticleTag findById(@Param("id") Long id);
List<ArticleTag> findByTagName(@Param("tagName") String tagName);
}
XML 文件(放在 resources/mapper/ArticleTagMapper.xml):
xml复制<mapper namespace="com.example.blog.mapper.ArticleTagMapper">
<resultMap id="ArticleTagMap" type="ArticleTag">
<id property="id" column="id"/>
<result property="tagName" column="tag_name"/>
<result property="articleCount" column="article_count"/>
<result property="metadata" column="metadata" typeHandler="com.example.blog.handler.JsonbTypeHandler"/>
<result property="relatedTags" column="related_tags" typeHandler="com.example.blog.handler.LongArrayTypeHandler"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findById" resultMap="ArticleTagMap">
SELECT id, tag_name, article_count, metadata, related_tags, created_at
FROM article_tag
WHERE id = #{id}
</select>
</mapper>
这里 BIGSERIAL 是 PostgreSQL 的自增主键写法,相当于 MySQL 的 AUTO_INCREMENT。注意我在 resultMap 里引用了两个自定义 TypeHandler,JsonbTypeHandler 和 LongArrayTypeHandler,这两个类还没写,所以项目现在启动会报错。如果你暂时不想写它们,也可以先去掉 metadata 和 related_tags 两个字段,等第 4 节再说,但既然我们要一次搭好最小闭环,不妨把表建全。第一个 TypeHandler 是为了读写 JSONB 字段,第二个是为了映射 PostgreSQL 的 bigint 数组,它们各自实现细节在后面的高级特性部分再展开。当前阶段只要保证 XML 语法正确、namespace 和 Mapper 接口全限定名一致,项目启动后访问 findById 就能拿到一条标签记录了。
2.4 启动类和 Mapper 扫描:少一个注解就白忙
如果你没有在启动类加 @MapperScan,那 @Mapper 注解就要保证每个 Mapper 接口上都标了。我习惯在启动类上统一扫描:
java复制@SpringBootApplication
@MapperScan("com.example.blog.mapper")
public class BlogApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApplication.class, args);
}
}
很多新手只写了一个空的 @SpringBootApplication 就在 Service 里注入 Mapper,启动时报 “No qualifying bean of type 'ArticleTagMapper'”。这个报错和 SQL 无关,就是 Spring 容器没有扫描到 Mapper 接口。另一个常见的低级错误是 XML 文件没有编译进 classpath,Maven 项目默认只把 resources 目录下的资源打包,如果你把 XML 随手放到了 src/main/java 下的 mapper 包里,没有额外配置 <resources>,运行时会一直提示 Invalid bound statement (not found)。排查这类问题,最简单的办法是直接打开项目 target/classes/mapper 目录,看看 XML 在不在。
3. 核心 CRUD 从模仿到理解:自增、更新、批量与返回值
3.1 插入数据并返回自增主键的三种写法
第一道容易绊倒人的坎就是插入后要拿主键。PostgreSQL 和 MySQL 不一样,JDBC 驱动在 INSERT 后返回生成键的方式有差异。第一种写法是用 MyBatis 的 useGeneratedKeys:
xml复制<insert id="insertTag" parameterType="ArticleTag" useGeneratedKeys="true" keyProperty="id">
INSERT INTO article_tag (tag_name, article_count)
VALUES (#{tagName}, #{articleCount})
</insert>
只要 id 字段确实是由数据库序列生成的,这段代码就能把新生成的主键反填到入参对象的 id 字段里。PostgreSQL 的 JDBC 驱动对 RETURN_GENERATED_KEYS 的支持在 42.x 版本已经非常稳定,日常使用没有毛病。
第二种是传统 <selectKey> 写法,跟驱动对生成键的支持无关,而是在插入之后单独执行一条“查当前序列值”的 SQL:
xml复制<insert id="insertTag">
INSERT INTO article_tag (tag_name, article_count)
VALUES (#{tagName}, #{articleCount})
<selectKey keyProperty="id" resultType="java.lang.Long" order="AFTER">
SELECT currval(pg_get_serial_sequence('article_tag', 'id'))
</selectKey>
</insert>
如果你用 LASTVAL() 会有一个隐患:如果同一个会话里先插入了 A 表,又插入了 article_tag,此时 currval 是 A 表的序列最后值,取回来就错了。currval(pg_get_serial_sequence('article_tag', 'id')) 会把表名和列名带进去,锁定我们要拿的序列,准确性更高。第三种写法是直接在 INSERT 语句末尾加 RETURNING id,效果等同于把新主键作为查询结果返回。MyBatis 没有直接从 INSERT 结果集提取主键的原生能力,所以想要用 RETURNING 就得在一个 <select> 中执行,但函数返回类型定义比较绕,我一般不用它,有一个小技巧是借助 @Options 和 statement 的 useGeneratedKeys,在参数对象中生成,更加稳定。
如果你在建表时用的是 GENERATED BY DEFAULT AS IDENTITY 或 GENERATED ALWAYS AS IDENTITY 这类 SQL 标准语法,useGeneratedKeys 也能正常工作。只要记住一点:主键生成逻辑放在数据库序列上,代码里不要给 id 赋值。
3.2 动态 SQL:为什么要用 而不是直接写 SET
更新操作对动态 SQL 的要求往往比插入高,因为常见的做法是前端只传了哪些字段,后端就更新哪些字段,没传的保持原值。错误写法是像下面这样在 XML 里直接写死 SQL:
xml复制UPDATE article_tag
SET tag_name = #{tagName}, article_count = #{articleCount}
WHERE id = #{id}
一旦前端传的 tagName 为 null,这把 null 写进数据库,覆盖了原值。正确的做法是用 <set> 标签,让 MyBatis 自动拼出需要更新的列:
xml复制<update id="updateTag" parameterType="ArticleTag">
UPDATE article_tag
<set>
<if test="tagName != null">tag_name = #{tagName},</if>
<if test="articleCount != null">article_count = #{articleCount},</if>
<if test="metadata != null">metadata = #{metadata, typeHandler=com.example.blog.handler.JsonbTypeHandler},</if>
</set>
WHERE id = #{id}
</update>
<set> 会自动去掉最后一个逗号,避免 SQL 因为尾巴上的逗号直接报语法错误。如果你手写 SET 加 <where> 的方式,很容易在 only 拼接一个字段时留下逗号,到时候排查起来特别费劲。这也体现了 MyBatis XML 的价值:它不只是把 SQL 字符串拼接得更整洁,而是帮你处理了常见的语法边界。
3.3 批量插入/批量更新:foreach 的正确姿势与一个隐藏问题
批量操作最常见的写法是 <foreach>:
xml复制<insert id="batchInsert">
INSERT INTO article_tag (tag_name, article_count, metadata)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.tagName}, #{item.articleCount}, #{item.metadata, typeHandler=com.example.blog.handler.JsonbTypeHandler})
</foreach>
</insert>
这条 SQL 能跑,但当批次很大(几百上千条)时会因为 SQL 过长导致数据库侧解析压力较大。PostgreSQL 对单条 SQL 的 param 数量虽然没有 MySQL 那么严格的 65535 限制,但过长的 SQL 同样会拖慢网络和解析。一个比较稳的做法是把一次批量数量控制在 200~500 之间,Service 层循环分片提交。另外批量插入时如果列表里有重复的 tagName 且表上有 UNIQUE 约束,会直接抛违反唯一约束异常,这时可选择在 SQL 末尾加 ON CONFLICT DO NOTHING,把“插入全部失败”变成“跳过冲突行”。这个特性等第 4 节专门展开。
批量更新时尤其要注意:PostgreSQL 不支持在 UPDATE 的 SET 子句里直接用 VALUES 做多行更新,不能套用 MySQL 的 CASE WHEN 方案太长的写法。多行更新一般只能循环 UPDATE,除非你能把更新逻辑转换为 UPSERT,也就是 INSERT ... ON CONFLICT DO UPDATE,这反而更贴合 PostgreSQL 的推荐用法。所以如果你的需求是“数据存在就更新、不存在就插入”,不要犹豫,直接走 UPSERT,代码更短,效果更可靠。
3.4 删除操作与返回行数
删除本身不难,但容易忽略返回值。比如要彻底删除标签,并且希望知道删除了几行:
java复制int deleted = articleTagMapper.deleteById(100L);
if (deleted == 0) {
throw new BusinessException("记录不存在");
}
MyBatis 会把执行 UPDATE/DELETE 影响的行数返回给调用方,前提是 Mapper 接口方法的返回值类型写成 int 或 long。如果方法写成了 void,MyBatis 虽然也会执行成功但不会把行数带回。有些人在这里喜欢用数据库的 RETURNING 拿到被删除行的完整数据,PostgreSQL 是支持的,但 MyBatis 处理 DELETE 语句加 RETURNING 需要把它改成 select 语句标签,否则拿不到返回集,别踩这个坑。
4. 打开 PostgreSQL 的正确姿势:JSONB、数组与 UPSERT
4.1 JSONB 字段的 TypeHandler 为什么不能省
如果你在业务里要存动态属性,比如标签的颜色、图标、所属分类,用关系模型建列会越建越多,用 JSONB 则灵活得多。但 PostgreSQL 对 JSONB 的操作让我第一次整合时栽了个跟头:用普通 String 直接设置 PreparedStatement 参数,插入时报了 column "metadata" is of type jsonb but expression is of type character varying。原因很简单,JDBC 驱动不知道你把 String 类型参数当作 JSONB 类型处理。
解决办法有两个。一是连接串加 stringtype=unspecified,这样驱动不再强制声明参数为 varchar,而是让 PostgreSQL 服务器根据目标列类型去推断,能解决大部分 JSONB 字符串插入问题。但这个参数是一把双刃剑,它会影响所有字符串参数的隐式转换行为,如果遇到其他列类型推断异常,排查起来不好定位。
我更推荐第二种:写一个 TypeHandler,在写入时显式使用 PGobject 设置 jsonb 类型:
java复制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;
public class JsonbTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
PGobject jsonObject = new PGobject();
jsonObject.setType("jsonb");
jsonObject.setValue(parameter);
ps.setObject(i, jsonObject);
}
@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);
}
}
这样一来,无论 XML 中的字段还是 Java 实体里的 String 字段,只要在 resultMap 或参数占位符上指定了这个 typeHandler,就能保证读写都按 jsonb 语义去操作。如果你只是读 JSONB 而不写,不写 typeHandler 也可能通过 rs.getString 读到 JSON 文本,但一旦涉及 insert/update,没有 typeHandler 或 stringtype=unspecified 就很容易报错。所以建议做成标准配置,不要偷懒。
4.2 利用 JSONB 做灵活查询
JSONB 真正让业务受益的地方是查询。你不需要把 JSONB 里的某个属性冗余成独立列,就能直接过滤。比如“查询元数据里 author 为 zhangshan 的文章标签”,SQL 可以写:
sql复制SELECT * FROM article_tag
WHERE metadata ->> 'author' = 'zhangshan';
在 MyBatis 的 XML 里,注意 > 符号在 XML 文本中是允许的,但如果你要写 < 比较或 -> 包含操作,要小心 XML 转义。一般来说,推荐用 CDATA 包住这类特殊操作符比较稳妥:
xml复制<select id="listByAuthor" resultMap="ArticleTagMap">
<![CDATA[
SELECT * FROM article_tag
WHERE metadata ->> 'author' = #{author}
]]>
</select>
JSONB 还有 ?、?|、?& 这些判断存在性的操作符,可以用在动态标签过滤场景。如果这张表数据量变大,可以给 JSONB 字段建 GIN 索引,比如 CREATE INDEX idx_tag_metadata ON article_tag USING GIN (metadata);,这种复杂查询也能走索引,性能不会差到哪里去。这也是我用 PostgreSQL 存半结构化数据的一个核心原因。
4.3 数组字段的类型转换与映射
PostgreSQL 的数组类型在 Java 中映射通常要借助 JDBC 的 java.sql.Array 对象。你可以把 BIGINT[] 映射为 Java 的 Long[],实现方式同样是自定义 TypeHandler:
java复制import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.Array;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class LongArrayTypeHandler extends BaseTypeHandler<Long[]> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Long[] parameter, JdbcType jdbcType) throws SQLException {
Connection conn = ps.getConnection();
Array array = conn.createArrayOf("bigint", parameter);
ps.setArray(i, array);
}
@Override
public Long[] getNullableResult(ResultSet rs, String columnName) throws SQLException {
Array array = rs.getArray(columnName);
return array == null ? null : (Long[]) array.getArray();
}
@Override
public Long[] getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
Array array = rs.getArray(columnIndex);
return array == null ? null : (Long[]) array.getArray();
}
@Override
public Long[] getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
Array array = cs.getArray(columnIndex);
return array == null ? null : (Long[]) array.getArray();
}
}
在实际项目里,用数组存 IDs 列表确实方便,查找某个标签是否关联某篇文章可以直接用 #{id} = ANY(related_tags) 这类写法。查询 XML 中要注意参数类型:
xml复制<select id="listByRelatedTagId" resultMap="ArticleTagMap">
SELECT * FROM article_tag
WHERE #{tagId} = ANY(related_tags)
</select>
和 MySQL 相比,这省掉了中间关联表,写法和读法都比较自然。但数组也有边界:如果数组元素需要参与复杂关联、需要单独统计,还是应该用规范的关联表。数组适合作为附带的查询条件,不适合作为业务关系的主存储结构。
4.4 UPSERT:用 ON CONFLICT 替代先查再插
在 MySQL 上做“存在则更新、不存在则插入”,很多人会先 select 判断再 update/insert,这样不仅多一次往返,还容易在并发下产生唯一键冲突。PostgreSQL 的 INSERT ... ON CONFLICT ... DO UPDATE 语法一步到位:
xml复制<insert id="upsertTag" parameterType="ArticleTag">
INSERT INTO article_tag (tag_name, article_count, metadata, related_tags)
VALUES (
#{tagName},
#{articleCount},
#{metadata, typeHandler=com.example.blog.handler.JsonbTypeHandler},
#{relatedTags, typeHandler=com.example.blog.handler.LongArrayTypeHandler}
)
ON CONFLICT (tag_name) DO UPDATE SET
article_count = EXCLUDED.article_count,
metadata = EXCLUDED.metadata,
related_tags = EXCLUDED.related_tags
</insert>
ON CONFLICT (tag_name) 要求 tag_name 上必须有唯一约束或唯一索引。EXCLUDED 代表本次想要插入但发生冲突的那行数据,这样发生冲突时就能拿新数据去更新旧记录。这个做法的意义不仅是减少了一次查询,更重要的是它天然是原子的,并发场景下不会出现“两个请求都判断不存在然后都插入,其中一个报唯一键冲突”的尴尬。如果你的需求是冲突时什么都不做,就写 ON CONFLICT (tag_name) DO NOTHING。
4.5 结合业务场景:一个完整的“新增或更新标签”方法
把上面的 TypeHandler 和动态 SQL 组合起来,一个完整的方法可以是:
java复制public ArticleTag createOrUpdateTag(ArticleTag tag) {
articleTagMapper.upsertTag(tag);
return articleTagMapper.findByTagName(tag.getTagName());
}
在这里,findByTagName 返回的是带主键的完整记录,避免你以为主键已经反填到对象里实际上却没有。虽然 ON CONFLICT 解决了并发插入,但如果你想在 UPSERT 后立刻拿到 id,可以考虑主键回填的逻辑仍然依赖于 useGeneratedKeys;对于 ON CONFLICT DO UPDATE 的语句,如果命中更新路径,有些驱动版本不会返回生成键,所以不要像普通 insert 那样期待 keyProperty 一定被填充,最稳的方案是在 UPSERT 后回查。这一点我在生产环境里遇到过两次,都以为是代码写错了,最后定位到是生成键语义在不同驱动版本里的差异。
5. 生产环境最容易翻车的五个坑(附完整排查思路)
5.1 表象:启动后所有 SQL 都报 relation "article_tag" does not exist
第一次把应用部署到测试环境时,满屏都是 relation "article_tag" does not exist,但用 psql 连同一个库明明能看到表。很多人第一反应是“用户没授权”,其实 PostgreSQL 有名为 schema 的逻辑隔离层,public 只是默认 schema。如果建表语句是在 psql 里用默认 search_path 创建的,表在 public 下;但你的应用连接串如果来自其他环境,例如 currentSchema=my_schema,那 MyBatis 执行 SELECT * FROM article_tag 时会去 my_schema 里找表,自然找不到。
排查链路我建议这样做:
- 在 psql 里执行
\dn,看实际有哪些 schema。 - 执行
\dt public.*,确认表建在哪个 schema 下。 - 执行
SHOW search_path;,看默认搜索路径。 - 检查应用连接串里是否带了
currentSchema参数,如果带的值和建表所在 schema 不一致,就会报这个错。 - 还可以直接执行
SELECT * FROM article_tag;(不带 schema 前缀),如果 psql 能查出数据而应用报错,基本确定是连接串 schema 设置问题。
如果把连接串改成 currentSchema=public 仍然报错,再检查是不是表名大小写问题。PostgreSQL 对不带引号的标识符一律折叠成小写。建表时如果你手抖用了大写字段或表名,比如 "ArticleTag",那实际存储的就是带引号的大小写敏感名称。MyBatis 生成的 SQL 通常不带引号,就会去查小写的 article_tag,结果必定不存在。这种问题没有捷径,只能规范建表命名,全部使用小写加下划线。
5.2 表象:时间字段查出来总是差 8 小时或带 UTC 后缀
PostgreSQL 的 TIMESTAMPTZ 类型在存储时统一转成 UTC,展示时才根据会话时区转回本地时间。这种设计是好的,却让不少从 MySQL 迁移过来的团队头疼。他们的 MySQL 习惯把数据库连接串写成 serverTimezone=Asia/Shanghai,于是很自然地把参数搬过来,结果 PostgreSQL 驱动直接报 Unsupported or invalid PGTZ value,或者查出来的时间变成 UTC 时刻。
在 PostgreSQL JDBC 里没有 serverTimezone 这个参数,正确做法是:
text复制jdbc:postgresql://127.0.0.1:5432/blog?TimeZone=Asia/Shanghai
URL 内的时区值如果有符号,要做 URL 编码,比如 UTC+8 写成 GMT%2B8。同时应用服务器的 JVM 时区最好统一用 -Duser.timezone=Asia/Shanghai 设置。还有一个更隐蔽的问题:如果你用 Java 8 的时间 API,应该把实体字段声明为 OffsetDateTime 或 Instant,而不是直接用 LocalDateTime。因为 TIMESTAMPTZ 是带时区的时间点,用 LocalDateTime 接收后,只有日期时间没有时区偏移,序列化给前端又会差 8 小时。我见过太多因为字段类型声明不当导致的时间错乱。
5.3 表象:查询条件是 deleted = 1 直接报 operator does not exist: boolean = integer
MySQL 里大家习惯用 TINYINT(1) 或 INT 来模拟布尔值,SQL 写 WHERE deleted = 1 是没问题的。但 PostgreSQL 的布尔值是真正的 boolean 类型,它不会把整数 1 自动转成 true。于是当你执行 SELECT * FROM article_tag WHERE deleted = 1(假设 deleted 是 boolean 类型),报错是很典型的:
text复制ERROR: operator does not exist: boolean = integer
解决办法不是强加类型转换,而是改掉习惯:布尔列在 Java 里用 Boolean 接收,在 XML 里直接用 deleted = #{deleted},传参时传 true/false;或者 SQL 里写 deleted = true。同理,空字符串和 null 在 PostgreSQL 中是两个完全不同的东西。MySQL 在非严格模式下可能把空串当 0 或 null,PG 不会,NOT NULL 约束下插入空字符串可能会触发 not-null constraint 更严格的情况。从 MySQL 迁移过来时,所有原生的 0/1 标志位、空串值逻辑都要重新审视一遍。
5.4 表象:LOGIC 和分页查询去了一个奇怪的内存占用
如果项目里没用 PageHelper,查询分页一般是自己写 LIMIT #{size} OFFSET #{offset}。PostgreSQL 的分页语法和 MySQL 差异明显,MySQL 的 LIMIT offset, size 在 PG 里是错的,要用 LIMIT size OFFSET offset。另外,MyBatis 的 RowBounds 内存分页在 PG 上会一次性把所有数据查出来再内存截断,大数据量下这是灾难。所以线上项目建议要么引入 PageHelper,并配置 dialect 为 postgresql,要么在 XML 里手写 LIMIT/OFFSET 并传 pageSize 和 offset。
排查这件事可以从 SQL 日志看出端倪:如果明明一页 20 条,日志里却输出了几千条数据,那基本就是 RowBounds 内存分页导致的。PageHelper 会自动改写 count 和 limit,但要注意它托管的线程上下文有可能被异步任务干扰,用的时候要小心 PageHelper.clearPage()。如果你在 Service 里先执行一个不相关查询再执行分页查询,PageHelper 的本地线程变量会把第一个查询也套上分页条件,产生难以置信的结果数量。
5.5 表象:启动时驱动类找不到或版本不匹配
PostgreSQL 官方驱动在不断更新,有些新驱动类在老 JDK 上跑需要升级到 JDK 8 以上的版本,有些企业内部的“安全扫描”会强制要求驱动版本不能过低。常见的错误是 ClassNotFoundException: org.postgresql.Driver,说明 postgresql 依赖没有正确打入当前运行时依赖范围。另一个问题是应用中同时存在多个版本的 PG 驱动,比如自己引了一个 postgresql 42.2.x,框架又传递依赖了一个 42.6.x,Maven 实际解析时只保留一个版本,而那个版本可能和你的连接参数不兼容。这种情况可以用 Maven dependency 插件查看依赖树,把多余的显式依赖排除掉。
版本不匹配的另一种表现是部分 JDBC 方法行为不同。比如旧版本驱动在 getGeneratedKeys() 上偶尔返回空主键,新版本则正常。我的建议是统一由 Spring Boot dependency management 管理 PG 驱动版本,不要单独锁版本,除非你真的知道自己在干什么。排查这一类问题,从启动日志中第一行 Spring Boot Banner 下方的版本号开始看,确认实际生效的驱动版本,再决定是升级还是降级。
6. 性能与稳定性:上线前这一层没有人替你检查
6.1 批量写入时给驱动打开 rewriteBatchedInserts
如果项目里有大量批量插入,比如初始化数据导入、定时任务统计写入,PG 驱动默认的批量插入方式是每条 SQL 逐条发送,性能很差。加上这个 URL 参数可以显著优化:
text复制jdbc:postgresql://127.0.0.1:5432/blog?rewriteBatchedInserts=true
它是 PostgreSQL JDBC 驱动的核心优化参数,开启后 PreparedStatement 的批量 addBatch() / executeBatch() 会被重写成一条多值的 INSERT,而不是逐条执行。不过这个参数只对 JDBC 的 addBatch 生效,MyBatis 的 <foreach> 批量插入本来就拼接成一条 SQL,走的是另一个路径,但如果你在代码里用 SqlSessionTemplate 的批量模式或自定义 JDBC 批处理,这个参数就非常重要。我在一个数据迁移功能上对比过,开启后导入 10 万条数据的速度大约提升了 4 到 5 倍。
6.2 HikariCP 连接池参数上的个人建议
Spring Boot 默认连接池是 HikariCP,不用换。按小流量项目建议配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 600000
idle-timeout: 600000
leak-detection-threshold: 60000
几个参数说一下理解。maximum-pool-size 不是越大越好,数据库连接太多反而会增加服务和数据库两边的线程切换开销,一个常规业务预估 20 左右足够。max-lifetime 建议小于数据库服务端等待超时时间,否则可能被数据库主动断开。leak-detection-threshold 是一个非常有用的连接泄漏检测项,如果一个连接被借走超过 60 秒仍未归还,HikariCP 会在日志中输出告警,这个值不能大于 max-lifetime。以前排查连接池耗尽问题时,会临时靠线程 dump 分析,开了 leak detection 之后定位速度快了很多。很多人还会在 URL 里加 tcpKeepAlive=true,我建议也加上,可以减少网络设备空闲断连导致的连接假死。
6.3 结合 micrometer 和 actuator 做健康检查
Spring Boot Actuator 提供了现成的健康检查端点,如果配合 micrometer,很多关键指标能直接暴露出来。比如你可以通过 /actuator/health 看到数据库连接状态,通过 /actuator/metrics/hikaricp.connections.active 看到连接池活跃连接数,通过 /actuator/metrics/hikaricp.connections.pending 看到等待连接的请求数。上线时如果发现 pending 数量长期大于 0,就说明连接池不够用或某个连接被占用太久,这时候就要去看是不是有慢 SQL 或连接泄漏。这个监控集成并不复杂,在 pom 里引入 actuator 依赖,按需引入 micrometer-registry-prometheus,再配置暴露的 endpoint 即可。对于 PostgreSQL 这类带状态的服务,集成这种基础的可观测性比多写几个接口更有价值。
6.4 慢 SQL 排查:别只盯着代码
数据库慢查询往往不是代码单方面的问题,而是 SQL 执行计划不行。PostgreSQL 提供了 pg_stat_statements 扩展,可以统计每类 SQL 的调用次数、平均耗时、缓存命中率。如果你有权限,可以执行:
sql复制CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
然后在数据库配置文件 postgresql.conf 里加载该模块,再查看 TOP 慢 SQL。另外,对单条 SQL 做 EXPLAIN ANALYZE 可以看到实际是否走了索引、扫描了多少行。很多查询慢就像“快递员在小巷里一栋楼一栋楼找门牌”,而不走索引就是这种全表扫描。JSONB 字段的 ->> 查询如果没有配套 GIN 索引,数据量一大也会非常明显。排查顺序建议是:先看 PostgreSQL 侧有没有慢 SQL 日志,再结合 pg_stat_statements 定位 SQL,最后用 EXPLAIN ANALYZE 验证执行计划,而不是一上来就在 Java 代码里加缓存。
7. 关于这套技术栈,我最后的几条经验
讲到这里,核心内容基本覆盖完了。如果是第一次用 Spring Boot 整合 MyBatis 和 PostgreSQL,建议先只把基础查询跑通,再逐步加入 JSONB、数组、UPSERT 这些高级能力,不要一开始就设计一个“万能”的 TypeHandler 和复杂 resultMap。我见过很多项目
