1. 从“column只能传一个参数”开始说起
@Result注解的column参数到底能不能传多个参数?这个问题我在两年前第一次碰到,当时被同事问住,后来自己踩坑、翻源码,才彻底搞清楚。先说结论:能,而且不止能传两个,三个四个都可以,写法就是column = "id, region_code, channel_type"这种逗号分隔的多列形式。但很多人照着写还是报错,原因往往不在column本身,而在子查询方法的方法签名、@Param注解、外层SQL返回的列名这三者没有对齐。这篇文章就围绕这个场景,把正确写法、底层传参逻辑、完整实操和常见坑位一次讲清楚。
适合看这篇文章的人,主要有两类:一类是在用注解方式写MyBatis Mapper,刚好被一对多关联查询、嵌套查询的映射问题卡住;另一类是把XML里resultMap用得滚瓜烂熟,但一换到注解就搞不清楚column到底该怎么传。两种情况我都经历过,下面说的每一条都是实测过的结论,不是从文档里抄来的理论。
1.1 真实的业务模型:配置单和规则明细
先看一个典型场景。假设你在做某个仓库配置系统,有一张父表supply_config,保存的是“某一个区域 + 某一个渠道 下的供货策略”,字段包括配置单id、配置编码、区域编码、渠道类型、状态。还有一张子表supply_config_rule,保存这个策略下的多条规则明细,比如运费规则、库存阈值规则、时效规则,每一条规则都归属某个配置单,同时保留了区域和渠道信息。
需求是:查一条配置单时,把该配置单下“所属区域和渠道完全匹配”的规则明细一次性带出来。在XML写法里,这通常是一个<collection>子查询或者一条JOIN语句搞定;但在注解式Mapper里,你需要用@Results+@Many来做嵌套查询。这时就会遇到那个灵魂拷问:子查询的WHERE条件有config_id、region_code、channel_type三个字段,column里到底写什么?
这个业务模型其实非常普遍,凡是做“主表+子表明细”的配置类系统,几乎都会遇到。很多人一开始会把column想当然地写成一个主键id,毕竟一对一、一对多的最常见映射就是传主键。但一旦子查询要多个过滤条件,只传一个id就明显不够用了。
1.2 新手常见的三种错误写法
有一种特别容易踩的错法,是@Result里只写了父表主键:
@Result(property = "rules", column = "id", many = @Many(select = "com.demo.mapper.SupplyConfigRuleMapper.findRulesByConfigScope"))而子查询方法长这样:
List<SupplyConfigRule> findRulesByConfigScope(@Param("configId") Long configId, @Param("regionCode") String regionCode, @Param("channelType") String channelType);运行时不报错,但你会发现查出来的规则列表要么为空,要么把别的区域的规则也带出来了。原因很简单:MyBatis 只给子查询传了一个id值,另外两个参数regionCode、channelType在传参Map里根本不存在,最终SQL执行时就变成了config_id = ? AND region_code = NULL AND channel_type = NULL,条件缺失,数据自然不对。
第二种错法更隐蔽,column确实写了多个列,但子查询方法没有加@Param:
@Result(property = "rules", column = "id, region_code, channel_type", many = @Many(select = "com.demo.mapper.SupplyConfigRuleMapper.findRulesByConfigScope"))List<SupplyConfigRule> findRulesByConfigScope(Long configId, String regionCode, String channelType);这种写法运行时会直接抛异常,报错信息类似:
Parameter 'region_code' not found. Available parameters are [arg0, arg1, arg2, param1, param2, param3]因为MyBatis给多参数方法生成参数Map时,默认key是arg0、param1这种,它根本不知道region_code是什么。你column里写的名字,必须在子查询方法参数里显式声明过,否则就是“查无此key”。
第三种错法是想把多个column值自动封装成一个对象参数,比如子查询方法只接收一个QueryParam对象。这里要明确:注解式@Many的select并不支持像XML那样传入一个包装对象再把column属性映射进去,column里的多个列名就是用来和方法的多个参数一一对应的。想传对象,请老老实实去写XML resultMap。
1.3 基本功:@Results/@Result 到底在映射什么
在展开正确写法之前,先把基础对齐。@Results是一组@Result的集合,等价于XML里的<resultMap>;@Result的property对应Java实体属性,column对应外层SQL查询结果集的列名,id = true表示主键映射。如果某个属性需要关联查询,就用@Many指定一个select方法,@One则用于一对一场景。
这里有个特别容易混淆的点:column里写的列名,不是内层子查询表的列名,而是外层查询返回结果集里的列名。比如父查询是SELECT id, config_code, region_code, channel_type FROM supply_config,那column里只能写这些列名,或者给这些列起的别名。很多人一上来就写子表字段名,结果MyBatis在外层结果集里找不到这一列,映射直接静默失败,返回null。
理解了这层关系,后面的坑就都好解释了。
2. 正确写法:逗号分隔 + @Param 点名
现在给出能直接跑通的标准写法。核心就两句话:@Result的column里用逗号分隔多个外层列名;内层子查询方法的每个参数都用@Param显式命名,名字必须和column里的字符串逐一对应。
2.1 先看一段能直接运行的标准示例
父表Mapper:
public interface SupplyConfigMapper { @Results(id = "configMap", value = { @Result(property = "id", column = "id", id = true), @Result(property = "configCode", column = "config_code"), @Result(property = "configName", column = "config_name"), @Result(property = "regionCode", column = "region_code"), @Result(property = "channelType", column = "channel_type"), @Result(property = "rules", column = "id, region_code, channel_type", many = @Many(select = "com.demo.mapper.SupplyConfigRuleMapper.findRulesByConfigScope")) }) @Select("SELECT id, config_code, config_name, region_code, channel_type, status " + "FROM supply_config WHERE id = #{id}") SupplyConfig findConfigWithRules(Long id); }子表Mapper:
public interface SupplyConfigRuleMapper { @Select("SELECT id, config_id, region_code, channel_type, rule_type, rule_value, enabled " + "FROM supply_config_rule " + "WHERE config_id = #{configId} " + "AND region_code = #{regionCode} " + "AND channel_type = #{channelType}") List<SupplyConfigRule> findRulesByConfigScope(@Param("configId") Long configId, @Param("regionCode") String regionCode, @Param("channelType") String channelType); }几个关键点,我逐个说。column = "id, region_code, channel_type"这三个名字,必须在外层查询SQL的结果集里真实存在。这里的id会作为configId传给子查询的第一个参数,靠的是@Param("configId")这个name进行匹配,也就是说column里的列名和@Param的value不一定要字面相同,只要能通过key正确映射上就行。
但我个人强烈建议:如果不是特殊情况,让column里的列名和@Param的value保持一致,或者用SQL别名把列名改成和@Param一致,后续排查问题省一半力气。比如你完全可以把外层SQL写成SELECT id AS config_id, ...,然后column写config_id, region_code, channel_type,子方法参数写@Param("configId")也还是能对上。逻辑上没问题,只是阅读时容易绕晕。
2.2 底层是怎么把多个参数打包送进去的
这一步值得讲透,因为很多人报错之后不知道为什么。MyBatis执行嵌套查询时,走的并不是“先查出父行,再手动调子方法”这么简单,而是在结果映射阶段自动完成的。
父查询执行完,MyBatis拿到一行ResultSet数据,开始按照@Results定义逐个处理属性。当处理到rules这个属性时,它发现这是一个@Many嵌套查询,于是把column里的字符串按逗号切分成一个多列名数组["id", "region_code", "channel_type"],然后从当前这行ResultSet里取出这三个列对应的值,组装成一个Map<String, Object>。这个Map的key就是列名,value就是列值。
接下来,MyBatis调用子select方法时,拿到一个方法的MethodSignature。它先看你每个参数上有没有@Param,有的话就以@Param的value作为key,从上面那个Map里取对应的值;没有的话就用默认的arg0、param1这一套自动命名规则去Map里找。
问题就出在这里:如果子方法参数名是regionCode,但方法体上没有@Param("region_code"),MyBatis根本不会拿regionCode去Map里找region_code,它找的是arg1或者param2。Map里没有这个key,于是抛错。这就是“column写了多列、但子方法不写@Param会直接炸”的根本原因。
打个比方。column就像你点外卖时的备注栏,你写了“不要辣、加双筷、送到前台”,商家(子查询方法)得按备注逐项准备。@Param就是商家贴在打包盒上的编号,没有编号,外卖员(MyBatis)就不知道该把哪样东西送到哪个位置。备注明明白白,但编号漏了,最后只能喊“查无此单”。
2.3 几个容易忽略的使用边界
第一,column里写的是外层查询结果集的列名,不是POJO属性名,也不是子表列名。很多人把camelCase属性名写进去,比如写regionCode,但SQL里列名是region_code,结果就是取不到值。如果你开了mapUnderscoreToCamelCase,它只影响属性映射,不影响 column 这个名字匹配结果集的逻辑。
第二,子查询方法的参数数量不需要和column列数强制相等吗?需要。如果你column里写了三个列,但子方法只声明了两个参数,多余的第三个列值会被忽略;如果子方法声明了三个参数,但column只写了两个列,那第三个参数就会在Map里找不到key,直接报错。所以两边数量必须一致,且@Param名字必须能对上。
第三,@Result注解没有parameterType属性,这是它和XML<association>、<collection>的一个重大差异。XML里可以通过column加foreignColumn的方式做更复杂的映射,注解方式就简单粗暴得多,只支持“一列对应一个参数”。想传对象、想传集合,注解方式做不到,只能换XML。
3. 完整实操:从建表到日志验证
理论讲完了,下面给一套可以直接复制到本地跑的完整案例,把整个流程串起来。我用的数据库是MySQL,MyBatis版本是3.5.x,Spring Boot 2.x环境。你如果用的MyBatis-Plus,注解机制一样,只是Mapper接口可以继承BaseMapper,核心逻辑不影响。
3.1 建表与测试数据
CREATE TABLE `supply_config` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `config_code` VARCHAR(64) NOT NULL, `config_name` VARCHAR(128) NOT NULL, `region_code` VARCHAR(16) NOT NULL, `channel_type` VARCHAR(32) NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE `supply_config_rule` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `config_id` BIGINT NOT NULL, `region_code` VARCHAR(16) NOT NULL, `channel_type` VARCHAR(32) NOT NULL, `rule_type` VARCHAR(32) NOT NULL, `rule_value` VARCHAR(255) NOT NULL, `enabled` TINYINT NOT NULL DEFAULT 1 );测试数据:
INSERT INTO supply_config (id, config_code, config_name, region_code, channel_type, status) VALUES (1, 'CFG_EAST_EC', '华东电商仓配置', 'EAST', 'EC', 1), (2, 'CFG_SOUTH_EC', '华南电商仓配置', 'SOUTH', 'EC', 1); INSERT INTO supply_config_rule (id, config_id, region_code, channel_type, rule_type, rule_value, enabled) VALUES (1, 1, 'EAST', 'EC', 'EXPRESS', '顺丰', 1), (2, 1, 'EAST', 'EC', 'EXPRESS', '圆通', 1), (3, 1, 'EAST', 'EC', 'MIN_STOCK', '500', 1), (4, 2, 'SOUTH', 'EC', 'EXPRESS', '中通', 1);注意这里子表也存了region_code、channel_type,是为了演示“按多个外层列值过滤子查询”的场景。实际业务里可能不需要冗余这两个字段,但为了把场景讲清楚,我故意保留了。
3.2 实体类与两个Mapper
实体类:
public class SupplyConfig { private Long id; private String configCode; private String configName; private String regionCode; private String channelType; private Integer status; private List<SupplyConfigRule> rules; // getter / setter 省略 } public class SupplyConfigRule { private Long id; private Long configId; private String regionCode; private String channelType; private String ruleType; private String ruleValue; private Integer enabled; // getter / setter 省略 }Mapper就按2.1节写的两份代码来,不需要额外配置文件,接口方法上的@Select已经写好了SQL。在Spring Boot项目里,记得给启动类或Mapper接口上加@MapperScan,或者用@Mapper注解逐个标记。
3.3 执行时序与日志解读
写一个测试方法,调用findConfigWithRules(1L),开DEBUG日志。正常情况下,日志会按这个顺序出现:
==> Preparing: SELECT id, config_code, config_name, region_code, channel_type, status FROM supply_config WHERE id = ? ==> Parameters: 1(Long) <== Columns: id, config_code, config_name, region_code, channel_type, status <== Row: 1, CFG_EAST_EC, 华东电商仓配置, EAST, EC, 1 ==> Preparing: SELECT id, config_id, region_code, channel_type, rule_type, rule_value, enabled FROM supply_config_rule WHERE config_id = ? AND region_code = ? AND channel_type = ? ==> Parameters: 1(Long), EAST(String), EC(String) <== Total: 3注意看第二段SQL的Parameters: 1(Long), EAST(String), EC(String),这三个值就是从父查询返回的那一行里自动提取出来的id、region_code、channel_type。能看到这行日志,说明多参数传递已经成功了。
如果你的日志里子查询没有立即出现,而是等你在代码里访问getRules()时才出现,那是MyBatis的懒加载配置生效了,属于正常现象,不影响参数传递。想看完整链路,可以在访问属性之前和之后各打一条日志确认。
3.4 扩展:多层级联查询怎么传
实际项目中嵌套不止一层的情况很常见。比如配置单下面有规则明细,规则明细下面又有审核记录,三层嵌套。这时候第二层Mapper也要写类似的@Results,比如:
@Select("SELECT id, config_id, region_code, channel_type, rule_type, rule_value, enabled " + "FROM supply_config_rule WHERE config_id = #{configId} ...") @Results(id = "ruleMap", value = { @Result(property = "id", column = "id", id = true), @Result(property = "ruleType", column = "rule_type"), @Result(property = "auditLogs", column = "id, rule_type", many = @Many(select = "com.demo.mapper.RuleAuditLogMapper.findByRuleIdAndType")) }) List<SupplyConfigRule> findRulesByConfigScope(...);第三层子查询的column里写id, rule_type,取的就是第二层SQL结果集的列。逐层传递的套路完全一样,没有任何特殊处理。真正需要注意的反而是可读性和性能:层级越深,注解越难维护,每层都会产生一次额外查询,如果数据量大,这种N+1查询会让你数据库压力飙升。
我在实际项目里,超过两层嵌套就会放弃注解,改成XML resultMap + JOIN查询。这条经验后面第4章还会展开说。
4. 进阶场景:IN查询、同名列和XML方案
多参数传递本身不难,难的是遇到一些“看起来该支持、实际不支持”的复杂场景。我把平时被问得最多的三种情况集中说一下。
4.1 子查询想要IN/多值匹配怎么办
有人会问,column里能传多个参数,那我是不是可以把某个参数传成一个集合,让子查询走IN条件?很遗憾,不行。@Many嵌套查询的机制是“外层每查到一行,就触发一次子查询”,column里每个值都是单值,MyBatis不会把多行数据聚合成集合传给子查询。
如果你确实需要“一个父查询返回多行,然后一次性用IN (id1, id2, id3)去查子表”,注解方式做不到,得换思路。
一种绕行的办法是外层用GROUP_CONCAT把多个id拼成一个字符串列,比如:
SELECT config_id, GROUP_CONCAT(id) AS ids FROM supply_config WHERE ... GROUP BY config_id然后在子查询SQL里用FIND_IN_SET或者动态拼接条件。但GROUP_CONCAT有长度限制,拼接字段如果很多容易截断,而且FIND_IN_SET很难走索引,数据量上来之后性能是个隐患,只适合数据量很小、对性能不敏感的管理端页面。
更推荐的做法是干脆放弃嵌套查询,改用一条LEFT JOIN直接查出来,然后用XML里的<collection>做集合映射。这样SQL只执行一次,没有N+1问题,参数传递也不需要column做任何花活,把多个过滤条件直接写在最外层的WHERE里就行。
4.2 外层SQL列名冲突怎么排查
多参数传列的另一个经典坑是“外层SQL返回了重复列名”。比如父查询用到了SELECT code, a.code FROM ...,如果column里写了code,MyBatis从ResultSet里按列名取值时,根本不知道你要的是哪一个code,最终取到的可能是第一个,也可能是最后一个,完全取决于驱动实现。
这种问题不报错,只是数据莫名其妙不对。排查方式也很简单:先把外层SQL单独执行一次,看返回的列名列表里有没有同名列。只要有,就必须用别名区分。
SELECT a.id AS config_id, a.region_code, a.channel_type, b.code AS attach_code FROM supply_config a LEFT JOIN supply_attach b ON a.id = b.config_id WHERE a.id = #{id}对应的column就写config_id, region_code, channel_type。给所有参与传递的列都加上唯一别名,是避免这类暗坑最有效的手段。我现在的习惯是,只要SQL里出现了JOIN,所有传给column的列全部强制起别名,不做例外。
4.3 注解方案 vs XML resultMap 取舍
注解方案的优势是代码集中、看Mapper接口就能了解SQL和映射关系,适合字段少、嵌套浅、映射关系稳定的查询。但它有几个硬伤,最重要的一点是:如果SQL需要动态拼接,注解上只能写死字符串,要么用<script>标签,可读性很差;而XML里可以自由使用<if>、<foreach>、<choose>,复杂度再高也能撑住。
我给自己定的规则是这样的:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单表、字段固定、无需动态SQL | 注解 | 代码量少,维护成本低 |
| 一对多嵌套,子查询条件固定,层级≤2 | 注解column多参数 | 写法直接,效率够用 |
| 嵌套层级≥3,或子查询需要动态条件 | XML resultMap | 可读性强,好调试 |
| 需要JOIN多张表且返回字段很多 | XML resultMap | 列名映射更灵活,避免注解里column满天飞 |
| 需要复用同一套结果映射多次 | 注解可用@ResultMap,XML用resultMap id | 两种都能复用,看团队习惯 |
另外,注解方式定义一次@Results之后,可以在其他方法上用@ResultMap("configMap")复用,不用每写一个查询都重复一大段@Result。但要注意@Results的id在同一个命名空间里必须全局唯一,不同Mapper接口里重复定义相同id,运行时会因为映射冲突出现各种诡异问题,这个坑我也踩过。
5. 高频报错与排查实录
最后把最常见的问题整理成一份排查实录,这些案例全部来自我实际开发中遇到的、以及在技术社区里帮别人看过的问题。能让你少走很多弯路。
5.1 “Parameter 'xxx' not found”的根因分析
这是多参数传递里出现频率最高的报错,完整信息一般是这样的:
org.apache.ibatis.binding.BindingException: Parameter 'region_code' not found. Available parameters are [arg0, arg1, arg2, param1, param2, param3]报错的关键在最后一行的Available parameters。MyBatis说它手里只有arg0/arg1/param1/param2这些默认key,没有你column里写的region_code。为什么没有?因为你子查询方法参数没加@Param("region_code")。
修复方式就是给每个参数都加上和column值一致的@Param。有人会想:MyBatis不是支持用#{regionCode}直接引用参数名吗?那是编译时开启了-parameters参数,MyBatis拿到了真实参数名才能这么用;但嵌套查询时column的值是运行时字符串,跟编译参数名是两套东西,两者不能混为一谈。所以最稳妥的做法永远是明确写@Param。
还有一个隐藏版本差异要提醒:不同MyBatis版本生成的默认key不完全一样,有些版本是arg0/arg1,有些是0/1,还有些老版本直接就是param1/param2。你把参数名写得整整齐齐,就不用关心这些差异。
5.2 查出来的List为null但SQL有数据
这个问题的隐蔽性最高,因为程序不报错,甚至日志里子查询都执行了,但返回结果里rules是null或者空List。我按经验给出的排查步骤:
第一步,先单独执行子查询SQL,参数手动填成父行对应的值,确认SQL本身没问题。如果单独查得到数据,说明问题出在参数传递环节。
第二步,检查外层SQL是否包含column里写的所有列。比如column写了region_code,但外层SQL查询结果集里没有这一列,MyBatis取不到值,子查询参数就会变成null。
第三步,检查列名大小写和别名。MySQL不敏感还好,Oracle这类区分大小写的数据库,一个大小写不一致就能让你排查半天。
第四步,看日志里子查询的Parameters:那行,如果全是null或者数量不对,基本就是前面某一步出了问题。
第五步,检查实体类属性名和property是否拼对。属性名写错,映射会静默失败,表现出来也是null,但根因完全不同。
这五步走完,90%的null问题都能定位。剩下的10%,大概率是懒加载配置和事务边界的问题,Accessing lazy properties outside boundaries会抛异常,而不是返回null,所以更好排查。
5.3 一页速查表
我把平时遇到最多的几种现象整理成一张速查表,可以直接截图保存。
| 现象 | 根因 | 解决办法 |
|---|---|---|
报错Parameter 'xxx' not found. Available parameters are [arg0...] | 子查询方法参数缺少@Param,column的值没有对应key | 给所有子查询参数加@Param,名字与column值一致 |
| 子查询返回null,但单独执行SQL有数据 | 外层SQL结果集缺少column对应的列名 | 让外层SQL把column所需的列都select出来,必要时加别名 |
| 子查询返回null,日志里Parameters全是null | column列名和外层ResultSet列名匹配不上 | 统一列名大小写,用AS别名对齐 |
| 数据串了,查到了别的区域的规则 | column只传了部分参数,子查询少条件 | 把需要的列全部加入column,确保每个槽位都有值 |
| 外层SQL返回重复列名,column取值不确定 | 多个表字段同名,ResultSet按列名取到随机一个值 | 父查询SQL用AS起唯一别名 |
| 查询性能骤降,子查询被频繁执行 | N+1查询,每行父记录都触发一次子查询 | 改为JOIN+XML collection,或加懒加载策略 |
| column传了3个值,子方法只有2个参数 | 参数数量不匹配,多余值被忽略 | 让column列数和子方法参数数量严格一致 |
最后再分享一个小技巧:调试这种嵌套查询问题,别上来就猜,先把MyBatis日志级别调到DEBUG,跑一次完整链路,看日志里出现了哪几条SQL、每条SQL的Parameters是什么。日志会把问题老老实实暴露出来。根据我个人的使用习惯,现在只要碰到注解嵌套查询相关的问题,第一件事永远是看日志,而不是改代码。这个习惯帮我省下了大量无意义的尝试时间。