把单个 ID 改成一组 ID 之后,SQL 还没执行就出现 Parameter 'ids' not found;或者列表为空时数据库报语法错误,甚至查询范围突然变大。排查 foreach,先把 Mapper 入参、XML 中的集合名和最终 SQL 对上。

本文按 MyBatis 3.5.x 的 XML Mapper 说明。示例是带租户条件的只读查询,假设测试表 app_user 已有 id、tenant_id、display_name 字段。包名、字段类型和分页方案要按项目调整,未连接本站生产数据库。

先看失败发生在哪一层

现象优先核对
Parameter not found@Param 名称、collection、对象属性路径
expression evaluated to null集合是否允许为 null、实际传入对象
数据库提示 IN 附近语法错误空列表、括号、分隔符和最终 SQL
查询结果超出预期空集合是否让过滤条件消失、租户条件是否保留
参数数量或报文过大集合规模、驱动与数据库限制、批次设计

先记录 Mapper 方法签名、对应 statement ID,以及失败输入是 null、空列表还是几个元素。不要只截取一行数据库错误,也不要把真实业务参数完整打印到公共日志。

如果错误是 Invalid bound statement,优先检查 XML 是否加载、namespace 和方法 ID 是否一致,foreach 还未必是问题所在;如果 SQL 有结果但实体字段为 null,可参考MyBatis 查询字段为 null。

给集合明确命名,避免依赖隐式参数名

示例 Mapper 如下,放在项目已配置的 Mapper 扫描范围中:

package demo.mapper;

import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;

public interface AppUserMapper {
    List<Map<String, Object>> findByIds(
            @Param("tenantId") long tenantId,
            @Param("ids") List<Long> ids);
}

对应的 XML 使用 collection="ids",租户参数使用 #{tenantId}。名字来自 MyBatis 的 @Param,不是数据库列名,也不是随便写的临时变量。

单个未标注的 List 参数、数组、Map 和包装对象,暴露给 XML 的名字规则并不相同,还可能受参数名保留等配置影响。多参数时更不应假设 collection="list" 总能工作。看异常列出的 available parameters,再对照实际签名;新代码用明确的 @Param 能减少这类歧义。MyBatis Java API说明了参数命名方式。

如果方法接收的是一个查询对象,集合是它的字段,就核对对象属性路径与 getter;不能因为字段在 Java 方法附近出现,就认定它已成为顶层 XML 参数。

空集合要有业务语义

假设这个接口约定“没有 ID 就没有结果”,可以先在服务层返回空结果,也可以在查询中保留明确的空结果分支。下面是完整 mapper 结构示例,查询不会因为列表为空而丢掉 ID 限制:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="demo.mapper.AppUserMapper">
  <select id="findByIds" resultType="map">
    SELECT id, tenant_id, display_name
    FROM app_user
    WHERE tenant_id = #{tenantId}
    <choose>
      <when test="ids != null and ids.size() > 0">
        AND id IN
        <foreach collection="ids" item="id"
                 open="(" separator="," close=")">
          #{id}
        </foreach>
      </when>
      <otherwise>
        AND 1 = 0
      </otherwise>
    </choose>
    ORDER BY id
  </select>
</mapper>

这份示例将 null 与空列表统一视为无结果。如果你的接口认为 null 是缺参错误,应在服务入口拒绝它,并保持测试与接口约定一致。不要默默把“未传 ID”和“查询所有 ID”当成同一件事。

nullable="true" 解决的是特定版本下 null 集合的处理方式,不会替你决定“空列表应该查全部还是查不到”。foreach 没有输出内容时,外面的 SQL 仍需成立,过滤范围也要正确。动态 SQL 文档展示了 foreach 的集合与分隔机制。

看 SQL 和绑定参数,不只看 XML

在测试环境为具体 Mapper 开启合适的日志,核对执行 SQL 中有几个 ?,以及绑定参数的数量、顺序和类型。对于两个 ID,预期结构类似:

SELECT id, tenant_id, display_name
FROM app_user
WHERE tenant_id = ? AND id IN (?, ?)
ORDER BY id

这是示意 SQL,不是生产日志。#{id} 是参数绑定;${id} 是文本拼接,不能为了消除占位符或类型报错把二者互换。Mapper XML 参数文档解释了两种表达式的差异。

手工拼接 IN (...) 字符串还会把类型处理、空值与转义问题带到应用层。先明确列表元素到底是 Long、String 还是对象;对象列表通常要绑定 #{item.someField},不能把整个对象当标量 ID。

排查顺序可以固定为:参数名正确,再看集合内容;SQL 形状正确,再看参数类型;两者都对,才看数据库执行层的异常。

大列表、查询和批量写入分开处理

一个 foreach 不会自动变成数据库的高效批处理。超大 IN 列表会增加 SQL 长度和绑定参数数量,具体限制取决于数据库、驱动和语句形状。

需要分批查询时,先限定允许输入的最大规模,再根据环境测试批次。合并结果还要处理重复 ID、稳定排序、分页语义和事务一致性;把列表随意切开并拼接结果,不等于原来的单次分页查询。

批量插入和更新则要单独设计事务、提交与失败回滚。不要为了让分号拼起来的多条 SQL 运行,就直接打开驱动的多语句开关;那不能修复参数名、空集合或批处理语义。

用五组输入验收

至少覆盖 null、空列表、一个 ID、多个 ID、包含重复或不存在 ID 的列表。对每组核对返回结果、SQL 条件和参数绑定;带租户或权限过滤的查询,还要确认这些条件始终保留。

不要仅验证“语法不报错”。一条在空列表时悄悄查询全表的 SQL,比明确报错更容易造成误判。只读查询验证通过后,若要复用到修改语句,应重新检查影响行数和空输入策略。

参考资料

更新记录

  • 2026-10-29:初稿;按 MyBatis 官方资料核对,示例仅用于测试库设计与复测,不作为生产实测记录。