前阵子我们接了个数据中台的项目,库选型直接定了 PostgreSQL,持久层则从原来的 JPA 换成了 MyBatis。整套 Spring Boot + MyBatis + PostgreSQL 的组合搭下来,前后折腾了小半个月,中间踩的坑比预想的多不少。这篇就把从零搭建的完整过程写出来——环境配置、SQL 方言差异、JSON 字段映射、分页调优这些,都是我实际跑过、验证过的,希望能给正在选型或者准备迁移的兄弟们省点时间。
作为最后交付的系统的核心底座,这三件套组合其实比想象中更常见:Spring Boot 负责提供自动配置和快速启动的骨架,MyBatis 守住半自动 ORM 的灵活性和 SQL 可控性,PostgreSQL 则用完善的约束机制和 JSON/JSONB 能力兜住数据层的复杂度。适用的场景很典型:需要复杂查询、动态 SQL、或者打算长期维护一套强约束数据模型的中大型项目。
1. 为什么是这三件套:选型背后的几笔账
先说清楚为什么弃 JPA 选 MyBatis。虽然 Spring Data JPA 在单表 CRUD 上确实省事,但一旦业务查询复杂起来,JPQL 写起来非常拧巴,动态条件组合、多表关联、SQL 原生调优全都受限制。我们这次的数据中台有一个核心场景:报表模块涉及十几张表,查询条件里有大量是可选的动态过滤条件,还有按时间窗口做分区统计的需求,SQL 手写是必然的。MyBatis 的 XML 映射机制可以让你把 SQL 完全掌握在自己手里,<if>、<where>、<foreach>这些动态标签在复杂查询场景下简直是救命的。
再说 PostgreSQL。很多人担心 PostgreSQL 没有 MySQL 那么普及、遇到的坑没处查,其实真实情况是 PostgreSQL 文档质量极高,天天在家用它自带的 pgAdmin 也好、命令行 psql 也好,都是稳定到无感。真正让我下决心的是它几个 MySQL 难以替代的能力:
- 无比强的约束体系:检查约束、外键、排他约束、自定义函数索引,数据一致性可以做到相当高的标准;
- JSON/JSONB 原生支持,且 JSONB 支持 GIN 索引,这比对一张多余的表做 join 灵活太多;
- 事务隔离级别实现规范和 MVCC 设计在业界是教科书级别的。
很多传统 MySQL 的老朋友可能会说“我们业务量不大用 PG 和 MySQL 没差别”,说得没错,但 PG 在复杂查询优化、地理位置、全文检索这些扩展场景上的上限明显更高。这一次我们直接定了 PG,因为数据中台注定要跑各种分析型 SQL,MySQL 的优化器在这种场景下偶尔会拉胯。
Spring Boot 作为胶水层就不用多说了,它最大的价值是把“配置驱动”的理念发挥到极致。一个spring-boot-starter-web、一个spring-boot-starter-jdbc,再加上对应的 MyBatis starter,启动类写了三五行代码,一个可用服务就起来了。对于团队里新人,上手成本也低很多。
这三样合到一起,本质上是做了一套“框架不替你做决定,但把决定权交给你”的架构。需要 ORM 的简单映射有 MyBatis,需要复杂 SQL 自己写,需要数据强一致靠 PG。
2. 环境准备与最基础的配置:不少坑其实埋在这里
2.1 数据库准备:账号权限和 schema 规划
先装 PostgreSQL,我们用的是 16 版本。这里有个容易忽略的事情:如果是在云平台直接用 RDS 实例,一定不要在安装好后直接用超级用户跑业务,也不要使用默认的 public schema 来建表。
我习惯的做法是:
-- 创建专用业务账号 CREATE USER app_user WITH PASSWORD 'StrongP@ssw0rd'; -- 创建业务专用 schema CREATE SCHEMA IF NOT EXISTS biz; -- 授权 GRANT ALL PRIVILEGES ON SCHEMA biz TO app_user;原因其实很简单:一是安全。业务账号只拥有必要权限,即使连接串泄露也不能针对全库做破坏性操作。二是 schema 隔离,团队做多租户或者模块隔离时大有用。注意后续 JDBC URL 里要配currentSchema=biz或设置 search_path,否则默认找 public 下面的表,你还得加前缀,挺烦的。
2.2 Maven 依赖坐标的选择:这里有版本雷区
Spring Boot 3.x 和 2.x 在整合MyBatis时坐标变化不小,最关键的一个点:Spring Boot 3 的 javax.* 全部换成了 jakarta.*,所以早期版本的 mybatis-spring-boot-starter 在这个版本下会直接启动报错。
我在项目中的依赖如下(pom.xml):
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <!-- Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 官方 starter,注意版本必须和 Spring Boot 3.x 兼容 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.4</version> </dependency> <!-- PostgreSQL 驱动 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- 数据库连接池:Spring Boot 默认 HikariCP,不需要额外引入 --> <!-- 这里可选:如果需要加密或专用池 --> </dependencies>如果你还在用 Spring Boot 2.7.x,那么mybatis-spring-boot-starter的版本一般选 2.3.x,否则会碰到自动配置类不兼容的报错。这是一个非常典型的版本暗坑,社区里几乎每周都有人踩。
另外,如果你更喜欢 MyBatis-Plus,注意它的自动配置对通用 Mapper 的扫描逻辑和原生 MyBatis 有所不同,需要提前决定用哪一个,它们底层虽然兼容,但混用容易造成重复代理。
2.3 application.yml 配置:连接池和 SQL 初始化机制
初始化数据源配置的时候,有几个参数是必须想清楚的:
spring: datasource: url: jdbc:postgresql://localhost:5432/myapp?currentSchema=biz&stringtype=unspecified username: app_user password: StrongP@ssw0rd driver-class-name: org.postgresql.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 30000 connection-timeout: 30000 pool-name: MyAppHikariPool sql: init: mode: always schema-locations: classpath:sql/schema.sql >CREATE TABLE biz.user_profile ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128), tags JSONB DEFAULT '[]', extra_info TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); COMMENT ON TABLE biz.user_profile IS '用户画像表'; COMMENT ON COLUMN biz.user_profile.username IS '用户名';这里BIGSERIAL是 PG 的自增序列语法,等价于 MySQL 的BIGINT AUTO_INCREMENT,但底层实现完全不同,后面会详细讲。
3. 核心功能实现:SQL 方言差异与 JSON 字段映射
3.1 从 MySQL 迁移到 PostgreSQL:这几个 SQL 语法差异最要命
很多项目不是一上来就用 PG,而是从 MySQL 迁过来的。那么 SQL 语法的差异就成了第一道坎。我整理了一个对比表,基本覆盖了绝大多数会遇到的问题:
| 能力 | MySQL | PostgreSQL | 迁移注意点 |
|---|---|---|---|
| 自增主键 | AUTO_INCREMENT | SERIAL / BIGSERIAL 或 GENERATED AS IDENTITY | 建表语法不同,插入时不要显式给 id 值 |
| 字符串拼接 | CONCAT(a, b) 或 a | b(要看模式) | |
| 分页 | LIMIT m OFFSET n | LIMIT m OFFSET n,也支持 FETCH FIRST ROWS ONLY | 语法相同,但 PG 深分页也要注意性能 |
| 布尔值 | TRUE/FALSE,习惯 1/0 | TRUE/FALSE/'true'/'false',返回实际布尔类型 | 不能把 1/0 直接传给布尔列 |
| 日期时间 | DATETIME / TIMESTAMP | TIMESTAMP / TIMESTAMPTZ | 推荐用 TIMESTAMPTZ,存带时区时间 |
| 修改表结构 | ALTER TABLE MODIFY COLUMN | ALTER TABLE ALTER COLUMN ... TYPE ... | 语法完全不同 |
| JSON 字段 | JSON 类型,需函数解析 | JSON / JSONB,性能更好 | JSONB 支持 GIN 索引 |
| 大小写处理 | 表名区分大小写(Linux) | 未加引号的标识符一律转小写 | 表名和字段名建表时最好全小写 |
| 字符串匹配 | LIKE 默认忽略大小写(部分排序规则) | LIKE 区分大小写,ILIST | 模糊搜索用 ILIKE |
| 空值排序 | NULL 默认排前面 | NULL 默认排最后(NULLS LAST) | 需要指定 NULLS FIRST/LAST |
上面这些如果迁移前没有通读一遍,上线后必然炸出各种奇奇怪怪的 SQLException。比如你写多了DATE_FORMAT(created_at, '%Y-%m-%d'),PG 直接报function date_format(timestamp with time zone, unknown) does not exist,只能用TO_CHAR(created_at, 'YYYY-MM-DD')。
还有一个很容易被忽略的陷阱是预编译参数类型。PostgreSQL 的 JDBC 驱动是强类型的,如果你把一个字符串传给一个 int 字段,驱动会直接报类型错误。此时可以在 JDBC URL 上加stringtype=unspecified,让驱动把字符串参数强制交给数据库去隐式转换。但我更推荐的做法是在 SQL 里显式转换:CAST(#{value} AS INTEGER),不要依赖 URL 参数来掩盖类型问题。
3.2 主键策略:SERIAL、IDENTITY 和 UUID 到底怎么选
MySQL 迁移过来的同学习惯用 BIGINT AUTO_INCREMENT,PostgreSQL 里对应两个选择:
SERIAL 系列本质是「序列 + 默认值」的语法糖:
CREATE TABLE biz.orders ( id BIGSERIAL PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE );建表后 PG 会自动创建一个序列orders_id_seq,自动生成下一个值。如果你在插入语句里手动给 id 赋值,序列不会自动跟随,后续插入再取序列值就可能撞主键。这是一个经典事故。
另一个更符合 SQL 标准的方式是GENERATED AS IDENTITY:
CREATE TABLE biz.orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE );GENERATED ALWAYS模式下你根本不允许手动插入 id 值,PG 会直接报错,从根上杜绝了序列不同步问题。所以在 PostgreSQL 14 以上的新项目里,我默认推荐这种写法。
如果是分布式系统需要全局唯一 ID,PG 也提供了 gen_random_uuid() 函数,配合 UUID 类型字段使用。但 UUID 作为主键在某些高并发写入场景下因为随机性会造成索引页分裂,性能略逊于自增序列,这个取舍要看具体场景。
实际项目里可以组合:主键用 BIGINT IDENTITY,另外加一个业务唯一键用 UUID。
3.3 JSONB 字段与 MyBatis 的映射:手写 TypeHandler
PG 的 JSONB 是很多项目的刚需,但在 MyBatis 里默认不能直接把它映射成 Java 对象,必须自己实现 TypeHandler。网上很多实现简单粗暴,把 JSON 字段映射成 String,然后业务层自己用 Jackson 解析,其实还有更好的方式。
先看数据库表:
CREATE TABLE biz.user_profile ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(64) NOT NULL, attributes JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now() );Java 实体:
public class UserProfile { private Long id; private String username; private Map<String, Object> attributes; private LocalDateTime createdAt; // getters and setters }接下来写一个通用的 JSONB TypeHandler:
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.type.BaseTypeHandler; import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.MappedTypes; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; @MappedTypes(Object.class) public class JsonbTypeHandler extends BaseTypeHandler<Object> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException("序列化 JSON 字段失败", e); } } @Override public Object getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private Object parse(String json) throws SQLException { if (json == null) { return null; } try { return MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {}); } catch (JsonProcessingException e) { throw new SQLException("反序列化 JSON 字段失败", e); } } }Mapper 接口里用注解标记字段:
@Insert(""" INSERT INTO biz.user_profile(username, attributes) VALUES (#{username}, #{attributes, typeHandler=com.example.demo.handler.JsonbTypeHandler}) """) int insertProfile(UserProfile profile); @Select(""" SELECT id, username, attributes, created_at FROM biz.user_profile WHERE id = #{id} """) @Results(id = "profileResult", value = { @Result(column = "id", property = "id"), @Result(column = "username", property = "username"), @Result(column = "attributes", property = "attributes", typeHandler = JsonbTypeHandler.class), @Result(column = "created_at", property = "createdAt") }) UserProfile selectProfileById(Long id);如果你习惯用 XML 映射,则在 resultMap 里声明:
<resultMap id="profileResultMap" type="com.example.demo.entity.UserProfile"> <id column="id" property="id"/> <result column="username" property="username"/> <result column="attributes" property="attributes" typeHandler="com.example.demo.handler.JsonbTypeHandler"/> <result column="created_at" property="createdAt"/> </resultMap>这里有两个值得注意的细节。第一,TypeHandler 反序列化时我推荐用TypeReference<Map<String, Object>>而不是Map.class,否则 Jackson 会把所有嵌套对象都当成 LinkedHashMap,遇到真正需要强转成 List 或具体业务类的场景会很头疼。第二,如果你的 JSON 结构是一个固定业务对象(比如某个配置类),完全可以泛型化 TypeHandler,把具体 Class 传进来,然后在 parse 里直接用传入的 class 反序列化,这样每个业务 JSON 字段都有类型安全。
3.4 日期时间类型映射:TIMESTAMPTZ 和 LocalDateTime 的配合
PostgreSQL 里时间日期类型有 TIMESTAMP(不带时区)和 TIMESTAMPTZ(带时区)。两个选择会直接影响 JDBC 映射行为:
TIMESTAMP WITHOUT TIME ZONE对应 Java 的LocalDateTime,JDBC 驱动直接映射,底层是同一套语义;TIMESTAMPTZ对应 Java 的OffsetDateTime或Instant,如果你用LocalDateTime去接它,驱动会按 JVM 默认时区转换,绕了一圈容易踩夏令时陷阱。
我的建议是:业务字段统一用TIMESTAMPTZ+OffsetDateTime/Instant,只在非常轻量的定时标记场景用LocalDateTime。之前在一个报表系统里,因为用了 TIMESTAMP WITHOUT TIME ZONE,服务器一迁移到 UTC 时区,所有“今天”的统计都错了,排查花了两个多小时。
MyBatis 在 3.5 之后对 JSR-310 时间类型有内置支持,不需要专门写 TypeHandler。你只需要保证 Java 实体里的字段类型和数据库字段类型语义对应即可。
4. 分页查询与常用优化实践:从 PageHelper 到游标分页
4.1 PageHelper 的接入与使用陷阱
分页是后端逃不掉的需求。MyBatis 领域最常见的方案是 PageHelper。配置起来很简单:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency>PageHelper 的核心用法是:
PageHelper.startPage(pageNum, pageSize); List<UserProfile> list = userProfileMapper.selectPage(); PageInfo<UserProfile> pageInfo = new PageInfo<>(list);这里有两个底层机制必须讲透。PageHelper 的startPage是线程绑定的 ThreadLocal 实现,所以它只对紧接着的下一条查询生效。如果你在分页查询前又执行了别的 Mapper 查询,那个查询也会被分页拦截,产生莫名其妙的结果。另外,每次查询返回的 List 都被包装成了Page对象,所以PageInfo能拿到 total、pages 这些信息。
还有一个常见困惑:PageHelper 如何产生 count 查询?它默认会在分页查询前单独执行一条 count 语句,如果你的主查询里有很重的 JOIN 或子查询,count 可能成为性能瓶颈。此时可以手动指定 count 语句:
PageHelper.startPage(pageNum, pageSize, true).setCountSql("SELECT COUNT(1) FROM ... ...");但我现在其实更倾向于少用 PageHelper,改用 PG 原生的LIMIT/OFFSET配合一个独立的 count 查询。原因有两点:一是 SQL 完全可控,不会因为 PageHelper 的优化逻辑在复杂 SQL 上生成错误的 count 语句;二是团队里新人读代码不需要理解 ThreadLocal 的魔法。
4.2 深分页性能:LIMIT/OFFSET 在大数据量下会拖垮数据库
LIMIT 100000, 20这种写法看着简单,但 PG 需要先把前面 100000 行全部扫描出来,再丢弃掉,OFFSET 越深,查询越慢。生产环境实测过,百万级表 OFFSET 到 5 万页之后,单条分页查询能飙到几秒钟。
解决办法是键集分页(Keyset Paging),也叫游标分页。示例:
SELECT id, username FROM biz.user_profile WHERE (created_at, id) < (#{lastCreatedAt}, #{lastId}) ORDER BY created_at DESC, id DESC LIMIT 20;简单讲就是用上一页最后一条记录的排序字段做条件,而不是用 OFFSET 跳过。这种方式在海量数据下依旧能走索引,性能稳定在毫秒级。缺点是不能精确跳页,只能一页一页往下翻,但对绝大多数 C 端列表、Feed 流应用来说完全够用。
如果是管理后台那种必须精确跳页的场景,我建议不要在大表上直接做深跳,可以加一个总数过滤条件把数据范围先收窄,再分页,效果会好很多。
4.3 N+1 查询与批量操作的常见优化
MyBatis 里最容易出现的性能问题是 N+1 查询:先查了列表,再循环逐个查关联信息。遇到这种场景,可以直接用 foreach 批量查或一次性 JOIN 查:
<select id="selectBatchByIds" resultMap="profileResultMap"> SELECT id, username, attributes, created_at FROM biz.user_profile WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>PostgreSQL 对IN列表执行计划优化得很不错,几千个 id 内性能都可以接受。但注意一个边界:当 id 数量超过几千时,PG 会把执行计划翻来覆去地计算,建议按 1000 一批分批去查。
批量插入的场景,PG 支持INSERT INTO ... VALUES (...), (...), (...) ...的多值语法,MyBatis 里用 foreach 构建多行插入,效率比一条条插入高一个数量级:
<insert id="batchInsert"> INSERT INTO biz.user_profile(username, attributes, created_at) VALUES <foreach collection="list" item="item" separator=","> (#{item.username}, #{item.attributes, typeHandler=...}, #{item.createdAt}) </foreach> </insert>注意一个限制:PG 一次插入的总包大小默认有上限,需要根据max_allowed_packet的对应参数去调,不过这个一般很难撞到,如果真的遇到,按 500 条一批切分。
4.4 连接池与事务隔离级别的取舍
PostgreSQL 的默认隔离级别是 Read Committed,这也是多数业务场景最喜欢的选择,因为每个语句看到的都是它开始时的已提交数据,写冲突时会直接等待或抛错,不会出现 MySQL 那种可重复读下间隙锁导致的大面积死锁问题。
如果你真的需要可重复读(比如对账、跑批),PG 的做法是为事务分配快照。但注意,PG 在可重复读模式下,一旦两个事务同时更新同一行,后提交的一方会直接收到 serialization failure 错误,而不是傻等。所以我在业务代码里对这类操作都做了重试逻辑,最多重试三次,成功概率很高。这里 Service 层用了 Spring 的@Transactional,异常类型是CannotSerializeTransactionException,别只捕获DataAccessException,否则重试逻辑不会触发。
5. 常见问题与排查技巧实录:用一张表把坑说完
整合过程中最多的困惑其实就是“为什么报这个错”。我这里把开发中遇到的高频问题整理成一份速查表,都是真实翻过源码、看过头皮发麻的堆栈总结出来的:
| 症状 | 根因 | 解决办法 |
|---|---|---|
ClassNotFoundException: org.postgresql.Driver | pom 缺驱动依赖或驱动被 runtime scope 排除 | 确认依赖存在,scope=runtime 没问题,但不要删 |
relation "user_profile" does not exist | schema 搜索路径不对,默认找 public | URL 加currentSchema=biz或执行SET search_path TO biz |
function xxx does not exist | MySQL 函数直接搬到了 PG | 按章节 3.1 的对照表改写 |
| 字段全是 null | 下划线转驼峰没开启 | 配置map-underscore-to-camel-case: true |
invalid input syntax for type integer: "abc" | 强类型参数不匹配 | 显式 CAST,不要依赖stringtype=unspecified |
Cannot insert the value NULL into column ... | 插入时显式包含了 null 字段且该列 NOT NULL | 检查 SQL 的动态<if>是否覆盖了必填字段 |
| PG 连接数大量占用 | 连接池设置过大 + 慢查询堆积 | 降低maximum-pool-size,优化慢 SQL,开 PgBouncer 做连接池代理 |
PSQLException: FATAL: terminating connection due to idle-in-transaction session timeout | 事务里长期占用连接不提交 | 检查 Service 层@Transactional是否加到了不合理的大方法上 |
| 中文乱码 | 数据库字符集不是 UTF-8 | 初始化集群时指定--encoding=UTF8 --locale=en_US.UTF-8 |
| JSONB 字段无法存入 | Java 传了 String 类型且多了一层引号 | 确认使用 TypeHandler 而不是直接 String 拼接 |
这里挑一个最典型的展开讲讲:“字段全是 null”的排查过程。
现象是查出列表后打印的实体里 id 有,username 也有,但 attributes 全是 null。第一反应是 TypeHandler 没生效。仔细看日志,SQL 输出是SELECT id, username, attributes, created_at但 attributes 在结果集里确实有值。最后发现是 resultMap 上 TypeHandler 没加@MappedTypes,MyBatis 默认用 Object 类型去匹配,结果属性找不到对应 handler 就退回了默认的 UnknownTypeHandler。
解决方案很简单:在 JsonbTypeHandler 类上加@MappedTypes(Object.class),或者直接在 XML resultMap 的<result>标签里明确指定jdbcType=OTHER:
<result column="attributes" property="attributes" jdbcType="OTHER" typeHandler="com.example.demo.handler.JsonbTypeHandler"/>这里我额外强调一个细节:PostgreSQL 的 JSONB 在 JDBC 驱动里返回的 jdbcType 是 OTHER(1111),所以如果你用了jdbcTypeForNull的全局配置,一定要关注 OTHER 类型。一个最稳妥的做法是让 TypeHandler 实现里直接通过ps.setString来写值,而不是依赖ps.setObject。
第二个要展开的坑是事务边界过长导致的连接耗尽。某一次生产事故里,一个导数据接口把@Transactional加在一个大循环方法上,每条数据都调用一次 Mapper 插入,整个事务保持了十几分钟。PostgreSQL 端连接全部卡在 idle-in-transaction 状态,新请求全部超时。解决方式是去掉外层事务,改成小事务逐批提交。这一点对新同学来说特别重要:不是所有操作都需要包在大事务里,事务粒度应该和方法粒度匹配,不是类粒度。
还有一个比较隐蔽的问题:MyBatis 开启二级缓存后,一旦数据库中某张表被外部程序改掉,缓存里的脏数据会一直存在。PG 项目里如果有多服务同时写一个库,建议默认关闭二级缓存,只依赖一级缓存和数据库自身的缓存能力。
写在最后:一个实用的小扩展
这套组合里的 JSONB + TypeHandler 其实还有更值钱的用法。我们后来在业务里做了一个动态表单场景,表单字段不固定,就把所有扩展字段塞进一个attributesJSONB 列,然后建了一个 GIN 索引:
CREATE INDEX idx_user_profile_attributes ON biz.user_profile USING GIN (attributes);查询的时候可以直接在 SQL 里做 JSON 路径匹配:
SELECT * FROM biz.user_profile WHERE attributes @> '{"level": "vip"}';MyBatis 里调用这个 SQL 时,只需要把 JSON 字符串拼进去即可,不需要拆表、不用改表结构,加字段就加 JSON key,省了一大堆 ALTER TABLE 的烦恼。我从这个项目里最大的体会就是:MyBatis 这种半自动 ORM 配合 PostgreSQL 的原生能力,能组合出的灵活度远超预期。如果你正在犹豫要不要上这套组合,别犹豫了,只要不是纯单表 CRUD 的超轻量应用,这套组合基本能陪你走很远。