news 2026/9/17 3:21:38

MyBatis自定义TypeHandler查询JSON字段返回null的排查与解决方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis自定义TypeHandler查询JSON字段返回null的排查与解决方案

如果你在 MyBatis 里自定义了一个 TypeHandler 来处理 MySQL 的 JSON 字段,结果查询返回 null,先别急着怀疑人生。这个问题我在项目里至少见过十几次,每次排查到最后原因都很简单,但没踩过坑的人就是看不出来。

所谓“自定义 TypeHandler 返回 null”,最典型的表现就是:数据库里 JSON 字段明明有值,插入的时候用 TypeHandler 也正常,唯独查询的时候映射到 Java 对象里的属性始终是 null。更折磨人的是,有时候本地全换一遍代码还是 null,同事电脑上却好的。这篇文章把背后的原理、最容易翻车的几个点、以及一份可以直接抄走的完整代码一次性讲清楚。如果你正在排查这个问题,建议按顺序看完,尤其是 3、4 两节,两个都是高频大坑。

1. 问题现场:一个明明很简单却很灵异的 null

1.1 先看你是不是这个姿势

假设你有一张用户表,里面有个字段extra_info,类型是 MySQL 的json,存的是一串 JSON 数组,比如["admin","vip"]。Java 实体里对应的属性是一个List<String>

public class UserInfo { private Long id; private String name; private List<String> extraInfo; // getter / setter }

为了能把 JSON 字符串映射成List<String>,你写了一个自定义 TypeHandler,先不管细节,大概长这样:

@MappedTypes(List.class) public class JsonListTypeHandler extends BaseTypeHandler<List<String>> { // 具体实现先省略,后面会详细说 }

然后在 MyBatis 的 XML 或注解里指定了它:

<resultMap id="userInfoMap" type="com.example.entity.UserInfo"> <id column="id" property="id"/> <result column="extra_info" property="extraInfo" typeHandler="com.example.handler.JsonListTypeHandler"/> </resultMap>

看起来没什么问题,但一执行:

UserInfo userInfo = userInfoMapper.selectById(1L); System.out.println(userInfo.getExtraInfo()); // 输出:null

不是空数组,不是空字符串,而是彻头彻尾的 null。如果你已经走到了这一步,把代码翻来覆去看了几遍也没发现问题,那重点排查两个方向:一是查询时有没有真的走这个 resultMap,二是 JDBC 拿到的 JSON 值到底是什么类型。

1.2 返回 null 和报异常是两码事

这里有个关键判断:如果你的 TypeHandler 在解析 JSON 时抛了异常,MyBatis 会把这个异常包装成PersistenceException抛出来,你根本不会看到 null。所以“静默返回 null”和“抛异常”背后的原因是截然不同的。

热搜词里有一条failed to deserialize the json body into the target type: input: missing fie,这是 Jackson 在反序列化 JSON 时遇到字段缺失或类型不匹配时报的错。如果你在 TypeHandler 里用了 Jackson,然后把异常吞掉了并返回 null,那才是真正的灾难:线上日志看不到任何异常,数据却悄悄变成 null。

我见过有人这样写:

try { return objectMapper.readValue(json, List.class); } catch (Exception e) { return null; // 千万别这么干 }

这个坑属于“自己给自己埋雷”。一旦 JSON 数据里出现脏数据,解析失败后直接返回 null,问题会变得极难排查。后面讲具体实现的时候,我会强调这一点的正确处理方式。

2. 先搞懂 MyBatis TypeHandler 的工作原理

2.1 TypeHandler 到底是干嘛的

TypeHandler 在 MyBatis 中的作用,本质上是解决“JDBC 类型”和“Java 类型”之间的双向转换。写库的时候,Java 属性作为PreparedStatement的参数,需要转换成 JDBC 能识别的类型;查库的时候,从ResultSet取出来的数据要变成 Java 对象的属性。

如果你不写自定义 TypeHandler,MyBatis 会用内置的一堆默认处理器。比如StringTypeHandler处理字符串,LongTypeHandler处理 Long,这些基础类型覆盖了绝大多数场景。但 MySQL 的json字段不在 MyBatis 内置支持的范围内,因为 MySQL 驱动返回给 JDBC 的值在不同版本、不同连接配置下可能不一样,所以你需要自定义一个 TypeHandler 来告诉 MyBatis:这个列,请你用我的逻辑来转换。

2.2 BaseTypeHandler 四个方法分别在什么时候被调用

自定义 TypeHandler 最常用的方式是继承BaseTypeHandler<T>,它本身是一个泛型抽象类,强制你实现四个方法:

方法作用被调用的时机
setNonNullParameter给 PreparedStatement 赋值执行 insert / update,且参数不为 null 时
getNullableResult(ResultSet rs, String columnName)从结果集按列名取值查询返回,MyBatis 按列名映射时
getNullableResult(ResultSet rs, int columnIndex)从结果集按下标取值查询返回,MyBatis 按下标映射时
getNullableResult(CallableStatement cs, int columnIndex)从存储过程的出参取值调用存储过程时

很多人只实现了前三个方法,把CallableStatement那个漏掉了。平时用不到存储过程没事,但如果别人在项目里加了存储过程调用,这个 TypeHandler 就会在运行时因为缺少实现而直接报错,报错场景还比较冷门。规范化实现还是四个都写完比较好。

注意一个细节:setParameter这个入口在BaseTypeHandler里已经帮你判断了参数是否为 null。参数为 null 时,MyBatis 会调用ps.setNull(i, jdbcType.TYPE_CODE),根本不会走进你写的setNonNullParameter。这本身是正常行为,但很容易引起误解——比如你 insert 时传了一个 null 的List,然后数据库里 JSON 字段存成了 SQL 的 NULL,查询出来自然也是 null。这个情况不算 TypeHandler 的 bug,但确实是把“返回 null”问题复杂化的一个来源。

3. 最常见的原因:JSON 列在 JDBC 层根本不是 String

3.1 MySQL Connector/J 的 byte[] 陷阱

这是整个问题里最容易踩、也最隐蔽的一个坑:MySQL 的 JSON 类型字段,经过 Connector/J 取出来的时候,不一定是你以为的String

MySQL 从 5.7 开始支持 JSON 类型,到 8.0 之后,InnoDB 存储引擎对 JSON 有自己的二进制存储格式,而不是简单存一串文本。JDBC 驱动在读取这种二进制格式时,不同版本的mysql-connector-java表现得不一样。实测下来,在 8.0.x 系列的大部分版本里,ResultSet#getObject返回的是byte[]ResultSet#getString在某些情况下会返回 null。

于是,你的 TypeHandler 里如果是这样写的:

@Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { String json = rs.getString(columnName); // 这里可能拿到 null return json == null ? null : objectMapper.readValue(json, List.class); }

当驱动返回 byte[] 时,rs.getString(columnName)的行为就变得不可控了。要么返回 null,要么抛异常。我见过最诡异的情况是:同一个 SQL,在本地跑能查出来,在测试环境跑却是 null,一查驱动版本,两个环境的mysql-connector-java版本不同,行为就不一样。

3.2 用一个 JDBC 测试快速验证

碰到这种问题,不要一上来就改 MyBatis 配置,先写一个最原始的 JDBC 测试,把问题定位在驱动层还是 MyBatis 层。代码很简单:

@Test public void testJdbcJson() throws Exception { Class.forName("com.mysql.cj.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/test?serverTimezone=Asia/Shanghai"; try (Connection conn = DriverManager.getConnection(url, "root", "123456"); PreparedStatement ps = conn.prepareStatement("SELECT extra_info FROM user_info WHERE id = ?")) { ps.setLong(1, 1L); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Object obj = rs.getObject("extra_info"); System.out.println("getObject 类型: " + (obj == null ? "null" : obj.getClass())); System.out.println("getString 值: " + rs.getString("extra_info")); System.out.println("getBytes 长度: " + (rs.getBytes("extra_info") == null ? "null" : rs.getBytes("extra_info").length)); } } } }

跑完你就知道答案了。如果getObject的类型是[B(byte[] 的 class 标示),或者getString确实返回 null,而getBytes有长度,那就实锤是驱动层的问题。此时 TypeHandler 里应该按字节数组来处理,而不是字符串。

3.3 TypeHandler 里该用 getString 还是 getBytes

既然 JSON 列可能返回 byte[],那最稳妥的写法就是用getBytes,拿到字节数组后再转成字符串做解析。你可能会担心:如果查询 SQL 里直接把 JSON 列 CAST 成了 CHAR,那 getBytes 也能正常工作,因为 CHAR 类型驱动返回的字节数组就是字符串本身的编码。这相当于两种场景都能兼容。

所以,TypeHandler 的读取方法推荐这样写:

private List<String> parse(byte[] bytes) throws SQLException { if (bytes == null || bytes.length == 0) { return null; } try { String json = new String(bytes, StandardCharsets.UTF_8); return objectMapper.readValue(json, new TypeReference<List<String>>() {}); } catch (IOException e) { throw new SQLException("解析 JSON 失败", e); } }

同时,为了双保险,查询 SQL 里也可以把 JSON 列显示地转成字符串:

SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id = #{id}

CAST(extra_info AS CHAR)让 MySQL 在服务端就把 JSON 转成文本,这样驱动返回的就是字符串而不是二进制。这条 SQL 再加上基于getBytes的 TypeHandler,基本可以覆盖所有驱动版本。有人会担心 CAST 影响性能,实际上对于绝大多数小表查询,这个开销可以忽略。如果 JSON 字段很大、查询量又高,那就更应该考虑把高频使用的字段拆出来做冗余列。

4. resultMap、resultType 与 TypeHandler 的匹配机制

4.1 resultType 自动映射为什么赋不上

另外一个高频坑是:你明明注册了 TypeHandler,全局也扫描了,但查询方法用的是resultType,而不是resultMap

resultType="com.example.entity.UserInfo"时,MyBatis 会根据结果集的元数据和实体的属性做“自动映射”。自动映射时的 TypeHandler 查找逻辑跟 resultMap 显式指定完全不一样。它会根据数据库列的 JDBC 类型去找对应 handler,再拿实体的 setter 类型去匹配。MySQL 的 JSON 列在 JDBC 层往往没有一个标准的JdbcType枚举值(MyBatis 内置枚举里根本没有 JSON),所以自动映射经常匹配不到你注册的那个处理器。

结果就是:MyBatis 可能用默认的ObjectTypeHandler处理这个列,ObjectTypeHandler直接rs.getObject(col),拿到一个byte[]或者别的东西,然后尝试 set 进实体的List<String>属性。类型不匹配时直接赋 null,也不报错,这就是你看到“查询返回 null”的原因之一。

所以,重要原则是:自定义 TypeHandler 想要 100% 生效,查询必须走 resultMap,并在 result 节点里显式指定 typeHandler。别指望全局注册了 typeHandler 之后,写个resultType它就能完美自动映射。自动映射只适合 Java 属性和数据库列类型一一对应的情况,自定义对象、复杂泛型这种场景,老老实实写 resultMap。

4.2 resultMap 显式指定 typeHandler 的正确姿势

resultMap 里的写法有讲究,很多人写错地方。正确的完整配置是这样的:

<resultMap id="userInfoMap" type="com.example.entity.UserInfo"> <id column="id" property="id"/> <result column="extra_info" property="extraInfo" javaType="java.util.List" jdbcType="VARCHAR" typeHandler="com.example.handler.JsonListTypeHandler"/> </resultMap> <select id="selectById" resultMap="userInfoMap"> SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id = #{id} </select>

注意几个点:

  • column是数据库列名,property是 Java 属性名,别写反。
  • typeHandler这里要写全限定类名。你也可以省略javaTypejdbcType,只写typeHandler,MyBatis 会从 TypeHandler 类的泛型信息里推断 Java 类型。
  • 如果这个字段在你的实体里是List<String>,但 XML 里 typeHandler 的泛型是List<String>,二者能匹配上才会正确生效。

还有一个容易被忽略的细节:如果你的实体是UserInfo,但 SQL 查询结果是u.id, u.name, u.extra_info,这里的列名extra_info和 resultMap 里的column="extra_info"必须一致。很多人用了表别名,但 resultMap 里没改成别名,映射半天映射不上,也是一长串排查时间。

4.3 注解方式和 MyBatis-Plus 的坑

现在很多项目用注解代替 XML,比如:

@Select("SELECT id, name, CAST(extra_info AS CHAR) AS extra_info FROM user_info WHERE id = #{id}") @Results(id = "userInfoMap", value = { @Result(column = "id", property = "id"), @Result(column = "extra_info", property = "extraInfo", typeHandler = JsonListTypeHandler.class) }) UserInfo selectById(Long id);

同一套 SQL 如果多个方法复用,@Results里的id属性要配上,其他地方用@ResultMap("userInfoMap")引用,避免每个方法都贴一大段注解。

如果你用的是 MyBatis-Plus,还有一个隐藏要求:在实体类的字段上加了@TableField(typeHandler = JsonListTypeHandler.class)之后,如果想让它对内置方法(如selectByIdselectList)也生效,光加注解不够,还要在@TableName上配置autoResultMap = true

@Data @TableName(value = "user_info", autoResultMap = true) public class UserInfo { private Long id; private String name; @TableField(typeHandler = JsonListTypeHandler.class) private List<String> extraInfo; }

MyBatis-Plus 4.x 之后的版本对这个行为做了一些调整,但依然建议显式声明。项目里如果同时有自定义 XML 和 MyBatis-Plus 的内置方法,最好统一用@Select或 XML 指定 resultMap,不要过度依赖实体注解到内置 SQL 上的自动映射。

5. 一个能直接抄的完整 TypeHandler 实现

5.1 基于 Jackson 的泛型基类,彻底解决“类型擦除”

前面讲了很多“为什么”,现在给一个能直接拿去用的方案。针对不同泛型,不要每个都全部重写一套逻辑,我习惯写一个泛型基类,然后在子类里指定TypeReference。这样扩展性最好,代码也干净。

先写基类:

package com.example.handler; 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 java.io.IOException; import java.nio.charset.StandardCharsets; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public abstract class JsonTypeHandler<T> extends BaseTypeHandler<T> { private static final ObjectMapper MAPPER = new ObjectMapper(); private final TypeReference<T> typeReference; protected JsonTypeHandler(TypeReference<T> typeReference) { this.typeReference = typeReference; } @Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (IOException e) { throw new SQLException("对象转 JSON 字符串失败", e); } } @Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getBytes(columnName)); } @Override public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getBytes(columnIndex)); } @Override public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getBytes(columnIndex)); } private T parse(byte[] bytes) throws SQLException { if (bytes == null || bytes.length == 0) { return null; } try { String json = new String(bytes, StandardCharsets.UTF_8); return MAPPER.readValue(json, typeReference); } catch (IOException e) { throw new SQLException("JSON 字符串解析失败: " + e.getMessage(), e); } } }

然后需要什么类型,写一个子类就行。比如处理List<String>

package com.example.handler; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; public class JsonStringListHandler extends JsonTypeHandler<List<String>> { public JsonStringListHandler() { super(new TypeReference<List<String>>() {}); } }

处理自定义对象List<UserAddress>也一样:

public class JsonAddressListHandler extends JsonTypeHandler<List<UserAddress>> { public JsonAddressListHandler() { super(new TypeReference<List<UserAddress>>() {}); } }

处理Map<String, Object>的话:

public class JsonMapHandler extends JsonTypeHandler<Map<String, Object>> { public JsonMapHandler() { super(new TypeReference<Map<String, Object>>() {}); } }

这样做的核心逻辑是:MyBatis 的 TypeHandler 最终是通过无参构造器创建实例的,所以子类里一定要写一个无参构造器,并在里面把TypeReference的具体泛型传进去。如果直接在基类上写死List<String>,那就没法复用了。泛型擦除的问题,用这种方式绕过去。

5.2 注册 TypeHandler 的四种方式

搞定了实现类,接下来是注册。有几种常见方式:

方式一:XML 全局配置。在mybatis-config.xml里显式注册:

<configuration> <typeHandlers> <typeHandler handler="com.example.handler.JsonStringListHandler" javaType="java.util.List" jdbcType="VARCHAR"/> </typeHandlers> </configuration>

方式二:Spring Boot 的 application.yml 配置扫描包:

mybatis: type-handlers-package: com.example.handler

这种方式的生效条件是:TypeHandler 类上用@MappedTypes@MappedJdbcTypes标好了对应的 Java 类型和 JDBC 类型。扫描包方式比较省事,但注意并非所有场景都能按预期自动匹配到,所以我依然建议在 resultMap 里显式指定。

方式三:resultMap 里显式指定,这个前面已经写过:

<result column="extra_info" property="extraInfo" typeHandler="com.example.handler.JsonStringListHandler"/>

方式四:注解方式:

@Result(column = "extra_info", property = "extraInfo", typeHandler = JsonStringListHandler.class)

个人建议:全局注册可选,但每个用到的地方尽量显式在 resultMap / @Result 里指定 typeHandler。显式指定的优先级最高,排错也最直观。

5.3 写入时的注意事项:别把 null 写进业务字段

写入方向同样有坑。前面提过,参数为 null 时 MyBatis 不会走setNonNullParameter,而会直接 setNull。如果你的 JSON 列在数据库里没有默认值,并且业务上必须存储[]或者{},那调用代码里要保证不会传 null 的 List 进去。

比较保险的做法是在 service 层做兜底:

if (userInfo.getExtraInfo() == null) { userInfo.setExtraInfo(new ArrayList<>()); }

或者在 TypeHandler 的setNonNullParameter里,把空集合统一序列化成[],避免数据库里出现 SQL NULL 和空 JSON 混用的情况。数据库里同一个字段,NULL、空字符串、[]{}都代表“空”,但映射到 Java 侧的行为完全不同,这个需要团队约定清楚。

5.4 解析失败时千万不要吞异常

我的建议是,解析失败时一定要抛出 SQLException,让错误暴露在日志里。虽然抛出异常会导致查询失败,但这总比线上数据悄悄变成 null 好一百倍。你可以再加一个兜底策略:对个别脏数据,允许解析失败时返回 null,但必须通过日志级别监控到,然后再决定要不要清洗数据。

这里正好对应前面那条热搜词里的failed to deserialize the json body into the target type。它虽然说的是 Spring MVC 接收请求体时的报错,但 Jackson 解析 JSON 的报错逻辑是一模一样的。你在 TypeHandler 里用readValue,如果 JSON 字段缺失、类型对不上,就会抛出类似异常。正确处理是往上抛 SQLException,而不是 catch 后 return null 装没看见。

6. 排查路线图与避坑清单

6.1 从日志到源码的完整排查步骤

如果你正被这个问题折磨,别慌,按顺序走一遍:

第一步:确认数据本身。先用 Navicat 或命令行查一下:

SELECT id, CAST(extra_info AS CHAR) FROM user_info WHERE id = 1;

确认数据库里确实有内容,不是 SQL NULL。

第二步:开启 MyBatis SQL 日志。在 Spring Boot 的配置文件里加上:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

或者用logging.level.com.example.mapper=debug。重点看 PreparedStatement 的查询参数和 ResultSet 的返回值数量。如果日志显示 ResultSet 有返回,但实体属性是 null,那就是映射层问题。

第三步:写一个前面提到的 JDBC 最小测试,确认 JSON 列在驱动层返回的是 String 还是 byte[]。

第四步:检查你的查询是否真的走了 resultMap。如果你用的是 XML,确认<select>标签里写了resultMap="userInfoMap",而不是resultType。如果你用的是注解,确认@Results确实生效,并且这个查询方法没有被 MyBatis-Plus 的BaseMapper.selectById这种内置方法替代。

第五步:检查 resultMap 里的column属性是否和 SQL 输出的列名一致。用了别名就对别名,没加前缀就对原列名。

第六步:检查 TypeHandler 类的泛型和方法实现。重点看getNullableResult三个重载是否都实现了,内部解析有没有吞异常。

6.2 高频坑位速查表

现象可能原因解决方向
查询返回 null,insert 正常查询没走 resultMap / @Results在 select 上显式指定 resultMap
resultMap 指定了 handler 还是 nullJDBC 层 JSON 值是 byte[],getString 拿不到TypeHandler 改用 getBytes,SQL 加 CAST
全项目好几处用,只有某些方法 null有的方法用了 resultType 自动映射统一改成 resultMap 或 @Results
同一个 SQL,本地正常测试环境 nullmysql 驱动版本不一致统一驱动版本,TypeHandler 兼容 byte[] 和 String
MyBatis-Plus 内置方法查出来 null实体上 autoResultMap 未开启@TableName 加 autoResultMap = true
查询不报错但字段一直是 null列名或别名和 resultMap column 不匹配核对 SQL 输出列与 resultMap column
缓存复用导致看起来没改生效一级/二级缓存存了旧结果排查时先清缓存或重启,确认配置生效

6.3 我最后想说的几个习惯

这个其实算不上什么高深技术,但真的把人折磨得够呛。我个人调试这类问题时有三个固定习惯,分享出来供参考。

第一,凡是实体里出现自定义对象、泛型集合、Map 这类属性,查询一律用 resultMap 显式映射,不依赖自动映射。省那几个字符的代价,可能是几小时的排查时间,不划算。

第二,JSON 字段在实体里尽量用具体的业务对象,而不是String加手动转换。与其在 Service 层反复写JSON.parseObject,不如一开始就在 TypeHandler 里把转换做掉,调用方拿到的就是标准对象。

第三,TypeHandler 里所有解析操作都走getBytes,不要用getString。这个习惯在 MySQL 8.0 之后尤其重要,因为 JSON 字段的返回类型在不同版本驱动下表现不一致。虽然现在不少新版驱动会把 JSON 直接当成 String 返回,但保不齐哪天升级驱动就踩一脚。用getBytes不需要改变逻辑,能兼容两种行为,算是花最小的成本买了一份保险。

如果你日常会大量用 MySQL JSON 字段,建议把泛型基类沉淀成一个公共模块,团队统一使用。另外,如果你碰到过“第一次查询返回 null,第二次查询又正常”的反向灵异现象,那多半和 MyBatis 一级缓存有关系,而不是 TypeHandler 的问题,可以去检查一下 SqlSession 的生命周期。这个属于另一条排查路线了,有机会再单独写一篇。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 3:18:29

火电机组协调控制Simulink高保真建模与工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 3:15:39

npm.ps1 无法加载?TaoToken 这样让 Codex 改执行策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华