先交代一下背景:今天被一个“MyBatis 返回 List 集合,所有元素都是 null”的问题折腾了快一整天。现象看上去很邪门——查询结果不是空集合,list.size()返回了正常的记录条数,但往里遍历时,取出来的对象和null一样没法用。如果你也被这个“巨坑”整得头皮发麻,这篇文章把整个排查过程、根因分析和修复方案全部摊开给你看。
这类问题最容易让人懵的点和最大的麻烦在于:表面现象都长一样,底层原因却有七八种。有的原因在配置文件里,有的在数据库列名上,有的甚至是被拦截器改坏的。我这里先把排查思路和修复方案讲透,再给你一份可以直接照着操作的步骤清单,保证你在实际项目里遇到同款问题时能少走弯路。
1. 先把症状说清楚:你遇到的到底是哪种“null”
很多同学一看到“所有元素为 null”就开始改代码,这是最浪费时间的地方。先花三分钟搞清楚“null”到底长在哪一层,比盲目试错有用得多。我在项目里遇到过的“null”,至少有三种不同含义。
1.1 列表非空,但元素对象本身为 null
这种场景下,users.size()返回比如 3,但users.get(0)拿到的就是null,对象引用都是空的。后续你访问任何属性、方法,都会直接抛空指针异常。这种情况往往和 MyBatis 的结果映射机制关系不大,更多是插件、拦截器或者某些特殊ResultHandler在往集合里塞了错误内容。
1.2 列表非空,元素对象非空,但对象的所有属性为 null
这种场景更隐蔽。users.get(0)拿到的对象不是null,但它的id、userName、email全都没有值。调试器里展开对象看,字段都是null。很多人看到列表里有一个个“对象”就觉得映射成功了,结果一取值全是 null,感觉就像“白查了一场”。真正的大量案例都集中在这一层。
注意:要区分这两种情况,代码里只需要加一行日志。
users.get(0)是否等于null是判定关键,别拿调试器默认显示的结果当唯一依据。
1.3 第三种“看起来像 null”:ArrayList 中元素无 toString
还有一种更迷惑的版本:对象其实有值,但因为类没有重写toString(),控制台打印...时只看到User@1a2b3c,某些转换工具把它当成 null 处理。这种情况和 MyBatis 本身无关,但如果你在排查中先看到打印结果是自己定义的对象引用,就要意识到大概率不是“全 null”。
建议先做一次快速自测,直接在代码里加三段输出:
System.out.println("size = " + list.size()); System.out.println("first element itself = " + list.get(0)); System.out.println("first element field = " + (list.get(0) == null ? "null object" : list.get(0).getUserName()));根据输出你就能把问题归类到 A、B、C 三类,排查方向完全不同。下面我会把每一步的原因都展开说。
2. 复现环境:我用一个最小 Demo 把坑稳定复现出来
先说结论:这种问题很难在没写几行代码的情况下聊清楚。我专门搭了一个最小项目来复现“列表非空但元素全 null”的现象,方便把根因一步步压缩到最核心的映射层。
2.1 环境与依赖版本
我用的版本组合如下,和大多数中小型项目比较接近:
- JDK 17
- Spring Boot 2.7.18
- mybatis-spring-boot-starter 2.3.2
- MySQL 8.0.36
依赖中关键的几个配置项如下:
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>2.2 最小表结构和实体定义
模拟场景是一张用户表,对应 Java 实体类。这张表设计的列名和 Java 属性名并不完全一致,为的就是踩中 MyBatis 默认自动映射的坑。
CREATE TABLE `user_info` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_name` VARCHAR(50), `email` VARCHAR(100), `create_time` DATETIME );Java 实体类:
public class UserInfo { private Long id; private String userName; private String email; private Date createTime; // getter / setter 省略 }注意数据库列是user_name,实体属性是userName,这正是后续出问题的根源之一。
2.3 映射文件里那种“看起来没问题”的写法
Mapper 接口方法:
List<UserInfo> selectByCondition(@Param("keyword") String keyword);XML 中一开始用的是最常见的写法:
<select id="selectByCondition" resultType="com.example.demo.entity.UserInfo"> SELECT id, user_name, email, create_time FROM user_info WHERE email LIKE CONCAT('%', #{keyword}, '%') </select>这个写法的确能查出来数据,List<UserInfo>里也确实有对象存在,但对象里的userName一定为null。原因在于 MyBatis 默认的自动映射规则是按“列名直等属性名”处理的,并不会自动把user_name转成userName。除非配置里手动开启了驼峰映射开关。
2.4 开启 SQL 日志观察真实执行结果
排查这类问题,第一步就打开 MyBatis 的日志。在application.yml里配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl logging: level: com.example.demo.mapper: debug此时控制台会打印 SQL、参数、查询总条数。如果日志里显示查询条数为 N,说明数据源和 SQL 本身没问题,问题一定出在结果映射阶段。很多新手看到 SQL 执行成功就不管了,但 MyBatis 的日志还有一个细节:当返回集合时,日志一般只会打印==> Preparing:和==> Parameters:以及<== Total:,并不会把每一条返回对象序列化出来。所以日志只能证明“查到了 N 条”,无法直接证明“每条对象字段都填了值”。
3. 排查路线:从 SQL 到映射,一层一层往下剥
一句话总结我的排查思路:先证明 SQL 没有问题,再去怀疑参数,最后聚焦映射关系。下面按步骤来,每一步都能用少量代码快速验证。
3.1 先用数据库工具跑一遍 SQL,排除“数据本身就是 null”
很多人拿出调试器一头扎进 MyBatis 内部,都没先确认 SQL 查询出来的数据长什么样。直接把 SQL 复制到数据库客户端执行,确认返回结果里每一列到底有没有值。如果数据库里某一行本来就全是 null,那 MyBatis 返回一个字段全为 null 的对象是完全合理的。
有一种很常见的业务场景:LEFT JOIN查出来一组“不存在”的关联数据,比如用户没有下单记录时,订单表的列全是 null,此时 MyBatis 照样会为这行结果创建一个对象,对象里和订单相关的字段就全是 null。这不是框架 bug,而是数据本身就是如此。
3.2 临时把返回类型改成 List
如果确认数据库里数据都有值,就把 Mapper 接口方法临时改成返回List<Map<String, Object>>,对应的 XML 也将resultType改成java.util.HashMap:
<select id="selectByCondition" resultType="java.util.HashMap"> SELECT id, user_name, email, create_time FROM user_info WHERE email LIKE CONCAT('%', #{keyword}, '%') </select>这样执行一次,Log 日志或者断点里直接观察 Map 的 key 和 value。如果 Map 里能看到user_name=某值、email=某值,就说明 SQL 正常、数据库连接正常、MyBatis 的基本查询链正常。这时候 90% 的问题都落在“从 Map 的列名到 Java 实体属性的映射”上。
提示:这一步换 Map 不是为了最终功能,而是为了快速隔离责任边界。Map 是 MyBatis 对列名最直接的反映,不会受实体类属性名影响。
3.3 集中检查列名与 Java 属性名的差异
这是“列表元素全 null”最核心的根因区。MyBatis 默认的自动映射行为是把结果集的列名直接当作属性名去找 setter。机制如下:
- 返回结果的列叫
user_name。 - 实体类有
setUserName(String userName)方法。 - 默认自动映射里,MyBatis 在找
setUser_name或者setUserName时,会因为列名和属性名不匹配而找不到,于是跳过该字段,最终该字段保持默认值 null。
更麻烦的是,有些中间件或 DBA 写 SQL 时习惯给列名加前缀,比如u.user_name AS u_user_name,或者联表查询里多个表都有id列,SQL 里会用别名区分,但这些别名和实体属性名完全对不上,自然填不上值。
此时有两种修法,任选其一:
- 在 SQL 中给列起别名,比如
user_name AS userName。 - 开启全局驼峰映射,让 MyBatis 自动把下划线转驼峰。
3.4 检查 resultType 是否写全类名,resultMap 是否正确加载
如果 XML 里resultType写错了,MyBatis 不会直接提示 100% 明确的错误。举个实际例子:实体类包名是com.example.demo.entity.UserInfo,但 XML 里手滑写成了com.example.demo.model.UserInfo,MyBatis 在运行时才根据字符串反射创建对象,如果类不存在,启动阶段可能不会报错,运行时会抛ReflectionException;但如果包路径下恰好有另一个同名类,只是字段完全不同,那结果就会出现“列表有值,字段全是 null”。
另外还需要留意resultMap和resultType不能同时存在。resultMap优先级高于resultType,如果你写了<resultMap>但里面只映射了 id,其他字段没有映射,那么所有结果映射里只有 id 有值,其他字段照样是 null。这种错误在复杂实体中尤其常见。
3.5 检查是否被 MyBatis 拦截器或插件改坏
如果实体类映射正常、SQL 正常、Map 正常,但实际的 List 里对象本身是 null,就要开始怀疑是不是有全局拦截器或插件动了结果。
比如某些分页插件会把查询结果包一层Page,内部返回的List其实是分页对象的某个字段。如果插件在包装或复制过程中逻辑有问题,就可能出现“size 正确但元素丢失”的情况。还有一些团队会自定义Interceptor做数据权限、字段脱敏、结果过滤,在@Signature拦截了ResultSetHandler之后,如果代码里没有把最终结果正确写入List,就容易把 null 塞进集合。
排查这类问题的办法是:把自定义拦截器暂时全部注释掉,跑一遍测试看问题是否消失。如果问题消失,那就是拦截器内部逻辑的问题,跟 MyBatis 核心映射无关。
3.6 自定义 ResultHandler 时未正确放入对象
还有一种边缘场景,用ResultHandler处理结果集时,开发者在handleResult方法里手动创建对象,但忘了给对象属性赋值,或者错误地给集合添加了null。代码可能长这样:
@Override public void handleResult(ResultContext<?> resultContext) { UserInfo user = (UserInfo) resultContext.getResultObject(); list.add(user); // 此时 user 的字段也许还未填充完成 }某些操作中,getResultObject()拿到的可能是尚未经过完整映射的中间对象,或者你为了做统计直接list.add(null)占位,最终集合非空但元素全为 null。这类情况不属于 MyBatis 的问题,但很容易被误判成框架的锅。
4. 根因归类与修复实操:不同原因对应不同解法
问题本身不可怕,怕的是拿一个方案套所有场景。下面按根因给出可落地的修复步骤,并解释每一步为什么要这么改。
4.1 配置层面:开启驼峰映射,解决下划线列名与属性名不一致
在application.yml中增加:
mybatis: configuration: map-underscore-to-camel-case: true这个配置开启后,MyBatis 会自动把数据库列user_name映射到userName,把create_time映射到createTime。这种映射是自动的,不需要写resultMap。
但你千万别以为开了这个开关就一劳永逸。它只处理“下划线转驼峰”这一种情况,并且有几个细节:
- 如果列名是
USER_NAME,那 MyBatis 也可以处理,因为它内部会把整个列名转成统一小写比较,前提是数据库返回的列名能拿到原始大小写。 - 如果列名带前缀,比如
u_user_name,它只会被转成uUserName,而不是userName,这种字段错位必须靠 SQL 别名或resultMap解决。 - 如果 SQL 里已经用了别名
userName,那开启开关也没有副作用,属性名能直接对上。
4.2 映射文件层面:复杂结构用 resultMap 显式指定映射
对于字段多、联表复杂的结果,优先使用resultMap。原因是resultMap的可读性和可控性更好,字段映射一眼就能看明白,不会出现“为什么这个字段没值”的悬案。
<resultMap id="userInfoResultMap" type="com.example.demo.entity.UserInfo"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> <result property="createTime" column="create_time"/> </resultMap> <select id="selectByCondition" resultMap="userInfoResultMap"> SELECT id, user_name, email, create_time FROM user_info WHERE email LIKE CONCAT('%', #{keyword}, '%') </select>注意一个非常容易被忽略的点:resultMap中只声明了部分字段,就意味着其余字段不会被自动映射。如果你在resultMap里只写了id和userName,那么email字段即使数据库有值也不会填充进来。很多同学在这里混淆了“自动映射”和“resultMap”的关系,导致修完一个字段,发现另一个字段又变 null。
4.3 SQL 层面:用别名把列名对齐属性名
当你不想开驼峰映射,又不想写一大堆resultMap时,可以直接在 SQL 中通过别名解决问题:
SELECT id, user_name AS userName, email, create_time AS createTime FROM user_info WHERE email LIKE CONCAT('%', #{keyword}, '%')这种方式同样能解决user_name和userName的映射问题。我个人的习惯是:简单查询用驼峰开关,多表查询汇总时更倾向于在 SQL 层把别名定义清楚,让后续其他同事接手时不用去猜某个列对应哪个属性。SQL 别名还有一个额外好处:可以解决多表 join 时多个表都有id列的冲突问题,比如:
SELECT u.id AS user_id, o.id AS order_id, u.user_name AS userName FROM user_info u LEFT JOIN order_info o ON u.id = o.user_id这样user_id和order_id都能对上实体类里的userId和orderId。
4.4 实体类层面:包装类型和无参构造不能忽略
实体类里的基本类型要特别小心。以前遇到过有人在实体类里写了int age,数据库里age列是NULL,MyBatis 映射时无法给基本类型赋 null,于是会保持默认值0,或者在某些版本下反射赋值直接失败。从排查的角度来说,如果你发现某个字段不是 null,而是出现了“默认值 0”,那不是“字段没映射”,而是“类型没选对”。
建议实体类统一使用包装类型:
Long而不是longInteger而不是intBigDecimal而不是double
另外要确保实体类有默认无参构造器。MyBatis 通过反射创建对象依赖无参构造器,如果类里有有参构造器却没有无参构造器,某些框架版本会反射失败。最好在类里显式写一个:
public UserInfo() { }4.5 一对多查询中 List 子元素全为 null 的修复
很多同学遇到“父对象列表正常,但其中某个List<Order>属性里全是 null”,这也是非常经典的一个坑。这种场景往往出现在查询用户和订单时,XML 使用collection映射:
<resultMap id="userOrderResultMap" type="com.example.demo.dto.UserOrderDTO"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <collection property="orderList" ofType="com.example.demo.entity.OrderInfo"> <id property="id" column="order_id"/> <result property="orderNo" column="order_no"/> </collection> </resultMap>问题现象是:orderList非空,但里面全是 null 对象。原因通常是 SQL 用了LEFT JOIN,并且把关联表的数据一起查出来,MyBatis 会根据resultMap构造集合,但在处理时如果主键列order_id为 null,它可能无法完成对象组装,某些版本会产出一个空壳对象放进集合。
这个问题的处理方式可以考虑:
- 尽量使用懒加载子查询
select映射,避免通过一条 SQL 强行 left join。 - 在
collection映射中明确指定关联表的非空主键列。 - 在 SQL 里过滤掉关联表主键为 null 的数据:
AND o.id IS NOT NULL,但这会改变原业务语义。
如果你确实需要保留 LEFT JOIN 后所有父记录,建议给每一列都打上明确的别名,然后在collection中映射完整字段。这样至少保证子对象字段是对齐的,不会出现一个“空壳对象”。
4.6 拦截器改动结果集导致元素为 null 的修复
如果是拦截器导致的问题,修复更为直接:在拦截器代码中,不要直接往返回值集合里塞 null。比如你做了一个统一脱敏的拦截器,拦截了ResultSetHandler,在handleResultSets里遍历结果并修改某些字段,此时如果原结果对象获取失败,你可能会错误地添加一个 null。应该改成:
if (parameter != null) { list.add(parameter); }另外,如果自定义拦截器要对查询结果做二次包装,不要试图修改 MyBatis 内部返回的List,最好直接返回一个新的List,并且保持泛型一致。否则很容易让size()正确但实际元素被替换成 null。
5. 避坑经验与问题速查表
排查这类问题,最重要的是形成一套自己的“反射式检查”顺序。我在这里整理了一份速查表和几个实操中积累的经验,建议你截图保存。
5.1 按症状快速定位原因对照表
| 症状 | 可能原因 | 检查点 |
|---|---|---|
| 列表 size 为 0 | 查询条件过严,或 SQL 本身无数据 | 数据库工具执行 SQL |
| 列表 size 正常,元素对象为 null | 插件、拦截器、ResultHandler 修改结果集 | 临时关闭自定义插件;检查 handler 代码 |
| 列表 size 正常,元素对象非 null,所有字段为 null | 列名和属性名不一致、resultType 写错、resultMap 遗漏字段 | 开启驼峰、检查属性名、检查 resultMap |
| 列表 size 正常,某个对象字段为 null | 该列数据本来就 null、字段类型不匹配、别名冲突 | 查看数据库原始数据 |
| 返回 List | 实体类属性映射失败 | 逐一核对列名与属性名 |
| 返回实体类字段有值,但序列化成 JSON 后全 null | 实体类 no-args / getter 缺失 | 检查 getter、无参构造、序列化配置 |
| 一对多集合内部元素 null | 左连接时关联表主键为 null 或 collection 映射不全 | 改变关联映射策略或过滤 null 记录 |
这张表是我自己在踩坑后总结的,基本覆盖了日常 95% 的“MyBatis 返回 List 元素全 null”的场景。
5.2 一些常规文档中不会告诉你的实操细节
先说第一个。MyBatis 的自动映射默认开启,但它的策略是“只要实体类能找到对应 setter 就赋值”。如果你在application.yml中只配置了map-underscore-to-camel-case: true,没有显式配置auto-mapping-behavior,在版本差异上可能会有不同表现。以 MyBatis 3.5.x 为例,默认是PARTIAL,意思是可以自动映射没有定义嵌套结果集的字段。如果你的查询包含了子查询或者一对多结果,某些深层次字段不会自动映射,必须靠resultMap完整定义。
第二个细节:写resultType="java.util.List"是个高危操作。在 XML 里,resultType指的是“每一行结果映射成的对象类型”,不是整个查询返回的集合类型。如果你写成resultType="java.util.List",MyBatis 会尝试把每一行数据封装成List对象,结果就会非常诡异。这种写法是很多初学者犯过的错误,它不一定表现为“所有元素 null”,但会导致转换异常或结构错乱。
第三个细节:联表查询时,不同表如果有同名字段,比如id,在 MyBatis 自动映射时,后面的列会覆盖前面的列。你看着 SQL 返回了两列id都正常,但实体类只能映射一个id属性,另一个id就变成 null。这种问题非常隐蔽,因为使用数据库工具看结果是正常的,只有到实体类里才发现另一个关联对象的 ID 为空。解决方法就是给每个id起不同的别名,或者使用resultMap精确指定column。
第四个细节:不要用select *和resultType组合去映射复杂对象。select *会返回create_time、update_time等字段,如果实体类里没有属性,自动映射阶段就会跳过;如果有人用工具类生成实体类时漏了字段,你是很难一眼看出来的。我在实际项目中更建议把每个字段列名都写清楚,就算不写全,也要至少写映射属性所需要的列。
5.3 修复完成后,用三步验证确认问题已彻底解决
改完代码不要急着上线,先跑下面三个验证步骤:
- 用数据库客户端执行 SQL,确认数据集完整。
- 把 Mapper 接口的返回类型临时改为
List<Map>,确认 MyBatis 拿到了每条数据的所有列。 - 恢复实体类返回类型,并在代码里输出每个字段值,确认所有关键字段都非 null。
第三步里不要只看toString()或调试器默认渲染,因为很多实体类没有重写toString()时,输出的是com.example.UserInfo@xxxx,你会误以为有对象存在,却不知道字段到底填没填。直接打日志看字段值是最靠谱的。
比如:
for (UserInfo user : list) { log.info("id = {}, userName = {}, email = {}", user.getId(), user.getUserName(), user.getEmail()); }输出结果如果全部有值,才算修复结束。
6. 最后分享一个我个人排查这个问题时养成的习惯
我在踩过几次坑之后,给自己定了一个铁律:遇到 MyBatis 返回结果不对,永远先质疑映射,而不是先质疑数据源。因为数据源的问题通常很容易用数据库工具排查,而映射问题复杂到能隐藏在各种别名、resultMap 和插件里。
具体操作上,我会先做一次“最小实验”:
- 第一遍,用
List<Map>查原始列。 - 第二遍,用实体类查,但只保留最简单的
id和单个字符串字段。 - 第三遍,再逐步添加字段,直到发现问题字段。
这种方式比直接看整个 XML 文件快得多,因为你能立刻知道是哪个字段导致的全 null 连锁反应。
另外,我会把 MyBatis 的配置文件和 XML 映射文件当成“高优先级代码评审对象”。很多人在代码评审时只看 Service 和 Controller,结果映射文件里的resultMap写错了一个字段,上线后所有查询字段全是 null,这种教训我真的见过太多次了。现在每次改动 Mapper XML,我都会在本地至少执行一次真实查询并断言关键字段,而不是只验证 SQL 不报错。
如果你现在也正被这个问题卡住,按我上面说的顺序一步步排查,别直接改实体类或者换框架,大概率在 30 分钟内就能定位到原因。这个坑的根源往往非常简单,只是因为现象太唬人,容易让人心浮气躁,才会折腾那么久。