☰
PostgreSQL JSONB与MyBatis-Plus自动注入方案详解
2026/10/17 21:22:27 网站建设 项目流程

先亮个结论:PostgreSQL 里的 JSONB 是真好用,但放到 Java 生态,尤其是我平时用得最多的 MyBatis-Plus 里,你会发现默认行为经常“对不上号”。实体里一个 Map 或自定义对象,数据库里一个 jsonb 列,插件默认的自动映射根本不知道该把两端怎么接起来;更麻烦的是插入数据时希望自动带上的 create_time、update_time 这类系统字段,一旦和扩展字段放一起,各种“该写没写”、“写了类型不对”的毛病全冒出来。

这篇文章不绕弯子,直接围绕“PostgreSQL 中使用 JSONB 类型并结合 MyBatis-Plus 实现自动注入”这个场景,把我在生产环境里用过、踩过坑、最后稳定跑着的几种方案,挨个讲清楚。会讲原理、贴代码、给配置,最后把常见的坑和排查思路也整理成清单。适合正在搞 Spring Boot + MyBatis-Plus + PostgreSQL 这套组合的 Java 开发,如果你刚被 JSONB 映射和自动填充折腾过,那这篇就是给你准备的。

1. 先弄明白:JSONB 与自动注入到底要解决什么问题

1.1 JSONB 的典型用途和 Java 侧的真实痛点

PostgreSQL 从 9.4 开始提供 JSONB 类型,相比普通 JSON 类型,它的优势是二进制存储、支持索引(GIN)、支持丰富的 jsonb 运算符和函数。实际项目里通常拿它存三类数据:一类是业务上的“扩展字段”,比如订单表里不同渠道带过来的不同属性;一类是高度动态的配置快照,比如某个规则引擎跑出来的完整参数;还有一类是日志、审计信息这类结构不固定的数据。

Java 侧的问题从来都不在数据库,而在于 ORM 框架对 jsonb 的支持。MyBatis 本身有 TypeHandler 机制,但默认并不认识 PostgreSQL 的 jsonb 类型。MyBatis-Plus(下面简称 MP)虽然封装了很多默认行为,但在 JSONB 面前也只是“能用默认映射硬闯”,结果通常就是:

  • 读出来是一个 String 或 PGobject,没法直接当对象用;
  • 写进去变成 varchar 的字符串,列的 jsonb 类型校验直接报错;
  • 查询条件里想用 jsonb 的->、->>、@>运算符,Wrapper 很难直接表达。

这些问题叠加 MP 的自动填充功能之后,场景就更复杂了。所谓“自动注入”,在 MP 语境里通常指两件事:一是MetaObjectHandler实现的字段自动填充(insert 时自动带 create_time,update 时自动带 update_time 这类操作);二是通过 SQL 注入器动态给 Mapper 注入自定义方法。而一旦实体里同时存在 jsonb 字段和需要自动填充的普通字段,顺序、类型、序列化方式任何一个环节出错,数据就写不进去。

1.2 自动注入在 MyBatis-Plus 里的两层含义

第一层是“自动填充”(Auto Fill),对应@TableField(fill = FieldFill.INSERT / UPDATE / INSERT_UPDATE)注解加MetaObjectHandler实现类。它的执行时机是 MP 在生成 INSERT 或 UPDATE 语句之前,通过反射把 handler 里设置的值注入实体字段,再拼进 SQL。这个过程发生在应用层,不经过数据库触发器,所以“填充什么值、填充到哪些字段”完全由 Java 代码控制。

第二层是“自动注入器”(ISqlInjector),对应DefaultSqlInjector的子类或自定义AbstractMethod。它的执行时机更早,在 Mapper 接口加载阶段就把自定义方法(比如insertWithJsonb、updateJsonbField)的逻辑注入进去。如果你需要一些 MP 标准方法表达不好的 SQL 片段,比如INSERT INTO ... (extra) VALUES ('{}'::jsonb)、或者WHERE extra @> '{"key":"value"}'::jsonb,就可以走这条路。

理解这两层区别很重要,因为很多教程把“自动注入”和“自动填充”混为一谈。在 JSONB 场景里,你可能两个都需要:用自动填充解决 create_time、create_by 这类审计字段的默认值,用 TypeHandler 或自定义 SQL 注入解决 jsonb 列的正确读写。

2. 准备工作和基础选型:别在第一步就埋坑

2.1 依赖与环境的推荐组合

先说清楚我验证过的组合,能让后面几种方案跑得最顺:

  • PostgreSQL 12 以上,实际上 9.4+ 都支持 jsonb,但 12 以上对 jsonb 的函数和 GIN 索引支持更完整;
  • Spring Boot 2.7.x,Java 8 以上都可以,Java 11/17 也没问题;
  • mybatis-plus-boot-starter 3.5.x;
  • 驱动用 PostgreSQL JDBC 42.x;
  • 序列化工具推荐 Jackson(spring-boot-starter-json 自带),后面 TypeHandler 默认就用 ObjectMapper 处理。

Maven 核心依赖大概是这样:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency>

有一个比较容易忽略的细节:如果你用的连接池是 HikariCP(Spring Boot 默认),建议给数据源配置加一个参数dataSource-properties: stringtype=unspecified。这个参数不能乱加,它只建议在你已经用 TypeHandler 或 PGobject 方案时不用加,因为驱动默认会把字符串参数当作 varchar 发送,而 jsonb 列要求接收 jsonb 类型输入,两边类型不一致就容易报“column is of type jsonb but expression is of type character varying”。我们后面用 PGobject 或者::jsonb转换来规避这个报错,并不依赖它。

2.2 表结构和实体模型的初始化

用一个线上最常见的“扩展字段”场景来做演示:一张业务订单表,除了固定字段,还有一列extra用来存渠道定制属性。建表 SQL:

CREATE TABLE biz_order ( id BIGSERIAL PRIMARY KEY, order_no VARCHAR(64) NOT NULL, amount NUMERIC(10,2) NOT NULL, status VARCHAR(32) NOT NULL, extra JSONB NOT NULL DEFAULT '{}'::jsonb, create_time TIMESTAMP NOT NULL DEFAULT now(), update_time TIMESTAMP NOT NULL DEFAULT now(), created_by VARCHAR(64) );

对应的 Java 实体:

@Data @TableName("biz_order") public class BizOrder { @TableId(type = IdType.AUTO) private Long id; private String orderNo; private BigDecimal amount; private String status; /** * 扩展字段,数据库类型为 jsonb */ private Map<String, Object> extra; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableField(fill = FieldFill.INSERT) private String createdBy; }

这里extra用Map<String, Object>接收是最通用的做法,既能覆盖大部分动态结构,又不至于让你为每个形态单独建类。如果你们公司有规范的数据字典约束,更推荐用具体的 POJO 类,比如ExtraInfo,可读性和校验性都更好。

需要特别强调一个容易踩的坑:建表时用了DEFAULT '{}'::jsonb,但 MP 的 INSERT 语句是列出所有字段的,如果实体里extra是 null,MP 仍然会把null写进 SQL,这时列默认值不会生效,最终写入的是一个null,而不是空对象{}。如果你希望数据库兜底,就必须在 Java 侧保证extra至少是一个空 Map,或者用后面的自定义注入方案让 SQL 里显式写'{}'::jsonb。

2.3 为什么常规配置解决不了 jsonb 映射

MP 默认的自动映射做两件事:根据@TableField和属性名生成 SQL 列,再用ResultSet按类型取数。Integer、String、LocalDateTime这些都有内置 TypeHandler,不会出问题。但Map走的是默认的UnknownTypeHandler,而 PostgreSQL 驱动把一个 jsonb 列读出来时,返回的类型是PGobject,getObject拿到的是一个类型为“jsonb”的对象,不是 Map,更不是 String。

也就是说,你不告诉 MP“这个字段怎么读、怎么写”,它就只能瞎猜。TypeHandler 存在的意义,就是从 JDBC 层接管这个字段的读写,让 MP 在 set 参数时调用setParameter,在读结果时调用getNullableResult。

3. 方案一:自定义 TypeHandler,从根上解决 Java 对象和 jsonb 的双向转换

3.1 BaseTypeHandler 子类的实现思路

TypeHandler 的原理不复杂,MyBatis 在执行 SQL 参数填充时,会按字段找到对应 TypeHandler,调用它的setNonNullParameter;执行完查询后,再调用它的三个getNullableResult重载把 JDBC 列值转成 Java 类型。我们要做的事就两件:

  • 写的时候:把 Java 的Map/ POJO,通过 Jackson 序列化成 JSON 字符串,再包一层PGobject,显式指定数据库类型为jsonb;
  • 读的时候:把PGobject拿到的字符串,通过 Jackson 反序列化成 Java 类型。

其中关键点就是用PGobject包一层并设置setType("jsonb")。这样 PostgreSQL 驱动会按 jsonb 类型发送参数,而不是按普通字符串发送,避免类型不一致的报错。这也是为什么我不建议直接用setString(ps, jsonString)硬写的原因——那本质上是在让 jsonb 列接收 varchar,绝大多数情况下都会撞上类型校验。

实现代码:

public class JsonbTypeHandler<E> extends BaseTypeHandler<E> { private static final ObjectMapper MAPPER = new ObjectMapper(); private final Class<E> clazz; public JsonbTypeHandler(Class<E> clazz) { this.clazz = clazz; } @Override public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException { try { String json = MAPPER.writeValueAsString(parameter); PGobject pgObject = new PGobject(); pgObject.setType("jsonb"); pgObject.setValue(json); ps.setObject(i, pgObject); } catch (JsonProcessingException e) { throw new SQLException("Failed to convert parameter to jsonb", e); } } @Override public E getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private E parse(String json) { if (json == null || json.isEmpty()) { return null; } try { return MAPPER.readValue(json, clazz); } catch (IOException e) { throw new RuntimeException("Failed to parse jsonb column", e); } } }

几个实现细节非常关键:

  • 我用了泛型Class<E>,这样同一个 Handler 既能处理Map又能处理 POJO,不用每个类写一个。MP 里通过@TableField(typeHandler = ...)指定时,会直接实例化并传入字段类型。
  • rs.getString(columnName)能拿到 jsonb 的原始文本,是因为 PostgreSQL 驱动对PGobject的toString()就是它的原始 JSON 字符串。
  • parse里如果用MAPPER.readValue(json, clazz),注意如果 json 是{}而 clazz 是Map.class,结果是空 Map,不会报错;但如果 json 是数组而你给它一个 POJO 类,会抛异常。这个在后面排错里会再提。

3.2 在实体字段上使用,以及 XML 和 Wrapper 的配合方式

在BizOrder实体中标注:

@TableField(typeHandler = JsonbTypeHandler.class) private Map<String, Object> extra;

这样 MP 生成的 INSERT、UPDATE、SELECT 语句都会自动应用这个 TypeHandler。需要注意:MP 默认生成的updateById只更新非 null 字段,而extra这个字段更新逻辑要看你业务需要,如果允许把扩展字段整个覆盖,那保持默认就可以;如果希望只更新 extra 里的某个子 key,就不能靠普通的updateById了,得用自定义 SQL 或 XML。

还有一种情况:如果你用了@Select注解或 XML 里的 resultMap,也需要手动标注 TypeHandler。例如 XML 写法:

<resultMap id="orderMap" type="com.example.entity.BizOrder"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <result property="extra" column="extra" typeHandler="com.example.handler.JsonbTypeHandler"/> </resultMap>

否则查询结果会因为 UnknownTypeHandler 直接返回 PGobject,赋值给 Map 字段时报反序列化错误。

Wrapper 的查询条件如果要用 jsonb 的运算符,建议直接用apply方法,比如:

QueryWrapper<BizOrder> wrapper = new QueryWrapper<>(); wrapper.apply("extra @> {0}", "'{\"channel\":\"app\"}'::jsonb"); List<BizOrder> list = baseMapper.selectList(wrapper);

这里用{0}占位而不是直接字符串拼接,是为了让 MP 走参数占位符,避免 SQL 注入问题,同时让::jsonb留在 SQL 片段里正常生效。这是我在实际项目里最常写的一行代码。

4. 方案二:用 MetaObjectHandler 实现字段自动填充,并和 JSONB 字段共存

4.1 标准自动填充的配置方式

MP 的自动填充并不神秘,核心就两步:

第一步,实体字段上用@TableField(fill = FieldFill.INSERT)或FieldFill.INSERT_UPDATE标注,告诉 MP 哪些字段需要自动处理。第二步,写一个MetaObjectHandler的实现类,在insertFill和updateFill两个方法里给字段赋值。

“自动填充”的触发时机有两个特点需要记住:只针对 MP 自带的方法(insert、insertBatch、updateById、update等),你自定义的 SQL 不会触发它;而且插入时它修改的是实体对象本身,不是数据库的默认值机制。这意味着如果一次批量插入几百条数据,填充逻辑会被反复执行,所以 handler 里逻辑要轻,不要做数据库查询。

一个最常用的实现:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "createdBy", String.class, getCurrentUser()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } private String getCurrentUser() { // 从上下文取当前登录人,比如 RequestContextHolder 或自定义上下文 return "system"; } }

这里我用的是strictInsertFill,而不是老的setFieldValByName。两者的差别在于strictInsertFill会先检查实体里这个字段是不是已经有值,有值就不覆盖;而setFieldValByName是不管三七二十一直接覆盖。默认用strictInsertFill更安全,比如你在代码里显式给某个订单设置了createTime,结果自动填充把它改成当前时间,那可就要查半天了。

4.2 自动填充与 JSONB 字段共存的三条经验

这个方案本身不直接处理 jsonb,但在同时有 jsonb 字段和审计字段的表上,有几条经验是踩过坑才换来的:

第一,不要在insertFill里给 jsonb 字段填充默认值。比如有人试图在 handler 里写this.setFieldValByName("extra", new HashMap<>(), metaObject),看起来能解决空值问题,但实际上会让所有 INSERT 语句都多一次 Map 序列化,而且如果后续 SQL 改成显式写'{}'::jsonb,这里又会重复覆盖。更干净的方案是让实体字段初始化为空 Map,或者在插入前统一处理。

第二,updateFill只在 MP 的 update 方法中生效,但如果你写了自定义 SQL 直接从 jsonb 里取一个子字段去更新(比如SET extra = jsonb_set(extra, '{stock}', '10')),这个 SQL 是感知不到MetaObjectHandler的。最容易被坑到的场景是:“我明明配了自动填充,但更新个 jsonb 子字段后 update_time 没变”。原因就在于你走的是@Update注解或 XML,不是 MP 的 updateById。解决方法是把 update_time 也显式写进 SQL,或者用后面要讲的 SQL 注入器方案把“更新 jsonb 子字段 + 更新 update_time”封装成一个原子操作。

第三,使用insertBatch/saveBatch时,MP 对批量插入的自动填充有性能损耗吗?有,但没那么夸张。一次插入几千条,每个对象反射塞两三个字段,实测影响不大。真正损耗大的是你给 jsonb 字段配置的 TypeHandler 在批量模式下的反复序列化,这后面会专门讲。

4.3 什么时候适合用“数据库默认值 + 自动填充”的组合

如果你们的 DBA 喜欢把约束都放到数据库层,也可以让 create_time、update_time 用数据库默认值兜底,Java 侧只依赖MetaObjectHandler做应用层的填充。两边的差异在于:数据库默认值对任何 SQL 都生效,包括别人用 navicat 手插的数据;应用层填充只对走 MP 的代码生效。最稳的组合是两层都做,create_time 数据库默认值now()+ 应用层自动填充,二者结果一致,不会冲突。

我个人的建议是:别把 jsonb 字段的默认值交给数据库兜底,因为空 Map 和 null 的语义差异太大。数据库DEFAULT '{}'::jsonb只能防止裸 SQL 插 null,但 Java 实体里如果不处理,MP 还是会把 null 写进去。所以要么实体里初始化private Map<String, Object> extra = new HashMap<>(),要么在插入前统一把 null 改成空 Map,两条路选一条走通就行。

5. 方案三:自定义 SQL 注入器,把 JSONB 操作封装成 Mapper 方法

5.1 为什么要用自定义 SQL 注入器,而不是手写 XML

方案一和方案二合起来,已经解决了“jsonb 字段读写”和“审计字段自动填充”两个问题。但遇到 “更新 jsonb 里的某个 key”“按 jsonb 内部条件批量更新”“插入时动态构造 jsonb 字段”这类高级操作,Wrapper 和注解都写不利索,每次都要手写 XML 又很累。自定义 SQL 注入器就是 MP 提供的“让 Mapper 拥有新方法”的官方通道。

和直接在@Update注解里写 SQL 相比,注入器方案的优势有三个:一是方法名和 Mapper 接口绑定,调用风格和普通 MP 方法一致;二是可以复用 MP 的TableInfo机制,自动拼表名、列名,减少硬编码;三是可以在同一个方法定义里插入MetaObjectHandler的自定义逻辑,比如注入当前用户到 jsonb 里。

缺点是学习曲线略陡,需要对 MP 的AbstractMethod和ISqlInjector有一定了解。但对于需要把 jsonb 操作沉淀为团队通用能力的场景,一次封装长期受益。

5.2 从 AbstractMethod 继承,实现“更新 jsonb 子字段”的通用方法

以“按主键更新 extra 中某个 key”为例,我希望 Mapper 里能有一个方法:

int updateJsonbField(@Param("id") Long id, @Param("field") String field, @Param("value") String value);

它会生成如下 SQL 并执行:

UPDATE biz_order SET extra = jsonb_set(extra, ARRAY['field'], 'value'::jsonb), update_time = now() WHERE id = #{id}

这样不仅能更新子字段,而且顺带把 update_time 一起维护,不需要依赖updateFill,彻底解决 4.2 里提到的自定义 SQL 不走自动填充的问题。

实现一个自定义方法:

public class UpdateJsonbField extends AbstractMethod { @Override public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { String sql = String.format( "UPDATE %s SET %s = jsonb_set(%s, ARRAY[#{field}], CAST(#{value} AS jsonb)), update_time = now() WHERE id = #{id}", tableInfo.getTableName(), tableInfo.getFieldList().stream() .filter(f -> "extra".equals(f.getProperty())) .findFirst() .map(TableFieldInfo::getColumn) .orElse("extra"), tableInfo.getFieldList().stream() .filter(f -> "extra".equals(f.getProperty())) .findFirst() .map(TableFieldInfo::getColumn) .orElse("extra") ); SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass); return addInsertMappedStatement(mapperClass, modelClass, "updateJsonbField", sqlSource); } }

这里要注意addInsertMappedStatement这个方法名有点误导,但它其实同时支持 insert 之外的语句类型。MP 内部会按你的 SQL 模板来判断返回类型,所以方法名保持和业务语义一致即可。

然后在SqlInjector子类里注册:

@Component public class MySqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) { List<AbstractMethod> methods = super.getMethodList(mapperClass, tableInfo); methods.add(new UpdateJsonbField()); return methods; } }

再在 Mapper 接口里声明:

public interface BizOrderMapper extends BaseMapper<BizOrder> { int updateJsonbField(@Param("id") Long id, @Param("field") String field, @Param("value") String value); }

这样bizOrderMapper.updateJsonbField(...)就能直接调用。实际执行时,PostgreSQL 的jsonb_set函数会返回新的 jsonb 值并覆盖extra,而update_time = now()是数据库层的时间,不会依赖应用服务器时钟,这在多实例部署时更可靠。

5.3 扩展思路:把“插入时自动构造 jsonb”也做成注入方法

同样的思路还可以做一个insertWithJsonb方法,解决“实体里 extra 为 null,但数据库列不允许 null,需要显式写入空对象”的情况。SQL 模板核心片段:

INSERT INTO biz_order (order_no, amount, status, extra, create_time, update_time) VALUES (#{orderNo}, #{amount}, #{status}, COALESCE(CAST(#{extra} AS jsonb), '{}'::jsonb), now(), now())

注意这里CAST(#{extra} AS jsonb)把参数转成 jsonb,如果extra为 null,就用COALESCE兜底为空对象。如果你在实体里初始化了new HashMap<>(),其实也可以不这么写,但 COALESCE 这对“从外部传 null”的情况更稳,因为实体初始化的空 Map 照样会被序列化成{},还能正常写入 jsonb。

我更推荐的是另一个做法:完全放弃 MP 的自动填充来处理 jsonb,把“补充默认元数据”放到注入器 SQL 里,写成比如COALESCE(#\{extra\}, '{"created_by":"system"}'::jsonb),这样一个插入方法就能同时解决 jsonb 空值和审计字段注入两个需求。但前提是你们团队对这种“SQL 里写业务元数据”的风格不反感。如果希望保持业务逻辑在 Java 层,建议还是使用MetaObjectHandler+ TypeHandler 的组合更直观。

6. 三种方案怎么选:对比和适用边界

三种方案并不是互斥关系,它们解决的问题各有侧重。我见过最稳的组合方案是:TypeHandler 做字段映射(方案一)+ MetaObjectHandler 做审计字段(方案二)+ 自定义 SQL 注入器做 jsonb 高级操作(方案三)。但如果你想最小化依赖,也可以只选其中一两条。

从功能覆盖度上看:

需求方案一 TypeHandler方案二 MetaObjectHandler方案三 SQL注入器
实体读 jsonb 并转成 Map/POJO支持不支持需要配合 TypeHandler
写入时自动塞 create_time 等字段不支持支持可在 SQL 里写 now()
更新 jsonb 的子 key不支持不支持原生支持
按 jsonb 字段条件查询可通过 Wrapper apply不支持原生支持
映射到 Java 的类型错误规避核心能力无关需要在 SQL 里显式转
团队上手成本中低较高

从性能角度说,三种方案在写 jsonb 字段时都需要把 Java 对象序列化成 JSON 字符串。方案一和方案三其实最终都要走 String 或 PGobject 参数,差异不大;真正影响性能的是你序列化的数据本身。我实测过一个 20 层嵌套、含数组和长文本的扩展字段,单次序列化大约在 0.2~0.5ms,单条插入的感受不明显,但如果批量插入 1 万条且每条都有大 JSON,序列化时间会累积到秒级。这时候优先考虑:要不要把这么大的数据拆到单独的表,而不是塞进 jsonb。

维护成本上,方案一需要维护一个 TypeHandler 和一个序列化器;方案二维护一个 Handler 和实体注解;方案三维护一个注入器和若干 Method 类,成本最高。一个很现实的问题:如果你的项目里 jsonb 字段不止一个表,实体也不止一个,方案三里的方法名和 SQL 模板一旦写死某列名,多表时容易踩坑。我的建议是,方案三只给“高频、跨表统一”的操作使用,比如统一的updateJsonbField,通过TableFieldInfo动态找列名,这个处理能大幅提高复用性。

从可靠性角度说,方案二完全依赖应用层时间,在服务器时钟和数据库时钟不一致时,写入的时间会有偏差。虽然这个偏差一般可以忽略,但如果你的规范要求“以数据库时间为准”,方案三里写now()更契合需求。反过来,如果你们做了逻辑删除、多租户字段、乐观锁这些 MP 扩展功能,方案三需要手工处理这些字段,复杂性就上去了,这也是为什么不建议把所有 SQL 都换成自定义注入器。

7. 常见问题与排查技巧实录

7.1 写入报错:column "extra" is of type jsonb but expression is of type character varying

这个报错是最典型的,基本对应两种可能。第一种是用了@TableField(typeHandler = ...)但 TypeHandler 里setNonNullParameter直接调了ps.setString而不是包PGobject,驱动就把参数当 varchar 发过去了,数据库类型不匹配。第二种是 XML 里写了#{extra}但没有指定jdbcType或 TypeHandler,用了默认处理器。

解决办法:方案一的实现里用PGobject.setType("jsonb")是正路。如果你不想每个字段都写 TypeHandler,还有一个替代做法是把实体里 jsonb 字段类型改成String,然后数据库列用jsonb;配合驱动配置stringtype=unspecified也能跑通,代价是 Java 侧多一次手动序列化和反序列化。

7.2 读出来为什么是 "{}" 字符串而不是一个 Map

这个问题的根源往往是查询语句没有走 TypeHandler。比如你用@Select("select * from biz_order where id = #{id}")返回实体,MP 在处理这种注解查询时,如果实体字段上的@TableField(typeHandler = ...)没有被识别,它就不会应用 TypeHandler,结果返回 PGobject 的字符串表示,赋值给 Map 字段时直接类型转换失败,或者在某些宽松配置下赋成了一个字符串。

排查方法是先在数据库客户端里查一下这个字段真实返回什么,确认没问题后在 XML 的 resultMap 里显式标注typeHandler。另外实体字段如果是Object类型,MyBatis 是不允许直接映射的,必须明确类型。最省事的方式还是全程使用 MP 的selectById/selectList,这样实体上的注解才会生效。

7.3 自动填充的 create_time 没有写进去

先确认三件事:实体字段是不是加了@TableField(fill = ...);MetaObjectHandler有没有被 Spring 管理(组件扫描是否覆盖);handler 里的字段名和实体字段名是否一致。注意strictInsertFill只在字段值为 null 时才填充,如果你在代码里先 new 了一个实体,set 了 createTime,又调 insert,createTime 会保留你设置的而不是被覆盖。

还有一个很少人注意的坑:MetaObjectHandler在执行时,实体里的List、Map等复杂字段如果被初始化过,反射取值的开销会比普通字段大一些,但不会导致填充失败。真正导致“没写进去”的原因,八成是 SQL 里传的字段名和数据库列名对不上,比如数据库列是create_time,实体属性是createTime,MP 正常情况下会做驼峰映射,但如果你全局配置里关闭了map-underscore-to-camel-case,那就要检查实体上有没有@TableField("create_time")。

7.4 批量插入时 jsonb 性能突然变差

saveBatch默认是分批执行,每批 1000 条左右,如果每条都经过 TypeHandler 的 Jackson 序列化,堆内存里会同时存在挺多 JSON 字符串。我遇到过一个极端情况:一个 50 个字段的 POJO 序列化出来有 10KB,批量插入 5 万条,结果 GC 频繁,插入时间慢得离谱。

优化方向有三个:一是给 ObjectMapper 配置复用(单例),不要在 TypeHandler 里每次 new;二是批处理时把rewriteBatchedStatements=true加到 JDBC URL 上,让驱动真正走批量;三是如果 JSON 数据确实很大,考虑列级压缩或拆表。经验值是,单条 JSON 超过 2KB 时就要开始关注批量插入的耗时了,不能只在小数据量下测过就上线。

7.5 jsonb 查询条件用不上 GIN 索引

这个不算 bug,但经常被误判为 bug。比如WHERE extra -> 'key' = 'value'这种写法,即使建了 GIN 索引也未必会走,因为->返回的是 jsonb,索引优化器不一定能用上。要走 GIN 索引,标准写法是用@>(包含)运算符:

WHERE extra @> '{"key": "value"}'::jsonb

所以在 Java 侧用 Wrapper 时,尽量用apply("extra @> {0}", "'{\"key\":\"value\"}'::jsonb")而不是apply("extra -> 'key' = {0}", ...)。后者在数据量大的时候全表扫描,体验很差。

8. 我个人实操中的体会

半年前第一次在项目里混用 jsonb 和 MP 时,我走了不少弯路,最深的体会是:方案选型之前,先别急着写代码,要去确认你们的项目里 jsonb 到底是“存”还是“查”。如果只是把一段结构化数据塞进去,偶尔整读整写,方案一 + 方案二就够用了,整那些 SQL 注入器反而是过度设计。如果高频地按 jsonb 内部字段筛选、更新子字段,那方案三不是可有可无的加分项,而是必需品。

还有一个小经验:数据库里的 jsonb 字段命名,尽量在所有表里保持一致,统一叫extra或者attributes,不要一张表叫extra、另一张表叫ext_info。这样写自定义 AbstractMethod 的时候,可以通过统一的字段名在TableInfo里找到列名,一个复用方法就能服务所有表。如果各表的 jsonb 列名不一致,注入器方法就得给每张表传一个列名参数或者写多个实现,维护成本立刻上来了。

最后关于自动注入的时机,我现在的习惯是:能用数据库函数解决的(比如now()、jsonb_set),绝不放到应用层做;能用 MP 标准机制解决的(比如 create_time 填充),绝不手写 SQL;只能自己写 SQL 的,才引入自定义注入器。这样一套下来,代码里 SQL 硬编码最少,TypeHandler 一个类通用,Handler 一个类管审计字段,真正需要手写 SQL 的场景少之又少,项目的可维护性能好不少。

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

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

立即咨询