☰
PostgreSQL JSONB与MyBatis-Plus自动注入:TypeHandler与MetaObjectHandler实践
2026/10/17 3:39:17 网站建设 项目流程

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. 四种方案横向对比与推荐组合

前面把四种方案单独讲完,现在放在同一张表里对比,会清晰得多。

方案解决层面实现成本自动注入能力维护成本适用场景
TypeHandlerJDBC 类型转换低只解决字段映射中任意 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: true

jsonb 字段内部 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 层的字段填充和数据库层的实际值之间,可能隔着好几个容易踩的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询