尧图精选

MyBatis-Plus复杂查询实战:自定义SQL、多表联查与分页

🕒 发布时间:2026/10/2 13:50:09 📁 来源:尧图网络
用 MyBatis-Plus 做单表 CRUD 确实很爽一行代码不写就能完成绝大部分增删改查很多项目的持久层因此干净得只剩一堆 Wrapper。但只要你开始碰多表联查、复杂统计、动态更新某些字段或者某个查询在 MySQL 里怎么想都拼不出 LambdaQueryWrapper 能表达的样子通用的 BaseMapper 就顶不住了。这里说的是 MyBatis-Plus 自定义 SQL 和复杂查询核心解决的就是一件事把 Wrapper 的便利和原生 SQL 的灵活性打通在既有的一套 MyBatis-Plus 体系里继续干复杂的活而不是急急忙忙引入别的 ORM 或者去写一堆重复的 XML 配置。我最初入坑时以为自定义 SQL 就是换个地方写 SQL无非注解或者 XML后来才意识到真正值钱的部分是 MyBatis-Plus 为你预留的几个钩子比如自定义 SQL 里拼接 Wrapper 的${ew.customSqlSegment}、分页插件对自定义查询的识别、还有 resultMap 在处理多表映射时和注解的配合。这些要是没搞清楚写出来的自定义 SQL 要么没法分页要么参数对不上要么只能在本地跑跑、一上生产就各种注入的隐患。这篇东西适合正在用 MyBatis-Plus、但遇到 Wrapper 满足不了的需求的开发者和架构师。我会按自己的实操顺序来写先讲清楚哪些场景必须自定义 SQL、哪些场景是假需求再讲注解方式和 XML 方式分别怎么选然后把分页、多表联查、统计查询这三个高频场景完整跑一遍最后把踩过的坑整理成一张排查表。全程有代码、有为什么这么写的解释你拿去就能改造成自己项目里的写法。1. 什么时候必须上自定义 SQL先从 IService 和 BaseMapper 的边界说起网上不少 MyBatis-Plus 教程喜欢一上来就贴 LambdaQueryWrapper 的各种链式写法好像所有查询都能用它一行搞定。但写多了就会碰壁。我先把我这边的判断标准说清楚只要 SQL 里出现 JOIN、GROUP BY、HAVING、子查询、UNION或者返回的字段不是单表实体而是某个统计结果通用 CRUD 就基本帮不上忙这时候就该换自定义 SQL。1.1 所谓“无状态增删改查”到底是什么热搜里出现了一句很典型的代码注释“基于 mybatis-plus 工具类实现无状态增删改查”很多人看到“无状态”三个字容易懵。说白了就是在 Service 层不保存任何 SqlSession、不手动管理数据库连接、也不依赖具体的 DAO 实现类只需要注入一个接口然后直接调用 IService 预设的方法。例如public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }调用的时候userService.save(user); userService.updateById(user); userService.list(new LambdaQueryWrapperUser().eq(User::getStatus, 1));这种模式的好处是业务代码里看不到任何 SQL 语句增删改查全是方法调用也没有“先取连接再关闭连接”的样板代码所以叫无状态。它适合单表的常规操作这是它的主战场。但问题也随之而来一旦你要按订单聚合统计每个用户的消费总额或者联查三张表字段这条漂亮的链路就断了。你没法让 UserMapper.selectList 去操作两张表也没法让 IService 自己构建一个 JOIN。于是你会试着用 SQL 片段拼进 Wrapper 里试一次就发现不是那么回事。1.2 从真实业务里筛出自定义 SQL 的典型场景我把自己经手的项目里需要自定义 SQL 的情况整理成了一张表你可以先对照着看别听见“复杂查询”三个字就觉得必须自定义场景通用 CRUD 能不能做需要的手段单表等值查询、范围查询、排序能Wrapper 很顺手不需要自定义 SQL单表模糊查询且需要防注入勉强能但 like 拼接容易踩坑自定义 SQL 配合 concat 或 wrapper多表 JOIN 联查返回 VO不能XML 或注解写 JOIN分组统计、聚合函数不能自定义 SQL resultMap/DTO只更新某些字段且字段是动态拼接的能但 SQL 可读性差自定义 SQL 更直观子查询、UNION、复杂嵌套不能Wrapper 表达能力不够自定义 SQL大分页深分页优化不理想普通 limit 可能性能差自定义 SQL 分页插件 延迟关联等注意表格里那句“单表模糊查询其实也能做”但我后来几乎都改成了自定义 SQL。原因是 LambdaQueryWrapper 的 like 方法在默认情况下是直接拼接整个字符串的一旦用户输入里带了%或_这两个符号会被当成通配符查出来的结果和预期完全不一样。与其在 Wrapper 上做各种转义不如在 XML 里写清楚WHERE name LIKE CONCAT(%, #{name}, %) ESCAPE /这样参数里出现的%转义逻辑由自己控制出问题也容易排查。这就是从“能用”到“好用”的差别。2. 注解式自定义 SQL适合轻量场景但有几个细节必须知道如果你只是为某个 Mapper 方法补一段简单的多表查询又不愿意建 XML 文件MyBatis-Plus 继承自 MyBatis 的注解式 SQL 是最快的路径。2.1 Select、Insert、Update、Delete 的基础用法在 Mapper 接口里直接写public interface UserMapper extends BaseMapperUser { Select(SELECT id, name, age, dept_id FROM user WHERE age #{age}) ListUser selectUsersOlderThan(Integer age); Update(UPDATE user SET status #{status} WHERE id #{id}) int updateStatusById(Param(id) Long id, Param(status) Integer status); Delete(DELETE FROM user_log WHERE create_time #{createTime}) int deleteLogsBefore(Param(createTime) LocalDateTime createTime); }这里第一要注意的是参数注解。如果你只有一个参数MyBatis 会自动把参数作为#{age}的值一旦你有两个及以上参数务必给每个参数加Param否则 MyBatis 会报参数找不到或者只能用诡异的#{param1}取值。这不是 MyBatis-Plus 的毛病是底层 MyBatis 的规则但很多人都在这里卡过。第二点返回类型。注解里我没写 resultTypeMyBatis 会自动把查询结果映射到方法返回类型上。对于字段名和实体属性名一致的场景这没问题一旦返回的是 JOIN 后的 VO且字段名对不上一定要用 resultMap见后面 3.2 节。2.2 在注解 SQL 里拼接 Wrapper 的神器${ew.customSqlSegment}这是 MyBatis-Plus 自定义 SQL 里最容易被忽略的一招。它解决的问题是我想自定义一段 SQL但又想保留 Controller 层传过来的 Wrapper 条件比如前端传了个筛选条件我要在后面拼一个age ? AND status ?。如果写死在注解里就失去灵活性了。MyBatis-Plus 给出的答案是在注解 SQL 里写ew.customSqlSegment然后在方法参数里接收WrapperSelect(SELECT id, name, age, dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id ${ew.customSqlSegment}) ListUserDeptVO selectUserDeptList(Param(Constants.WRAPPER) WrapperUser wrapper);调用方可以正常用 Wrapper 传条件ListUserDeptVO list userMapper.selectUserDeptList( Wrappers.UserlambdaQuery().eq(User::getAge, 30).orderByDesc(User::getId) );这里有几个硬性要求少一个都不行参数名必须写Param(Constants.WRAPPER)这个常量值就是ew别自己随手写个wrapper变量名MyBatis-Plus 默认只认ew。${ew.customSqlSegment}用的是${}不是#{}。它会把 Wrapper 里生成的 SQL 片段直接拼进主 SQL。因为 Wrapper 本身是程序员在代码里构造的条件值已经做了参数化所以这里不会产生 SQL 注入但条件片段是由 MyBatis-Plus 生成的你别再往里直接拼接任何外部字符串做变量。如果 Wrapper 里的条件是orderByDesc拼接出来的片段是ORDER BY id DESC注意你主 SQL 里不能自己再写一个 ORDER BY不然会拼出两个 ORDER BY 导致 SQL 语法错误。我自己最常用这个特性的是写一个通用数据权限过滤在 XML 里留一个ew.customSqlSegment然后在 Service 层往 Wrapper 上 eq 上当前用户的数据范围条件。这样数据权限逻辑可以统一收敛到 ServiceSQL 模板保持干净。2.3 注解方式的边界动态 SQL 一多维护成本就上来了注解里写动态 SQL 分分钟能把 SQL 可读性干没。比如下面这个Select(script SELECT * FROM user WHERE deleted 0 if testname ! null and name ! \\ AND name LIKE CONCAT(%, #{name}, %)/if if testdeptId ! null AND dept_id #{deptId}/if /script) ListUser selectByCondition(Param(name) String name, Param(deptId) Long deptId);看起来还好一旦条件超过四五个字符串拼接里全是转义引号和if标签代码又乱又容易漏引号。我的习惯是动态 SQL 少于两三个标签就在注解里写再多一点无脑选择 XML。真正的复杂查询、长 SQL、需要复用 SQL 片段的地方XML 是唯一靠谱的选择。3. XML 方式管理复杂 SQL把 SQL 和 Java 代码分离维护性才拉得起来如果你的复杂查询要长期维护或者一个 SQL 可能被好几个方法复用我强烈建议直接上 XML Mapper。XML 的好处不仅仅是能写更复杂的动态标签还包括 SQL 片段复用sql、结果映射resultMap、以及在script里写可读性更高的判断语句。3.1 从一个多表查询的 XML 配置说起假设我现在要做用户-部门-角色三张表联查返回一个UserInfoVO。对应的 XML 头部长这样?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper resultMap iduserInfoMap typecom.example.vo.UserInfoVO id propertyid columnid / result propertyname columnname / result propertydeptName columndept_name / result propertyroleNames columnrole_names / /resultMap select idselectUserInfoList resultMapuserInfoMap SELECT u.id, u.name, d.dept_name, GROUP_CONCAT(r.role_name ORDER BY r.role_name SEPARATOR ,) AS role_names FROM user u LEFT JOIN dept d ON u.dept_id d.id LEFT JOIN user_role ur ON ur.user_id u.id LEFT JOIN role r ON r.id ur.role_id WHERE u.deleted 0 GROUP BY u.id, u.name, d.dept_name /select /mapper注意几点namespace必须和 Mapper 接口全限定名一致。方法名和select的 id 必须一致MyBatis 靠这个绑定方法。返回的是 VO而不是实体就一定要用resultMap不能用resultType。resultType需要字段名和 Java 属性名能靠 map-underscore-to-camel-case 自动对应但role_names这种聚合字段或者多表同名字段根本没法自动映射。对应 Java Mapper 接口ListUserInfoVO selectUserInfoList();就这么简单。没有Select也没有任何 SQL 字符串SQL 全在 XML 里。3.2 resultMap 和复杂关联映射的实战理解很多人会把 resultMap 想得很复杂以为要配 N 个 association、collection。实际业务里我绝大多数情况只需要一个扁平 resultMap因为返回的 VO 基本都是平铺字段。只有当你把对象嵌套进另一个对象时才需要association和collection。嵌套场景示例resultMap iduserRoleMap typecom.example.vo.UserWithRolesVO id propertyid columnid / result propertyname columnname / collection propertyroleList ofTypecom.example.vo.RoleVO id propertyid columnrole_id / result propertyroleName columnrole_name / /collection /resultMap这种写法配合一条 JOIN 是一条仙路但有一个极其隐蔽的坑如果一个人有多个角色联查出来的结果集中会有多行如果使用Nested Select或者 ResultHandler 的方式不同部分集合数据可能丢失或重复。MyBatis 的默认行为是用 id 字段去重你在collection里一定要有正确的id列否则 MyBatis 会把这一行当成另一个对象处理。排查这类问题时眼睛盯着 resultMap 的 id 和 result column 的匹配往往比打日志高效得多。3.3 动态 SQL复杂查询最锋利的武器MyBatis 的动态 SQL 标签就那几个if、choose、when、otherwise、foreach、where、set、trim。看起来简单但组合起来几乎能覆盖所有业务查询。写一个带条件的多表查询select idselectUserInfoListByCondition resultMapuserInfoMap SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testuserName ! null and userName ! AND u.name LIKE CONCAT(%, #{userName}, %) /if if testdeptId ! null AND d.id #{deptId} /if if teststatusList ! null and statusList.size() 0 AND u.status IN foreach collectionstatusList itemstatus open( separator, close) #{status} /foreach /if choose when testageMin ! null AND u.age gt; #{ageMin} /when when testageMax ! null AND u.age lt; #{ageMax} /when otherwise AND u.age IS NOT NULL /otherwise /choose /where ORDER BY u.create_time DESC /select几个细节where标签会自动去掉第一个条件前的AND所以每个if里的 AND 可以放心写。XML 里写gt;gt;写lt;lt;。有些人偷懒直接写一旦出现if这种结构XML 解析就崩了。稳妥做法是遇到比较符号用 CDATA 包住或者转义。foreach里 collection 名字对照方法参数名如果参数是List默认叫list要指定名字就得加Param(statusList)见下方方法签名。choose和 Java 的 switch 一样只会命中第一个条件别指望它 fall-through。对应方法ListUserInfoVO selectUserInfoListByCondition( Param(userName) String userName, Param(deptId) Long deptId, Param(statusList) ListInteger statusList);动态 SQL 看起来简单实际项目里最常见的错误是if test里写了错误的属性名或者类型判断。statusList ! null and statusList.size() 0我很少用更稳的是if teststatusList ! null and statusList.size() 0如果你的实体属性是 List直接list ! null and list.size() 0也有效。关键点在于 test 表达式里的属性名必须和参数/实体属性名一致否则报的异常信息还特别隐晦只有There is no getter for property named ...。4. 复杂查询三大高频场景实战分页、多表联查、统计查询很多教程会把注解和 XML 分开讲好像是很割裂的两套东西。但实际项目里我会把它们组合起来用。这里集中把三个高频场景完整跑一遍这几个场景我几乎在每个项目里都会遇到。4.1 自定义 SQL 怎么用 MyBatis-Plus 分页插件MyBatis-Plus 的分页插件叫PaginationInnerInterceptor。先注册Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注册了分页插件后最关键的一个规则分页插件只对实现了 IPage 参数的方法做分页不是对所有 SQL 都分页。所以自定义 SQL 要分页方法签名必须带上Page或IPage参数IPageUserInfoVO selectUserInfoPage(Page? page, Param(userName) String userName); // 或者返回 IPage IPageUserInfoVO selectUserInfoPage(IPageUserInfoVO page, Param(userName) String userName);配套 XMLselect idselectUserInfoPage resultMapuserInfoMap SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testuserName ! null and userName ! AND u.name LIKE CONCAT(%, #{userName}, %) /if /where ORDER BY u.create_time DESC /select有意思的地方是分页插件会把这条 SQL 改写成两个动作。一个是带上LIMIT ?, ?的查询另一个是把原 SQL 包一层SELECT COUNT(*) FROM (...)去做 count 查询。这对多表 JOIN 来说很危险因为 count 阶段也会带着全部 JOIN 去统计性能会非常差。我通常在左侧表是两个大表 JOIN 做列表时不直接分页而是让自定义 SQL 只返回主键再用主键拼第二段 SQL 查询详情。这也是俗称的“先分页后 JOIN”。示意select idselectPageIds resultTypejava.lang.Long SELECT u.id FROM user u LEFT JOIN dept d ON u.dept_id d.id WHERE ...条件... ORDER BY u.create_time DESC /select然后第二段select idselectByIds resultMapuserInfoMap SELECT ... FROM user u LEFT JOIN dept d ON u.dept_id d.id WHERE u.id IN foreach collectionids itemid open( separator, close) #{id} /foreach ORDER BY FIELD(u.id, foreach collectionids itemid separator,#{id}/foreach) /select这种方式能让深分页的性能好很多代价是多一条 SQL。但是当数据量真的超过几百万时这几乎是必须的选择教科书里说的“先取主键再查详情”不是空话。4.2 多表联查实操JOIN 方式、VO 设计、分页参数传递我见过很多同事一上来就把所有字段选出来然后让 MyBatis 映射到一个巨型 VO 里。这种做法短期内很爽一旦接口增多VO 字段膨胀一个 SQL 前端能取到十几个字段Mapper 回归测试没人敢动。我的做法是每个列表页按需查字段能少查就少查。下面是一个典型的多表联查分页方法IPageUserDeptPageVO selectUserDeptPage( PageUserDeptPageVO page, Param(keyword) String keyword, Param(deptId) Long deptId);XMLselect idselectUserDeptPage resultTypecom.example.vo.UserDeptPageVO SELECT u.id, u.username, u.age, d.dept_name AS deptName, d.id AS deptId FROM user u LEFT JOIN dept d ON u.dept_id d.id where if testkeyword ! null and keyword ! AND (u.username LIKE CONCAT(%, #{keyword}, %) OR d.dept_name LIKE CONCAT(%, #{keyword}, %)) /if if testdeptId ! null AND d.id #{deptId} /if /where ORDER BY u.create_time DESC /select这里我特意用了resultType而不是resultMap前提是被查字段的所有别名都和后端 VO 的属性对应上比如dept_name AS deptName。如果个别字段对不上宁可用 resultMap也不要在 Java 代码里再写一段字段映射。字段映射放 Java 里每次改动要动两处维护成本直接翻倍。JOIN 的选型也要注意Inner Join、Left Join、Right Join 的业务语义不同但你主要关心会不会丢数据。联查过滤条件写在 JOIN 的 ON 后面和写在 WHERE 后面是有区别的写在 ON 后保留左表所有行即使条件不满足也会返回左表记录右边字段为 NULL。写在 WHERE 后会过滤掉左右两边不匹配的行效果类似于 INNER JOIN。我见过一个跨天排查的数据 bug统计部门人数时把“部门启用状态”条件写在 WHERE 后结果没有启用部门的员工也被过滤掉了人数对不上业务。后来把它移到 ON 后数据就符合预期了。4.3 统计查询聚合结果必须用 DTO/Map 接收统计查询是日常开发里最容易被忽略的领域。很多人图省事直接在 Mapper 接口返回ListMapString, Object然后在 Service 层一层一层从 Map 里取数据类型转换能写出一堆 if-else。这在一个临时统计接口里勉强能用一旦统计逻辑复杂代码可读性就会崩掉。我的建议是引入一个带聚合字段的 DTO。比如Data public class UserCountByDeptDTO { private Long deptId; private String deptName; private Long count; private BigDecimal avgAge; }XMLselect idselectUserCountByDept resultTypecom.example.dto.UserCountByDeptDTO SELECT d.id AS deptId, d.dept_name AS deptName, COUNT(u.id) AS count, AVG(u.age) AS avgAge FROM dept d LEFT JOIN user u ON u.dept_id d.id WHERE d.deleted 0 GROUP BY d.id, d.dept_name HAVING COUNT(u.id) gt; 0 /select这里有两个坑GROUP BY后面除了聚合函数以外出现的列必须全部出现在 group by 中否则 MySQL 在ONLY_FULL_GROUP_BY模式下直接抛异常。这个问题在生产环境极常见开发环境默认配置宽松反而不报一上线才炸。HAVING 条件里如果用了聚合函数别名在 MySQL 里虽然可以用别名但为了兼容性我建议 HAVING 后面写完整表达式HAVING COUNT(u.id) 0。统计查询一般不推荐上分页插件因为统计结果集通常只有几十行。真要是上亿数据维度聚合那就不是 MyBatis-Plus 的职责范围了应该考虑 ES 或 ClickHouse 这类专门的存储。4.4 在 Service 层把复杂 SQL 的结果封装成业务对象当 SQL 已经返回了 DTOService 层剩下的活就是把它组装成最终响应。这里要提一个和 MyBatis-Plus 关系不大的经验不要在 Controller 里直接暴露 Mapper 查询结果哪怕是简单列表也最好走 Service 层。为什么因为一旦你后面要加数据权限、过滤字段、改响应结构Service 层是唯一需要改动的地方。我在实际项目里会在 Service 层写一个专门的方法Transactional(readOnly true) public PageResultUserDeptPageVO pageUserDept(UserDeptQuery query) { PageUserDeptPageVO page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper wrapper buildQueryWrapper(query); // 如果走 wrapper 分支 IPageUserDeptPageVO result userMapper.selectUserDeptPage(page, query.getKeyword(), query.getDeptId()); return PageResult.of(result); }这种做法让事务边界和读操作语义都很清晰。Transactional(readOnly true)在 MySQL 等数据库中主要起到标记作用对 JDBC 层的优化有限但代码可读性和团队规范意义是实实在在的。5. 经验清单自定义 SQL 过程中最容易踩的坑和排查思路这一节不按教程顺序讲完全是我自己踩坑记录整理出来的速查表。每一个坑都真实发生在项目里排查时对照着看能省很多时间。5.1 查询列表为空但 SQL 能查出数据常见原因有两个分页插件 count 查询和自己手动加了条件之间冲突导致 count 和 list 的 SQL 不一致列表不返回数据。解决办法是检查日志里打印的两条 SQL重点对比 WHERE 部分。实体类逻辑删除字段MyBatis-Plus 默认在查询时自动追加deleted 0但自定义 SQL 写在 XML 里时MyBatis-Plus 的自动逻辑删除拼接逻辑不会生效。也就是说你自己写SELECT * FROM user WHERE age 18会连已删除用户一起查出来。这个问题特别隐蔽因为单表 BaseMapper 查不会踩。解决方式是手动在 SQL 里加deleted 0条件或者统一用 logic-delete 搭配 BaseMapper 的现成方法。5.2#{}和${}的注入问题绝不只是语法区别自定义 SQL 里#{}会使用 PreparedStatement 参数占位安全${}是直接字符串替换有注入风险。MyBatis-Plus 封装的ew.customSqlSegment里值部分已经参数化所以能用。但你自己写 SQL 时如果为了拼表名、列名、排序字段用了${}一定要保证传入内容是白名单。我一般这样处理排序字段String orderBy create_time; if (age.equals(sortField)) { orderBy age; } else if (name.equals(sortField)) { orderBy name; } // 然后再拼到 SQL前端传什么就拼什么是最常见的安全漏洞。任何字段只要是用户可控的都按“不可信输入”处理。5.3 XML 里的lt;和 CDATA出错率最高的写法XML 中小于号必须转义否则会被解析为标签开始。常见写法WHERE u.age 18这段在 XML 里会直接报错。改成WHERE u.age lt; 18或者用 CDATAWHERE u.age ![CDATA[ ]] 18CDATA 更适合包含大量比较符号的复杂表达式比如日期范围判断WHERE u.create_time ![CDATA[ ]] #{startTime} AND u.create_time ![CDATA[ ]] #{endTime}记住CDATA 只包裹或表达式整体别把整个 SQL 用 CDATA 包起来一旦包裹范围太大里面的if标签就无法被 MyBatis 解析了。5.4 resultType 自动映射 DTO 时的小驼峰匹配MyBatis 开启下划线转驼峰配置后SQL 查询结果里dept_name可以自动映射到 DTO 的deptName。但是带前后缀或者别名有特殊命名时很容易失败。为了避免这个问题我个人的习惯是所有自定义 SQL 的查询列全部显式加别名并且别名直接用驼峰风格。这样即使哪天关闭了 map-underscore-to-camel-case代码也不会出问题。例如SELECT u.id AS id, u.username AS username, d.dept_name AS deptName而不是写d.dept_name后指望自动转。多写几个别名换来的是排查字段不匹配问题时的爽快。5.5 Mapper 方法重载的坑MyBatis 的 Mapper 方法不能被重载不要试图写两个同名方法只是参数列表不同。Mapper 接口是通过方法名绑定 XML 里的 id重载会让绑定冲突。我在项目里见过有人写ListUser selectUserList(Page page, Param(name) String name); ListUser selectUserList(Param(name) String name);启动直接报Mapper method ... has multiple definitions。解决办法是方法名要唯一比如selectUserPage和selectUserListByCondition。5.6 Mapper XML 里的delete和update也需要逻辑删除保护如果你给表配置了逻辑删除但自定义 SQL 里用DELETE FROM user WHERE id #{id}那逻辑删除同样不生效会真实删掉记录。最稳妥的自定义删除写法UPDATE user SET deleted 1 WHERE id #{id}如果你确认要物理删除除非表本身不需要逻辑删除否则一定要在 SQL 里写明白deleted 0避免误删。5.7 深分页和 count 性能不是所有场景都靠 LIMIT 硬扛深分页问题在任何 ORM 里都存在MyBatis-Plus 分页插件只是拼一个 LIMIT不会为你做优化。当页码特别大、偏移量很深时SQL 的性能会直线下降。这里的排查步骤我建议按顺序来看 count SQL 是否慢慢就把 LEFT JOIN 改成 INNER JOIN或者把 count 改成只查主表主键。看主 SQL 的 EXPLAIN 是否走对索引重点是 ORDER BY 和 WHERE 的组合。如果仍然慢用“先查主键再查详情”或者用延迟关联deferred join。零基础理解延迟关联不是直接查一页完整行而是先查这一页的主键再用主键去回表查完整行。这样数据库扫描的页数据量最小查询深度越大优势越明显。上面 4.1 节展示的两段 SQL 就是延迟关联的一种表达。5.8 多数据源和事务场景下的自定义 SQL 注意事项如果你的项目引入了多数据源自定义 SQL 执行在哪个数据源看的是它所属的 Mapper 绑定的数据源和 XML 内容无关。这个坑我踩过一次动态数据源切到从库但某个自定义 update 方法依然走到主库排查半天发现是事务注解把数据源锁住了。MyBatis-Plus 加多数据源之后事务和数据源的切换顺序得靠规范保证先切数据源再开启新事务通常做法是单独写一个切库服务在事务外调用。6. 最后聊一点个人习惯什么时候别写自定义 SQL虽然这篇写了很多自定义 SQL 的用法但我想提一个反向建议不要为了让 SQL 看起来“高级”就去自定义。如果你只需要查一个表能用 LambdaQueryWrapper 表达的查询尽量用 Wrapper。Wrapper 的优势是类型安全字段名写错了编译期就报错而 SQL 写错了要等运行期或者静态扫描才能发现。我见过有些项目组喜欢把简单查询也写成 XML理由是“风格统一”。结果一条SELECT * FROM user WHERE status 1都要写 5 行 XML维护成本白白增加。我的建议是分两条线走单表简单查询用 MyBatis-Plus 内置 CRUD 和 Wrapper尽量让代码保持精简。多表复杂查询、统计查询、动态条件特别多的查询考虑自定义 SQL并且按照这篇的方式组织。这个平衡点在每个项目里略有不同。早期我倾向于所有 SQL 都写 XML觉得可读后来发现 Wrapper 和 ServiceImpl 的简洁性在业务简单的模块里确实省力。真正该执着的不是“用哪种方式”而是“查询意图是否清晰、变更是否容易控制”。当你某天发现自己在一个自定义 SQL 里塞了 20 个if不如停下来想想这个查询是不是被过度设计了能不能拆成多个更小的查询。把一个 20 个if的 SQL 拆成三个职责单一的查询往往比在 SQL 里给每个字段都加一个判断更容易维护也更不容易让后续接手的同事在心里骂你。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →