1. 先搞清楚:JSONB 映射和“自动注入”到底在解决什么问题
PostgreSQL 配 MyBatis-Plus 这个组合,我用了很长时间,最大的感受是:普通表结构一切顺利,一碰到 JSONB,官方文档那几行说明就远远不够了。标题里“自动注入”这个词看起来简单,实际牵扯到三个不同的层面:Java 对象怎么自动序列化进 JSONB 列、公共审计字段怎么在 insert/update 时自动填进去、数据库侧能不能再做一道兜底。如果不先把这三件事分开,后面看任何方案都会觉得糊涂。
先说 JSONB 映射为什么是“问题”。PostgreSQL 的 JSONB 在底层是以二进制格式存储的,JDBC 层最终打交道的是 JSON 文本,而不是 Java 对象。驱动本身不知道你把一个Map<String, Object>塞过来之后,应该用哪种序列化方式变成 JSON 字符串。更麻烦的是,如果直接把字符串塞给一个 jsonb 类型的列,PG 在类型推断上偶尔会给你一个模糊的错误,比如“column is of type jsonb but expression is of type character varying”。这就是为什么需要 TypeHandler:它负责在 Java 类型和 JDBC 类型之间搭一座桥,把对象序列化成 JSON,驱动才能正确写入。
再说“自动注入”。我把它拆成三层理解:
- 应用层自动填充:MyBatis-Plus 自带的
MetaObjectHandler,在insertFill、updateFill时把创建人、创建时间、更新人、更新请求 ID 等公共字段填进实体,这是绝大多数项目所说的“自动注入”。 - JDBC 层自动转换:通过 TypeHandler,把实体里的 POJO、Map、List 自动转换成 JSONB 写入,再把查询结果自动反序列化回对象,这一层解决“字段类型怎么匹配”的问题。
- 数据库层自动生成:触发器或者默认值,在数据库内部完成字段的合并、更新、兜底,好处是不管什么入口写入,数据都保持一致。
这篇文章会围绕这三层展开,梳理几种我在实际项目里验证过、也踩过坑的做法,最后给一套组合建议。适合正在用 MyBatis-Plus 做 PostgreSQL 开发,尤其被 jsonb 字段、公共审计字段搞到头大的后端工程师。哪怕你是刚接手项目的初学者,按文里的步骤也能直接上手复现。
1.1 普通实体字段与 JSONB 字段的本质差别
普通字段,比如BIGINT、VARCHAR、TIMESTAMP,MyBatis-Plus 通过内置类型处理器就能完成映射。但 JSONB 字段在 Java 实体上通常被声明成这几类:
- 直接声明成
String,入库前手动objectMapper.writeValueAsString(...),出库后手动readValue(...)。简单粗暴,但每个业务地方都要手写,容易漏。 - 声明成
Map<String, Object>或JsonNode,解决动态结构问题,但仍然需要处理器。 - 声明成自定义 POJO,比如商品详情里有一个
List<ProductSpecItem>,结构固定但有多层嵌套。这种情况下必须注册 TypeHandler,否则 MyBatis-Plus 只把它当成普通对象,入库时会出现无法序列化的问题。
这三种声明方式,在“读写都正常”的前提下,代码量差异不大,真正的分水岭在于:你是否要求字段自动注入、自动合并、自动兜底。如果只是纯粹把对象存进去再读出来,一个 TypeHandler 就够;如果还要求插入或更新的时候自动往这个 JSONB 对象里塞审计信息、操作上下文,那单纯靠 TypeHandler 就不够了,需要结合自动填充或者数据库触发器。
1.2 为什么选 jsonb 而不选 json 或 text
既然要存 JSON,为什么不用text或者普通的json类型?我在项目里对比过:text存 JSON 也能用,但你没法直接对内部字段做查询和索引,所有解析都要拉到应用层做;json类型虽然能校验 JSON 格式,但不支持索引,也没有高效的内部操作符。jsonb做了二进制分解,支持 GIN 索引,支持@>、?、#>>这类操作符,可以直接在 SQL 里对 JSON 内部字段做条件过滤。
比如要查规格里颜色等于红色的商品,jsonb可以直接写:
SELECT id, name, spec FROM product WHERE spec @> '{"color":"red"}'::jsonb;这条查询如果要跑得快,还能给 spec 建一个 GIN 索引:
CREATE INDEX idx_product_spec ON product USING GIN(spec);这是json/text很难做到的事情。所以只要业务里需要对 JSON 内容做检索、统计、部分更新,选jsonb几乎是必然的。本文后面所有例子都以jsonb为基础。
2. 方案一:TypeHandler 直接映射,最基础也最容易踩坑
第一个方案是绕不开的基础,后面几个方案都会用到它。它的核心思路是:给实体里的 JSONB 字段指定一个自定义 TypeHandler,让 MyBatis-Plus 在写入时自动把 Java 对象序列化成 JSON 文本,在查询时自动把 JSON 文本反序列化成 Java 对象。这一层解决的是“字段级自动转换”,也是“自动注入”最底层的部分。
2.1 建表与实体准备
先看一个典型场景。某个商品表需要存规格信息,规格是不固定的若干键值对,因此用spec字段存成 jsonb:
CREATE TABLE product ( id BIGSERIAL PRIMARY KEY, name VARCHAR(128) NOT NULL, spec JSONB NOT NULL DEFAULT '{}'::jsonb, creator_id BIGINT, created_at TIMESTAMP DEFAULT now() );对应实体类里,我希望spec字段在 Java 侧是List<ProductSpecItem>,因为业务上规格是多个条目的集合:
@Data @TableName(value = "product", autoResultMap = true) public class Product { private Long id; private String name; @TableField(typeHandler = SpecListTypeHandler.class) private List<ProductSpecItem> spec; private Long creatorId; private LocalDateTime createdAt; }这里有两个关键点必须强调:
第一,@TableName上必须加autoResultMap = true。如果不加,MyBatis-Plus 在查询的时候不会使用你声明的typeHandler,spec字段反序列化时会失败或变成 null。这是我见过频率最高的坑。MP 只有在autoResultMap = true时才会根据@TableField上的 typeHandler 生成正确的 ResultMap。
第二,@TableField(typeHandler = SpecListTypeHandler.class)是 JDBC 层自动注入的关键。不写这个注解,MP 只会按 Java 类型做默认处理,POJO 类型一般会直接报错。
2.2 手写一个 TypeHandler 的完整代码
网上很多帖子的做法是直接用 MyBatis-Plus 自带的JacksonTypeHandler,但它有一个隐藏问题:如果你把字段声明成List<ProductSpecItem>,由于 Java 泛型擦除,反序列化时它不知道List里装的是什么类型,很容易抛cannot deserialize from Object token或直接得到一个List<LinkedHashMap>,赋值时再报类型转换失败。所以我更推荐自己继承BaseTypeHandler,把类型信息写死,一劳永逸。
以 PostgreSQL 为例,最稳妥的做法是构造驱动自带的 JSONB 类型对象,而不是直接ps.setString。下面这段代码是我实际项目里用过很多次的模板:
@MappedJdbcTypes(JdbcType.OTHER) public class SpecListTypeHandler extends BaseTypeHandler<List<ProductSpecItem>> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<ProductSpecItem> parameter, JdbcType jdbcType) throws SQLException { try { PGobject json = new PGobject(); json.setType("jsonb"); json.setValue(MAPPER.writeValueAsString(parameter)); ps.setObject(i, json); } catch (JsonProcessingException e) { throw new SQLException("spec 字段序列化失败", e); } } @Override public List<ProductSpecItem> getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public List<ProductSpecItem> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public List<ProductSpecItem> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private List<ProductSpecItem> parse(String json) throws SQLException { if (json == null || json.isEmpty()) { return Collections.emptyList(); } try { return MAPPER.readValue(json, new TypeReference<List<ProductSpecItem>>() {}); } catch (JsonProcessingException e) { throw new SQLException("spec 字段反序列化失败", e); } } }注意这里为什么用PGobject:PostgreSQL 的 JDBC 驱动对未知类型有时会推断为 text/varchar,导致 jsonb 列报类型不匹配。通过PGobject显式声明类型是jsonb,能避开大部分类型推断问题。如果你不想引入驱动类,也可以直接ps.setString,然后在连接串上追加stringtype=unspecified参数,让数据库根据目标列自行推断类型。但这个参数会全局影响所有字符串参数的类型推断,不建议无脑加,后面避坑清单里会展开说。
2.3 查询时的读取流程与自动映射
写入的问题解决之后,再来看读取。执行selectById时,MyBatis-Plus 会走autoResultMap生成的 ResultMap,发现spec字段配置了SpecListTypeHandler,于是调用getNullableResult,把数据库返回的 JSON 文本转成List<ProductSpecItem>。整个过程对业务代码透明,你只管操作实体,序列化和反序列化都在框架内部完成,这就是“自动注入”最直观的体验。
但有三种情况会让 typeHandler 不生效,我逐一排查过:
- 使用了自定义 SQL,比如在 Mapper XML 里手写了
select * from product且没有配置<resultMap>,MP 不会自动帮你处理 jsonb 字段,需要在 resultMap 里手动指定 typeHandler。 - 使用
QueryWrapper.select("id", "name", "spec")这种局部查询时,如果 MP 生成的结果映射没有带上 typeHandler,spec也可能返回 null。 - 表里有多个 jsonb 字段,但实体类只给其中一个加了 typeHandler,另一个会退化回默认映射。
方案一的局限性也很明显:它只解决了“Java 对象和 JSONB 字段之间的自动转换”,并没有解决“insert/update 时自动把公共字段塞进 JSONB 里”。如果只是做最基本的存取,它完全胜任;如果还要自动填充审计信息,就得看方案二。
3. 方案二:MetaObjectHandler 自动填充,顺手把审计信息塞进 JSONB
如果说 TypeHandler 是“列级转换器”,那MetaObjectHandler就是 MyBatis-Plus 在应用层的“字段自动注入器”。它能在 MP 执行内置的 insert/update 方法之前,通过反射把你指定的字段值填进实体对象,然后随 SQL 一起入库。这个机制非常适合处理创建人、创建时间、更新人、更新时间、请求追踪 ID 这类的公共字段。
3.1 内置方法与执行时机
实现方式很简单。定义一个 Spring 组件,继承MetaObjectHandler:
@Component public class AuditMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "creatorId", Long.class, LoginContext.getUserId()); this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "auditInfo", Map.class, buildAuditMap(null)); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updaterId", Long.class, LoginContext.getUserId()); this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, "auditInfo", Map.class, buildAuditMap(LoginContext.getUserId())); } }实体字段上需要标注填充时机:
@TableField(fill = FieldFill.INSERT) private Long creatorId; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT_UPDATE) private Map<String, Object> auditInfo;这里有一个重要特性:strictInsertFill是“严格填充”,它只会在目标字段类型与传入 Type 匹配时生效。比如creatorId是Long,你传入Long.class,这个没问题;但如果你把它设成long基本类型,Long.class就对不上,字段不会被填充。出现这种诡异情况时,可以退一步用setFieldValByName直接写入,或者把实体的公共字段统一改成包装类型Long。
3.2 把整个审计快照作为一个 JSONB 字段注入
比单纯填creatorId更进一步的做法是:把当前用户信息、请求 ID、来源端、操作时间打包成一个 Map,整体注入到一个 JSONB 字段里。这样在事后排查问题时,一条记录就能看到“谁、在哪个请求、什么时候、从哪个端”修改了数据。
buildAuditMap可以这样实现:
private Map<String, Object> buildAuditMap(Long userId) { Map<String, Object> audit = new LinkedHashMap<>(); audit.put("creatorId", userId); audit.put("traceId", TraceContext.getTraceId()); audit.put("source", "backend-admin"); audit.put("operatedAt", System.currentTimeMillis()); return audit; }注意:MetaObjectHandler里 set 进去的是一个Map,最终能不能变成 PostgreSQL 的 jsonb,取决于实体auditInfo字段上是否配置了对应的 TypeHandler。就像方案一里的SpecListTypeHandler一样,这里需要一个能把Map<String, Object>序列化成 JSONB 的 Handler。如果不配,MP 会把 Map 当普通对象处理,入库时多半会失败。
所以方案二其实是和方案一组合使用的:MetaObjectHandler负责在内存中构造注入内容,TypeHandler负责把注入内容写成 jsonb。两者缺一不可。
实体字段完整写法:
@TableField(value = "audit_info", typeHandler = JsonMapTypeHandler.class, fill = FieldFill.INSERT_UPDATE) private Map<String, Object> auditInfo;3.3 update 时“合并”JSONB 的并发隐患
方案二在INSERT场景下很好用,但在UPDATE场景下要小心。如果你想在更新时给auditInfo里追加updaterId,最直接的做法是先在应用层查出旧数据,把旧 Map 取出来,put 新字段,再整体更新回去。这个流程有两个问题:
- 多一次查询,性能损耗不可忽略。
- 如果两个并发请求同时读旧数据、同时 put、同时写回,后写的会覆盖先写的字段,造成“丢失更新”。
数据库层方案用jsonb_set可以解决,但应用层方案做不到,因为应用层拿到的 JSONB 永远是“某个时刻”的旧快照。如果auditInfo里需要积累多次修改记录,比如“最近一次操作人”“最近一次操作时间”,方案二的正确做法是只在实体里维护一个“增量字段”,让 SQL 层用jsonb_set去合并,而不是整个 Map 覆盖。这点在方案三里会体现得更明显。
3.4 自动填充在哪些情况下失效
用MetaObjectHandler踩过的坑,我总结如下:
MetaObjectHandler只在 MyBatis-Plus 内置方法上生效,比如insert、insertBatch、updateById、update等。如果你在 Mapper XML 里手写 SQL,或者自己实现了一个CustomMethod,框架不会自动调用fill。- 批量插入
saveBatch时,MP 走的是SqlSessionTemplate批量提交,MetaObjectHandler实际上仍会被调用,但因为 BatchExecutor 刷新时机的问题,某些字段填充会提前完成,遇到异常时回滚判断要格外小心。 - 同一实体同时使用逻辑删除
@TableLogic、乐观锁@Version、自动填充@TableField(fill = ...)时,字段注入顺序不确定,最好自己先在单测里把insert、updateById、selectById三个路径都跑一遍,验证字段到底有没有被填上。 - 多线程共享同一个
ObjectMapper没问题,但共享同一个LoginContext/TraceContext就要小心线程隔离,通常用ThreadLocal或请求作用域实现,别把上一个请求的用户信息误填进下一个请求。
4. 方案三:数据库触发器加 JSONB 函数,把自动注入下沉到数据库
方案二的局限在于:它只覆盖走 MyBatis-Plus 的写路径。如果系统里还有其他写入入口,比如数据导入工具、定时任务、运维直接执行 SQL、别的服务直连数据库,这些入口不会经过MetaObjectHandler,自动注入就会出现缺口。这个时候,把“自动注入”下沉到 PostgreSQL 触发器,是一条值得认真考虑的路线。
4.1 触发器的思路设计
触发器方案的核心是:在BEFORE INSERT OR UPDATE时修改NEW记录里的 JSONB 字段,由数据库保证每次写入都自动带上审计字段。应用层哪怕完全不传auditInfo,数据库也会把它补全。这种“兜底”能力和应用层自动填充不冲突,很多人是两层都做,以防万一。
需要特别注意的是:触发器修改NEW的字段,是为了让最终 insert/update 生效。你只能在BEFORE触发器里改NEW,不能在AFTER触发器里修改NEW,因为AFTER阶段数据已经写入或更新完了。
4.2 一个可直接落地的触发器函数
下面是一个实际可用的 PostgreSQL 触发器函数,用于在插入时自动给audit_info注入创建时间和创建人字段,更新时注入更新时间和追踪 ID:
CREATE OR REPLACE FUNCTION trg_product_audit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN NEW.audit_info := jsonb_set( COALESCE(NEW.audit_info, '{}'::jsonb), '{created_at}', to_jsonb(clock_timestamp()) ); NEW.audit_info := jsonb_set( NEW.audit_info, '{creator_id}', to_jsonb(NEW.creator_id) ); RETURN NEW; ELSIF (TG_OP = 'UPDATE') THEN NEW.audit_info := jsonb_set( COALESCE(NEW.audit_info, '{}'::jsonb), '{updated_at}', to_jsonb(clock_timestamp()) ); NEW.audit_info := jsonb_set( NEW.audit_info, '{trace_id}', to_jsonb(current_setting('app.trace_id', true)) ); RETURN NEW; END IF; RETURN NEW; END; $$; CREATE TRIGGER trg_product_audit_biu BEFORE INSERT OR UPDATE ON product FOR EACH ROW EXECUTE FUNCTION trg_product_audit();这段代码里有两个关键函数和一个易错点,我分别解释一下。
jsonb_set(target, path, new_value)用于在一个 JSONB 对象内部指定路径处插入或更新值。路径必须是一个文本数组,比如'{created_at}'表示 JSONB 根下的created_at字段;如果想要更新第二层的字段,可以写成'{user, id}',它等价于audit_info -> 'user' -> 'id'。new_value必须是 jsonb 类型,所以now()要先用to_jsonb()包裹,普通的字符串常量也要用to_jsonb('abc')转一下。
COALESCE(NEW.audit_info, '{}'::jsonb)是为了处理初始值为 NULL 的情况。如果应用层插入时把audit_info传成 NULL,直接对 NULL 做jsonb_set会返回 NULL,所以先兜底为一个空 JSON 对象{}。这是一个很典型的触发器写法。
易错点是current_setting('app.trace_id', true)。PG 默认不允许读取未定义的配置参数,第二个参数true表示“缺失时返回空值而不是报错”。但想把这个参数传进事务,应用层必须先执行:
SET LOCAL app.trace_id = 'req-12345';这个SET LOCAL只在当前事务内有效,连接归还连接池后不会污染其他请求。如果你的项目里所有写操作都开启事务,就可以在事务开启后、执行 insert/update 之前先发送一条SET LOCAL命令。实现方式可以是 MyBatis-Plus 的拦截器,也可以在连接代理层执行。如果事务边界不清晰,建议只把current_setting当作可选增强,不要依赖它做核心链路。
4.3 触发器方案的优缺点和适用边界
触发器方案最吸引人的点,是“任何写入入口都被兜底”。我曾经在一个数据迁移项目里试过把它作为唯一防线,效果确实好,应用层代码几乎没写。但它也有几个明显的坑:
- 业务上下文不容易传递。触发器能拿到
NEW记录里的字段,拿不到应用层的登录用户、请求 ID,除非你自己定义一套会话参数传递机制。 - 触发器逻辑是隐式的。新人接手时如果不看数据库脚本,根本不知道成绩数据被谁改过,排查问题容易懵。
- 如果触发器里写了
clock_timestamp(),同一事务里不同语句的返回值不同,而now()返回的是事务开始时间。两者语义差异很大,别混用。 - 触发器函数中不能访问应用层抛出的异常,也不好做复杂校验。复杂的校验应该留在应用层,数据库层只做合并与兜底。
所以我的判断是:触发器方案适合“写入口特别多、团队 DBA 能力强、审计一致性要求极高”的项目。普通的 CRUD 后台项目,优先用方案二,触发器和方案二可以共存,但不要重复写同样的字段。如果两边都写,最终生效的是触发器覆盖后的内容,排查时会分不清值到底从哪来的。
5. 方案四:自定义 SQL 注入器,把 JSONB 自动注入写进 SQL 模板
方案二依赖 MyBatis-Plus 内置方法,触发器依赖数据库对象,那么有没有一种方式,既不需要数据库触发器,又能让“所有写 SQL 都自动带上 JSONB 审计字段”成为团队规范?答案是自定义 SQL 注入器。这个方案比前三个要重一些,但在某些强规范团队里非常实用。
5.1 自定义 SQL 注入器的基本原理
MyBatis-Plus 允许通过DefaultSqlInjector来扩展 BaseMapper 的方法。默认的DefaultSqlInjector把selectById、insert、updateById这些方法以AbstractMethod的方式注入到 Mapper 接口中。你也可以新增一个AbstractMethod,把一段通用的insertWithAuditSQL 注入进去,然后所有 Mapper 直接调用这个方法。
听起来很绕,你可以把它理解成“给 MyBatis-Plus 增加一个内置的 SQL 模板”。这个模板里写死了一条 SQL 的骨架,但字段名和表名通过TableInfo动态替换,调用方只需要传入实体,框架自动拼装。
5.2 实现一个 insertWithAudit 方法的步骤
先看自定义注入器:
public class AuditSqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) { List<AbstractMethod> methods = super.getMethodList(mapperClass, tableInfo); methods.add(new InsertWithAudit()); return methods; } }然后继承AbstractMethod来实现InsertWithAudit。我自己在实验性项目里写过一版,核心过程是这样的:
- 解析
TableInfo,拿到表名、主键、普通字段列表、自动填充字段列表。 - 拼接 INSERT INTO 语句的字段列表和 VALUES 列表。
- 对于 jsonb 字段,在 SQL 里插入一段
jsonb_set表达式,把审计字段合入传入的 jsonb 值。 - 最后用
SqlSource创建对应的 MappedStatement,注册到 mapper。
这里代码量比较大,完整实现适合单独开一篇,我先把最关键的一段拼 SQL 思路写出来:
INSERT INTO product(name, spec, audit_info) VALUES ( #{name}, #{spec, typeHandler=SpecListTypeHandler}, jsonb_set( COALESCE(#{auditInfo, typeHandler=JsonMapTypeHandler}, '{}'::jsonb), '{created_by}', to_jsonb(#{creatorId}) ) )这条 SQL 有几个点值得注意:
#{spec, typeHandler=...}代表在 SQL 中显式指定 TypeHandler,防止 MP 在自定义 SQL 中丢掉 jsonb 映射。COALESCE(#{auditInfo}, '{}'::jsonb)和触发器里的COALESCE同理,避免 NULL。jsonb_set被直接写进 SQL,相当于把“自动注入”的责任放在 SQL 层。
5.3 方案四的适用场景与协调成本
方案四的灵活性是最高的,因为 SQL 是显式写的,团队成员 review 代码时一眼就能看到 SQL 里做了什么。但它也有明显的成本:
- 实现门槛高。需要理解 MyBatis-Plus 的
AbstractMethod、SqlSource、MappedStatement机制,一般项目没必要为了省几个字段就引入。 - 与方案二的自动填充容易冲突。你已经手写了
insertWithAudit,如果同时实体字段上还有fill = FieldFill.INSERT,MP 可能重复填充。 - 自定义 SQL 和内置方法维护两套逻辑,团队里不同人风格不同,容易混乱。
在我个人经验里,方案四更多是“备选”,而不是“首选”。如果团队里有人已经把 MyBatis-Plus 的注入器机制玩得很透,并且希望审计逻辑完全显式化,可以上;否则,我更倾向于稳妥的方案二。
6. 四种方案横向对比与推荐组合
前面把四种方案单独讲完,现在放在同一张表里对比,会清晰得多。
| 方案 | 解决层面 | 实现成本 | 自动注入能力 | 维护成本 | 适用场景 |
|---|---|---|---|---|---|
| TypeHandler | JDBC 类型转换 | 低 | 只解决字段映射 | 中 | 任意 JSONB 字段读写 |
| MetaObjectHandler | 应用层字段填充 | 低 | 强,可填充任意实体字段 | 低 | 普通 CRUD 业务,绝大多数项目 |
| 数据库触发器 | 数据库层兜底 | 中 | 强,所有写入口生效 | 中 | 多写入口、数据迁移、一致性要求高 |
| 自定义 SQL 注入器 | SQL 模板层 | 高 | 中,依赖 SQL 模板 | 高 | 强规范团队、审查严格的项目 |
我实际项目中保留时间最长的是“方案一 + 方案二”。理由很简单:TypeHandler 解决所有 jsonb 字段能不能正确读写的问题,MetaObjectHandler 解决公共字段怎么自动注入的问题,两者配合已经覆盖了 95% 的业务场景。触发器方案只用在数据导入表或历史表上,因为那些表的写入口不受我代码控制。自定义 SQL 注入器我在两个项目里见过别人用,效果分化的主要原因不在于技术本身,而在于团队愿不愿意维护那套 SQL 生成逻辑。
6.1 组合使用时字段填充的顺序问题
同时使用多种方案时,注意不要重复填同一个字段。比如creator_id在应用层由MetaObjectHandler充填,数据库侧触发器又往audit_info里写creator_id,两边各写各的没问题;但如果两边都写实体的同一个普通字段,就会产生不确定性。
推荐分工是:
- 普通字段的创建人、创建时间、更新人、更新时间:交给
MetaObjectHandler。 - 动态审计信息 JSONB(包含 traceId、来源端、修改历史):交给数据库触发器的
jsonb_set合并。 - TypeHandler 是底层的“翻译官”,不管谁往 JSONB 里塞内容,最终都要经过它。
6.2 性能对比与 JSONB 使用心态
每次有人问“JSONB 性能是不是很差”,我都会反问一句:你是拿它和谁比?和普通BIGINT索引字段比,JSONB 的存储开销和序列化开销确实更大;但和 text 拼 JSON、再在应用层整体解析后做判断相比,JSONB 反而更快。PGLite 场景下,GIN 索引加@>查询可以在毫秒级返回结果;
一个比较实用的经验是:不是所有的 JSON 都值得用 jsonb。如果某个字段只是存一份原样 JSON,后续不会做内部查询,不需要索引,也不需要jsonb_set增量更新,那用json或者text反而更省空间。JSONB 的优势在于“用得起来”,而不是“存得下去”。
另外,很多团队最开始的开发者喜欢在每个表上都放一个 JSONB 扩展字段,美其名曰“灵活”。结果是实体越来越大,查询时SELECT *永远把大 JSONB 捞出来,应用层反序列化一次,哪怕只用到其中一个字段。这种情况下性能差和 JSONB 本身关系不大,是使用方式的问题。我的原则是:JSONB 字段应该配合 GIN 索引和部分字段提取来用,别在不需要的查询路径上把它整个加载出来。比如只取规格里的颜色,SQL 可以直接写:
SELECT spec ->> 'color' AS color FROM product WHERE id = #{id};这样应用层拿到的就不是整个 JSONB,而是一个字符串,省掉一次大字段反序列化。
7. 实操避坑清单:驱动、命名、事务与测试
最后一部分,我把踩过的坑按频率排一个清单,每条都是实际发生过的问题,不是理论推导。
7.1 autoResultMap 缺失导致查询失败
这是最容易被忽略的。实体类@TableName上没有autoResultMap = true,字段上再写@TableField(typeHandler = SpecListTypeHandler.class)都没用。查询的时候 ResultMap 是默认生成的,typeHandler 没被挂上去,spec字段要么返回 null,要么反序列化时按字符串赋值失败。经验是:只要实体里有任何一个 jsonb 字段配了 TypeHandler,autoResultMap = true就必须写。
7.2 JDBC 驱动的类型推断问题
PostgreSQL JDBC 驱动对 jsonb 列有几种处理方式。第一种是在 TypeHandler 里用PGobject显式指定类型,这是我最推荐的,可控性最强。第二种是直接ps.setString,如果遇到类型不匹配错误,再在连接串上追加stringtype=unspecified。这个参数把所有字符串都变成 unknown 类型,让数据库自己去推断目标类型,问题能解决,但副作用是数据库对所有字符串参数都要多一步类型推断,某些复杂场景下甚至会影响执行计划。如果你只是为了让 jsonb 能写入,别用这种全局开关,老老实实写 TypeHandler。
7.3 列名大小写与驼峰映射
PostgreSQL 对不带引号的列名会自动折叠成小写。实体里如果写creatorId,没有配置map-underscore-to-camel-case的情况下,SQL 生成时可能查不到列,或者报 “relation does not exist”。建议所有物理列都用 snake_case,实体字段用驼峰,然后开启配置:
mybatis-plus: configuration: map-underscore-to-camel-case: truejsonb 字段内部 JSON 的 key 不受数据库列名规则影响,可以保持业务习惯,比如creatorId、traceId,这是 JSON 自己的命名空间,和 PostgreSQL 列名大小写是两个维度。
7.4 事务边界和自动填充的协作
MetaObjectHandler的填充发生在事务开启、SQL 执行前,不依赖数据库当前事务。这里有个隐藏问题:如果同一个事务里先执行一次updateById,修改实体字段后再执行第二次updateById,第二次触发updateFill时,LoginContext里的用户信息可能已经变了,比如异步线程切换后 ThreadLocal 丢失。此时填充的可能是 null,导致数据库字段被清空。建议在updateFill里对 null 做保护:
Long userId = LoginContext.getUserId(); if (userId != null) { this.strictUpdateFill(metaObject, "updaterId", Long.class, userId); }7.5 集成测试怎么验证自动注入是否真的生效
最后分享一个笨但有效的方法。不要只断言 Java 实体里的字段有没有被填充,要直接查数据库里的 JSONB 内容。写一个集成测试,插入一条 Product,然后执行:
SELECT audit_info -> 'creator_id' AS creator_id FROM product WHERE id = #{id};断言结果是当前测试用户 ID,同时检查spec字段能用@>操作符命中。这一步能同时验证 TypeHandler 和自动注入链路是否真的通。另外,把测试环境跑在容器化的 PostgreSQL 上,而不是用内存数据库去模拟,因为jsonb_set、GIN索引、PGobject这些行为和具体数据库版本强相关,内存数据库很容易给你一个“测试全绿、上线全红”的假象。
7.6 序列化细节别忽略
反序列化时,JSON 里多了未知字段,如果ObjectMapper配置了FAIL_ON_UNKNOWN_PROPERTIES,会直接抛异常。我在一个历史遗留表上就遇到过,库里audit_info已经被旧系统写入了大量未定义字段,新实体只声明了其中几个,结果查一条抛一条。正确的做法是全局关掉这个特性,或者按字段单独处理。
日期和时间也要统一格式。JSONB 里如果既有人存2025-01-01 12:00:00,又有人存时间戳1735689600000,后续做jsonb_path_query排序时会被迫做类型转换,非常痛苦。建议团队定一个硬性约定:内部 JSONB 的时间字段统一存 ISO-8601 字符串,外部接口需要再转换。
我个人在实际操作中的体会是:不要试图找一个“万能方案”,而是把底层 TypeHandler、应用层自动填充、数据库层兜底这三件事分开治理。TypeHandler 是地基,无论如何都要配好;应用层自动填充适合大多数业务;触发器作为兜底只用在信不过的写入口上。这种分层方式,既不会过度设计,又能把每个方案的优点都用上。另一点心得:任何自动注入逻辑,最终都要回到数据库里验证一次。JSONB 字段值到底是什么样,只有查过真实 jsonb 列之后才算数,Java 层的字段填充和数据库层的实际值之间,可能隔着好几个容易踩的坑。