ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis TooManyResultsException 报错原因与五种修复方案

MyBatis TooManyResultsException 报错原因与五种修复方案 如果你用 MyBatis 做开发大概率在某个下午见过这串又长又吓人的报错nested exception is org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2。说实话这个异常在 MyBatis 的日常问题里能排进前三凡是和“查询单条记录”沾边的接口一不小心就会踩中。它本身不难解决但很多人第一次看到时容易被那一长串英文吓住不知道是 SQL 写错了、参数传错了、还是数据脏了。这篇文章我会把这套异常从原理到实战完整拆开讲清楚它到底在抱怨什么、哪些场景最爱触发、五种常见的修复方式以及我在实际项目里沉淀下来的排查方法希望对正在被这个报错折磨的你有帮助。1. 从异常信息入手先搞清楚 MyBatis 到底在说什么1.1 拆开这串异常链真实项目里这个报错通常不是一个类而是一串嵌套异常。最外层常见的样子是org.springframework.dao.IncorrectResultSizeDataAccessException: Query returned non unique result set. Expected a single result but returned multiple rows. at ... Caused by: org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2 at org.apache.ibatis.session.DefaultSqlSession.selectOne(DefaultSqlSession.java:79)之所以有nested exception这个词是因为 MyBatis 整合进 Spring 之后Spring 会把 MyBatis 抛出的异常统一转换成自己的一套DataAccessException体系。所以你在日志里看到的最外层往往不是 MyBatis 的异常而是 Spring 包装过的IncorrectResultSizeDataAccessException真正的根源被藏在了nested exception也写作Caused by里。很多人打开日志只盯着最上面那一行结果搜了半天 Spring 的异常方向就跑偏了。正确的做法是先找Caused by或nested exception is那里才是 MyBatis 的直接报错。1.2 selectOne 的源码逻辑为什么 MyBatis 要管这么多要理解这个异常最直接的办法是看DefaultSqlSession.selectOne的源码逻辑其实非常短Override public T T selectOne(String statement, Object parameter) { ListT list this.selectList(statement, parameter); if (list.size() 1) { return list.get(0); } else if (list.size() 1) { throw new TooManyResultsException(Expected one result (or null) to be returned by selectOne(), but found: list.size()); } else { return null; } }看清楚了吗selectOne并不是真的只查一条它内部会先执行selectList查出完整的结果集合然后根据集合大小做分流结果是 0 条返回null不报错。结果恰好是 1 条返回那唯一的一条。结果是 2 条及以上抛出TooManyResultsException。所以这个异常出现的必要条件只有两个你的代码调用了selectOne或对应的方法并且 SQL 实际查出来的记录数大于 1。为什么 MyBatis 要把这个判断放在框架层而不是把多出来的数据返回给开发者自己处理这其实是一种“契约保护”。selectOne在语义上承诺“返回一个对象”如果框架明知道查出了多条还随便返回第一条调用方拿到错误数据后排查的成本比直接抛异常高得多。你可以把它类比成自动售货机你选了一瓶水机器必须确认货道里正好掉下来一瓶如果一下掉出来两瓶机器分不清哪瓶该算你的干脆吐币报错让你来处理。虽然体验上有点粗暴但至少不会把错误结果悄悄塞给你。顺带一提selectList本身是永远不会抛这个异常的不管查出多少条它都会原样返回 List。这个本质区别是后面所有修复方案的基础。2. 实战场景复盘这几种情况最容易踩雷2.1 场景一查询条件本身不唯一这是我见过最多的情况通常是本来想按某个唯一字段查一条但 SQL 的条件写窄了甚至没写条件。举个最常见的例子用户表里user_id是主键但代码里拼 SQL 时写成了这样select idselectUserByUserId resultTypecom.example.User SELECT user_id, user_name, phone FROM sys_user WHERE user_id #{userId} /select这个本来没问题。问题往往出在参数传递环节调用方因为某些原因传了一个null进来或者传了个空对象又或者接口参数从userId写成了id导致#{}里的值变成NULL。在 SQL 层面WHERE user_id NULL其实是查不出任何东西的不会抛TooManyResultsException。真正危险的是动态 SQLselect idselectLatestOrder resultTypecom.example.Order SELECT * FROM t_order where if testuserId ! null AND user_id #{userId} /if /where /select如果调用方没传userId这个 SQL 就变成了不带条件的全表查询查出来几百条selectOne立刻抛异常。而且这类问题特别坑开发环境数据量小可能碰巧查出了 0 条或 1 条接口还能正常跑一旦生产环境数据量上来found: 53这种数字就会突然冒出来。2.2 场景二多表 JOIN 结果集膨胀第二个高频场景是关联查询。比如查订单时想顺便带出用户信息于是写了这样的 JOINSELECT o.id AS order_id, o.amount, u.user_name FROM t_order o LEFT JOIN t_user u ON o.buyer_id u.user_id WHERE o.order_no #{orderNo}如果t_user表里user_id是主键那这个 JOIN 没问题。问题在于很多团队的表设计并没有严格唯一的关联字段。比如你可能 JOIN 了t_user_address表而这个表一个用户有多个地址或者 JOIN 了一张明细表、扩展表一张子表对多条子记录SQL 的结果集就会从预期的一条膨胀成多条。执行完selectOne异常里found:的数字基本等于子表记录数一查一个准。这种场景下SQL 本身语法没毛病数据也没脏纯粹是表关系上的“一对多”被当成“一对一”用了。2.3 场景三动态 SQL 把条件“抖”没了动态 SQL 的坑尤其隐蔽。很多人喜欢在if test...里写各种判断但判断条件写错时MyBatis 不会直接报错而是静默跳过那段 SQL结果就是查询条件“消失”。看一个我实际遇到过的例子select idselectUserByPhone resultTypecom.example.User SELECT * FROM sys_user WHERE del_flag 0 if testuserPhone ! null and userPhone ! AND phone #{userPhone} /if /select我当时的用法是调用时传了一个Map但 key 写成了phone而不是userPhone。MyBatis 对Map取值是map.get(userPhone)取不到时返回nullif判断不成立SQL 变成全表查询。配合selectOne直接爆出TooManyResultsException。这里分享一个底层经验MyBatis 的if test里如果引用了不存在的参数在大多数版本中不会抛异常而是按null处理导致条件默默失效。这个问题排查起来非常抓狂因为你回头看自己写的 XML 觉得毫无问题但实际上参数名根本不匹配。2.4 场景四脏数据与重复记录还有一种情况SQL 和参数都没问题就是数据本身出了问题。比如业务上设计phone字段是唯一的但历史原因导致表里出现了两条相同手机号的记录或者本来应该唯一的业务编号因为导入脚本跑了两次出现重复。这时候按手机号或编号查单条必然炸。数据层的脏数据问题和代码层面不一样它往往不是每次都报错而是“偶尔报一下”。因为重复记录可能在某个时间段被插入也可能被软删除标记del_flag不同这会导致同样的接口一会儿正常一会儿异常明明代码没改过行为却飘忽不定。这时候别急着怀疑缓存或并发先把数据翻出来看看重复记录到底怎么来的。3. 修复方案整理几种解法按场景取用3.1 方案一修正 SQL让查询条件真正唯一最正统的思路当然是保证查询条件下能拿到的结果集合最多只有一条。适用场景是业务上确实应该只有一条只是因为 SQL 或参数写得不严谨才多出来。具体做法有几种补全 WHERE 条件把唯一约束字段加进去比如除了user_id再加上tenant_id。修正动态 SQL 的判断逻辑确保参数传入时不会因为null跳过核心条件。检查多表 JOIN 的关联字段是否唯一必要时把 JOIN 改成子查询或改成先查主表再查子表。比如上面说的订单 JOIN 用户地址表导致结果膨胀可以改成只取地址表中的一条SELECT o.id AS order_id, o.amount, (SELECT a.address FROM t_user_address a WHERE a.user_id o.buyer_id ORDER BY a.create_time DESC LIMIT 1) AS user_address FROM t_order o WHERE o.order_no #{orderNo}这样无论地址表里有多少条记录子查询只会返回一条主查询的结果集合就不会膨胀。3.2 方案二按业务语义改用 selectList如果业务上压根不是“必然只有一条”的语义就不该用selectOne应该改成selectList。举个例子一个用户可能有多个角色你写个selectUserRoles方法去查角色列表但返回值错写成了一个Role对象// 错误写法 Role role userMapper.selectUserRoles(userId);正确做法是ListRole roles userMapper.selectUserRoles(userId);然后把“取哪一条”的逻辑交给调用方来决定比如默认取第一个、取最近生效的一条或者循环处理全部。这种改法不需要动 SQL改动面最小语义也最符合实际业务。我见过不少团队为了省事硬把本来是一对多的查询包在selectOne里然后祈祷数据别变多这是很不稳妥的。数据库的数据量是不可控的业务规则一变今天的一条可能明天就变三条。与其赌概率不如从接口定义上就按真实关系来设计。3.3 方案三LIMIT 1 兜底只取业务上需要的那一条有些场景确实只需要任意一条比如查询用户最近一条登录记录、查询某个配置项的最新值、查询一批订单中随便一条做验证。这时可以用分页或者方言语法把结果集限制成一条让selectOne不再报错。MySQL 的写法最简单select idselectLatestLoginLog resultTypecom.example.LoginLog SELECT * FROM t_login_log WHERE user_id #{userId} ORDER BY login_time DESC LIMIT 1 /select不同数据库的“取一条”写法不一样这里整理一个对照表方便你复制数据库语法示例MySQL / PostgreSQLLIMIT 1OracleFETCH FIRST 1 ROWS ONLY或老语法ROWNUM 1SQL ServerSELECT TOP 1 ...DB2FETCH FIRST 1 ROWS ONLY用LIMIT 1有一个前提你要确定“任意一条”或者“排序后的第一条”就是业务想要的。比如取“最新一条”必须配合ORDER BY不然 MySQL 大概率返回主键最小的一条并不一定是时间上最新的那条。3.4 方案四用 row_number() 分组取每组第一条如果多出来的记录不是全表重复而是分组内重复比如每个用户有多条地址但你要查每个用户的默认地址这时候GROUP BY也没法直接写因为非聚合列会报错。更通用的办法是用窗口函数row_number()先给分组内记录编号再取编号为 1 的那行。以 MySQL 8.0 为例SELECT user_id, address FROM ( SELECT user_id, address, ROW_NUMBER() OVER( PARTITION BY user_id ORDER BY create_time DESC ) AS rn FROM t_user_address ) t WHERE rn 1 AND user_id #{userId}这个写法的好处是不管表里有多少重复记录只取每组内按业务规则排好序的第一条结果天然唯一。缺点也明显窗口函数在旧版 MySQL5.7 及以下里用不了需要改成 JOIN 子查询或者临时表方案。3.5 方案五从源头治理给数据加约束SQL 层面再怎么兜底都只是事后补救。真正的源头治理是确保“业务上唯一的字段在数据库里真的唯一”。比如sys_user.phone业务上应该唯一那就加唯一索引ALTER TABLE sys_user ADD UNIQUE INDEX uk_phone (phone);如果担心历史脏数据导致加索引失败先查重复SELECT phone, COUNT(*) FROM sys_user GROUP BY phone HAVING COUNT(*) 1;把重复数据处理掉再去加索引。加唯一索引有两个好处第一从数据库层面杜绝后续再插入相同记录TooManyResultsException的根源直接消失第二很多隐含 bug 会被提前暴露比如定时任务重复执行、接口重复提交导致重复插入这些问题如果不加索引可能很久都发现不了加了索引后数据库会直接报错逼着你修复真正的业务逻辑。需要提醒的是加唯一索引要谨慎评估业务规则。有些字段表面上看应该唯一实际上在某些条件下允许重复比如逻辑删除后允许重新使用同一手机号这时候盲目加唯一索引反而会引入新的麻烦。4. 排查这套问题的完整思路与工具4.1 第一步从异常日志里定位到具体的 Mapper 方法和 SQL接到这个报错先别急着改代码。异常堆栈里其实有两处关键信息值得仔细看。一是nested exception或Caused by那行里的but found: N。这个数字直接告诉你查出来了几条是 2 条、5 条还是几百条。数字越大越可能是动态 SQL 条件没生效或者 JOIN 膨胀如果只是 2 条那大概率是数据本身有重复。二是堆栈中的 Mapper 方法调用链。我在实际项目里排查时通常会在日志里搜索自己项目的包名比如com.example.*找到最近的业务方法往上一层层翻基本就能定位是哪个 Service 里的哪一行调用了selectOne。这一步不用任何工具会看日志就行。4.2 第二步把 SQL 拿到数据库客户端实测定位到接口和 Mapper 方法后下一步是拿到实际执行的 SQL。这里有个关键点如果 SQL 里用了#{}参数占位符日志里往往显示成?你可以从参数列表里把真实值填进去然后到 Navicat、DataGrip 这类客户端执行。执行的时候要带着“还原现场”的意识参数值是什么就原样填什么。如果是动态 SQL把if分支按参数情况模拟一下看最终拼接出来的 SQL 少了哪个条件。执行后看结果集有多少行再针对性地处理。很多人在这一步就会发现SQL 在客户端执行时返回了十几行但代码里查的明明是一个“应该唯一”的条件。这时候问题就清楚了不是代码 bug就是数据重复。4.3 第三步开启 MyBatis 日志打印锁定动态 SQL 的真实形态定位动态 SQL 导致的问题时直接看 XML 往往不够直观最好把 MyBatis 实际拼接出来的 SQL 打出来看。如果你用的是 Spring Boot最朴素的配置是在application.yml里设置日志级别logging: level: com.example.mapper: trace把com.example.mapper换成你自己的 Mapper 接口包名。这样 MyBatis 会打印完整的 SQL、参数和查询结果行数。如果你用的是 MyBatis-Plus也可以配置StdOutImpl把 SQL 输出到控制台mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果用的是 IntelliJ IDEA强烈推荐装一个叫 MyBatis Log Plugin 的插件。它可以把日志里Preparing: ...和Parameters: ...两行自动拼接成可直接执行的完整 SQL省去手动把?替换成参数值的功夫。这个插件在排查参数传递问题时特别好用尤其是遇到Map传参导致的参数名不匹配一眼就能看出来 SQL 里少了 WHERE 条件。4.4 一个更省事的办法用拦截器快速定位上面的步骤已经能解决 90% 以上的问题但有时你面对的是一个运行几年的老项目Mapper 方法多、调用链复杂光靠日志一层层翻还是很慢。这时候可以用 MyBatis 拦截器做一个全局的“异常明细捕获”在异常抛出前把执行的 SQL、参数、Mapper 方法 ID 全部记录下来。MyBatis 的拦截器可以拦截Executor.query方法示例代码大概长这样Component Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class QueryResultInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; Object result invocation.proceed(); String methodId ms.getId(); List? list (List?) result; if (list.size() 1 ms.getSqlCommandType() SqlCommandType.SELECT ms.getResultMaps() ! null) { // 在这里输出 methodId、list.size()、参数对象 // 方便和 TooManyResultsException 出现的位置对号入座 System.out.println([QueryResultInterceptor] methodId returned list.size()); } return result; } }这个拦截器并不会修改任何查询行为只是在结果集数量异常时主动打印日志。这样做的好处是不用等异常发生后再去复现只要结果超过一条就会被记录相当于给系统装了一个“预警雷达”。当然线上环境如果数据量大这种打印会增加一点 IO 开销建议只在测试环境开启或者接入日志框架并控制在 WARN 级别。5. 预防性设计与团队规范5.1 Mapper 方法的命名习惯一个面试官经常问、但团队里很少真正执行的规范selectOne系列方法的使用应该和 Mapper 方法命名语义一致。我见过太多文件名是selectUserByUserId、selectConfigByKey看起来是查一条但实际 SQL 连条件都没有的代码。一个可行的自查方式是看到 Mapper 方法名中是selectOne/selectById/selectByXxx这种单数语义就要求 SQL 里必须有“唯一确定性条件”或LIMIT 1如果方法名是selectList/selectPage那查询结果多条就没问题。这个规范不用额外工具代码 Review 时肉眼就能检查。5.2 参数校验与空值处理前面说了动态 SQL 的一个重大隐患是参数传过来为null导致条件失效。所以关键的查询入口一定要做参数校验。比如public User getUserByUserId(Long userId) { if (userId null) { throw new IllegalArgumentException(userId 不能为空); } return userMapper.selectUserByUserId(userId); }很多团队觉得多写几个if是啰嗦但这类“前置防御”恰恰能帮你把问题拦截在业务层而不是让数据层用异常来提醒你。尤其在使用Map传参时更要在入口检查 key 是否存在、value 是否为null。5.3 单元测试与数据准备对于按唯一键查询的方法最稳妥的做法是写单元测试把边界情况覆盖掉。我所在的团队一直保留着几条“魔鬼测试数据”比如同一个手机号注册两次软删除一条、一个用户对应多条地址、订单表里缺失关联用户等。每次改动 Mapper 的 XML先把这些数据跑一遍TooManyResultsException基本能被提前发现。单元测试里值得关注的不只是“正常路径”反而要重点测这些异常场景传null参数时是否抛业务异常、单条查询在结果集膨胀时是否被兜底、动态 SQL 条件不满足时是否导致全表查询。5.4 从 MyBatis 面试角度理解这个异常顺带说一句这个话题在 MyBatis 面试题里也很常出现。面试官会问“MyBatis 的selectOne和selectList有什么区别”“查多条数据时selectOne会怎样”背后考察的就是这个TooManyResultsException的触发机制。如果你能答出“selectOne内部调用了selectList并且通过list.size()判断是否唯一”这个源码细节基本就能看出你是真正看过源码的而不是靠背面试题。理解了这一层你在实际写代码时也会更主动地思考我到底应该用“查一条”的接口还是“查多条”的接口这个字段在业务规则和数据约束上是否真的唯一这种思维习惯对代码质量的影响其实远大于会背一个异常类名。5.5 缓存可能让问题“隐藏”而不是“消失”排查这类问题时有两点和缓存相关的经验值得留意。第一MyBatis 的一级缓存默认开启作用域是同一个SqlSession。如果同一个SqlSession内第一次查询已经返回了一条记录之后数据表里被别人插入了重复数据再用相同参数和 SQL 查询会直接命中缓存返回的还是那一条不会抛异常——这时候反而掩盖了重复数据的问题。要验证数据到底有没有重复最直接的办法是绕开应用在数据库客户端里执行一次 SQL。第二不要指望通过清缓存来“修复”这个异常。缓存不会让本来多条的数据变成一条它只会掩盖或延迟问题暴露。遇到TooManyResultsException老老实实排查 SQL 和数据比折腾缓存有效得多。6. 高频问题速查与经验总结6.1 我整理过一张排查参考表放在项目文档里每次团队有人遇到这个报错先对着表自查一遍大部分情况都能快速解决现象可能原因推荐解法found: 2且数据量不大表里存在重复记录查重复数据清理或加唯一索引found: NN 很大动态 SQL 条件未生效 / 全表查询检查if参数名和传入值多表 JOIN 后报错一对多关系导致结果膨胀改子查询取一条 / 改 selectList偶发性报错数据被更新/批量任务重复插入查任务幂等性和并发场景刚上线时正常数据量上来后报错查询条件不够唯一补唯一条件 / 加 LIMIT 1使用 MyBatis-Plus 的 selectOne 报错Wrapper 条件不唯一使用last(LIMIT 1)或改 selectList6.2 高频问答实录问selectOne查不到数据会报这个错吗不会。查不到数据时列表大小为 0selectOne返回null。如果你在代码里直接把返回值当对象用而不判空那会得到NullPointerException但那是另一码事。问MyBatis-Plus 的selectOne也会抛这个异常吗会。MyBatis-Plus 的selectOne(Wrapper)底层同样会走 MyBatis 的查询逻辑查出多条一样会抛TooManyResultsException。不过 MyBatis-Plus 提供了重载方法selectOne(Wrapper, boolean throwEx)当第二个参数传false时会取第一条而不是抛异常。注意这只是“兜底”并不能替代真正的数据治理。问LIMIT 1会不会影响查询性能正常情况下不会。如果查询条件本身能走索引LIMIT 1会让数据库在找到第一条满足条件的记录后立刻停止扫描在某些场景下反而会更快。要当心的是没有条件的全表LIMIT 1它虽然不报错了但可能取出完全不确定的一条记录隐藏业务逻辑风险。问实际开发中这种情况多吗我的经验是非常多。尤其在一个系统运行一两年之后数据约束逐渐失效、历史脏数据增多、业务逻辑频繁调整这种异常会像“定时炸弹”一样在某个凌晨的告警群里爆出来。大部分时候它不是一个高深的技术难题而是一个数据和规范问题。解决起来也不难难的是让团队养成“查询单条记录前先确认它在业务上和数据上都唯一”的习惯。问如果用了批量写操作之后出现这个异常怎么排查先看你的批量操作是否可能产生重复数据。比如批量插入前没有做存在性判断、批量更新时条件范围过宽、定时任务重复执行导致同一条业务数据被写入多次都会导致后续按业务编号查单条时出现多条。排查思路还是先找到重复记录再根据谁产生的重复来判断是任务幂等性问题还是写入逻辑问题。6.3 最后再分享一点实操体会我在实际项目里踩过太多次这个坑现在给自己定了一条规则凡是返回selectOne的查询一律在代码里默认它“有可能返回多条”然后从 SQL 层面或者参数校验层面把这个可能性消灭掉。哪怕当时业务逻辑百分百只有一条也会顺手加上唯一约束或者LIMIT 1兜底。因为系统的数据增长和业务变更从来不以人的意志为转移今天觉得“绝对只有一条”的数据三个月后可能因为一个运营后台的导入功能就变得面目全非。做技术方案时永远给数据的“恶意”留一份想象空间你会少很多半夜爬起来看告警的体验。
返回列表