
1. 为什么单靠QueryWrapper搞不定多表查询1.1 从一次真实的分页失效说起先说一个我踩过的坑。去年做一个运营后台订单列表需要同时展示订单信息、用户昵称、商品标题。订单表、用户表、商品表三张表关联我一开始图省事直接用QueryWrapper在订单表上拼条件然后想着在Service层再查一次用户和商品补全数据。结果上线第二天运营就反馈翻到第3页之后数据开始重复第5页直接空白。排查了半天问题出在两个地方。第一我用Page对象做分页时QueryWrapper只作用于主表关联数据是循环单查补的N1问题让接口响应从200ms飙到2.3s。第二分页插件在count阶段和实际查询阶段走的SQL不一致导致total算出来是120条实际能查到的只有80条。这个经历让我彻底明白一件事MyBatis-Plus的QueryWrapper本质是单表条件构造器它擅长的是在已知表结构上拼WHERE条件而不是描述表与表之间的关系。多表查询这件事QueryWrapper能帮你做一半剩下那一半必须交给Select注解或者XML来补。1.2 QueryWrapper与Select的分工边界很多人对这两个东西的定位是模糊的我把它讲透一点。QueryWrapper以及它的兄弟LambdaQueryWrapper解决的是条件动态化的问题。你的查询条件可能来自前端筛选框可能是用户选了时间范围就加时间条件没选就不加这种动态拼接用Wrapper最舒服因为它天然支持链式调用和条件判空。Select注解解决的是SQL结构固定化的问题。多表JOIN的写法、字段的别名映射、聚合函数的运用这些用Wrapper表达起来非常别扭甚至根本表达不了。你不可能用Wrapper写出一个LEFT JOINWrapper里压根没有join方法。那两者怎么结合答案就是标题里那个关键词${ew.customSqlSegment}。这是MyBatis-Plus留给我们的接口——它允许你把Wrapper生成的WHERE片段原封不动地塞进你手写的SQL里。这样一来JOIN的骨架你手写WHERE的动态条件交给Wrapper各司其职。注意${ew.customSqlSegment}用的是$而不是#这意味着它是字符串直接拼接不是预编译参数。所以Wrapper里的条件值必须由MyBatis-Plus自己处理成安全的参数占位你不能在Wrapper里塞原生SQL片段否则会有注入风险。1.3 这套方案适合谁、解决什么问题这套组合拳最适合的场景是后台管理系统的列表查询。典型特征是——有多表关联展示需求、有大量动态筛选条件、有分页、有排序。比如订单列表、工单列表、日志列表基本都是这个套路。不适合的场景也要说清楚如果你的查询是纯单表、条件也固定那直接Select写死就行别硬套Wrapper。如果你的关联逻辑复杂到需要子查询嵌套、UNION那还是老老实实写XML注解里塞太长的SQL可读性会很差。我个人的判断标准很简单JOIN的表在3张以内、WHERE条件有动态性、需要分页就用这套方案超出这个范围转XML。2. 核心机制拆解customSqlSegment到底做了什么2.1 从Wrapper到SQL片段的转换过程要理解${ew.customSqlSegment}得先知道一个QueryWrapper对象是怎么变成SQL的。当你调用wrapper.eq(status, 1).like(name, 张)的时候Wrapper内部维护了一个Expression列表每个Expression记录了字段、操作符、值。真正生成SQL是在getSqlSegment()被调用时它把这些Expression按顺序拼成WHERE (status #{ew.paramNameValuePairs.MPGENVAL1} AND name LIKE #{ew.paramNameValuePairs.MPGENVAL2})这样的字符串。注意这里的关键值不是直接拼进去的而是变成了#{ew.paramNameValuePairs.xxx}这样的占位符。真正的值存在Wrapper的一个Map里MyBatis执行时再从Map里取。这就是为什么用${ew.customSqlSegment}也安全的原因——$拼接的只是SQL骨架值还是走预编译的。而customSqlSegment比getSqlSegment()多了一层处理它会自动判断是否需要加WHERE关键字。如果Wrapper里一个条件都没有customSqlSegment返回空字符串如果有条件返回WHERE (...)。这个细节很重要它让你不用在SQL里写WHERE 11这种丑陋的兜底。2.2 Select注解里怎么接住这个片段看一段我实际项目里的代码这是订单列表的Mapper方法Select(script SELECT o.id, o.order_no, o.amount, o.create_time, u.nickname AS userName, p.title AS productTitle FROM t_order o LEFT JOIN t_user u ON o.user_id u.id LEFT JOIN t_product p ON o.product_id p.id ${ew.customSqlSegment} /script) IPageOrderVO selectOrderPage(IPageOrderVO page, Param(Constants.WRAPPER) WrapperOrderVO wrapper);几个关键点必须说清楚。第一外层必须包script标签。因为${ew.customSqlSegment}在注解里需要动态拼接MyBatis解析注解SQL时只有script包裹的内容才会走动态SQL解析流程。不加这个标签${}不会被替换。第二参数必须用Param(Constants.WRAPPER)。Constants.WRAPPER的值就是字符串ew这是MyBatis-Plus约定的名字。你写Param(ew)效果一样但用常量更规范避免手滑打错。第三分页参数IPage要放在第一个位置。MyBatis-Plus的分页插件会拦截这个参数自动改写SQL加上LIMIT同时生成count语句。如果你把page放到后面分页会失效——这就是热搜词里mybatisplus分页失效最常见的成因之一。2.3 字段别名与VO映射的坑上面SQL里我用了u.nickname AS userName这个别名不是随便起的。返回类型是OrderVOVO里的字段叫userName如果SQL里不写别名查出来就是nickname映射不上值就是null。这里有个容易忽略的点MyBatis-Plus默认开启了下划线转驼峰所以create_time能自动映射到createTime。但userName这种驼峰别名数据库返回的列名就是userName恰好能对上。如果你写成u.nickname AS user_name也能通过驼峰转换映射到userName。两种写法都行我习惯用驼峰别名因为和VO字段名完全一致一眼能看出对应关系。还有一个隐藏问题多表JOIN时如果有同名字段必须起别名。比如订单表和用户表都有create_time你SELECT *的话后一个会覆盖前一个。所以永远不要在多表查询里用SELECT *把需要的字段一个个列出来这是铁律。3. 完整实操从零搭一个动态多表查询3.1 环境准备与依赖确认先把地基打牢。pom.xml里需要的东西dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency版本我建议用3.5.3以上因为3.5.2之前的版本在customSqlSegment和分页插件配合上有过几个bug特别是count语句生成时会把JOIN的表也算进去导致count变慢。3.5.3之后这个问题基本稳定了。分页插件必须手动配置这是新手最容易漏的一步Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不配这个你的IPage参数就是个摆设分页不会生效查出来永远是全量数据。热搜词里接触mybatisplus单页500条限制说的就是这种情况——没配分页插件或者配了但maxLimit没设置默认单页最多返回500条。3.2 定义VO与Mapper接口VO的设计有个原则只放查询需要的字段不要继承Entity。我见过有人让VO继承Order实体结果JOIN查询时字段冲突调试半天。老老实实平铺Data public class OrderVO { private Long id; private String orderNo; private BigDecimal amount; private LocalDateTime createTime; private String userName; private String productTitle; }Mapper接口就是前面那段Select。这里补充一个细节返回类型用IPageOrderVO而不是ListOrderVO这样分页插件才能正确拦截并填充total、pages等分页信息。如果你返回List分页插件虽然也会改写SQL加LIMIT但total拿不到前端分页组件就没法渲染总页数。3.3 Service层组装动态条件这是整套方案的核心环节Wrapper在这里被构造出来。看一段实际代码public IPageOrderVO pageQuery(OrderQuery query) { PageOrderVO page new Page(query.getPageNum(), query.getPageSize()); QueryWrapperOrderVO wrapper new QueryWrapper(); if (StringUtils.hasText(query.getOrderNo())) { wrapper.like(o.order_no, query.getOrderNo()); } if (query.getUserId() ! null) { wrapper.eq(o.user_id, query.getUserId()); } if (query.getStartTime() ! null) { wrapper.ge(o.create_time, query.getStartTime()); } if (query.getEndTime() ! null) { wrapper.le(o.create_time, query.getEndTime()); } if (StringUtils.hasText(query.getUserName())) { wrapper.like(u.nickname, query.getUserName()); } if (query.getMinAmount() ! null) { wrapper.ge(o.amount, query.getMinAmount()); } wrapper.orderByDesc(o.create_time); return orderMapper.selectOrderPage(page, wrapper); }几个必须注意的点。字段要带表别名。wrapper.like(o.order_no, ...)里的o.不能省。因为你的SQL里有JOIN如果只写order_noMySQL会报ambiguous column错误因为可能多张表都有这个字段。带上别名指向明确。排序字段也要带别名。orderByDesc(o.create_time)同理。而且排序字段建议加索引否则数据量大了ORDER BY会触发filesort性能断崖式下跌。条件判空是灵魂。每个if都是在判断前端有没有传这个筛选条件。这就是Wrapper的价值所在——它让动态这件事变得极其自然。如果换成纯Select写死SQL你得写一堆if test...标签可读性差很多。3.4 分页与count的性能优化分页插件默认会生成一条count语句形如SELECT COUNT(*) FROM t_order o LEFT JOIN t_user u ON o.user_id u.id LEFT JOIN t_product p ON o.product_id p.id WHERE (...)问题来了如果JOIN的表数据量大这个count会非常慢。热搜词里select count就会很慢说的就是这个。因为count不需要JOIN的字段JOIN纯属浪费。优化方案是自定义count语句。在Mapper里加一个方法Select(script SELECT COUNT(*) FROM t_order o ${ew.customSqlSegment} /script) Long countOrderPage(Param(Constants.WRAPPER) WrapperOrderVO wrapper);然后在Service里手动设置page.setSearchCount(false); Long total orderMapper.countOrderPage(wrapper); page.setTotal(total);setSearchCount(false)告诉分页插件别自动生成count我们自己算。这样count语句里没有JOIN速度能快好几倍。代价是你要保证Wrapper里的条件不依赖JOIN表的字段——如果筛选条件里有u.nickname那count还是得JOIN。所以这个优化要看具体场景条件全在主表上才能用。提示如果筛选条件确实涉及JOIN表但又想优化count可以考虑用子查询改写或者对关联表建覆盖索引。我一般先看慢查询日志确认count确实是瓶颈再动手别过早优化。4. 那些文档里不会写的踩坑记录4.1 分页失效的三种典型场景分页失效是这套方案里最高频的问题我总结了三种情况。第一种没配分页插件。前面说过了MybatisPlusInterceptor必须注册。判断方法很简单看你的SQL日志里有没有LIMIT没有就是插件没生效。第二种page参数位置不对。分页插件靠识别方法参数里的IPage类型来拦截如果IPage不是第一个参数或者被Param改了名插件可能识别不到。我的习惯是永远把IPage放第一个不加Param。第三种返回类型不是IPage。如果你Mapper方法返回ListOrderVO分页插件虽然会加LIMIT但total信息丢失。前端拿不到总数分页组件显示共0条看起来就像分页失效了。排查顺序建议先看SQL日志有没有LIMIT有LIMIT再看返回类型返回类型对了再看total有没有值。三步定位基本不会错。4.2 customSqlSegment为空时的SQL语法错误这个坑很隐蔽。假设你的Wrapper一个条件都没加customSqlSegment返回空字符串你的SQL就变成SELECT ... FROM t_order o LEFT JOIN ...后面直接没了。如果原本SQL里${ew.customSqlSegment}后面还有ORDER BY那没问题。但如果后面什么都没有SQL也能执行。真正出问题的是——你在${ew.customSqlSegment}后面写了AND或ORDER BY而前面为空时语法就错了。比如这种写法... ${ew.customSqlSegment} AND o.status 1当Wrapper为空时变成... AND o.status 1前面没有WHERE直接语法错误。正确做法是把固定条件也放进Wrapper或者用if判断。我倾向于前者统一由Wrapper管理SQL里只留${ew.customSqlSegment}一个动态点干净。4.3 排序字段注入风险orderByDesc如果直接传前端参数会有SQL注入风险。比如前端传create_time; DROP TABLE虽然MyBatis-Plus对排序字段做了部分校验但不能完全依赖。我的做法是白名单校验private static final SetString ALLOWED_SORT_FIELDS Set.of(o.create_time, o.amount, o.id); public void applySort(QueryWrapperOrderVO wrapper, String sortField, String sortOrder) { if (!ALLOWED_SORT_FIELDS.contains(sortField)) { sortField o.create_time; } if (asc.equalsIgnoreCase(sortOrder)) { wrapper.orderByAsc(sortField); } else { wrapper.orderByDesc(sortField); } }前端传的字段必须在白名单里否则用默认排序。这样既灵活又安全。4.4 常见问题速查表问题现象可能原因排查方向分页不生效返回全量未注册分页插件检查MybatisPlusConfigtotal为0但数据有值返回类型不是IPage改Mapper返回类型SQL报ambiguous column字段未带表别名Wrapper里字段加o.前缀customSqlSegment为空导致语法错SQL里写了固定AND固定条件移入Wrappercount查询慢自动count带了JOIN自定义count语句单页最多500条maxLimit默认限制设置setMaxLimit(-1)字段映射为null别名与VO字段不匹配检查AS别名排序报错排序字段有注入风险加白名单校验这张表我贴在工位上出问题先扫一遍八成能对上。5. 进阶玩法让这套方案更顺手5.1 用LambdaQueryWrapper提升类型安全前面用的都是字符串字段名写错了编译期发现不了。LambdaQueryWrapper能解决这个问题LambdaQueryWrapperOrderVO wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getOrderNo()), OrderVO::getOrderNo, query.getOrderNo()); wrapper.ge(query.getStartTime() ! null, OrderVO::getCreateTime, query.getStartTime());注意这里用了条件重载第一个参数是boolean为true才拼这个条件。这样连if都省了代码更紧凑。但有个限制LambdaQueryWrapper的字段名是从VO的getter推导的推导出来是orderNo不带表别名。而我们的SQL里字段是o.order_no对不上。解决办法是在VO字段上加TableField注解指定别名或者干脆在SQL里把o.order_no改成order_no前提是不冲突。我一般用后者简单直接。5.2 多表查询结果去重JOIN查询如果一对多主表记录会重复。比如一个订单有多个商品JOIN商品表后订单会出现多次。这时候分页的total会偏大。解决方案有两种。一是用GROUP BY o.id但会影响count。二是用子查询先分页主表再JOIN。我推荐第二种虽然SQL复杂点但分页准确SELECT o.*, u.nickname, p.title FROM (SELECT * FROM t_order ${ew.customSqlSegment} LIMIT #{offset}, #{size}) o LEFT JOIN t_user u ON o.user_id u.id LEFT JOIN t_product p ON o.product_id p.id不过这种写法分页插件就不好使了得手动算offset。所以我的建议是能避免一对多JOIN就避免把商品信息单独查一次用Map组装。性能反而更好。5.3 与PageHelper的取舍热搜词里出现了pagehelper如果数据太多了说明有人在这两个分页方案之间纠结。我的观点很明确用了MyBatis-Plus就用它的分页插件别混用PageHelper。两者混用会打架。PageHelper是基于ThreadLocal拦截的MyBatis-Plus分页是基于参数拦截的同时开启可能导致分页参数被覆盖出现莫名其妙的结果。我见过最离谱的case是分页页码跳着走第1页、第3页、第5页排查了两天才发现是两个插件冲突。如果项目里已经有PageHelper要么全用PageHelper要么全用MyBatis-Plus别脚踏两条船。5.4 一个实用的小封装最后分享一个我常用的封装把分页查询的模板代码抽出来public T, V IPageV pageQuery(IPageV page, WrapperV wrapper, FunctionWrapperV, IPageV queryFunc, FunctionWrapperV, Long countFunc) { page.setSearchCount(false); Long total countFunc.apply(wrapper); page.setTotal(total); if (total 0) { return page; } return queryFunc.apply(wrapper); }调用的时候return pageQuery(page, wrapper, w - orderMapper.selectOrderPage(page, w), w - orderMapper.countOrderPage(w));total为0时直接返回省一次查询。这个优化在筛选条件很严格、命中数据少的时候效果明显。6. 性能与索引别让JOIN拖垮你的查询6.1 JOIN字段必须建索引多表查询性能的第一杀手是关联字段没索引。LEFT JOIN t_user u ON o.user_id u.id如果t_user.id是主键通常有索引但t_order.user_id没索引MySQL会走全表扫描。我做过一个测试订单表50万数据user_id没索引时JOIN查询耗时1.8秒加上索引后降到45毫秒。差距40倍。所以铁律是所有JOIN的ON字段两边都要有索引。主键天然有外键字段要手动建。CREATE INDEX idx_order_user_id ON t_order(user_id); CREATE INDEX idx_order_product_id ON t_order(product_id);6.2 覆盖索引减少回表如果查询的字段都能从索引里拿到就不用回表查数据行速度更快。比如订单列表经常按create_time排序可以建联合索引CREATE INDEX idx_order_create_status ON t_order(create_time, status, user_id);这样WHERE status 1 ORDER BY create_time的查询能直接从索引拿到数据不用回表。代价是索引占空间、写入变慢所以只对高频查询建。6.3 分页深翻页的优化LIMIT 100000, 20这种深翻页MySQL会扫描前100020行再丢弃前100000行非常慢。优化方案是游标分页记住上一页最后一条的id下一页从它之后取。SELECT ... FROM t_order o WHERE o.id #{lastId} ${ew.customSqlSegment} ORDER BY o.id LIMIT 20这种方式不管翻到第几页速度都一样。缺点是只能顺序翻页不能跳页。后台管理系统如果允许跳页就得用传统分页那就接受深翻页的代价或者限制最大页数。我一般给运营后台设置最多只能翻到100页超过就提示用筛选条件缩小范围。这样既避免深翻页又引导用户用筛选体验反而更好。7. 写在最后的一点个人体会这套QueryWrapper加Select的组合我从2020年用到现在大大小小十几个项目基本没换过。它的价值不在于多高级而在于把动态条件和固定SQL结构这两件事解耦了。Wrapper管变化的部分注解管不变的部分边界清晰维护起来心里有数。新手最容易犯的错是什么都想用Wrapper解决结果JOIN写不出来就开始用循环单查性能一塌糊涂。或者反过来什么都写死在SQL里前端加个筛选条件就得改代码重新发版。这两种极端都要避免。我的经验是先想清楚哪些条件是动态的哪些是固定的。动态的交给Wrapper固定的写进SQL。分页、排序、字段映射这些细节按我上面说的坑一个个避开基本就稳了。最后提醒一句永远看SQL日志。MyBatis-Plus的日志配置打开每次查询打印出实际执行的SQL和参数什么问题都藏不住。我调试这套方案的时候日志开着一眼就能看出是Wrapper没拼上、还是分页没生效、还是字段别名对不上。比瞎猜快十倍。