如果你在 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这里要写全限定类名。你也可以省略javaType和jdbcType,只写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)之后,如果想让它对内置方法(如selectById、selectList)也生效,光加注解不够,还要在@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 还是 null | JDBC 层 JSON 值是 byte[],getString 拿不到 | TypeHandler 改用 getBytes,SQL 加 CAST |
| 全项目好几处用,只有某些方法 null | 有的方法用了 resultType 自动映射 | 统一改成 resultMap 或 @Results |
| 同一个 SQL,本地正常测试环境 null | mysql 驱动版本不一致 | 统一驱动版本,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 的生命周期。这个属于另一条排查路线了,有机会再单独写一篇。