ARTICLE DETAIL

资讯详情

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

MyBatis TooManyResultsException 从源码到实战:一次搞定查询多行异常

MyBatis TooManyResultsException 从源码到实战:一次搞定查询多行异常 任何一个用MyBatis写过业务代码的Java开发者大概率都在控制台里见过这条“红得刺眼”的异常nested exception is org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2先说结论这不是框架的Bug也不是Spring的Bug更不是数据库的Bug——这是你的SQL查询返回了多行记录但你的Mapper方法在语义上只“接得住”一个结果。说白了是查询结果和代码预期之间发生了错位。我最初遇到这个错时第一反应是“Spring和MyBatis集成出了问题”后来翻了不少资料、调试了几轮才发现问题几乎全出在SQL或者Mapper方法设计上。这篇文章就把我从“看不懂异常”到“一分钟定位问题”的过程完整拆解一遍包括底层源码逻辑、高频触发场景、修复方案以及几个我在实战里踩过的坑。如果你正被这个异常折磨或者想把MyBatis的底层机制吃透一点这篇应该能给你省不少时间。1. 先认识一下这个异常不是Bug而是“预期管理”出了问题1.1 一次典型的翻车现场假设有张用户表user里面有两条记录的用户名都叫admin。然后你写了这样的Mapperpublic interface UserMapper { User selectByUsername(Param(username) String username); }对应的XML是select idselectByUsername resultTypecom.example.User SELECT * FROM user WHERE username #{username} /select调用时User user userMapper.selectByUsername(admin);运行到这一行异常“啪”一下就来了org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2这个场景是不是特别眼熟用户名通常应该唯一但数据库里因为缺少唯一索引、历史脏数据或者软删除逻辑没写好早就躺了好几条重复数据。你基于“用户名唯一”的假设写了代码数据库却给了你一巴掌。1.2 异常信息的逐行拆解先把这个异常拆开了看每一段话都有它的信息量nested exception is ...这是Spring框架包装异常时加的cause描述。说明底层抛出的其实是TooManyResultsExceptionSpring把它包在了MyBatisSystemException里。它提示你这不是业务异常而是MyBatis系统层面的异常。Expected one result (or null)这是selectOne()方法的语义——要么返回一个结果要么返回null都算正常。but found: 2这是关键中的关键数字告诉你实际查出了几条记录。看到这个数字你就该知道是“数据多行”而不是“数据缺失”。很多人在日志里看到“nested exception”就直接往“Spring配置问题”想其实方向就错了。这个异常根子永远在SQL执行结果上跟Spring Bean装配、依赖注入没有任何关系。2. 源码视角MyBatis是怎么把这个异常扔出来的2.1 从 MapperMethod 到 DefaultSqlSession想彻底理解这个异常不能只看表面报错。我们从MyBatis的源码层面把调用链路捋一遍你就知道它为什么叫selectOne。当你的Mapper接口方法被调用时MyBatis通过动态代理进入MapperMethod的execute方法。在这个方法里MyBatis会根据方法返回值类型决定走哪个SQL会话操作// org.apache.ibatis.binding.MapperMethod public Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case SELECT: ... if (methodReturnsVoid) { // 返回void不取结果 } else if (methodReturnsMany) { // 返回List、数组、Cursor等走 selectList result sqlSession.selectList(...); } else { // 返回单个对象走 selectOne result sqlSession.selectOne(...); } ... } }注意这里的判定逻辑只要你的Mapper方法返回值不是List、数组、Cursor这种集合类型MyBatis就会走selectOne。也就是说哪怕你的方法叫selectListByXXX只要返回类型写成User而不是ListUser它照样走单值逻辑。selectOne最终会来到DefaultSqlSession// org.apache.ibatis.session.defaults.DefaultSqlSession 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; } }源码就这几行逻辑非常直白selectList先执行SQL把结果全部塞进List。list.size() 1正好一条返回唯一那条。list.size() 1多条直接抛TooManyResultsException。list.size() 0没查到返回null。所以“Expected one result”的真正意思是底层的selectList查出多条但selectOne这个入口只答应处理一条于是它拒绝执行并抛出异常。2.2 为什么返回值类型决定了走 selectOne 还是 selectList有些读者可能会问“MyBatis为什么不根据SQL的实际结果动态判断查询返回一条就返回对象返回多条就自动变成列表这样多好”这个想法听起来很美好但违背了Java静态类型设计的初衷。方法声明时返回值是User还是ListUser在编译期就确定了。MyBatis作为一个ORM框架它得尊重编译器已经定好的契约你说返回User那框架就老老实实帮你调selectOne你说返回ListUser框架才帮你调selectList。这就是设计上的“预期管理”。所以排查方向第一条就清楚了如果你的方法返回的是单个对象而SQL可能查出多条赶紧从SQL上做约束别想着让框架帮你“自动取第一条”因为它根本不会那么干。2.3 顺带聊聊一级缓存和二级缓存的嫌疑排查这个异常的过程中不少人会把注意力放到MyBatis缓存上怀疑是不是缓存导致数据错乱。这里我明确说一句TooManyResultsException和缓存没有因果关系。MyBatis的一级缓存是SqlSession级别的默认开启作用范围只在同一个SqlSession内共享查询结果。二级缓存是namespace级别的默认不开启。这两个缓存缓存的是“已经执行成功的查询结果”它们本身不会篡改返回的行数。查询SQL返回两条那就是两条缓存不会帮你凭空多出一条来。但有一种间接关联值得注意如果开发环境开启了一级缓存你在同一个SqlSession里先执行了一个返回多条数据的查询再把同样的SQL换到selectOne入口执行MyBatis会直接命中缓存并返回多条继而抛出异常。这会让排查者误以为是“缓存搞坏了数据”。实际上缓存只是尊重了SQL的真实执行结果。排查时要记住先把缓存因素排除把注意力集中到SQL和数据本身上。3. 高频触发场景自查清单为什么你的SQL返回了多行3.1 查询条件本身不唯一这是最常见的场景也是文章开头用户表例子的来源。业务上觉得“用户名应该唯一”“手机号应该唯一”“订单号应该唯一”但数据库表里没有加唯一约束。历史数据、定时任务重放、手工修复数据、多环境混用数据库任何一个环节出纰漏重复数据就进来了。判断方法很简单把Mapper里的SQL复制到数据库客户端用同样的参数执行一遍看返回多少行。如果查询结果确实大于1行那问题不在代码在数据上。3.2 动态SQL拼接导致条件失效这个场景隐蔽很多。MyBatis的动态SQL特强大但用不好就是坑。比如你用if标签拼条件本意是“传入用户名就按用户名精确匹配”但参数在某个调用链路上没传进来条件直接被跳过了查询就退化成了SELECT * FROM user那结果自然不止一条。更隐蔽的是choose标签的逻辑搞反了。choose类似Java里的switch-case如果otherwise分支里写的是“不带任何条件”的查询那一旦前面的when都不满足就会走“全表查询”分支后面接selectOne必然翻车。3.3 多表关联查询出现重复数据多表JOIN产生重复行也是高频原因。比如订单表和订单明细表关联一个订单有多条明细JOIN之后订单信息会被复制多份。如果你写的查询主表是“订单”但返回结果里一个订单对应多条明细SQL结果集中就会出现重复的订单行。这个时候用selectOne去接订单对象大概率就“found: 2”了。3.4 数据库脏数据和唯一索引缺失这个场景在接手老项目时尤其常见。早期表结构设计时没用唯一索引代码层面又没做幂等控制于是同一业务主键的数据被插入了多次。比如用户重复注册、重复提交订单、补偿任务重复执行等等。排查到这一步光改代码是不够的。你得先确认历史数据里哪些是无效重复的清理完再考虑要不要加唯一索引否则LIMIT 1只会“掩盖问题”不会“解决问题”。3.5 自定义拦截器修改了返回结果MyBatis拦截器Interceptor可以拦截Executor、StatementHandler、ParameterHandler和ResultSetHandler四个核心组件。如果你或者团队里的某个“大牛”自定义过拦截器并且对查询结果做了二次处理比如统一脱敏、统一填充字段这类拦截器有可能把单行处理的逻辑改成多行或者修改了SQL拼接逻辑导致结果集数量变化。排查这类问题有个笨办法把自定义拦截器暂时禁用复现同样的操作。如果不报TooManyResultsException了那基本可以判定是拦截器“动了手脚”。4. 修复方案详解从SQL、代码、数据三层下手4.1 SQL层用 LIMIT 1含分页差异对照如果业务上确实只需要任意一条记录最直接的改法是给SQL加LIMIT 1select idselectByUsername resultTypecom.example.User SELECT * FROM user WHERE username #{username} LIMIT 1 /select这样selectList最多拿到一条selectOne自然能正常处理。注意LIMIT是MySQL的方言不同数据库写法不同数据库限制返回一条的写法MySQL / PostgreSQL / SQLiteLIMIT 1OracleFETCH FIRST 1 ROWS ONLY或ROWNUM 1SQL ServerSELECT TOP 1 ...DB2FETCH FIRST 1 ROW ONLY很多团队用MySQL开发但生产环境或者将来的数据库替换可能切到其他数据库。从可移植性的角度考虑比起依赖特定数据库方言更好的思路是“用足够精确的条件让查询结果天然只有一条”。4.2 代码层用 List 兜底和 Optional 处理如果你不想改SQL也可以改方法签名让返回值变成ListUserpublic interface UserMapper { ListUser selectByUsername(Param(username) String username); }然后在Service层自己决定取哪条ListUser users userMapper.selectByUsername(admin); User user users.isEmpty() ? null : users.get(0);这种方式在特殊场景下是合理的比如“查询最新的那一条”时需要先按时间排序再取第一条。但注意它应该是临时方案而不是长期设计。理由很简单Mapper方法叫selectByUsername返回List调用方每处都要写“取第一条”的判断逻辑时间一长就会到处都是重复代码还容易漏判空列表。更好的代码层姿势是使用Optional。MyBatis 3.5.0支持Mapper方法直接返回OptionalUserpublic interface UserMapper { OptionalUser selectByUsername(Param(username) String username); }注意返回OptionalUser时如果查询结果有多条依然会抛TooManyResultsException。Optional只解决“可能为空”的问题不解决“可能多条”的问题。所以别指望Optional能兜底它的底层走的还是selectOne。4.3 数据层唯一索引与数据清洗如果问题出在数据本身不唯一光改SQL和代码是治标不治本。正确做法分两步第一步清理历史脏数据。先找出重复数据保留业务上有效的那条其余删除或标记作废。以用户表为例-- 查找重复用户名 SELECT username, COUNT(*) AS cnt FROM user GROUP BY username HAVING COUNT(*) 1;确认好保留策略后手工或脚本清理。注意生产数据操作前一定先备份。第二步给业务上唯一的字段加唯一索引。还是以用户表为例ALTER TABLE user ADD UNIQUE KEY uk_username (username);加了唯一索引之后数据库层面就不允许重复数据再进来相当于从源头堵住了问题。这一步做完selectOne才真正安全。4.4 预防性设计Mapper方法命名的自我约束排查过几次这个异常之后我给自己定了一条规矩单值查询的Mapper方法命名和SQL必须对“唯一性”负责。具体来说方法名里带ById、ByCode、ByNo这类“唯一标识”语义的保证SQL按主键或唯一索引查询。方法名里带First、Latest、One这类“单条”语义的SQL里必须显式排序和LIMIT 1或对应方言写法。方法名里带List、All、Page的返回值必须是集合类型。不确定是否唯一时首选返回List再接业务判断不要上来就写单值返回。这些约定不需要什么工具支撑全靠团队Code Review时执行。但确实能帮你少踩很多坑。5. 被问烂的 MyBatis 面试题TooManyResultsException 的三种答法“TooManyResultsException是什么什么时候会抛怎么解决”在Java面试里是高频题。既然热词里有“MyBatis面试题”这里顺便从面试官的视角拆解一下你可以根据自己的水平选择回答深度。5.1 一分钟快答版本先说结论TooManyResultsException是MyBatis在执行单值查询selectOne时发现SQL结果集包含多行而抛出的异常。它表示“代码只想接收一个结果但数据库返回了多个”。常规解决方式是给SQL加LIMIT 1、把返回值改成List、确保查询条件命中唯一索引、清理重复数据。这个版本适合基础岗位能答出“什么场景抛”“怎么解决”已经能过及格线。5.2 加分项源码级展开想加分就要从DefaultSqlSession的selectOne方法源码说起。面试官通常想听到的关键点是Mapper接口底层通过动态代理进入MapperMethod.execute。execute里根据方法返回值类型决定走selectList还是selectOne。selectOne内部其实调用了selectList然后判断list.size()等于1返回大于1抛异常等于0返回null。TooManyResultsException的完整限定名是org.apache.ibatis.exceptions.TooManyResultsException。能说出这些说明你不只是用过MyBatis还读过核心源码。面试官对你的源码阅读能力会有一个不错的印象。5.3 再进一步结合Spring事务和缓存聊更高水平的答法是把它放到Spring集成场景里聊。比如Spring包装后异常变成org.mybatis.spring.MyBatisSystemException里面包含nested exception本质是MyBatisExceptionTranslator把MyBatis的PersistenceException翻译成了Spring统一的DataAccessException体系。这个翻译机制意味着你在写catch时最好捕获Spring抽象的DataAccessException而不是直接捕获MyBatis的TooManyResultsException否则在Spring环境下容易出现“异常类型不匹配”的困惑。如果开了缓存缓存只缓存“查询成功”的结果不会加密行数但如果同一SqlSession内先执行了多值查询再走单值入口缓存命中会让异常更隐蔽排查时注意绕开缓存干扰。把你平时积累的缓存原理、事务边界、异常翻译机制串进来这一问基本就是“高分回答”了。6. 实操避坑记录我在项目里踩过的那几个坑6.1 坑一列表接口改成单值查询后没改返回值有一次我重构一段老代码原本是查“用户最新一条登录记录”SQL里加了ORDER BY login_time DESC但没加LIMIT 1。测试环境数据少一直没事。上了生产某个用户登录记录一多接口直接报TooManyResultsException。这次经历给我的教训很直接“我知道数据唯一”和“数据库真的保证唯一”是两回事。只要SQL没显式限制返回行数哪怕业务上再确定唯一都有可能因为历史数据、并发写入、脏数据而出现多条。生产环境没有“应该”只有“实际”。6.2 坑二分页插件配合 selectOne 的迷惑行为还有一次排查半天发现某个查询在开发环境好端端的到了生产环境偶尔报TooManyResultsException。后来发现是团队在项目里统一加了PageHelper分页插件某个调用方在执行单值查询前调用了PageHelper.startPage(1, 10)分页插件拦截到SQL后自动追加了分页参数。按说分页之后结果集变小了不应该报错才对——但实际情况是PageHelper在某些版本下对selectOne的支持并不友好它会把SQL改写成带LIMIT的查询但返回结果的判断逻辑依然受限于MyBatis的selectOne流程。这个场景报错的概率不高可一旦碰到就会让人非常困惑。排查方法很简单在代码里全局搜索PageHelper.startPage看看有没有哪个调用链在与单值查询相交前调用了分页。有的话要么把分页调用挪到只影响列表查询的地方要么在单值查询的Mapper方法上避免走分页拦截。6.3 坑三批量写操作里的 selectKey 与返回结果热词里有“使用MyBatis进行批量写操作”这里也提醒一个相关注意点。在使用selectKey回填自增主键时如果你恰好把selectKey的resultType或order配置写错可能导致SQL执行后产生了额外结果集。虽然这个场景更常见的是报BindingException或结果集解析异常但也确实出现过间接导致TooManyResultsException的情况尤其是批量插入后马上又在同一个SqlSession里执行单值查询时。我的建议是批量写操作之后如果要查询刚写入的数据一定要明确查询条件确认能唯一命中。批量插入往往涉及多行数据随后的查询如果条件写得太宽很容易就“found: N”了。6.4 我的排查SOP踩了几次坑之后我总结了一套排查这个异常的固定流程分享出来供参考第一步把异常堆栈里but found: N的N记下来。N大于1确认是“多行问题”。第二步把Mapper对应的SQL复制出来在数据库客户端用实际参数跑一遍。看结果集到底几行。第三步如果结果集确实多行判断是SQL条件不够精确还是数据库数据本身重复还是JOIN导致结果膨胀。第四步按“数据是否允许重复”来决定方案允许重复就加LIMIT 1不允许重复就清理数据并加唯一索引。第五步复查代码里是否有分页插件、自定义拦截器、二级缓存等干扰项。有就逐一排除。整个过程熟练之后通常在10分钟以内就能定位问题。7. 写在最后一次异常排查带来的思考TooManyResultsException算是我遇到的MyBatis异常里“最有教育意义”的一个。它不像空指针那样来路不明也不像连接超时那样依赖外部环境它把“代码预期”和“数据现实”之间的裂缝直接暴露给你看。我个人在实际操作中的体会是这个异常绝大多数时候不是框架问题而是“我们以为数据是唯一的但数据库并没有替我们保证”的问题。所以修完一次之后别急着收工花两分钟看一眼表结构如果这个字段将来也必须有唯一性约束那就把索引加上。修代码是止血加约束是治病。最后再分享一个小技巧排查这类异常时把IDEA里MyBatis相关的日志输出级别调到DEBUG配合MyBatis Log Plugin这类插件查看完整SQL和参数。很多时候你“以为”传给数据库的是usernameadmin实际传的可能是usernamenull——参数和SQL一真实真相就八九不离十了。
返回列表