如果你看到这篇文章大概率也遇上了那个让我熬夜到凌晨两点的诡异问题MySQL字段类型是json实体类里想直接映射成一个ListString或者MapString, Object按网上的教程自定义了TypeHandler结果查询结果里其他字段都正常唯独这个JSON字段永远是null。更气人的是插入数据的时候它又是好的数据库里也存得进去就是查不出来。翻日志没有任何报错打印SQL也一切正常甚至拿同样的SQL去数据库客户端里查数据明明白白摆在那里。这种情况我后来在好几个项目里都见过包括我自己和朋友的团队。先说结论绝大多数情况下问题不在TypeHandler本身而是MyBatis根本没有调用你写的那个Handler或者调用之后JSON解析被静默吞掉了。这篇文章就是一次完整的事故复盘从最表面的现象一路拆到MySQL驱动层的类型差异最后给出一套能直接抄走的解决方案。无论你是用纯MyBatis还是Spring Boot整合甚至用MyBatis-Plus应该都能在里面找到你缺的那一块拼图。1. 现象定位插入正常、查询全null的诡异映射1.1 复现场景表结构、实体类和Handler的初始写法先还原一下我当时项目里的代码。表结构大致是这样CREATE TABLE user_profile ( id bigint NOT NULL AUTO_INCREMENT, user_name varchar(64) NOT NULL, tags json DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;实体类对应字段public class UserProfile { private Long id; private String userName; private ListString tags; // 映射数据库里的 json 字段 // getter/setter 省略 }为了把tags这个JSON数组映射成ListString我按照常见的做法写了一个自定义TypeHandlerMappedTypes(List.class) MappedJdbcTypes(JdbcType.VARCHAR) public class StringListTypeHandler extends BaseTypeHandlerListString { private final ObjectMapper objectMapper new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, objectMapper.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException(Failed to write json, e); } } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String raw rs.getString(columnName); return parse(raw); } Override public ListString getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String raw rs.getString(columnIndex); return parse(raw); } Override public ListString getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String raw cs.getString(columnIndex); return parse(raw); } private ListString parse(String raw) { if (raw null) { return null; } try { return objectMapper.readValue(raw, new TypeReferenceListString() {}); } catch (IOException e) { return null; // 当时这里是吞掉异常直接返回null } } }Mapper XML里的映射resultMap iduserProfileMap typecom.example.entity.UserProfile id columnid propertyid/ result columnuser_name propertyuserName/ result columntags propertytags/ /resultMap select idselectById resultMapuserProfileMap SELECT id, user_name, tags FROM user_profile WHERE id #{id} /selectInsert的时候又特别奇怪setNonNullParameter里的ps.setString(i, json)是生效的数据库里能看到一条类似[java,python]的记录。但selectById查出来tags字段就是null其他字段都正常。我当时的第一反应是是不是我的parse方法有问题于是我在parse方法里打了日志发现压根没走到那一步也就是说连getNullableResult都没被调用。1.2 排除数据库和驱动的基本嫌疑在怀疑MyBatis之前我先把数据库和JDBC驱动排除了一遍。在MySQL客户端里执行SELECT id, user_name, tags FROM user_profile WHERE id 1返回的tags确实是[java,python]不是SQL层面的问题。接着我用原生JDBC跑了一遍同样的查询try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(SELECT tags FROM user_profile WHERE id 1)) { ResultSet rs ps.executeQuery(); if (rs.next()) { System.out.println(rs.getString(tags)); } }控制台正常打印出[java,python]。这一步至少确认了两件事MySQL驱动版本没问题getString(tags)能取到完整的JSON字符串表中存的JSON内容是合法数组不是null或空串。所以问题几乎可以锁定在MyBatis的映射环节。但是这里有个很容易被误导的方向很多人第一反应是一级缓存/二级缓存把旧数据缓存成null了。说实话我当时也查了一遍缓存配置还特意在测试里调用了sqlSession.clearCache()结果一样是null。后来想明白了缓存不会凭空把非null值变成null它最多让你看到旧值。如果你的SQL走缓存返回的可能是之前某个错误映射的结果但根本源头还是映射没配对。排查这类问题时可以先关掉二级缓存、甚至清掉一级缓存做验证但不用在缓存上花太多时间它通常只是帮凶不是真凶。2. 排查链路自定义TypeHandler到底有没有被MyBatis调用2.1 第一步在Handler方法里加日志让事实说话排查MyBatis映射问题最直接的证据就是看Handler方法有没有被调用。我重新编译了一个带日志的版本在每个重载方法入口都打了一行日志Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { System.out.println([StringListTypeHandler] getNullableResult by columnName columnName); String raw rs.getString(columnName); System.out.println([StringListTypeHandler] raw raw); return parse(raw); }重新运行查询后控制台里没有任何一行来自getNullableResult的日志。而插入时却有setNonNullParameter的日志。这说明同一个TypeHandler在写和读两个方向上得到了完全不同的待遇插入时MyBatis用了它查询时MyBatis根本没用它。这个现象其实非常典型。它背后映射的是MyBatis两个不同阶段的TypeHandler查找机制在insert语句中如果你在#{}参数里写了typeHandler...或者全局注册能匹配到对应的handlerMyBatis会直接用它执行setNull/setNonNullParameter。在select语句的结果集映射中MyBatis主要靠resultMap里result标签显式指定的typeHandler来干活如果没有指定它才会尝试根据property的Java类型去全局TypeHandlerRegistry里自动匹配。我的resultMap里tags那一行只写了result columntags propertytags/连javaType都没写MyBatis觉得这个字段的类型是ListString但全局TypeHandler里没有一个明确注册了处理ListString类型的handler于是它就走了默认行为。至于为什么结果是null而不是报错后面在第3.2节会解释得更清楚。2.2 第二步逐个检查注解、全局注册和resultMap既然怀疑是Handler没有被匹配上就开始检查三个地方。1. 注解是否匹配Java类型我的handler上写的是MappedTypes(List.class)而实体属性是ListString。理论上List.class能匹配上因为MyBatis拿到的是List.class而不是ListString.class泛型擦除。问题不大。但如果MappedJdbcTypes(JdbcType.VARCHAR)写错了就可能出问题。MySQL的JSON列在驱动里返回的JDBC类型不一定是VARCHAR甚至可能是OTHER或者LONGVARCHAR后面会细说。总之依赖注解自动匹配在这里不是个稳妥方案。2. MyBatis全局配置里是否注册过这个handler我的配置文件里当时只写了typeHandlers typeHandler handlercom.example.handler.StringListTypeHandler/ /typeHandlers这里注意全局注册并不等于所有场景都会自动用上它。尤其是当你使用resultMap时如果result标签上没有指定typeHandlerMyBatis在构建结果对象时先尝试根据property类型和jdbcType去TypeHandlerRegistry里找最匹配的handler。如果找到了会用它找不到则走默认的ObjectTypeHandler/UnknownTypeHandler那一套兜底逻辑最终设置属性的值就可能变成null。全局注册只能保证如果完全匹配就自动用不能保证只要注册了就会在所有地方自动用。3. resultMap里是否显式指定了typeHandler这是我最终的根因。我的resultMap显然没写。把result标签改成这样之后问题立刻消失result columntags propertytags typeHandlercom.example.handler.StringListTypeHandler/所以当你在自定义TypeHandler时查询方向的resultMap必须显式声明typeHandler这是第一条铁律。全局注册和MappedTypes注解只能让你少写一点配置但千万不要依赖它在resultMap中自动生效尤其当字段类型是容器类型、泛型T时MyBatis的自动匹配非常保守。2.3 第三步验证Handler确实被调用并检查JSON解析是否被吞修改完resultMap后再运行日志里终于出现了getNullableResult的调用记录raw也打印出来了是[java,python]。到这里映射链路通了。但是这里还有第二个隐藏雷如果你在parse方法中像我一开始那样catch了所有异常然后返回null那么这个阶段很容易被误判成Handler没生效因为结果同样是null。正确做法是至少在catch里打印错误日志比如} catch (IOException e) { throw new RuntimeException(Failed to parse JSON string: raw, e); }让异常直接抛出来。MyBatis会把它包装成PersistenceException虽然查询会报错但至少你能立刻知道是解析失败而不是对着一个安静的null发呆。开发阶段宁可要让程序快速暴露问题也不要让它默默吞掉。2.4 一个容易被忽略的细节列名还是别名在resultMap中result columntags .../中的column指的是SQL返回结果集里的列名或列别名不是实体类属性名。如果你在SQL里写了SELECT id, user_name, tags AS tag_list FROM user_profile WHERE id #{id}那么resultMap里的column必须对应tag_list而不是tags。HasColumnName不匹配MyBatis会认为结果集里没有这个列设置属性时自然就是null。这种错误和TypeHandler无关但现象完全一样排查时要一并想起来。3. 修复方案一套可靠可复制的JSON TypeHandler配置3.1 完整实现基于Jackson的泛型TypeHandler既然要写TypeHandler就不要用一个只配ListString的死写法。我后来按泛型版本整理了一份虽然MyBatis的TypeHandler在泛型擦除下不能直接注册任意T但保留泛型类作为公共基类再让具体子类继承并指定类型会清晰很多。先看公共基类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.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public abstract class AbstractJsonTypeHandlerT extends BaseTypeHandlerT { protected static final ObjectMapper OBJECT_MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, OBJECT_MAPPER.writeValueAsString(parameter)); } catch (IOException e) { throw new SQLException(Failed to convert object to JSON string., e); } } Override public T getNullableResult(ResultSet rs, String columnName) throws SQLException { String raw rs.getString(columnName); return parse(raw); } Override public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String raw rs.getString(columnIndex); return parse(raw); } Override public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String raw cs.getString(columnIndex); return parse(raw); } protected abstract T parse(String raw) throws SQLException; }然后你需要针对具体Java类型写子类。注意MyBatis的TypeHandler匹配基于javaType如果你要同时支持List和Map建议分开写不要在一个handler里靠类型判断那样会让自动匹配变得更混乱。两个子类示例MappedTypes(List.class) public class ListJsonTypeHandler extends AbstractJsonTypeHandlerListObject { Override protected ListObject parse(String raw) throws SQLException { try { return OBJECT_MAPPER.readValue(raw, new TypeReferenceListObject() {}); } catch (IOException e) { throw new SQLException(Failed to parse JSON array: raw, e); } } }MappedTypes(Map.class) public class MapJsonTypeHandler extends AbstractJsonTypeHandlerMapString, Object { Override protected MapString, Object parse(String raw) throws SQLException { try { return OBJECT_MAPPER.readValue(raw, new TypeReferenceMapString, Object() {}); } catch (IOException e) { throw new SQLException(Failed to parse JSON object: raw, e); } } }注意我这里在parse里直接抛出SQLException而不是返回null。这是从实战里换来的教训一个JSON映射的TypeHandler宁可让它报错也不要让它返回null。返回null意味着业务层拿到的是一个虚假的空值这条数据可能因为users返回了null而被逻辑跳过最后产生更难排查的数据问题。如果你项目里已经引入了fastjson或者Gson也可以基于它们实现同样的逻辑。核心不变写入用String读取也用String中间走JSON序列化和反序列化。3.2 配置方式一在resultMap和SQL参数里显式指定这是我最推荐的方式因为它最直观也最不容易被MyBatis的自动匹配机制干扰。resultMap iduserProfileMap typecom.example.entity.UserProfile id columnid propertyid/ result columnuser_name propertyuserName/ result columntags propertytags typeHandlercom.example.handler.ListJsonTypeHandler/ /resultMap insert idinsertUser parameterTypecom.example.entity.UserProfile INSERT INTO user_profile (user_name, tags) VALUES (#{userName}, #{tags, typeHandlercom.example.handler.ListJsonTypeHandler}) /insert select idselectById resultMapuserProfileMap SELECT id, user_name, tags FROM user_profile WHERE id #{id} /select这里有个细节很多人不注意查询和插入要同时指定typeHandler。很多时候你只在查询的resultMap里加插入那边用全局注册侥幸通过了暂时没问题等到某天有人清掉了全局配置或者换了一个TypeHandler的包名插入立刻坏掉。规范的写法是写入参数和读入结果两端都显式指定。3.3 配置方式二全局注册加注解减少重复如果你的Mapper XML里resultMap数量很多每个字段都写typeHandler确实很烦。这时可以考虑全局注册typeHandlers typeHandler handlercom.example.handler.ListJsonTypeHandler/ typeHandler handlercom.example.handler.MapJsonTypeHandler/ /typeHandlers并且保留MappedTypes注解。这样在resultMap中不写typeHandler时MyBatis会根据property的javaType去尝试匹配。比如property是ListObjectMyBatis查到的javaType是List.class如果全局注册了处理List.class的handler它可能被选中。我在实际项目中验证过这个方案对简单的查询确实能生效但一旦涉及泛型、多级嵌套、或者JdbcType不匹配自动匹配就会变得像薛定谔的猫一样不可预测。所以我的实际建议是如果JSON字段很少直接用3.2的显式方式如果JSON字段很多可以把全局注册和显式指定结合使用全局注册保证插入参数能用resultMap中继续显式指定保证查询结果永远正确。3.4 配置方式三Spring Boot下的简化设定如果你用的是Spring Boot和mybatis-spring-boot-starter在application.yml里配置包扫描也可以mybatis: type-handlers-package: com.example.handler # 可选配置下划线转驼峰 configuration: map-underscore-to-camel-case: true此时package下所有handler都会被自动注册到MyBatis的TypeHandlerRegistry。但要注意自动注册并不等于自动匹配生效resultMap里的显式指定依然是最直接的手段。如果你正好在用MyBatis-Plus实体类上还有一条捷径TableName(value user_profile, autoResultMap true) public class UserProfile { TableField(typeHandler ListJsonTypeHandler.class) private ListObject tags; }加上autoResultMap true后MyBatis-Plus生成结果映射时会自动使用字段上的TableField(typeHandler...)这在查询和更新时都能生效省掉Mapper XML里的各种重复配置。不过如果你的Mapper里还有自定义SQL自定义SQL那块仍然需要遵循普通MyBatis的规则。4. 驱动层揭秘MySQL JSON列在JDBC里的真实类型4.1 为什么不能用getObject直接拿JSON我在排查过程中翻看了不少同事写的TypeHandler内部几乎都用rs.getObject(columnName)来取JSON字段。这看起来更面向对象但在MySQL的JSON列上往往是最容易踩坑的一步。原因在于MySQL Connector/J对JSON列的处理并不统一。JSON是一种服务端校验过的文本格式但JDBC规范并没有一个专门的JSON types映射。不同版本的驱动会把JSON列映射成不同的JDBC类型较早版本的Connector/J可能把它当作LONGVARCHAR返回用getObject()拿到的是String某些版本可能返回byte[]需要自己转成字符串还有的版本在某些连接参数组合下可能返回你意想不到的类型。如果你在TypeHandler里直接写(String) rs.getObject(columnName)一旦驱动返回的是byte[]这里会直接抛ClassCastException如果你写rs.getObject(columnName).toString()确实不会抛错但多了一次隐式转换而且对byte[]来说toString()得到的是类似[B123abc这样的对象地址不是JSON字符串。这种错误非常隐蔽因为你打印raw时看到的是一串没有任何JSON特征的内容解析自然失败最终也可能落到null或异常。结论在TypeHandler里读写MySQL JSON字段最稳的就是写入用ps.setString读取用rs.getString。让驱动在文本层面帮你完成格式转换这比依赖getObject返回的具体类型可靠得多。我的AbstractJsonTypeHandler就是这么写的。4.2 JdbcType和MappedJdbcTypes的匹配误区前面提到过如果一个TypeHandler上加了MappedJdbcTypes(JdbcType.VARCHAR)而MyBatis在解析SQL结果时认为该列的JDBC类型不是VARCHAR自动匹配就可能失败。MySQL驱动对JSON列返回的ResultSetMetaData.getColumnType()在不同版本里可能返回Types.LONGVARCHAR、Types.OTHER甚至Types.VARCHAR。这是个随驱动版本变化的不确定值。因此如果你的TypeHandler是为了JSON字段服务的我个人的做法是在MappedJdbcTypes注解里不要写死JDBC类型或者干脆不写这个注解只保留MappedTypes指定Java类型在resultMap里显式指定typeHandler绕开JDBC类型匹配。这样最省心。你不需要去查当前数据库驱动到底把JSON列当成什么JDBC类型MyBatis也不会因为类型对不上而不调用你的handler。4.3 查询SQL层面容易忽略的坑还有一个驱动层之外但同样常见的问题SQL语句根本没查出这个列。比如有个同事在XML里写select idselectById resultMapuserProfileMap SELECT id, user_name FROM user_profile WHERE id #{id} /selectSELECT里压根没有tagsresultMap里却映射了tags属性。这种情况MyBatis只会得到null不会报错。你会在控制台看到日志里getNullableResult方法根本不会被调用然后陷入漫长的TypeHandler排查。所以排查的时候一定要先确认SQL查询字段里带了JSON列再看是不是真的走了resultMap里的映射。另外使用SELECT *时如果表的列名和实体属性名映射不上比如列名为tags属性名为tagList但没有配置下划线转驼峰或显式映射同样会是null。这个属于MyBatis基础问题但每次排查TypeHandler时都值得顺手检查一遍。5. 避坑总结三个不会写在官方文档里的隐蔽细节5.1 坑一catch吞异常把真问题变成静默null我见过太多人为了避免JSON解析失败导致接口报错在parse方法里写了一个巨大的try-catch然后在catch里return null。这样的handler看起来非常友好实际上是在给后续维护者埋雷。数据里混进一个格式不标准的JSON比如有人手动改库把数组写成了对象你的handler会安静地返回null线上日志毫无痕迹业务方拿到的数据就是不完整的很多人会先怀疑代码最后手动查库才发现是脏数据。我的实践经验是TypeHandler是基础设施它对数据的容忍度应该非常低。解析失败就让它抛异常让调用方感知到让日志能捞到让监控能报警。如果你实在不想因脏数据影响主流程那也要在catch里打error级别的日志而不是裸吞。5.2 坑二ColumnIndex与ColumnName方法只实现一个BaseTypeHandler里有三个getNullableResult重载方法分别用于按列名取、按索引取、以及存储过程调用取。不少人偷懒只实现getNullableResult(ResultSet rs, String columnName)另外两个直接返回null或者留空。这么做在常见的select *或resultMap按列名匹配时可能没问题但只要SQL里用了ORDER BY或其他导致MyBatis选择索引访问的场景就可能被调到另一个方法结果自然是null。所以我的建议是三个方法都老老实实实现并且内部逻辑统一。比如三个方法都先取String再交给同一个parse方法确保行为一致。我提供的AbstractJsonTypeHandler就是这样做的你直接复制就能用不用再纠结漏实现哪个方法。5.3 坑三一个TypeHandler想通吃所有泛型类型MyBatis的TypeHandler匹配基于javaType和jdbcType而你的实体字段的类型在运行时经过泛型擦除后MyBatis只知道它是List不知道它是ListString还是ListMap。所以如果你写了一个JsonTypeHandlerT然后在实体里同时放了ListString tags和ListMapString, Object configs打算共用同一个泛型handlerMyBatis在自动匹配时很容易懵掉两个字段的javaType都是List.class到底用哪个handler这种情况下全局自动匹配极有可能选择一个错误的handler或者根本匹配不上最后又是静默null。解决办法就是为不同目标类型定义不同的handler类或者在resultMap里对每个字段显式指定各自的handler。花一点配置时间能省掉半夜排查的大把时间。我在实际项目中最终定下的策略是数据库JSON字段如果不是必须一律在实体里用String存储拿到service层再用ObjectMapper转成需要的对象。这样既不需要TypeHandler也不需要担心驱动层的类型差异而且JSON字符串随时可以直观地打日志排查。只有需要按JSON内容做数据库层面筛选、或者实体对象的嵌套结构确实很复杂时才采用自定义TypeHandler的方案。如果你也是被这个null折磨了一整天建议先把resultMap里加typeHandler这个操作做了90%的问题都能当场解决剩下的10%就回到这篇文章的4.2和5.1再对照一遍。
