
如果让我列一个 MyBatis 高频报错的前三名TooManyResultsException绝对能排进去。很多人第一次看到屏幕上的nested exception is org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2第一反应就是哦查出了多条数据。然后呢然后就没有然后了——因为你只知道了现象还不知道为什么偏偏是这个 Mapper、这个 SQL、这组参数会翻车。这篇文章就从一个真实排查视角把这个异常从底层抛出机制、常见根因、完整定位链路到团队预防方案全部过一遍。不管你是刚写 MyBatis 的新手还是已经被线上告警砸醒过的老开发这里面的内容都能直接用上。1. 异常堆栈的第一行MyBatis为什么宁可抛错也不还你一个列表1.1 这行异常到底在翻译什么Expected one result (or null) to be returned by selectOne(), but found: 2这句话是 MyBatis 底层在selectOne方法里抛出来的。它的意思是你的 Mapper 方法声明了我只要一个对象但 SQL 实际查出了 2 条记录。在 Spring 环境下MyBatis 的异常会被包装成MyBatisSystemException所以你看到的堆栈经常是nested exception is org.apache.ibatis.exceptions.TooManyResultsException这种嵌套结构。这其实是一个很轴的设计Java 方法签名里已经写死了返回User而不是ListUserMyBatis 拿到多条数据后不知道应该把哪一条塞给调用方与其悄悄丢掉某条数据或者返回 null它选择直接抛异常把问题摊开给你看。让我用个生活化的例子。你打电话给前台“帮我把张三叫过来。”结果前台一查公司里有三个张三她直接愣住反问你“你要哪个张三”这就是selectOne的处境。它没能力替你判断只能把选择权抛回给你。1.2 源码层面它是怎么检查的真正抛异常的代码在org.apache.ibatis.session.defaults.DefaultSqlSession#selectOne里逻辑简单得让人怀疑人生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然后对集合大小做判断。等于说 MyBatis 永远是先查出所有满足条件的记录再在内存里数一数有几条。如果恰好一条就返回一条都没有就返回 null超过一条就抛异常。这个检查是发生在 SQL 查询完成之后的也就是说数据库已经老老实实把结果集返回给 MyBatis 了。所以排查这个异常的时候你首先要明确的结论是SQL 本身没有执行错误它是正常执行并且返回了多条记录问题出在结果数量和你声明的返回类型不匹配上。这也引出一个很重要的区分selectOne不等于SQL 里写了 LIMIT 1。前者是 Java 方法层面的约定后者是数据库层面的限制。很多人把这两个概念混在一起后面就会踩坑。1.3 为什么不直接返回第一条这是我在实际带人的时候被问过最多的问题“既然查出了多条为什么 MyBatis 不默认返回第一条这不更符合直觉吗”答案是在绝大多数业务场景下你查的应该是一条业务上唯一的记录而不是随便哪条。比如按用户 ID 查用户、按订单号查订单这些字段在业务上天然唯一查出多条就意味着数据有问题——可能是插入逻辑有 bug 导致重复数据可能是查询条件漏了租户 ID、公司 ID 这类必填过滤项。如果 MyBatis 悄无声息返回第一条这个错误会被永远掩盖掉直到某天线上数据异常爆发你才回头查为什么会有重复记录。所以我的观点一直是这个异常是 MyBatis 在帮你兜底而不是在给你添乱。它把结果数量不对这个信号强制放大让你正视数据或者 SQL 的问题。理解这一层你才算是真的看懂了异常的本质而不是停留在加个 LIMIT 1 压压惊的层面。2. 五个最容易触雷的写法从Mapper到XML逐一排查2.1 返回类型声明错误用实体对象接住了List的活最常见的翻车现场是 Mapper 方法返回类型声明和 SQL 返回结果不匹配。举个例子// UserMapper.java User findUserByName(String name);!-- UserMapper.xml -- select idfindUserByName resultTypecom.example.User SELECT * FROM user WHERE name #{name} /select只要user表里有两个同名用户这个方法必然抛TooManyResultsException。问题不在 SQL而在接口语义设计上findUserByName这个名字本身就不保证唯一。名字是可以重复的你用名字作为查询条件却声明返回单个对象这在逻辑上就是矛盾的。正确做法要么改返回类型为ListUser要么在 SQL 层面加上唯一的约束条件比如 ID。我个人更倾向于前者——如果你真的需要按名字查用户列表就不要假装它只有一个结果。2.2 动态SQL条件失效if标签让你以为是精确查询这个坑隐蔽得多。动态 SQL 是 MyBatis 最灵活的地方也是条件最容易失效的地方。select idgetActiveUser resultTypecom.example.User SELECT * FROM user WHERE status 1 if testtenantId ! null AND tenant_id #{tenantId} /if /select乍看之下传了tenantId就是精确查询返回单个用户。但如果你调用的地方没传tenantId这个 SQL 就变成了查所有 status1 的用户一旦系统里有多个生效用户异常就来了。这种问题在联调阶段很难发现因为测试数据太少等到上线后数据量上来就集中爆发。排查这类问题的时候不要光看 XML 里的 SQL 长什么样要把 MyBatis 实际执行的 SQL 打出来看。最直接的办法是让日志框架打印 SQL# application.yml (Spring Boot 场景) logging: level: com.example.mapper: debug可以看到 Preparing和 Parameters两行对照一下就明白实际执行的 SQL 少了哪个条件。2.3 分页插件和单对象返回的隐形冲突如果你在项目里用了分页插件PageHelper 或者 MyBatis-Plus 的分页拦截器又写了一个返回单个对象的查询分页插件会在 SQL 上自动拼接LIMIT或分页方言语法。表面上看它限制了返回条数但实际上某些分页插件在查询总数的时候或者在大数据量下返回的结果集会超出预期进而触发 selectOne 的检查。还有一个反向问题用了分页插件后返回单对象的查询确实被 LIMIT 1 限制住了不再报错但这恰恰掩盖了业务数据重复的问题。我遇到过真实的案例一个按客户编号查询客户的接口上线前加了分页插件后突然不报错了大家以为问题解决了。三个月后做月度对账发现客户表里有两条相同编号的记录因为 SQL 一直取的是第一条后面那条的数据怎么更新都查不到。所以分页插件不是解决 TooManyResultsException 的正确工具它只是让问题隐形了。2.4 JOIN 查询的一对多陷阱还有一类问题常见于联表查询。你写了一个 JOIN 查询比如查订单同时连上订单明细Order getOrderDetail(Long orderId);select idgetOrderDetail resultTypecom.example.Order SELECT o.*, d.item_name, d.quantity FROM order o LEFT JOIN order_detail d ON o.id d.order_id WHERE o.id #{orderId} /select一个订单有多条明细的时候JOIN 结果自然返回多行。但你声明的返回类型是OrderMyBatis 想把多行结果映射到一个订单对象上当然映射不过去。正确做法是用resultMap做一对多映射把明细映射成Order里的ListOrderDetail字段方法返回类型仍然是Order——这时候 MyBatis 会基于主键合并做嵌套结果映射不会抛异常。或者你干脆拆成两条 SQL 分别查询。我见过很多项目为了图省事直接LEFT JOIN然后取第一条这在报表场景还能忍在业务查询里就是定时炸弹。2.5 误把列表方法当单条用SqlSession 与 Mapper 的混用在少数老项目里有人直接用SqlSessionTemplate手写数据访问代码容易写出这种逻辑User user sqlSession.selectOne(com.example.UserMapper.findUserByName, name);但有没有可能你本来想查的其实是多条如果业务上这个 name 对应的就是多条记录你却用了selectOne当然会炸。这时候应该用selectList然后自己处理列表或者直接用 Mapper 接口方式让方法签名说话。这个场景听起来低级但我在 Code Review 里真的见过不少。尤其是从 iBatis 时代迁移过来的老代码风格保持得很原始很容易踩。2.6 小结上面五个场景按我在实际项目中的观察出现频率从高到低排序大概是动态 SQL 条件失效 返回类型声明错误 JOIN 一对多 分页插件冲突 老式 SqlSession 误用。前两类占了至少七成的线上告警。如果你正在排查这个问题先从这两类入手命中率最高。3. 一次典型线上事故的完整定位链路从堆栈到数据库3.1 第一件事从异常堆栈里读取线索光讲理论没意思我拿一次真实的线上事故走一遍完整排查过程。那天是周四下午监控平台突然弹出告警某个查询接口的异常率超过阈值。点开异常堆栈核心内容是这样的org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.exceptions.TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 3这种堆栈信息其实已经暴露了两条关键线索第一这个问题发生在 MyBatis 的 selectOne 链路里第二查出了 3 条记录不是 2 条也不是几十条说明数据重复得有限很可能是某个业务键出现了少量重复。很多人看到这里就直接去查 Mapper 了但我习惯先做一步看请求参数。在告警后台把出问题的那次请求参数捞出来通常能帮你快速缩小范围。这次捞到的参数是customerId10086对应的接口是getCustomerContract。3.2 复现路径把偶然变成必然拿到参数后我不急着改代码先做复现。因为在测试环境你很难保证数据一致直接本机连测试库用同样的参数跑一次 SQL 是最快的验证方式。先看 XML 里的 SQLselect idgetCustomerContract resultTypecom.example.Contract SELECT * FROM contract WHERE customer_id #{customerId} AND status #{status} /select然后我在测试库里执行SELECT * FROM contract WHERE customer_id 10086 AND status ACTIVE;结果返回了 3 行和异常信息里的found: 3对上了。到这里问题从Mysterious exception变成了这个客户确实有 3 份 ACTIVE 状态的合同。那么接下来的问题就变成业务上这个客户允许有 3 份生效合同吗去问业务方答案是允许的。这就说明getCustomerContract这个方法的语义从一开始就错了——客户可能有多份生效合同但方法名用了单数表达。3.3 修复与验证改完别急着走修复方案要分两层接口层和数据层。接口层是治标。如果调用方确实只需要一份合同那应该在 SQL 里明确排序规则和 LIMIT 1比如取最近创建的一份select idgetLatestCustomerContract resultTypecom.example.Contract SELECT * FROM contract WHERE customer_id #{customerId} AND status ACTIVE ORDER BY created_time DESC LIMIT 1 /select注意这里我把方法名也改了从getCustomerContract改成getLatestCustomerContract因为方法名的语义变化会影响调用方的理解。如果查询结果本来就有多条你就别让方法签名假装它只有一条。数据层是治本。如果业务上确实不允许同一客户有多份 ACTIVE 合同那就需要查为什么会有 3 条。那次排查最终定位到是合同创建接口在并发提交时因为缺少唯一索引两条请求同时插入成功。最后在contract表上加了(customer_id, status)的联合唯一索引业务约定每位客户只能有一份生效合同但这里加了条件限制仅对 ACTIVE 状态做部分唯一索引。数据层的问题不解决你只在 SQL 层加 LIMIT 1本质上是让错误永远沉默。我见过太多团队在 LIMIT 1 的掩护下带着重复数据跑了好几个月直到对账才发现账目对不上。验证环节也很关键。改完代码后先用刚才复现的customer_id10086跑一遍确认返回单条且是期望的那条。然后跑关联的回归用例确保上游调用方拿到的数据行为一致。很多这种问题改完后接口是通了但调用方本来期望拿到的是旧数据顺序你换了个排序规则他的业务逻辑可能就不对了。3.4 排查小工具MyBatis日志插件排查阶段我强烈建议手边有个 SQL 日志利器。热词里有个mybatis log plugin它是一个 IDEA 插件作用是把 MyBatis 运行过程中带参数占位符的 SQL?还原成完整的可直接执行的 SQL对于排查TooManyResultsException这类SQL 实际执行了什么的问题非常有用。安装后在 Debug 模式下 IDE 会自动捕获 MyBatis 日志直接复制到数据库工具里执行验证。没有它的话手动从Preparing和Parameters里拼 SQL 也完全可行只是稍微繁琐一点。4. 把异常扼杀在代码审查阶段团队层面的三道防线4.1 防线一返回类型设计强约束这个异常解决一次容易真正难的是让整个团队以后少写这种代码。我在团队里推的第一条规范是Mapper 方法的返回类型必须和业务语义严格对应。业务上只想查一条返回实体对象或OptionalTSQL 必须包含唯一性条件。业务上可能有多条一律返回ListT。不确定会不会有多条宁可先返回ListT由调用方用ListUtils判断元素数量也不要直接返回单个对象。另外Java 8 之后 MyBatis 支持返回OptionalT我觉得这比直接返回实体类更能表达可能查不到的语义。调用方用orElseThrow显式处理空值情况比拿到 null 再判断更安全。但要注意OptionalT遇到多条结果同样会抛TooManyResultsException它只是解决了空值表达问题没有解决数量问题。4.2 防线二查询参数强制化很多TooManyResultsException的根本原因是查询参数不足。要么是调用方没传某个必填参数要么是动态 SQL 让条件变成了可选。我推荐的写法是对于必须按业务键精确查询一条记录的接口尽量使用不可为空的参数并且在 SQL 层面拒绝全表扫。一个非常实用的技巧是在 XML 里加一个回退保护。举个例子select idgetUserByPhone resultTypecom.example.User SELECT * FROM user WHERE phone #{phone} if testmerchantId ! null AND merchant_id #{merchantId} /if /select这个 SQL 看着没问题但merchantId一不传就出事。更稳妥的是让传参在入口处做校验用 Spring 的Validated或者手动判断merchantId是业务上的必传项就直接拒绝请求而不是期待 SQL 条件兜底。服务端校验能拦住的错误不要下放到 SQL 层去扛。4.3 防线三Code Review 清单与测试兜底我把 TooManyResultsException 这类问题归纳进了团队的 Review 清单重点关注以下几点检查项说明Mapper 返回类型是否与业务语义一致单个对象查询是否包含唯一性条件动态 SQL 条件是否存在可选条件导致查询范围扩大JOIN 映射一对多关系是否使用 resultMap而非返回单实体分页插件单对象查询是否混用了分页拦截器查询排序使用 LIMIT 1 时是否明确了 ORDER BY测试兜底的方案更直接给 Mapper 写单元测试时主动插入多条数据验证是否抛出预期异常或返回正确结果。很多团队写 Mapper 测试只覆盖能查到数据和查不到数据两个场景恰恰漏了查到多条这个最关键的边界。把重复数据这个场景补进去可以提前暴露大量问题。MyBatis 拦截器在这里有一个重要的应用场景你可以写一个简单的拦截器在 Mapper 方法返回单对象却查到多行时打印带参数的业务告警日志。这个拦截器不需要阻止异常抛出但能在测试环境和预发环境提前把问题暴露出来。在团队规范里这个属于主动防御比被动等线上告警体感好很多。5. 容易被忽略的边界缓存、批量操作与MyBatis-Plus的特例5.1 缓存和TooManyResultsException到底有没有关系热词里有不少关于 MyBatis 缓存的内容这里我直接说结论缓存本身不会触发TooManyResultsException。MyBatis 的一级缓存默认开启作用范围是SqlSession它在 Executor 层工作缓存的是查询后的结果对象而selectOne的检查发生在拿到结果列表之后无论列表来自缓存还是数据库都会执行相同的 size 判断。但是有一个和缓存相关的间接坑二级缓存。如果项目开启了二级缓存并且查询结果是缓存住了的当底层表数据发生变化比如另一个服务直接改了数据库你有可能会拿到旧数据。不过这是数据一致性问题和结果数量无关。你可以放心排查遇到 TooManyResultsException不要往缓存方向去找那是浪费时间。5.2 批量写操作与返回值的语义陷阱热词里还有使用mybatis进行批量写操作、mybatis批量这块也容易出幺蛾子。批量插入的时候经常有人写这种方法int insertBatch(ListOrder orders);注意这里的返回int在 MyBatis 中代表的是影响行数不是业务 ID。如果你用的是批量插入并且想要回填自增主键需要配合useGeneratedKeystrue keyPropertyidinsert idinsertBatch useGeneratedKeystrue keyPropertyid INSERT INTO order (order_no, customer_id, amount) VALUES foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.customerId}, #{item.amount}) /foreach /insert执行完批量插入后MyBatis 会把数据库生成的主键回填到传入的orders列表的每个元素里而不是返回一个主键集合。很多人把useGeneratedKeys和return 主键列表混在一起以为批量插入后拿selectKey能查到一批主键结果发现拿到的是影响行数。这个问题虽然不是TooManyResultsException但在开发中出现的频率极高而且它反映的是同一个问题对 MyBatis 返回语义的理解偏差。如果你因为批量写操作后要查主键又写了一个selectOne去查最新插入记录那才会真的把两个坑叠在一起。5.3 MyBatis-Plus用户需要格外注意的selectOne行为现在很多新项目直接基于 MyBatis-Plus 开发它的selectOne底层还是 MyBatis 的机制所以异常表现完全一样但使用方式上多了几个特例。最常见的是这样的代码User user userService.lambdaQuery() .eq(User::getPhone, phone) .one();如果phone字段在表里不唯一这里就会抛TooManyResultsException。MyBatis-Plus 的.one()和.list()语义和 MyBatis 的selectOne/selectList是一一对应的。很多人以为 MyBatis-Plus 会智能地返回第一条这是误区。MyBatis-Plus 的回避方案有两种。一种是明确业务上只需要一条直接在查询条件里加last(LIMIT 1)User user userService.lambdaQuery() .eq(User::getPhone, phone) .last(LIMIT 1) .one();另一种是允许有多条用.list()拿到列表后自己处理。这里我把话说重一点.last(LIMIT 1)是一种合法但容易滥用的手段。如果业务上 phone 应该唯一你加上 LIMIT 1 之后重复数据就永远不会被发现。我建议在加 LIMIT 1 之前先在数据库层面确认这个字段是否已经建了唯一索引。没建唯一索引就加 LIMIT 1等于告诉系统重复数据没关系我只要一条。——这通常不是你想表达的意思。MyBatis-Plus 还有一个selectById系列的方法这类按主键查询天然唯一设计上也安全尽量优先用它们。只有在按业务字段精确查询时才需要认真思考唯一性约束。5.4 面试题视角为什么这个异常在开发中反复出现热搜词里有 mybatis面试题、实际开发时这种情况多吗这两个问题我可以一起回答多而且相当多。我在面试中经常问候选人selectOne 查出多条会怎样很多人只知道会抛异常但说不出异常类名更说不清底层的 selectList 检查逻辑。这从一个侧面说明很多人在日常开发里都是在 IDE 里看到异常、加 LIMIT 1、提交、完事从没想过这个异常背后的设计意图和业务信号。实际开发中这个异常出现频率高的原因其实和数据模型密切相关很多业务表在项目启动阶段没有加唯一索引等数据量大了、并发高了重复数据自然就冒出来了。所以治理这个异常表面上是修 SQL本质上是推动数据模型加唯一约束、完善接口参数校验、规范 Mapper 返回类型。这几个动作做下来这类异常的活跃度能下降一大半。写在最后我处理这类问题的习惯顺序按我自己的经验一个完整的处理流程是先从堆栈里确认selectOne的位置从异常信息里的数量判断重复的严重程度拿到请求参数复现 SQL再回到代码层面看方法签名和动态 SQL 条件最后判断数据层需不需要加唯一索引。这个顺序不是固定的但核心原则不变——不要急着用 LIMIT 1 压掉异常先搞清楚为什么会有多条。最后分享一个小技巧。在排查这种问题的时候我习惯把异常堆栈里那个 SQL 的 MappedStatement ID通常长这样com.example.mapper.UserMapper.findUserByPhone直接复制到 IDE 的全局搜索里2 秒钟就能定位到 XML 文件。很多时候 Mapper XML 文件名和接口名能对上但集群式项目里明明叫UserMapper却可能放在UserInfoMapper.xml里这种名不副实的文件结构最容易让人排查到一半走错路。工具用得越干净利落你在问题本身上花的时间才越有价值。