先说个结论:这套组合我用了三年多,从单体小项目到日均千万级查询的服务,一直没换过。Spring Boot 负责把一切都“拧好”,MyBatis 把 SQL 控制权还给你,PostgreSQL 则在数据可靠性和扩展性上兜底。三者各司其职,配合起来非常顺手。这篇文章我会从依赖选型、环境搭建、CRUD 实操、动态 SQL、缓存与日志排查这几个角度完整走一遍,中间穿插大量我在实际项目中踩过的坑和验证过的写法,适合刚接触这套组合、或者正在做项目集成但总在细节上卡住的同学。
1. 整体设计思路:为什么是 Spring Boot + MyBatis + PostgreSQL
1.1 选型背后的真实考量
很多新手上来会纠结一个问题:Spring Data JPA 写起来不是更爽吗?为什么偏要用 MyBatis?我在几个团队待过,也见过因为 JPA 过度封装导致 SQL 失控的教训。JPA 在简单 CRUD 上确实省心,但只要业务稍微复杂一点,比如多表关联、复杂统计、需要针对特定数据库写优化 SQL,JPA 就会逼着你去学它的 DSL 和 Specification,学习成本反而更高。
MyBatis 的定位很明确:半自动 ORM。它不帮你生成 SQL,它只是把 SQL 和 Java 方法绑定起来,让你在 XML 里完全控制每一条语句。用 MyBatis 的人通常都有一种执念:我的 SQL 必须是我自己写的,我清楚它在数据库里执行了什么。这种掌控感在排障时价值巨大——线上慢查询你直接拿到 SQL 去 explain 就行,不用先脑内翻译 Hibernate 生成的语句。
PostgreSQL 在这个组合里承担的是“底盘”角色。它和 MySQL 的最大区别,在我看来不是性能,而是数据完整性和能力边界。PostgreSQL 对约束、事务、外键、MVCC 的处理非常严谨,加上原生支持 JSONB、数组、全文检索、窗口函数这些高级特性,你不需要为了一个 JSON 字段去额外引入 MongoDB,也不需要为了全文搜索单独搭 Elasticsearch。对于大多数中大型单体应用和微服务的数据底座,这个组合在成本、可控性、能力密度上是平衡得最好的。
1.2 这套组合解决的核心问题
这套组合适合什么场景?我总结下来有这么几类:
- 中小团队的基础设施统一:组件少、易维护,不需要专职 DBA 也能把数据库跑稳。
- 复杂业务 SQL 需要精细化控制:比如报表统计、对账查询、动态条件拼装,MyBatis 的动态 SQL 能让你写得很优雅。
- 需要 PostgreSQL 高级特性的项目:地理信息(PostGIS)、JSONB 存储、数组标签、UPSERT 等等。
- 长期演进、SQL 需要沉淀为团队资产:MyBatis 的 XML 文件本身就是文档,新同学接手时打开 XML 就能看懂业务 SQL。
如果你已经确定要用这个组合,接下来最现实的问题就是:版本怎么选?依赖怎么加?配置怎么写?我们从环境开始一步一步来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:PostgreSQL 安装与 Spring Boot 依赖配置
2.1 PostgreSQL 版本选择与安装方式
先解决版本问题。PostgreSQL 当前已经到 16、17 了,我的建议是:新项目不要低于 14,有条件直接上 16 或 17。14 版本之后默认开启了分区表改进、并行查询增强,性能提升非常明显。不要为了“稳定”刻意选老版本,PostgreSQL 的小版本迭代非常快,每个大版本都持续维护五年,选新的完全没问题。
安装方式,Windows 用户直接去官网下 EnterpriseDB 的安装包,exe 一路 next 就行;macOS 用 Homebrew 一行命令:
bash复制brew install postgresql@16
Linux 用户,如果只是本地开发,用发行版自带源装就好:
bash复制# CentOS/RHEL
sudo dnf install postgresql-server postgresql-contrib
# Ubuntu/Debian
sudo apt install postgresql postgresql-contrib
如果你需要在 Ubuntu 上从源码编译安装(比如要定制编译参数、安装到特定目录),那就走经典三件套:
bash复制wget https://ftp.postgresql.org/pub/source/v16.3/postgresql-16.3.tar.gz
tar -zxvf postgresql-16.3.tar.gz
cd postgresql-16.3
./configure --prefix=/usr/local/pgsql
make -j4
sudo make install
源码编译核心是 ./configure 那一步,--prefix 指定安装目录,--with-pgport=5432 可以改端口。编译时间大约十几分钟,好处是你能拿到完全贴合自己环境的二进制。不过对大多数人来说,直接用官方源更省事,源码编译适合有定制需求的情况。
装好之后,初始化数据目录并启动服务:
bash复制# Linux 上用包管理器安装的,一般已自动初始化和启动
systemctl status postgresql
# 手动初始化(源码编译安装时需要)
/usr/local/pgsql/bin/initdb -D /usr/local/pgsql/data
/usr/local/pgsql/bin/pg_ctl -D /usr/local/pgsql/data -l logfile start
默认安装后,PostgreSQL 会创建一个 postgres 超级用户。你需要先切到该用户设置密码,再创建一个业务数据库和专用账号。这里有一个很重要的习惯:不要用超级用户跑业务。业务账号只授权一个数据库的读写权限,避免误操作影响全局。
bash复制sudo -u postgres psql
CREATE USER app_user WITH PASSWORD 'strong_password';
CREATE DATABASE app_db OWNER app_user;
GRANT ALL PRIVILEGES ON DATABASE app_db TO app_user;
2.2 Spring Boot 依赖选型与版本对应关系
Spring Boot 和 MyBatis 的整合,官方推荐用 mybatis-spring-boot-starter。这里有个容易踩坑的地方:不同版本的 Spring Boot 要配不同版本的 starter。Spring Boot 3.x 对应 mybatis-spring-boot-starter 3.x,Spring Boot 2.x 对应 2.x,不要混用。
我用的是 Spring Boot 3.2.x + mybatis-spring-boot-starter 3.0.3,Maven 的核心依赖就这几项:
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>3.0.3</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 如果需要连接池监控,可以用 HikariCP(Spring Boot 默认自带) -->
注意三点:
- PostgreSQL JDBC 驱动的版本,Spring Boot 的 dependencyManagement 已帮你管好,不需要写 version。
- MyBatis starter 3.0.3 要求 JDK 17+,这正好和 Spring Boot 3.x 对齐。
- 如果你用的是 Gradle,依赖写法类似,只是换到
implementation即可。
2.3 核心配置文件:数据源、连接池与 MyBatis 设置
依赖加好后,关键是 application.yml 的配置。下面这份是我项目里的标准模板,可以直接抄:
yaml复制spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/app_db?stringtype=unspecified
username: app_user
password: strong_password
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
pool-name: AppHikariPool
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
这里每一项都不是随便写的,逐个说下:
stringtype=unspecified这个参数很关键。PostgreSQL 的 JDBC 驱动在处理 Java 传入的字符串参数时,如果没有这个参数,在某些类型推断场景下会报column "xxx" is of type integer but expression is of type character varying之类的错。加上它,驱动会把所有字符串参数交给数据库去自动推断类型,很多诡异问题直接消失。maximum-pool-size默认是 10,如果业务高峰时连接不够用,可以先加到 20 观察。别调太大,数据库连接是稀缺资源,每个连接都占用内存和文件描述符。map-underscore-to-camel-case必须开,这样数据库的user_name才能自动映射到 Java 的userName,不用手写一堆 resultMap。log-impl配置成StdOutImpl是开发环境最方便的方式,SQL 会直接打到控制台,方便排查问题。生产环境建议改成org.apache.ibatis.logging.slf4j.Slf4jImpl或者直接去掉,避免日志噪音。
最后别忘了在启动类上加 @MapperScan,告诉 MyBatis 去哪里扫 Mapper 接口:
java复制@SpringBootApplication
@MapperScan("com.example.demo.mapper")
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
到这里,环境算是搭好了。但环境能跑起来只是第一步,真正常用顺手,还得看 Mapper 层怎么设计和 SQL 怎么写才算稳。接下来我用一个完整的用户模块来拆解。
3. 从零实现一个用户模块:CRUD 与 XML 映射实操
3.1 建表设计与 PostgreSQL 类型映射
先建一张用户表,用 PostgreSQL 的序列做主键(这是它和 MySQL 自增最大的不同点):
sql复制CREATE TABLE sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
nickname VARCHAR(50),
email VARCHAR(100),
password_hash VARCHAR(100) NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
BIGSERIAL 会自动创建一个序列,底层等价于 BIGINT NOT NULL DEFAULT nextval('sys_user_id_seq')。这段 SQL 里有几个 PostgreSQL 特有的东西值得注意:
BIGSERIAL是自增主键的推荐写法,它不是一个真正的类型,而是BIGINT + DEFAULT + 序列的组合语法糖。TIMESTAMP WITH TIME ZONE对应 Java 的OffsetDateTime或Instant,如果你用LocalDateTime也没有问题,但WITH TIME ZONE字段一定要理解它存的是时间点,不是墙上时间。SMALLINT映射到 Java 的Short,或者你在实体里直接用Integer也能兼容。
实体类我建议直接用 Lombok 简化样板代码:
java复制@Data
public class SysUser {
private Long id;
private String username;
private String nickname;
private String email;
private String passwordHash;
private Short status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
}
password_hash 映射到 passwordHash 靠的就是前面开启的驼峰映射,不需要额外写 @TableField 或者 resultMap。
3.2 Mapper 接口与 XML 的完整写法
Mapper 接口只需要声明方法签名:
java复制public interface SysUserMapper {
SysUser selectById(@Param("id") Long id);
SysUser selectByUsername(@Param("username") String username);
List<SysUser> selectListByStatus(@Param("status") Short status);
int insert(SysUser user);
int update(SysUser user);
int deleteById(@Param("id") Long id);
}
对应的 XML 放在 src/main/resources/mapper/ 下,命名 SysUserMapper.xml。文件头的约束写法是死的,直接复制:
xml复制<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.SysUserMapper">
<select id="selectById" resultType="com.example.demo.entity.SysUser">
SELECT id, username, nickname, email, password_hash, status, created_at, updated_at
FROM sys_user
WHERE id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO sys_user (username, nickname, email, password_hash, status)
VALUES (#{username}, #{nickname}, #{email}, #{passwordHash}, #{status})
</insert>
<update id="update">
UPDATE sys_user
SET nickname = #{nickname},
email = #{email},
updated_at = now()
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM sys_user WHERE id = #{id}
</delete>
</mapper>
几个细节值得展开:
useGeneratedKeys="true" keyProperty="id" 这是 MyBatis 自动回填主键的方式。执行完 insert 后,user.getId() 会被自动赋上数据库生成的序列值。如果你在 PostgreSQL 里省略这两个属性,插入后拿不到主键,后面你要用这个 id 去关联子表,就得多查一次。
#{} 是预编译占位符,${} 是字符串拼接。绝大多数场景只用 #{},它能防止 SQL 注入,底层走 PreparedStatement。${} 只在动态表名、动态排序字段这类无法用占位符的场景用,而且必须做白名单校验。
3.3 主键回填为什么在 PostgreSQL 上容易踩坑
如果你把 useGeneratedKeys 配好,MyBatis 底层用的是 JDBC 的 getGeneratedKeys 方法。PostgreSQL 的驱动对这个方法的支持一直很好,前提是你的表主键确实属于 BIGSERIAL 或者由序列驱动。反过来,如果你把主键设置成 BIGINT GENERATED ALWAYS AS IDENTITY,驱动同样识别得到,主键回填也能正常工作。
我遇到过的实际问题是:某些老项目把主键设计成业务主键(比如用身份证号、订单号当主键),这时候 useGeneratedKeys 就没意义了,还会报异常。遇到这种表,要么别开这个属性,要么在 insert 前先通过一个独立语句取序列值:
xml复制<insert id="insertWithSeq">
<selectKey keyProperty="id" resultType="java.lang.Long" order="BEFORE">
SELECT nextval('sys_user_id_seq')
</selectKey>
INSERT INTO sys_user (id, username, nickname, email, password_hash, status)
VALUES (#{id}, ...)
</insert>
order="BEFORE" 表示在 insert 之前先执行查询序列的语句,然后把结果赋给实体的 id。这种写法在 MySQL 上很少有人用,但在 PostgreSQL 老版本或者特殊主键设计里非常实用。
3.4 条件查询与动态 SQL 实战
现实中几乎没有“查询条件固定”的需求。用户列表页面,用户可能按用户名模糊搜、按状态筛选、按时间范围筛选、甚至按某个字段精确匹配。MyBatis 的动态 SQL 就是为了这个场景设计的。
举个例子,一个支持多种筛选条件的用户列表查询:
xml复制<select id="selectByCondition" resultType="SysUser">
SELECT id, username, nickname, email, status, created_at
FROM sys_user
<where>
<if test="username != null and username != ''">
AND username LIKE CONCAT('%', #{username}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startTime != null">
AND created_at >= #{startTime}
</if>
<if test="endTime != null">
AND created_at < #{endTime}
</if>
</where>
ORDER BY created_at DESC
</select>
<where> 标签会自动处理掉第一个条件前面的 AND,你不用在 SQL 里硬写 WHERE 1=1。这个设计非常贴心,因为手写 WHERE 1=1 虽然能用,但会影响执行计划优化器的判断,尤其在大表上,1=1 会让部分数据库无法正确走索引。
注意两个细节:
LIKE CONCAT('%', #{username}, '%')这种写法是 PostgreSQL 安全的。别学 MySQL 的经典写法LIKE '%${username}%',后者有注入风险,还容易被数据库当成常量误判。>=和<是 XML 里的转义写法。如果你在 XML 里直接写>没问题(XML 允许),但写<必须转义成<,否则 XML 解析会把你整段 SQL 报错。
如果你用的是 MyBatis 3.5.x 之后的版本,还可以利用 @Mapper 接口里的注解直接写一些简单 SQL,减少 XML 文件数量。不过我的经验是:业务 SQL 一律放 XML。注解方式一旦 SQL 变长、需要动态条件,可读性会差很多,也不好统一管理。
4. 高级封装:分页、批量操作与常见坑位
4.1 PostgreSQL 分页:LIMIT OFFSET 与 PageHelper 整合
PostgreSQL 的分页语法是标准的 LIMIT xx OFFSET yy,没有 MySQL 那种怪异的 limit 10, 20 写法。MyBatis 里如果你不想引入插件,用原生参数分页也足够:
xml复制<select id="selectPageByStatus" resultType="SysUser">
SELECT id, username, nickname, email, status, created_at
FROM sys_user
WHERE status = #{status}
ORDER BY id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
Java 侧传入 limit 和 offset 两个参数,简单直接。但实际业务中,你往往需要同时返回总数和列表数据,于是大多数人会引入 PageHelper。
PageHelper 在 Spring Boot 里的用法很固定:
java复制PageHelper.startPage(pageNum, pageSize);
List<SysUser> list = sysUserMapper.selectListByStatus(status);
PageInfo<SysUser> pageInfo = new PageInfo<>(list);
这里有一个我见过无数次的坑:PageHelper.startPage 只对紧随其后的第一条查询语句生效。如果你在 startPage 之后、真正调用 Mapper 之前,不小心先执行了一次其他查询(比如查配置、查字典),分页参数就会被那次无关查询“吃”掉,导致列表查询没有分页效果。我在项目里明确要求:分页调用必须写在一个事务方法里,而且 startPage 和 Mapper 调用之间不允许有任何其他 SQL 操作。
另一个 PostgreSQL 特有的分页问题:大数据量深翻页时 LIMIT/OFFSET 性能急剧下降。比如 LIMIT 20 OFFSET 100000,数据库得先扫出 100020 行再丢掉前 100000 行,代价随 offset 增长。替代方案是用 WHERE id > 上次最大值 ORDER BY id LIMIT 20 这种键集分页方式。如果你的项目有百万级用户列表且支持深翻页,建议从设计阶段就按这个思路来。
4.2 批量插入与批量更新:如何真正快起来
有人说 MyBatis 批量插入很慢,其实问题不在 MyBatis,而在用法。批量插入有一种低效写法是在 Java 层循环调用 insert 方法,每条记录都走一次完整的事务提交,慢且不优雅。正确做法是用 <foreach> 拼成一条多值 INSERT:
xml复制<insert id="batchInsert">
INSERT INTO sys_user (username, nickname, email, password_hash, status)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.username}, #{item.nickname}, #{item.email}, #{item.passwordHash}, #{item.status})
</foreach>
</insert>
这种写法在 PostgreSQL 上性能提升非常明显,因为一次语句只跟数据库交互一次。但要注意:MySQL 默认有 max_allowed_packet 限制,而 PostgreSQL 对单条语句大小没有这么严格的限制,你只需要关注 JDBC 的 fetch 缓冲区和内存占用。批量一次建议控制在 500~2000 条,超过这个量可以分批提交。
批量更新是另一个话题。最稳妥的批量更新是先查出要更新的数据,逐条 update。但如果数据量大、又需要一次会话内完成,可以用 CASE WHEN 拼接:
xml复制<update id="batchUpdateStatus">
<foreach collection="list" item="item" separator=";">
UPDATE sys_user SET status = #{item.status}, updated_at = now() WHERE id = #{item.id}
</foreach>
</update>
这里的分隔符是 ;,PostgreSQL 执行多条语句时需要在 JDBC URL 上额外加一个参数 rewriteBatchedStatements=true 吗?实际上这个参数是给 PreparedStatement.executeBatch() 准备的。如果用 <foreach separator=";"> 这种方式,驱动会当作多语句执行,你要保证 URL 里没有禁止多语句的设置。我个人的建议是:批量更新频率高的场景走 ExecutorType.BATCH 的 SqlSession 模板,代码更清晰且性能稳定。
4.3 MyBatis 缓存机制与 Spring Boot 整合时的坑
热搜词里有“mybatis缓存”,这是面试高频题,也是日常开发容易翻车的地方。MyBatis 有一级缓存和二级缓存:
- 一级缓存:默认开启,作用域是同一个 SqlSession。在 Spring 整合下,每次 Mapper 调用通常都在独立 SqlSession 中,一级缓存作用不大,但要注意事务内多次查询同一数据,缓存会生效。
- 二级缓存:默认关闭,作用域是 namespace(即同一个 Mapper)。开启后,所有经过该 Mapper 的查询结果会缓存下来,跨 SqlSession 共享。
在 Spring Boot 里开启二级缓存,需要三步:
xml复制<mapper namespace="com.example.demo.mapper.SysUserMapper">
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>
</mapper>
实体类要实现 Serializable,因为二级缓存设计中会有序列化后存储的场景。
但我强烈建议:互联网项目默认不开 MyBatis 二级缓存。为什么?因为缓存一致性很难控制。你在 A Mapper 里查了用户列表,缓存在 SysUserMapper 的 namespace 下;另一个 Mapper SysUserRoleMapper 更新了用户角色关联,但用户数据本身没变,不会触发 SysUserMapper 的缓存失效,于是用户列表继续返回旧数据。这种问题在并发环境里排查起来极其痛苦。
如果你确实需要缓存,更推荐直接用 Spring Cache 或 Redis,把缓存控制和业务代码显式捆绑在一起,至少你知道什么时候缓存了、什么时候清楚了。MyBatis 的二级缓存更适合单机、低并发、数据极少变更的场景,比如字典表。
4.4 SQL 日志打印与慢 SQL 定位
开发阶段 SQL 打印是刚需。配置 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 之后,控制台会输出每条 SQL 和参数:
code复制==> Preparing: SELECT id, username, nickname, email, status, created_at FROM sys_user WHERE id = ?
==> Parameters: 1(Long)
<== Columns: id, username, nickname, email, status, created_at
<== Row: 1, admin, 管理员, admin@example.com, 1, 2024-01-01 10:00:00
<== Total: 1
这里能看到 MyBatis 底层确实是 PreparedStatement,参数和 SQL 分离,这也是它防止注入的根基。生产环境建议切到 Slf4j 并按包级别控制日志:
yaml复制logging:
level:
com.example.demo.mapper: debug
这样只打印 Mapper 包下的 SQL,不会全站刷屏。慢 SQL 定位则要结合 PostgreSQL 侧的配置,在 postgresql.conf 里开启:
code复制log_min_duration_statement = 1000
超过 1000 毫秒的语句都会落到 PostgreSQL 的日志里,这比你在 Java 层统计时间更贴近数据库真实执行情况。
5. 常见问题与排查技巧实录
5.1 PostgreSQL 里那些“合情合理”但让你头疼的类型问题
案例一:Boolean 类型映射出错。Java 的 Boolean 字段映射到 PostgreSQL 的 boolean 列,在 MyBatis 里默认没问题。但如果你在 XML 里写 WHERE status = 1,而 status 是 boolean 类型,会报错。因为 PostgreSQL 的布尔类型不接受 int 字面量。要么改成 status = TRUE,要么 Java 用 Boolean 类型传入:
xml复制<select id="selectByEnabled" resultType="SysUser">
SELECT * FROM sys_user WHERE enabled = #{enabled}
</select>
案例二:JSONB 字段如何查询。PostgreSQL 的 jsonb 是强项,但 MyBatis 默认不知道如何把 JSONB 映射到 Java 对象。比较规范的做法是为 JSONB 字段配一个 TypeHandler。我项目里的通用写法是存字符串,读取时再转换:
java复制public class JsonbTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) {
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
Object obj = rs.getObject(columnName);
return obj == null ? null : obj.toString();
}
// 其他重载方法略
}
然后在字段上标注:
java复制@TableField(typeHandler = JsonbTypeHandler.class)
private String profileJson;
SQL 里用 profile_json::text 或直接 CAST(profile_json AS TEXT) 避免 PG 返回 PGobject 类型导致映射失败。
案例三:Java LocalDateTime 与 PostgreSQL timestamp 的时区问题。如果 Java 用的是 LocalDateTime,数据库字段是 timestamp with time zone,两者直接映射会有 8 小时偏差的风险。建议要么 Java 统一用 OffsetDateTime 映射 timestamptz,要么数据库字段干脆就用 timestamp without time zone 并约定所有时间按 UTC 存储。这个坑一旦上线后再改,成本极高。
5.2 事务失效:最常见的三个原因与检查清单
MyBatis 整合 Spring Boot 后,你可能很自然地用 @Transactional 管理事务。但事务失效往往比报错更阴险,因为它不提示任何异常,你只能在脏数据出现后追查源头。三个最常见的失效原因:
- 方法内部自调用:同一个类里
methodA()调用methodB(),而事务注解加在methodB()上。Spring 的事务是通过 AOP 代理实现的,自调用绕过了代理对象,注解完全失效。解决办法是把methodB()放到另一个 Service 类中,或者手动通过AopContext.currentProxy()调用。 @Transactional加在非 public 方法上:Spring 4 之后,非 public 方法上的事务注解会被忽略,且不会报错。如果你把事务写在一个 package-private 或 private 方法里,等于没写。- 异常被方法内吞掉:
try { ... } catch (Exception e) { ... }里面把异常捕获且不重新抛出,Spring 看不到异常自然无法回滚。如果确实需要在 catch 里做日志处理,记得throw new RuntimeException(e)或使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
顺带提一个我项目里强制要求:连接池配上 defer-close ?不需要。但在 @Transactional 方法内尽量别调用远程接口或耗时操作,连接会一直占着,长事务是大忌。
5.3 MyBatis XML 文件不生效:Mapper 扫描失败的排查思路
一个典型报错:Invalid bound statement (not found)。意思是 Spring 容器里找到了 Mapper 接口的 Bean,但 MyBatis 没找到对应的 XML 语句。
排查顺序如下:
- 确认
mapper-locations配置正确。classpath:mapper/*.xml表示只扫resources/mapper根目录下的 XML,如果 XML 在子目录里就扫不到。改成classpath*:mapper/**/*.xml更稳妥。 - 确认 XML 文件里的
namespace和 Mapper 接口的全限定名完全一致。namespace="com.example.demo.mapper.SysUserMapper",接口就在这个包下且同名。 - 确认 XML 里每个语句的
id与接口方法名一致,方法参数使用了@Param命名。 - 排查构建工具是否把 XML 漏掉了。Maven 默认只打包
resources目录下的文件,如果你的 XML 放在src/main/java下(虽然不推荐),必须在pom.xml里额外配置<resources>,否则打出来的 jar 里根本没有 XML。
大多数 not found 问题都出在第 1 和第 4 条,尤其是用了自定义构建脚本后容易漏。
5.4 连接池连接耗尽与 PostgreSQL 默认连接数冲突
HikariCP 连接池和 PostgreSQL 的 max_connections 经常出现一种矛盾:数据库默认最大连接数往往是 100,但你有多个应用实例,每个实例的连接池配了 50,两个实例就把数据库连死了。
这个问题本地开发不常见,生产环境部署多个副本时很容易踩中。解决方案:
- 把 Hikari 的
maximum-pool-size调到一个全局规划过的数字,比如每实例 20,三个实例共 60,留 40 给运维和后台任务。 - 同时调整 PostgreSQL 的
max_connections,在postgresql.conf里改完重启生效,或者用ALTER SYSTEM SET max_connections = 200;再重启。 - 给连接池加一个连接保活机制,
connection-test-query: SELECT 1,避免数据库重启后连接池里全是死连接。
经验数值:对绝大多数业务系统,单实例 20 个连接足够支撑每秒几百次数据库请求。连接不是越多越好,每条连接背后都有事务快照和内存开销,过大会拖慢数据库的整体性能。
5.5 一个来自线上环境的真实踩坑案例
最后分享一个真实案例。之前有一个报表服务,SQL 在 MySQL 上跑得好好的,迁移到 PostgreSQL 后某些查询突然慢了几十倍。排查发现,问题出在一个函数上:原有的查询里用了 DATE_FORMAT(created_at, '%Y-%m-%d') 对时间字段做格式化,然后在 GROUP BY 中使用了这个格式化后的字段。PostgreSQL 里没有 DATE_FORMAT,大家改成了 to_char(created_at, 'YYYY-MM-DD')。语法没问题,但结果就是慢。
原因在于:to_char(created_at, 'YYYY-MM-DD') 会让 PostgreSQL 无法直接使用 created_at 上的索引,变成全表扫描。优化方案是改用 created_at >= ? AND created_at < ? 这种范围条件,把“按天统计”改成“对当天时间范围做聚合”。改造后,同样的统计从 6 秒降到 200 毫秒。
这个案例说明一个通用规律:从 MySQL 迁移到 PostgreSQL,语法只是第一道坎,真正要命的是函数对索引的影响。PostgreSQL 的优化器比 MySQL 更成熟,但它也无法自动重写用户自定义表达式去匹配索引。写 SQL 时始终要问自己:这个条件能不能命中索引?
6. 补充一个实用细节:MyBatis 中 #{} 与 ${} 的选择,以及动态排序的安全实现
面试题里的常客,但日常写代码时还是有人犯错。#{} 走预编译占位符,能防注入;${} 是字符串替换,直接把值拼进 SQL。动态排序是 #{} 无法处理的场景,因为占位符不能放在 ORDER BY 后面。
安全的做法是白名单映射,不让用户直接传列名:
java复制private static final Map<String, String> SORT_COLUMN_MAP = Map.of(
"createTime", "created_at",
"status", "status",
"id", "id"
);
public String resolveSortColumn(String input) {
return SORT_COLUMN_MAP.getOrDefault(input, "id");
}
然后把解析后的列名拼进 SQL:
xml复制<select id="selectPage" resultType="SysUser">
SELECT * FROM sys_user
ORDER BY ${sortColumn} ${sortOrder}
</select>
sortColumn 只能来自白名单,sortOrder 也只能是 ASC 或 DESC,不能直接信任前端传参。这样既满足了动态排序,又规避了注入风险。
7. 收尾:这套组合还能怎么用
分页、缓存、日志、事务这些基础能力跑通之后,这套组合的想象空间其实很大。我在实际项目里还做了三件额外的事:
第一,用 PostgreSQL 的 LISTEN/NOTIFY 机制做数据变更通知,配合 MyBatis 的 Mapper 层,实现了一套轻量级的事件推送,不需要再引入消息队列。
第二,利用 JSONB 字段和 MyBatis 的 TypeHandler,把业务表单里的动态扩展属性直接存成 JSON,查询时用 PostgreSQL 的 @> 操作符做包含判断,省了一张关联表。
第三,把复杂统计 SQL 沉淀为 PostgreSQL 的视图,MyBatis XML 里直接 SELECT * FROM v_user_stat,把不可控的动态拼接 SQL 转化为稳定的视图逻辑。
最后再分享一个关于版本升级的经验:Spring Boot 从 2.x 升到 3.x 时,除了要注意 mybatis-spring-boot-starter 的版本,还要检查 javax 包名是否换成 jakarta,以及 Java 版本是否已经到 17。很多第三方组件在这个升级过程中会暴露出依赖冲突,建议升级前把所有依赖的版本先拉齐看一遍。这套组合的真正优势不是某个单一功能有多强,而是它们在长期演进中踩坑的统一性——只要你守住了上面这几点,后续加功能、优化性能都会非常顺手。
