凌晨一点半手机连着震了三次群里已经有人在我。线上一个报表导出接口突然大面积报错堆栈里躺着一行扎眼的java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0。第一反应是数组越界可翻遍业务代码也没找到直接操作数组的地方。最后顺着调用链一路往上追才发现问题出在最底层的 Mapper 查询——准确说是 MyBatis 在解析和映射查询结果时把一个索引越界异常给抛了出来。这类问题有个特点SQL 本身用EXPLAIN看完全正常语句能跑数据也能查但结果一映射就炸。今天就把我踩过的、排查过的 mapper 索引越界异常完整摊开讲。这篇文章适合谁主要是用 MyBatis 写查询、每天跟 Mapper XML 打交道又没系统排过这种怪的 Java 后端开发。你大概率遇到过ArrayIndexOutOfBoundsException或IndexOutOfBoundsException但很少往 Mapper 层想。我会从异常堆栈怎么读开始讲到三个最容易触发的隐藏点再用一个生产环境案例完整还原排查链路最后给出一套可以直接抄的预防规范和排查节奏。1. 先看懂异常堆栈索引越界到底抛在哪一层很多人一看到IndexOutOfBoundsException下意识就在 Java 集合、数组代码里找问题方向完全错了。Mapper 查询抛出索引越界堆栈的特征非常明显关键是看at行的包名。如果是org.apache.ibatis开头的问题基本锁定在 MyBatis 的映射过程如果是java.sql或数据库驱动包名开头则是 JDBC 驱动层的参数绑定或结果集读取阶段出了问题。我随便贴一个典型的堆栈片段你感受一下java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0 at org.apache.ibatis.reflection.factory.DefaultObjectFactory.instantiateClass(DefaultObjectFactory.java:106) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createByConstructor(DefaultResultSetHandler.java:622) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:558) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getRowValue(DefaultResultSetHandler.java:460) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:426) at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValues(DefaultResultSetHandler.java:399)看到DefaultResultSetHandler出现就知道这异常发生在结果集映射阶段而不是 SQL 执行本身。此时 SQL 已经跑完MyBatis 拿着 ResultSet 的列去构造目标对象中间某个数组、列表按列序号取值时越界了。判断层级的快速方法我总结成一张表堆栈特征抛出阶段优先怀疑点org.apache.ibatis.reflection.*/DefaultResultSetHandler结果集映射ResultMap 构造参数错位、列索引计算异常org.apache.ibatis.binding.MapperMethod参数绑定方法参数和#{}占位符数量/顺序不匹配com.mysql.cj.jdbc.*或org.postgresql.*JDBC 驱动层SQL 参数占位符与传入参数数量不一致、驱动版本问题org.mybatis.spring.*Spring 集成层代理对象创建、Mapper 扫描配置错误为什么要先分层因为这决定了排查方向。如果你在DefaultResultSetHandler里找问题结果去改 SQL 参数绑定折腾半天一无所获。堆栈就像体检报告先看指标指向的器官再决定挂哪个科。还有一个容易忽略的细节MyBatis 的DefaultResultSetHandler在映射时会对列做getColumnIndex之类的操作依赖ResultSetMetaData提供的列数。如果某些驱动返回的列数和实际 ResultSet 的列数不一致——比如使用某些版本数据库驱动配分页插件时——就会出现越界的假象。所以看到IndexOutOfBoundsException别急着改业务代码先把堆栈前 10 行完整截图分层之后再做下一步。2. 三个最容易被忽略的越界触发点动态SQL、参数绑定、结果映射2.1 动态SQL中foreach的collection参数传错类型这是我在代码评审里见过最多的一种。场景很典型Mapper 接口方法接收一个String类型的 ID 列表代码里是101,102,103这种逗号分隔字符串开发图省事直接把它传到foreach里以为 MyBatis 会自动拆分成数组。结果 XML 里写的是select idselectByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select如果ids是StringMyBatis 的ForEachSqlNode会把它当作单个对象而不是 Iterable 或数组。它内部会调用PropertyAccessor去遍历这个对象的所有属性然后把每个属性值当作 SQL 片段。这一顿操作下来参数拼接的片段数量和期望完全不一致后续访问索引时自然越界。正确做法有两种// 接口层就把类型定好 ListUser selectByIds(Param(ids) ListLong ids); // 或者调用前转换 mapper.selectByIds(Arrays.asList(101,102,103.split(,)));还有更隐蔽的一种在foreach里把collection写成list但方法参数没有加Param(list)而是用了别的名字。MyBatis 在解析时找不到对应的属性也会走到奇怪的遍历逻辑里最后抛出越界异常。判断口诀凡是foreach出现先确认传入的对象实现了Iterable或是一个数组否则一律加Param配合明确的collection名称。2.2 多个参数未加Param导致的占位符绑定错位另一个高频触发点是多参数查询。看下面这个接口ListOrder selectOrders(String status, String type, Date startTime, Date endTime);XML 里对应select idselectOrders resultTypeOrder SELECT * FROM order WHERE status #{param1} AND type #{param2} AND create_time BETWEEN #{param3} AND #{param4} /select这段代码在 MyBatis 3.5.x 下确实能跑因为没加Param时MyBatis 会默认提供param1、param2这样带param前缀的参数名和#{param1}能对得上。但如果你把 SQL 里的占位符写成了#{arg0}这是另一个默认命名而代码里既有arg0又有param1这种混用绑定的时候参数列表的索引就会错位最终驱动层在拼接 PreparedStatement 的参数时发现占位符数量比实际传入的多或者少直接抛IndexOutOfBoundsException。更危险的情况出现在用了SelectProvider或注解 SQL 的场景。动态拼接出来的 SQL 字符串里写死#{0}、#{1}这类下标一旦方法签名调整了参数顺序SQL 里引用的旧下标就指向不存在的参数。我的建议很直接所有超过一个参数的 Mapper 方法一律在参数前显式加Param并且在 XML 里只用Param指定的名称不用任何param1、arg0。一行注解省掉一整晚排查时间。2.3 ResultMap的constructor映射与列索引错位第三类藏得很深和DefaultResultSetHandler强相关。你定义了一个 ResultMap用了constructor子元素来映射实体类的有参构造器resultMap iduserMap typeUser constructor arg columnid javaTypeLong / arg columnuser_name javaTypeString / arg columnemail javaTypeString / /constructor /resultMap实体类长这样public User(Long id, String email, String userName) { this.id id; this.email email; this.userName userName; }出问题的地方在于ResultMap 里arg的顺序会和实体类构造器的参数顺序做一一对应。MyBatis 在createByConstructor阶段会按 ResultMap 里定义的 column 顺序去结果集里取列值然后按顺序填入构造器参数。上面这个例子里第二个arg是user_name但构造器第二个参数是email映射一错位整个对象的字段就全乱了更糟的是如果查询 SQL 里列的顺序和 ResultMap 不一致取值时按列序号访问超出实际列数越界异常立刻爆发。这个问题的隐蔽性在于数据库表有几十个字段实体类构造器参数顺序看一眼根本记不住完全靠开发和数据库表的惯性思维默认应该没问题。憋一天查不出来是常有的事。规避方案能用id和result映射字段就尽量不用constructor映射构造器。如果实体类必须用有参构造器那就手动给构造器加上ConstructorProperties({id, userName, email})注解让 Java 反射能拿到参数名MyBatis 在生成对象时就会根据名称去匹配而不是死板地按索引。加了之后列名和构造器参数就不容易错位。3. 一次生产环境排查实录从报错到定位真凶这个案例是我印象最深的一次。一个导出报表的接口查询逻辑不算复杂从订单表里按商品类目聚合统计每个类目的销售额和订单数然后用resultTypemap返回ListMapString, Object最后导成 Excel。某天上午开始线上每隔几分钟就报一次IndexOutOfBoundsException堆栈直指DefaultResultSetHandler.handleRowValues。刚开始我怀疑是数据量太大或者是某个特殊类目的数据触发了问题但同样的查询在测试库一切正常。整个排查过程我分成四步走。第一步完整抓取异常堆栈确定是否有 Caused by。异常被 Spring 事务切面包了一层最初只知道IndexOutOfBoundsException但没有根因。我把完整堆栈捞出来后发现跟在后面的就是MapResultHandler的内部调用没有额外的Caused by。这说明异常不是 SQL 执行抛出来的而是 MyBatis 在组装 Map 时自己触发的。第二步本地复现用生产数据的子集跑一遍。我把生产库的单表数据同步到本地跑了同样的 SQL结果第一遍没复现。试着把GROUP BY的维度换成时间粒度还是没有异常。这一步提醒我问题可能和特定数据分布相关而不是 SQL 语句本身。第三步打印 MyBatis 实际执行的 SQL 和参数用 EXPLAIN 验证。日志里把 SQL 完整打印出来手动在数据库客户端执行结果是正常的返回 3000 多行记录。这时候已经能明确排除 SQL 语法、数据库死锁、慢查询之类的问题。既然 SQL 没问题那一定在 MyBatis 的 Map 映射逻辑里。第四步检查查询列名发现两列别名完全相同。这是最后定位到的根因。SQL 里分组聚合的列是order_status同时 SELECT 里又有一个字段也写成了别名order_status和order_status列重名。也就是说查询返回的结果集里出现了两个同名列。MyBatis 在处理resultTypemap时会用列名作为 Map 的 key当结果集里有两列同名时它的内部处理逻辑会踩到一个索引计算的边界条件在特定的结果集大小和数据分布下就会把索引算到越界。真实情况是SELECT order_status, SUM(amount) AS order_status, COUNT(*) AS order_count FROM order GROUP BY order_status这种 SQL 谁敢想SELECT 第一列和第二列别名完全一致。第一列是分组字段第二列是聚合结果别名写成同一个名字纯属手滑。测试库数据量小结果集小侥幸没触发生产库数据多了处理同名列时索引计算直接越界。修复方案简单到让人想笑给聚合列换个别名SELECT order_status, SUM(amount) AS total_amount, COUNT(*) AS order_count FROM order GROUP BY order_status一个字符改动线上恢复。但排查的时间成本是巨大的——如果一开始就意识到resultTypemap的列唯一性问题可能 10 分钟就找到了。这个案例给我的教训MyBatis 处理resultTypemap时SELECT 里的列名必须保证唯一性。不管是显式别名还是函数自动生成的列名都不要出现重复。平时写 SQL 只关注数据对不对很少有人会关注列名是否在结果集里唯一但 MyBatis 的 Map 映射对重名列的处理机制并不健壮遇到大数据量很容易暴露索引计算的边界问题。4. 结构性预防让Mapper层从设计上绕开越界踩过几次坑之后我在代码规范层面做了几件事。这些不能说是绝对杜绝但至少能把 80% 的越界问题在源头掐灭。4.1 参数绑定规范不用裸参数我把规则定死所有 Mapper 接口方法超过一个参数必须给每个参数加Param。这个方法从底层规避了arg0、param1混用带来的索引错位。有人嫌注解啰嗦但对比一次线上事故的排查成本这行注解便宜得几乎不要钱。同时 XML 里的#{}只使用Param定义的名称禁止用param1、arg0。这点在代码评审时硬性卡住新代码只要出现#{param1}直接打回。4.2 动态SQL的collection类型检查凡是 XML 里出现foreach我要求对应的collection属性值必须在接口层有明确的类型声明。比如ListUser selectByIds(Param(ids) ListLong ids);如果调用方手里是逗号分隔字符串必须先转换不在 Mapper 层做隐式转换。这样 IDE 在编译期就能检查出类型问题而不是等运行时爆炸。4.3 结果集列名唯一性检查针对上一节的教训我总结了一条自查清单放在 SQL 审查里SELECT的子查询、函数表达式、常量统一用别名同一SELECT内所有列名和别名不得重复resultType是 Map 时特别检查重名列多表 JOIN 场景所有业务字段必须加表别名前缀。这条清单可以直接写进团队的数据库 SQL 评审标准。不要只靠人眼尽量配合静态检查工具在 MR 阶段就能发现重名列。4.4 用单元测试兜住异常边界Mapper 层也要写测试这一点很多团队忽略了。我给核心查询方法补了一些关键用例空结果集WHERE 10必须能正常返回空 List结果集中出现 NULL 列值不会导致映射错位GROUP BYresultTypemap时列名重名场景要单独用例守住参数数量异常时提前抛出参数绑定异常而不是越界异常。这些测试不用覆盖全量 SQL只要把最常用的查询模板骨架搭好就能把大部分索引越界的老问题锁死在 CI 阶段。4.5 配置拦截器提前暴露参数不匹配如果你想把检查做到运行时可以写一个 MyBatis 拦截器在StatementHandler.prepare阶段校验 SQL 里的占位符数量和传入参数数量是否一致。拦截器的核心逻辑很简单Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class ParameterCheckInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); // 简单解析统计 ? 数量 String sql boundSql.getSql().replaceAll([^]*, ); int placeholderCount countOccurrences(sql, ?); ParameterHandler parameterHandler handler.getParameterHandler(); Object parameterObject parameterHandler.getParameterObject(); // 根据 parameterObject 的类型和 Param 元数据计算实际参数数量 // 数量不匹配时直接抛出业务异常避免走到 IndexOutOfBounds return invocation.proceed(); } }这只是个防御手段。更好的思路是在 MyBatis 的ParameterHandler里做精细的占位符与参数索引配对如果索引超过范围立刻改成抛出带参数名提示的绑定异常。虽然实现起来有一点工作量但能让越界异常的可读性直接提升一个量级。5. 该按什么顺序查一套可以直接抄的排查节奏如果你现在正被一个查询 mapper 索引越界异常折磨别慌按下面的节奏来能少走很多弯路。5.1 先分层再看堆栈看到IndexOutOfBoundsException或ArrayIndexOutOfBoundsException第一步不是翻代码而是把异常堆栈的包名和行号截下来。判断它是哪个层抛出的MyBatis 自己的包 → 查 ResultMap、collection、结果映射JDBC 驱动包 → 查参数数量、SQL 占位符、驱动版本Spring 包 → 查 Mapper 代理、事务配置。这一步 30 秒就能完成能直接砍掉一半的错误排查方向。5.2 本地复现用最小数据量验证尽量在本地搭一套能连测试库的环境。如果本地复现不了优先怀疑数据量或数据分布触发的边界条件。此时可以拉一部分生产数据回来但注意脱敏。大规模数据拉不下来的话就重点查有没有重复列名、NULL 列、超长字段这类极端数据。5.3 对照越界场景做逐项核对我整理了一张自查表你排查的时候一项项打勾检查项具体操作触发越界的典型表现foreachcollection 类型确认传入的是 List/数组而非 String参数片段数量异常拼接阶段抛异常多参数Param一致性核对接口注解和 XML 占位符名称占位符找不到参数绑定阶段越界ResultMap constructor 顺序对照实体类构造器参数顺序列值映射错位实例化阶段越界resultTypemap 列名唯一性SELECT所有列别名查重大数据量下 Map 映射越界SQL 参数占位符数量打印完整 SQL 人工比对?个数驱动层绑定参数时越界分页插件与 ResultMap 组合检查 PageHelper 等插件版本插件拦截 SQL 后列数计算偏差5.4 改了别急着上线先补回归用例定位到根因后修复代码只是第一步。更重要的是把这次触发问题的场景固化成一条回归测试。比如这次是重名列导致的那测试里就专门构造一个重名列的 SQL验证后续改动不会破坏这个场景。根据我个人经验Mapper 层出越界异常十有八九不是随机 Bug而是数据特征或代码风格长期积累出来的隐患。**平时写 SQL 的时候把列名唯一性、参数绑定清晰性当成硬规则比在线上反复救火要舒服得多。**最后再分享一个小技巧项目里统一把 MyBatis 的 SQL 日志打开并设置成DEBUG级别线上保留最近一小时日志。这样真出了越界问题你能直接拿到实际执行的 SQL 和参数总数排查速度至少快一倍。