
前几天一个同事跑过来问我他用MyBatis注解写关联查询Result里的column只能传一个字段可是嵌套的查询方法需要两个参数一个用户ID一个用户类型不然查出来的数据就不对。他翻了一会儿文档又去翻了别人的代码最后挠着头说这玩意儿到底能不能传多个参数能而且写法很简单但网上讲这个的帖子确实不多。大多数教程只演示了column user_id这种单列传参一旦遇到“按两个条件查关联数据”就卡住了。这篇文章我就把Result注解的column参数从头到尾拆一遍重点讲多参数是怎么传的、底层怎么匹配的、实战里会遇到哪些坑看完你就能直接上手用。1. 认识Result注解与column参数的定位1.1 Results与Result的基本语法在MyBatis注解开发里Results扮演的是XML中resultMap的角色而Result则对应id和result子标签。一个典型的例子长这样Select(SELECT id, user_name, email FROM user WHERE id #{id}) Results({ Result(property id, column id, id true), Result(property userName, column user_name), Result(property email, column email) }) User getUserById(Long id);这里每个Result都在做一件事把SQL查询结果里的某一列映射到Java对象的一个属性上。property是Java属性名column是查询结果的列名或者是别名。id true表示这个字段是主键不光用于映射还会在MyBatis做一级缓存和嵌套查询的结果复用判断时用到。这一点后面讲多参数时还会牵扯到先留个印象。1.2 column参数到底是什么很多人用Result只是觉得它在“帮忙映射字段”但忽略了column还有另一层身份它是嵌套查询时的参数来源。看这个例子Select(SELECT id, user_name, email FROM user WHERE id #{id}) Results({ Result(property id, column id, id true), Result(property orders, column id, many Many(select com.example.mapper.OrderMapper.findByUserId)) }) User getUserWithOrders(Long id);column id同时干了两件事把查询出的id列映射给property id这个属性把id列的值取出来作为参数传给findByUserId方法。所以本质上column就是嵌套查询的参数坐标。它告诉你“拿哪一列的值塞给下一个SELECT用”。那问题就来了如果嵌套方法要两个参数怎么办比如findByUserIdAndType(Long userId, Integer userType)一个column只能对应一个列难道再写一个Result很多人第一次就卡在这Result的column确实是字符串但你把它改成ColumnResult数组就能一次传多个列值进去。这就是多参数传递的核心突破口。1.3 多参数到底在什么场景下出现先别急着写代码我们想一想实际业务里什么地方会用到这种多参数嵌套查询。最常见的是带条件的子集合查询。比如一个用户有订单订单里有不同类型。你要查某个用户的状态为“已支付”的订单列表嵌套查询的条件就是user_id和status两个字段。这时候如果只把user_id传过去查出来的可能包含已取消、已退款的订单数据就脏了。再比如自关联查询一个分类表有父级ID和层级字段查子分类时既要匹配父ID又要限制层级这也是两个条件。还有一类场景是租户隔离查关联数据时除了业务ID还要带tenant_id防止数据串租户。这种在多租户系统里特别常见。总之只要嵌套查询的SQL中WHERE条件超过一个字段就必然涉及多参数传递。这也是这篇文章要解决的核心问题。2. 多参数传递的标准写法与原理解析2.1 用ColumnResult数组打包多个字段先看最标准、最通用的写法。假设有两张表用户表user和订单表orders订单表里既有user_id字段又有status字段。现在要查询一个用户同时带出该用户“指定状态”的订单列表。UserMapper里的写法是这样的Select(SELECT id, user_name FROM user WHERE id #{id}) Results({ Result(property id, column id, id true), Result(property userName, column user_name), Result(property orderList, column { ColumnResult(property userId, column id), ColumnResult(property status, column order_status) }, many Many(select com.example.mapper.OrderMapper.findByUserIdAndStatus)) }) User getUserWithOrders(Long id);注意这里的变化column不再是普通字符串而是一个ColumnResult数组。每个ColumnResult都由两部分组成property要给嵌套查询的哪个参数赋值column取当前查询结果里的哪一列的值OrderMapper里对应的嵌套方法这样写Select(SELECT id, order_no, amount FROM orders WHERE user_id #{userId} AND status #{status}) ListOrder findByUserIdAndStatus(Param(userId) Long userId, Param(status) Integer status);ColumnResult里的property userId会自动匹配嵌套方法中Param(userId)的值property status匹配Param(status)。怎么匹配的下面详细说。注意ColumnResult的全限定名是org.apache.ibatis.annotations.ColumnResult和Result在同一个包下不需要额外引入依赖。如果你用的是Spring Boot 2.x以上版本MyBatis的starter里已经自带了。2.2 嵌套查询参数是怎么匹配上的搞清楚这个匹配逻辑你遇到任何诡异报错都能自己分析。MyBatis在执行嵌套查询时会先构建一个参数对象。当column是ColumnResult数组时它会遍历数组中的每个元素读取当前查询结果行里对应column列的值然后以property指定的名字作为key塞进一个Map或者参数对象里。接下来MyBatis调用嵌套SELECT方法时会拿这个参数对象去匹配方法的入参如果嵌套方法只有一个参数且参数没有加Param那这个对象会整体作为方法的那个入参。比如入参是一个LongMyBatis会尝试把Map中唯一的值取出来转成Long。如果嵌套方法加了ParamMyBatis会按Param的value值去参数对象里找对应的key。找不到就报错类型不同也可能报错。所以ColumnResult的property名必须跟嵌套方法里Param注解的value保持一致。如果不加Param那要么只有一个参数要么就得用对象封装。这里有一个多参数方法必须注意的细节嵌套Mapper方法如果有两个参数且都没加Param即使ColumnResult配得再完美MyBatis也会报错。它无法自动知道第一个参数对应userId第二个参数对应status。所以多参数时要么加Param要么把参数封装成一个对象二选一没有第三条路。2.3 参数对象封装方式什么时候用更省事除了ColumnResult数组还有一种更简洁的写法column直接用点号表达式比如user.id。这种写法适用于一个场景当前查询的结果里有一列本身就是一个对象或嵌套属性。举个例子Results({ Result(property id, column id, id true), Result(property orderList, column user.id.userType, many Many(select ...)) })这表示从当前对象的user.id属性里继续向下取userType字段作为参数。它省去了ColumnResult数组的定义代码看起来干净很多。但说实话在实际项目里column a.b.c这种写法其实不算常用。因为注解开发一般查出来都是扁平结构一个查询方法对应一个结果集很难天然形成对象嵌套。绝大多数场景下你要传的多个字段都在同一行结果里这时候用ColumnResult数组是更直接、更可控的选择。我个人建议两个参数以内用ColumnResult数组如果参数很多超过3个就该考虑把参数封装成一个查询对象传给嵌套方法否则ColumnResult数组会显得很臃肿。不过参数很多时也建议审视一下是不是应该拆查询而不是硬嵌套了。2.4 和XML写法做个对照如果你对XML的resultMap更熟看注解写法时容易觉得陌生。其实两者是一一对应的。XML里的关联查询多参数传递长这样resultMap iduserMap typeUser id propertyid columnid/ result propertyuserName columnuser_name/ collection propertyorderList ofTypeOrder selectcom.example.mapper.OrderMapper.findByUserIdAndStatus id propertyid columnid/ result propertyorderNo columnorder_no/ /collection /resultMap这里collection标签并没有像注解那样显式写ColumnResult数组因为XML里collection的column属性可以这样写collection propertyorderList selectcom.example.mapper.OrderMapper.findByUserIdAndStatus column{userIdid, statusorder_status} /花括号里的userIdid和注解中ColumnResult(property userId, column id)表达的意思完全一样。如果你做过XML开发理解注解写法就是把这个花括号文本换成了Java注解。这两种方式各有各的适用场景注解适合简单映射XML适合复杂映射。后面第4章我会专门说什么时候该切回XML。3. 完整实战用户-订单-明细三级关联查询3.1 表结构与需求定义先构造一个完整的案例方便你照着敲一遍。模拟一个电商后台的简化场景三张表user用户表字段有id、user_nameorders订单表字段有id、user_id、order_no、status、total_amountorder_item订单明细表字段有id、order_id、product_name、quantity需求是这样查询用户时带出该用户状态为有效status 1的订单列表每个订单再带出它的明细列表。第三次嵌套时“按订单ID查明细”只要一个参数就够了但第二次嵌套从用户表到订单表需要两个参数userId和status。建表SQL就不写了假设数据已经存在。这里重点放在Mapper上。3.2 Mapper完整代码第一个是UserMapperpublic interface UserMapper { Select(SELECT id, user_name FROM user WHERE id #{id}) Results({ Result(property id, column id, id true), Result(property userName, column user_name), Result(property orderList, column { ColumnResult(property userId, column id), ColumnResult(property status, column order_status) }, many Many(select com.example.mapper.OrderMapper.findValidOrdersByUserId)) }) User getUserWithOrders(Long id); }注意这里有个细节ColumnResult(property status, column order_status)column指向的是order_status。为什么是order_status而不是status因为UserMapper主查询里根本没有status列如果column statusMyBatis在当前查询结果里找不到这一列参数拿到的就是null。这个order_status是哪来的看下面OrderMapper的查询语句就明白了public interface OrderMapper { Select(SELECT id, order_no, total_amount, 1 AS order_status FROM orders WHERE user_id #{userId} AND status #{status}) ListOrder findValidOrdersByUserId(Param(userId) Long userId, Param(status) Integer status); Select(SELECT id, order_id, product_name, quantity FROM order_item WHERE order_id #{orderId}) ListOrderItem findItemsByOrderId(Long orderId); }查询订单时我在SELECT里主动加了一个常量列1 AS order_status目的就是给UserMapper的ColumnResult提供一个可以引用的列名。这种做法你可能一眼没看明白。我解释一下嵌套查询的执行顺序是先跑外层SQL拿到结果集然后MyBatis根据Result的配置从结果集里取列值作为嵌套查询参数再跑内层SQL。外层SQL返回什么列里层才能取什么列。OrderMapper的findValidOrdersByUserId最终返回的Order对象里需要包含status但查询时又不想真的把status取出来就用了一个常量别名一石二鸟。为啥不直接取orders.status这一列你当然可以只要给UserMapper的ColumnResult配置column status前提是外层SQL的结果集里有status这一列。这个案例里主查询只有id和user_name没有status所以必须通过某种方式让它出现在结果集里。最干净的办法就是让嵌套查询自己生成一个常量列。这个思路要记住ColumnResult的column引用的是当前查询结果集的列名而不是数据库表的列名。两者经常不是一回事下面第4章还会再强调。3.3 三张表的属性对象定义User、Order、OrderItem三个类的定义并不复杂关键在属性命名上要和Result(property ...)严格对应。public class User { private Long id; private String userName; private ListOrder orderList; // getter/setter 省略 } public class Order { private Long id; private String orderNo; private Integer status; private BigDecimal totalAmount; private ListOrderItem itemList; // getter/setter 省略 } public class OrderItem { private Long id; private Long orderId; private String productName; private Integer quantity; // getter/setter 省略 }status字段类型是Integer对应的ColumnResult(property status, column order_status)中常量1也是整数类型匹配没问题。如果这里类型不匹配运行时会直接报错后面排坑部分会提。3.4 运行结果与SQL日志验证调用入口很简单UserMapper userMapper sqlSession.getMapper(UserMapper.class); User user userMapper.getUserWithOrders(1L); System.out.println(user.getOrderList().size());跑起来以后控制台会打印两条SQL第一条是查用户第二条是查订单。如果你开启了MyBatis的SQL日志能看到类似这样的输出 Preparing: SELECT id, user_name FROM user WHERE id ? Parameters: 1(Long) Total: 1 Preparing: SELECT id, order_no, total_amount, 1 AS order_status FROM orders WHERE user_id ? AND status ? Parameters: 1(Long), 1(Integer) Total: 3注意看第二条SQL的参数1(Long), 1(Integer)两个参数都正确传过去了。这就证明ColumnResult数组的配置生效了。如果没有生效会是什么表现最常见的是第二个参数变成null日志里显示user_id ?, status ?但参数只有一个。这种情况下先检查外层查询结果集里到底有没有ColumnResult中column指定的列八成是列名对不上或者根本没有这一列。这种“先跑通、再优化”的方式在实际开发中效率最高。不要一上来就写特别复杂的映射先写一个最小闭环打印SQL日志确认参数传递没问题再往上叠加字段和关联。4. 常见问题与排坑记录4.1 类型不一致导致参数匹配失败ColumnResult传参时MyBatis会把列值转换成目标参数类型。如果这两者类型差距过大比如数据库列是VARCHAR但Java参数是IntegerMyBatis可能会尝试转换也可能直接报错。一个真实的例子某个字段在表里是BIGINT嵌套查询方法里却定义成Integer。结果运行时报错提示无法从Long转换成Integer。看起来是小问题排查却花了不少时间因为MyBatis的错误信息不会直接说“你这俩类型不匹配”而是会包装成一大堆异常堆栈。所以我的建议是ColumnResult里property对应的类型最好和嵌套方法Param的类型保持一致。要么都用包装类型要么都用基本类型不要混。尤其是主键字段很多人习惯用Long但嵌套方法的参数却是Integer这种低级错误最容易在联调时才暴露。4.2 列名必须是查询结果集别名不是数据库原始列名这是Result系列注解最容易踩的坑也是最多人问的。column指的是SQL查询结果集里的列名或别名不是数据库表字段名。比如数据库表里有一列叫user_name如果你在主查询里写了SELECT user_name FROM user那么column user_name没问题。但如果你写了SELECT u.user_name AS name FROM user u映射就得写column name。如果这时候还照着表字段写user_nameMyBatis会告诉你获取不到这个列的值拿到的是null。在ColumnResult数组里同样适用。前面案例里那个order_status本质上就是我故意生成的别名。如果你在外层SQL里查询了status AS s那ColumnResult里就得写column s。始终记住一条准则跟着SELECT语句的结果集走别跟着数据库表结构走。排查这个问题时最快的办法是把这个SQL单独拎到数据库客户端里跑一遍看结果集到底返回哪些列名。很多看起来像是MyBatis配置问题的情况最后都是SQL别名不一致。4.3 主键ID不配置idtrue会导致结果集错乱在嵌套查询里主键标识的作用比你想的重要得多。如果Results里没有给主键字段加Result(property id, column id, id true)MyBatis在组合嵌套结果时会出问题。典型表现是关联的集合数据只有第一条重复出现或者加载的结果数量对不上。这是因为MyBatis要依赖主键来判断两行数据是不是同一个对象。如果没有主键标记它会拿整个行数据去比较一旦两行除了关联字段外其他字段都一样有可能被错误地合并。如果两行数据内容完全一样但主键不同它就搞不清楚该属于哪个对象。所以在任何使用Results的嵌套查询里第一件事就是给主键加上id true。这不是可选项是必须项。4.4 N1查询与延迟加载嵌套查询最大的性能隐患就是N1问题。还是用户-订单的例子查一个用户触发一次订单查询这是两次SQL。但如果查100个用户每次查用户都单独触发一次订单查询那就是1100101次SQL。数据量小无所谓数据量大了数据库很可能扛不住。MyBatis注解里可以通过Many和One的fetchType来控制加载时机Many(select com.example.mapper.OrderMapper.findValidOrdersByUserId, fetchType FetchType.LAZY)设置了LAZY之后只有真正访问orderList属性时才会触发订单查询。但这也有两面性如果在一个需要遍历100个用户的循环里访问了orderList懒加载并不会减少SQL条数只是把开销延后了如果没访问才能省下不必要的SQL。另一个思路是在真正需要一次性拉取大量关联数据的场景不要用嵌套查询直接用一条JOIN SQL配合Result做扁平化映射或者干脆用XML的collection做一步查询。嵌套查询写的爽但性能坑也多。用之前先想清楚数据量和访问模式。4.5 什么时候该放弃注解转XML注解写起来方便但Results一多代码会变得密集而且同样的Results无法复用每个Mapper方法都要重新写一遍。我见过一个项目里有个方法堆了10多个Result注解代码看起来像一个巨型洋葱改一个字段要翻很久。这时候就该考虑把对应查询改成XML了。XML的优势有三个resultMap可以被多个查询复用不用每个方法重复定义collection和association的配置表达能力更强尤其是嵌套多层时比注解清晰动态SQLif、foreach之类天然在XML里写注解里的script写法不仅别扭可读性也差具体判断标准很简单如果你的嵌套查询超过两层或者一个查询结果要关联多个子集合建议直接上XML。注解适合“简单查询一两个嵌套”的场景超过这个复杂度就用XML写起来轻松维护起来也轻松。5. 再补充两个实用扩展5.1 传参时顺便传递排序或分页条件既然ColumnResult数组可以传多个字段那你甚至可以把排序字段、分页大小也传下去。比如按用户查订单时希望订单按金额倒序排列可以在OrderMapper的嵌套方法里接收orderBy参数Select(SELECT id, order_no, total_amount FROM orders WHERE user_id #{userId} AND status #{status} ORDER BY ${orderBy} DESC) ListOrder findOrders(Param(userId) Long userId, Param(status) Integer status, Param(orderBy) String orderBy);主查询里加一个常量列SELECT id, user_name, total_amount AS sort_key FROM user WHERE id #{id}然后ColumnResult(property orderBy, column sort_key)就能把这个排序字段传下去。不过这里要提醒一下ORDER BY ${orderBy}是字符串拼接存在SQL注入风险。如果排序字段来自外部输入千万别这么写。要么用白名单校验要么写死判断逻辑。这个技巧只适合内部固定值。5.2 复用同一个嵌套方法的不同条件组合项目里经常遇到同一个子查询这次按这个条件查下次按那个条件查。如果你用XML的resultMap会想通过多个collection引用不同方法来实现。但注解开发里Result的column数组是可以动态组合的你完全可以为一个property配置不同的嵌套方法。比如用户详情页需要“有效订单”用户管理后台需要“全部订单”那可以让主查询里的同一个orderList属性在UserMapper的A方法里指到findValidOrdersByUserId在B方法里指到findAllOrdersByUserId。两种查询共用一套Results的话就把公共映射提取出来但注解没法像XML那样直接引用外部映射只能复制一遍。这个缺点是注解开发绕不过去的。如果你发现同一个Results在三个方法里复制粘贴那大概率到了用XML的临界点了。复制粘贴一时爽维护的时候就是灾难。写在最后Result注解的column参数传多个值本身不难核心就是把column从单个字符串改成ColumnResult数组再保证property和嵌套方法的参数名对得上。难的是背后的几个细节列名必须是结果集别名、类型必须匹配、主键必须标id true、复杂场景要及时转到XML。我实际操作中最大的体会是遇到嵌套查询问题先别埋头看代码。把SQL单独跑一遍看看结果集长什么样再回来对照Result配置80%的问题都能一眼定位。剩下的类型不符、参数没匹配这类问题看日志里的Parameters输出也比瞎猜快得多。你只要记住一个原则MyBatis做的所有事情本质都是在SQL结果集和Java对象之间搬数据搞清楚源头长什么样映射规则自然就通了。