我记得第一次在项目里看到有人往 Mapper 接口里塞了六七个参数心里就冒出一个念头这段代码迟早要出问题。结果没过多久同事就拿着一个BindingException的报错来找我日志里写着Parameter name not found. Available parameters are [arg1, arg0, param1, param2]。这种报错对刚接触 MyBatis 的人来说几乎是必经之路而它的根子往往不在 SQL 写错而在于传参方式没选对、没讲透。这篇文章我想把 MyBatis Mapper 传递参数这件事从头到尾盘一遍。从单参数直接传、Param注解、POJO 对象、Map、集合数组到混合传参再把背后的参数解析链路翻出来看一眼最后说清楚哪些坑我在生产环境里真实踩过。如果你是刚入门的 Java 开发看完可以直接照着用如果你已经写了两三年 MyBatis我相信里面那些排查思路和选型规范也能给你一些参考。1. 传参方式这么多为什么值得单独梳理一篇1.1 一个高频报错引出的核心问题先看那个报错。方法签名长这样User findByNameAndPwd(String name, String pwd);XML 里写的是select idfindByNameAndPwd resultTypeUser select * from t_user where name #{name} and pwd #{pwd} /select一运行直接抛异常。原因很简单Java 方法定义了两个参数MyBatis 在解析#{name}时并不知道name对应的是哪个参数。它只认自己构建出的参数映射默认情况下你能用的 key 是arg0、arg1这种位置索引或者param1、param2这种兼容别名。你写#{name}它当然找不到。这种问题之所以高频本质上是因为 MyBatis 的传参有一套自己的约定它不完全等同于 Java 方法的参数语义。你不主动告诉它name 就是第一个参数它就只能按位置猜。猜不到就报错。1.2 传参方式直接影响代码的可维护性有人可能会觉得不就是传个参嘛写法能有多大差别在只有一两个参数的简单查询里确实差别不大。可一旦进入真实业务条件查询、批量操作、分页组合、复杂更新这些场景对传参方式的要求就完全不一样了。我见过一个项目某个统计报表的 Mapper 方法签名字面是ListMapString, Object queryReport(MapString, Object params);写的时侯确实爽啥条件都能往里塞。几个月后要加一个查询维度接手的人打开 XML 一看十几个if testxxx ! null完全不知道哪些 key 是必需的、哪些是可选更不知道这些 key 在什么时候被赋值。最后只能一层层往调用处追追了半小时才定位到 Key 在某个 Service 里拼错了。Map 传参看起来灵活但它的灵活是用可读性和类型安全换来的。相反如果一开始就把查询条件封装成一个 DTO 对象参数名、类型、默认值全都有迹可循后面即使换人维护也只需要看一眼 DTO 的字段定义就能把 XML 里的引用全部对应上。传参方式的选择从来不只是能不能跑通的问题而是后续好不好维护的问题。1.3 面试里也常考理解背后原理才算真会传参方式还是 MyBatis 面试题里的常客。常见的问法是MyBatis Mapper 接口有哪些传参方式各有什么优缺点以及不写Param会怎么样。这类问题其实是在考察两个层面第一面试者有没有写过足够多的 MyBatis 代码对各种场景都有感知第二面试者有没有读过源码理解参数解析的机制。只背答案的人通常会卡在单参数为什么不写Param也能跑通上因为这个问题光靠经验很难想明白得看过ParamNameResolver和DefaultParameterHandler才能说清楚。所以这篇文章后面专门留了一节讲链路帮大家把这层窗户纸捅破。2. 六种传参姿势逐个拆解用法与适用边界2.1 单参数直接传最简单的路径但有两个隐藏规则这是最省事的一种写法User findById(Long id);select idfindById resultTypeUser select * from t_user where id #{id} /select这里有两个新手不太清楚的规则。第一如果参数是简单类型String、Integer、Long 等#{}里写什么名字都能取到值。你可以写#{id}也可以手滑写成#{abc}运行都不会报错。因为 MyBatis 在绑定参数时发现 parameterObject 本身就有对应的 TypeHandler就直接把这个对象当成了参数值不会再拿花括号里的名字去反射。当然我强烈建议你老老实实写语义化的名字写#{abc}虽然能跑但对读代码的人来说是一种精神污染。第二如果参数是一个普通 Java 对象#{}里写的必须是它的属性名User insertUser(User user);insert idinsertUser insert into t_user (name, age, email) values (#{name}, #{age}, #{email}) /insert在这里#{name}等价的不是参数变量 user而是user.getName()。MyBatis 通过反射去读取这个属性。如果字段名拼错比如写成#{nmae}运行时会抛出常见的ReflectionException告诉你没有这个属性。拼写错误能直接暴露这反而是好事。2.2 Param 注解多参数场景的主力方案一旦方法里有多个参数最规范的做法就是用Param显式声明参数名User findByUsernameAndAge(Param(username) String username, Param(age) Integer age);select idfindByUsernameAndAge resultTypeUser select * from t_user where username #{username} and age #{age} /selectParam是告诉 MyBatis这个参数在 SQL 环境里的引用名是什么。加了注解之后#{}里就只能用注解值不能用 Java 方法里的变量名去期待它自动匹配两者其实不一定同名比如User findByUsername(Param(name) String username);只有#{name}有效#{username}反而报错。所以这个注解相当于一种重命名映射。为什么多参数必须用它因为 MyBatis 对多参数有一个默认兜底机制会生成param1、param2这类序列 key。你不用Param也能通过#{param1}、#{param2}取到值可用这种方法写出来的 SQL 毫无可读性参数一多基本分不清谁是谁。显式Param后SQL 看着就像在用真实业务字段可读性大幅提升。2.3 POJO 对象传参让参数有归属感当参数超过三个而且这些参数表达的是同一个业务概念时就应该把它们封装成一个对象public class UserQuery { private String username; private Integer minAge; private Integer maxAge; private String email; // getter / setter }ListUser searchUser(UserQuery query);select idsearchUser resultTypeUser select * from t_user where if testusername ! null and username ! and username like concat(%, #{username}, %) /if if testminAge ! null and age gt; #{minAge} /if if testmaxAge ! null and age lt; #{maxAge} /if if testemail ! null and email #{email} /if /where /select这种写法的最大优势是把散落的参数收拢成一个查询上下文。测试、复用、扩展都比散装参数方便。比如后期要加一个status筛选条件只需要在UserQuery里加一个字段再在 XML 里对应加一段if调用方想传就传不传也不会破坏现有逻辑。它也有自己需要注意的点第一对象里的字段不要拆成两个用途比如同一个字段既当过滤条件又当排序字段会让 XML 的if逻辑变复杂第二传给 Mapper 的对象不要复用 Controller 层的 VO因为接口语义随时可能变Controller VO 是面向视图的和持久层的查询条件往往不是一回事。2.4 Map 传参灵活是优点失控是代价Map 传参在 MyBatis 里同样很常见ListUser searchByMap(MapString, Object params);select idsearchByMap resultTypeUser select * from t_user where if testusername ! null and username #{username} /if if testminAge ! null and age gt; #{minAge} /if /where /select调用时MapString, Object params new HashMap(); params.put(username, 张三); params.put(minAge, 18); mapper.searchByMap(params);Map 的好处在于key 名和 XML 里的引用直接对应几乎没有学习成本动态查询要加条件时也不用改动方法签名。这对于字段非常不固定的统计报表类需求尤其好用。但它的问题我也在前面说过key 拼错不报编译错误只会在运行时得到错误结果Map 里所有 value 都是 Object根本没有类型约束传错了类型比如 Integer 传成 String也是到了数据库层才可能暴露。这种事事需要自律的风格在多人协作的中大型项目里很容易失控。我的建议是Map 可以用于少数几个明确定位的场景比如动态报表但不要让它成为整个持久层的主流传参方式。2.5 集合与数组传参批量 SQL 的必经之路批量查询或批量插入最常见的写法是传入一个集合ListUser findByIds(Param(ids) ListLong ids);select idfindByIds resultTypeUser select * from t_user where id in foreach collectionids itemid open( separator, close) #{id} /foreach /select用Param(ids)后foreach的collection属性就写ids这个值是显式定义的没有任何歧义。如果你不打算写ParamListUser findByIds(ListLong ids);那么foreach的collection有三种选择list、collection或array。规则是这样的传入List实例时MyBatis 会往参数 Map 中放入list和collection两个 key所以collectionlist或collectioncollection都行传入数组时放入的 key 是array所以collectionarray传入Set等其他 Collection 子类时只有collection可用。这里有个很容易让人困惑的点如果你传的是数组却写成collectionlistMyBatis 会直接报参数找不到。相反传List却写collectionarray也一样报错。批量场景里最常见的异常就是这一处名字写错。为了避免团队里各自猜我建议统一用Param显式定义集合名别去依赖list、array这类默认 key。2.6 混合传参对象 基础参数的组合技巧还有一些场景既有一个查询条件对象又需要分页参数、排序参数这类独立基础参数。这时可以把两者混搭ListUser searchWithPage( Param(query) UserQuery query, Param(offset) Integer offset, Param(limit) Integer limit);select idsearchWithPage resultTypeUser select * from t_user where if testquery.username ! null and query.username ! and username #{query.username} /if if testquery.minAge ! null and age gt; #{query.minAge} /if /where limit #{offset}, #{limit} /select注意两个细节。第一访问对象里的属性时要写成#{query.username}MyBatis 支持这种嵌套属性路径它会自动调用query.getUsername()。if里的 test 同样可以写query.username ! null。第二如果我把参数顺序打乱成ListUser searchWithPage( Integer offset, Param(query) UserQuery query, Integer limit);那 XML 里#{offset}和#{limit}还能不能直接用可以但有个前提——这些没有Param的参数会被 MyBatis 按位置规则处理。因为方法里存在一个有Param的参数整个方法会走命名参数路线没有Param的参数依然会生成arg0、arg1、param2这类位置 key。如果 XML 里写#{offset}抱歉还是不行。这就引出一个非常重要的结论想用语义化引用名就得给每个参数都加Param不要混着写。最稳妥的方案是上面那种所有参数全部加注解谁也不依赖默认行为。3. 揭开传参链路的面纱ParamNameResolver 与 ParameterHandler 的协作3.1 Mapper 接口调用到参数解析中间发生了什么面试官爱问MyBatis 底层是怎么把 Mapper 接口方法参数和 SQL 里的#{}关联起来的这个问题的答案藏在几个核心类里。当你调用userMapper.findByUsernameAndAge(张三, 18)时实际执行的链路是MapperProxy拦截方法调用组装一个MapperMethod然后由SqlSession调用对应的 executor。在MapperMethod内部有一个重要步骤用ParamNameResolver解析参数数组得到一个参数映射容器。ParamNameResolver做的事情可以理解为翻译官遍历方法的所有参数位如果某个参数标了Param(xxx)就把该位置的 key 记为xxx如果没有标Param默认用位置名比如arg0、arg1也可能因编译参数配置而显示成真实参数名为了让旧版本兼容它还会额外把param1、param2加入容器指向同一批参数如果只有一个参数且没有Param而且它又不是集合或数组就直接返回这个参数对象本身不去做包装。所以前面的报错日志里会出现[arg1, arg0, param1, param2]这种列表其实就是ParamNameResolver把能用的 key 都列出来给你看了。你写#{name}找不到因为它只有name这个业务名之外的位置名。3.2 单参数不写 Param 为什么也能跑通回到那个关键问题单参数时MyBatis 为什么不要求Param因为ParamNameResolver的第三步明确写了一条规则无注解且只有一个参数时直接返回该参数对象本身不包装成 Map。既然是对象本身DefaultParameterHandler在处理#{}时有两条路径参数对象本身就是简单类型并且注册过 TypeHandler那直接用对象本身作为参数值参数对象是 POJO通过MetaObject按属性路径去取字段值。如果强制包装成 Map反而会多一层 key 映射单参数场景完全没有必要。这个设计是性能与易用性的折中也解释了为什么#{value}、#{id}、#{anything}在单简单类型参数下都能取到同一个值。但要注意单参数场景也有例外。如果这个参数是个集合或数组ParamNameResolver会额外包装一层放入list、collection或array这些 key。这就是为什么foreach批量查询要区分list和array的根源。也就是说单参数的免注解规则对集合和数组并不完全适用。3.3 ParameterHandler 与 typeHandler 的隐藏作用参数映射容器构造好之后SQL 最终还是需要设置到PreparedStatement上这一步交给DefaultParameterHandler处理。它遍历BoundSql里每个ParameterMapping按顺序处理占位符对应的值。取值环节有一套优先级逻辑我简化后是这样的如果参数映射来自动态 SQL 的额外参数比如foreach里的 item 变量从BoundSql.additionalParameters中拿如果参数对象本身为 nullvalue 直接置 null如果参数对象类型能直接匹配某个 TypeHandler常见于 String、数字这类简单类型整个参数对象就是 value否则通过MetaObject反射从参数对象上取属性值。取到 value 后还需要把它塞进 JDBC 的PreparedStatement这就是 TypeHandler 的职责。根据 value 的 Java 类型MyBatis 会选择对应的 TypeHandler比如StringTypeHandler、LongTypeHandler。如果 value 是 null它会依据配置的jdbcTypeForNull来设置 JDBC 的 NULL 类型。这节可能读起来偏理论但它能解释很多实战问题。比如为什么Map里 key 写错结果会静默变成 null因为MetaObject对一个不存在的 key 取不到值返回 null 后 MyBatis 照样执行ps.setNull()SQL 不会报错只是查询条件变了。这类不报错的异常往往最难排查理解了取值链路排查方向就会清晰很多。4. 踩坑实录传参环节最常见的五类问题与排查链路4.1 多参数没加 Param从报错信息反推可用参数我在文章开头提到的报错实际排查过程其实不难。假设你有如下代码ListUser findByNameAndEmail(String name, String email);select idfindByNameAndEmail resultTypeUser select * from t_user where name #{name} and email #{email} /select报错是org.apache.ibatis.binding.BindingException: Parameter name not found. Available parameters are [arg1, arg0, param1, param2]翻译成人话就是MyBatis 说你要的name不在我手里的参数列表里我能用的 key 是 arg1、arg0、param1、param2。正确的排查顺口溜是报错里 Available parameters 列了哪些 key就把#{}改成哪个 key但同时要想办法把 key 稳定成业务名。最直接的修复是加注解ListUser findByNameAndEmail(Param(name) String name, Param(email) String email);改完重启再试问题消失。如果当时很急临时用#{arg0}、#{arg1}也能跑通但这种写法可读性太差Java 编译器对参数名的处理结果也有可能随构建工具配置变化今天能跑换个环境可能又崩了。所以Param才是根治方案。4.2 foreach 的 collection 写错list、array、param1 的庐山真面目这原本是一个很简单的需求批量查询用户 ID 列表方法签名ListUser findByIds(Long[] ids);同事的 XML 写的是foreach collectionlist itemid ...结果报Parameter list not found. Available parameters are [array, arg0, param1]。原因现在很好理解了参数是数组ParamNameResolver在单参数集合/数组场景只注入了array这个 keylist当然不存在。排查这类问题第一件事是看清楚调用方传的到底是数组还是List再回来看 XML 的collection属性。这里最容易踩的坑是接口定义里写了Param(ids) ListLong ids但 XML 里foreach却沿用了之前某段代码的collectionlist导致参数解析时ids存在、list不存在报错和上一种情况几乎一模一样。我建议在代码评审阶段就统一规则涉及集合、数组的 Mapper 方法参数一律写Param(xxx)XML 的foreach里只写这个自定义名。直接废除list、collection、array三种默认写法在团队里的使用一劳永逸。4.3 插入 null 值时 JDBC 类型报错jdbcType 与 null 参数的纠缠这个坑和传参方式也有关系。几年前我在做用户信息导入时遇到一个诡异的问题批量插入用户只要某个用户没有手机号传入 null就会抛类似JDBC exception on H2/MySQL ... Error setting null for parameter #3 with JdbcType OTHER严格来说这是参数绑定环节的典型问题。MyBatis 会为 null 值调用ps.setNull(parameterIndex, jdbcType)而 JDBC 驱动在未知 JdbcType默认 OTHER下部分数据库无法正确处理。解决办法有两种第一种在 SQL 的占位符上显式指定insert into t_user (name, phone) values (#{name}, #{phone, jdbcTypeOTHER})第二种在 MyBatis 全局配置里把 null 值的默认 JDBC 类型改成允许的类型mybatis: configuration: jdbc-type-for-null: NULL这种问题在 MySQL 下通常不发作但在 Oracle、PostgreSQL 某些驱动版本下很常见。排查时要记得看异常栈里是setNull还是setString如果卡在setNull八九不离十就是 JdbcType 的问题跟传参方式是Map 还是 POJO没有直接关系。4.4 Map 传参 key 写错不报错的异常比报错更危险有的坑会让你夜不能寐因为它不报错只是结果不对。Map 传参就是典型。有一次同事做查询导出方法ListReportData exportReport(MapString, Object params);调用方写的 key 是userIdXML 里判断和引用的却是uidif testuid ! null and user_id #{uid} /if因为 Map 里确实没有uid这个 keytest 判断恒为 false整个条件被跳过SQL 变成全表查询。功能看起来没坏数据却全错了。这类问题在报表、筛选条件很多的场景里尤其危险因为老板一眼就能看出数字不对但排查起来要翻 Service、XML、Map 的 put 逻辑三层。如果说 POJO 传参拼错字段名会立即抛异常Map 传参拼错 key 则倾向于安静地失败。这也是我不主张在核心业务里大量使用 Map 传参的最大原因。如果非要用 Map至少让 XML 里引用的 key 统一集中在一个常量类里管理而不是散落在 Service 层随手写的字符串里。4.5 动态 SQL 判空失效问题不一定出在传参方式还有一种状况参数传了但if判断怎么都不进。很多人以为是传参方式不对其实要分两种看。第一种多参数没加Paramtest 引用名和参数名对不上导致判断恒 false。这个跟 4.1 是同一个根因加Param就好。第二种参数对象里的某个字段永远是 null因为调用方根本没给它赋值。比如前端没传status后端直接拿一个刚 new 出来的 DTO 去查询status当然是 nullif不进入SQL 条件就少了。这不是 MyBatis 传参方式能解决的而是数据准备层的逻辑问题。排查时不要只盯着 Mapper 层先确认调用方到底有没有把值塞进参数对象。判断问题范围的技巧很简单在 MyBatis 配置文件里打开日志比如log-impl: org.apache.ibatis.logging.stdout.StdOutImpl让 MyBatis 打印最终执行 SQL 和参数绑定日志。一眼就能看出传进去的参数是不是 null、参数个数对不对。排查传参问题日志永远是最快的手段。5. 项目实战中的选型建议与团队规范5.1 按场景选型一张表理清什么时候用哪种写代码不是哪个流行用哪个而是针对场景选合适的工具。我总结了下面这张表基本覆盖日常开发里最常见的传参场景场景推荐方式理由单条件查询1 个简单参数单参数直接传代码最少无需额外注解多条件查询2 个以上参数Param命名参数语义清晰SQL 可读性高查询条件多且有复用价值自定义 Query/Param 对象参数收敛便于扩展和测试动态报表、筛选维度不固定Map 传参限定局部范围key 灵活扩展方便但要有约束批量 select / insertParam(ids)foreach显式命名集合名避免默认 key 踩坑查询对象 分页/排序参数Param混搭对象与基础参数既保留对象语义也保留分页参数独立性这里没有银弹方案。单参数场景非去封装一个 DTO是过度设计复杂查询场景硬拆散装参数是自找麻烦。关键看参数之间的关联程度和变化频率。关联度高、变化频繁的条件适合收敛成对象关联低、独立性强的基础参数用Param单独传就行。5.2 从散装参数到查询对象的演进思路很多 P0/P1 项目都是从小接口起步的一开始只有两三个查询条件大家图省事全写在方法签名里。后来需求迭代条件越加越多方法签名变成一长串各种Param满天飞XML 里 test 判断又臭又长。等到这一步就该做查询对象重构了。我推荐的做法是新建一个XxxQuery类把散装参数按业务维度收进去同时加上默认值和校验注解。比如用户查询public class UserQuery { private String username; private Integer minAge; private Integer maxAge; private Integer status; private String orderColumn; private String orderDirection; // 构造、getter、setter }重构成查询对象后方法签名变成ListUser searchUser(UserQuery query);XML 里的if判断跟着变得规整起来每个字段对应一段独立判断。新增条件时只需要在 Query 类加字段、在 XML 加段落不需要改动 Mapper 接口方法签名也就不需要动调用方传入参数的形状。数据库索引也能根据这些条件做针对性优化。这个过程我一般建议分成三步走第一步把散装参数在 Service 层组装成 Query 对象XML 不动第二步把 Mapper 方法签名从多参数改为对象参数同时把 XML 里的#{param}改成#{object.field}第三步清理掉不再使用的旧方法。每一步都能独立测试降低重构风险。5.3 我在团队里定的三条传参规约在团队协作里传参方式如果各写各的代码评审时会很痛苦。我自己在项目里定过三条规约简单务实第一多参数方法必须全部加Param不允许出现部分注解、部分不注解的混搭。理由前面已经说过混搭时会有一部分参数退回位置名XML 里的引用名必然会乱。第二Repository/Mapper 层对外暴露的方法参数类型优先是业务对象或基础简单类型禁用裸 Map 作为公共核心方法入参。报表等少数动态场景可以例外但要限定在独立的 Mapper 模块里且 XML 里的 key 尽量集中定义避免随手 put 字符串。第三批量方法的集合参数一律用Param显式命名XMLforeach的 collection 属性只写注解值。不依赖list/array/collection默认 key从根源上消灭那类Available parameters are [array, arg0, param1]的报错。这三条规约本身没有太高深的技术含量但执行下来我看到的效果是MyBatis 相关的传参问题几乎只剩业务逻辑本身的问题像参数到底传没传这个 key 从哪来的这类无谓争论基本消失了。回到文章开头那个BindingException其实等到你把日志打开、把可用参数列表看清、再对照这几种传参方式一一排查你会发现 MyBatis 传参并没有那么玄乎。它的规则无非是单参数直接拿对象多参数要么自己声明名字、要么接受位置名。真正影响项目质量的不是能不能跑通而是你选择哪种方式去承载那些会持续演变的业务参数。最后分享一个小技巧如果你用的是 Spring Boot除了开启 MyBatis 日志还可以在开发环境的application.yml里把 mapper 接口上下文的参数映射打印出来。配合StdOutImpl每次 SQL 执行都会输出类似于 Parameters: 张三(String), 18(Integer)的行一眼能核对所有参数绑定顺序和值。排查传参问题时这个输出比单纯看 SQL 文本有用得多。
阅读完成 · 觉得有帮助?