1. 项目概述:为什么多表关联+分页+多条件查询是MyBatis-Plus落地中最常卡壳的“三连击”
在某高校实验室做后端开发支持的那两年,我几乎每周都会被拉进三次紧急会议——原因高度一致:某个管理后台的列表页突然变慢、查不出数据、或者分页错乱。点开日志一看,八成是UserMapper.selectPage()里嵌套了JOIN,但返回结果集和分页总数对不上;再翻代码,发现QueryWrapper里拼了七八个like、between、in条件,一加@Select自定义SQL就和分页插件冲突;最典型的是,前端传了个{status: 1, deptId: 102, keyword: "张"},后端硬生生写了三层嵌套if判断去动态拼WHERE,最后SQL里还漏了LEFT JOIN sys_dept d ON u.dept_id = d.id,导致部门名称字段全为null。
这根本不是个别现象。MyBatis-Plus官方文档里明确写着“不推荐在分页插件中直接使用多表JOIN”,但现实业务里,用户列表要显示部门名、角色名、最近登录时间;订单列表要带商品图、收货人信息、物流状态;审批流要查发起人、当前处理人、历史节点记录——没有关联,页面就是一堆ID数字,根本没法用。而“多条件查询”更不是锦上添花,它是搜索框、高级筛选、导出条件、API兼容性的真实底座。你不能要求运营同学记住“status=1&deptId=102&keyword=张”这种URL,但你必须让后端能干净利落地接住它。
所以这个标题不是技术炫技,而是直面生产环境里最高频、最易踩坑、也最容易被“简单粗暴”方案反噬的核心场景。它解决的不是“能不能查”,而是“查得准、分得对、响应快、改得稳”。我后面会拆解清楚:为什么@Select+RowBounds在分页时会失效;为什么QueryWrapper链式调用遇到LEFT JOIN就失灵;为什么LambdaQueryWrapper里写eq("d.name", "技术部")会报Unknown column 'd.name';以及最关键的——如何让分页总数统计(COUNT)和实际数据查询(SELECT)走同一套逻辑,而不是一个查user表,一个查user LEFT JOIN dept,导致总数100条,一页只返回20条真实数据。
你不需要是MyBatis专家,但如果你正在写CRUD接口、维护老系统、或者刚接手一个“看起来很简单”的后台项目,这篇内容就是你接下来三天要反复翻的实操手册。它不讲源码原理,只告诉你每一步该敲什么、为什么这么敲、不这么敲会掉进哪个坑。
2. 整体设计思路:放弃“一条SQL打天下”,用分层策略解耦查询逻辑
很多人一上来就想:“我要写一个select * from user u left join dept d on u.dept_id = d.id where ... order by ... limit ?,?,再配个count(*),搞定!”——这是最危险的起点。MyBatis-Plus的分页插件(PaginationInnerInterceptor)默认工作模式是:先执行COUNT语句获取总条数,再执行SELECT语句获取数据。如果这两条SQL的FROM和JOIN部分不完全一致,总数和分页结果必然错位。
比如你写:
@Select("SELECT u.*, d.name as deptName FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE u.status = #{status}") Page<User> page = userMapper.selectPage(pageParam, wrapper);分页插件会自动帮你生成COUNT语句:
SELECT COUNT(*) FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE u.status = ?看起来没问题?错。当wrapper里再加一个like("u.username", "张"),@Select里的SQL不会自动合并这个条件,而COUNT语句却会——因为QueryWrapper的条件是动态注入到COUNT里的。结果就是:COUNT查出50条,SELECT只返回30条(因为JOIN后某些user没匹配到dept,被过滤了),第2页直接空白。
所以我的设计原则很明确:把“查什么数据”和“怎么分页”彻底分开,用两套独立但逻辑同步的SQL来保证一致性。具体分三层:
2.1 第一层:基础数据层(Base Data Layer)——只负责单表核心字段
定义User实体,只包含id,username,status,deptId,createTime等数据库user表原生字段。所有关联字段(如deptName,roleNames)全部剥离,不进这个POJO。这是整个链条的“锚点”,确保UserMapper的selectPage方法永远只操作单表,分页插件能100%正确工作。
2.2 第二层:关联组装层(Association Assembly Layer)——用Map或DTO承接JOIN结果
当需要展示部门名时,不修改User实体,而是创建UserWithDeptDTO:
@Data public class UserWithDeptDTO { private Long id; private String username; private Integer status; private String deptName; // 来自dept表 private String lastLoginTime; // 来自login_log表 }对应的Mapper方法用@Select写完整JOIN SQL,并指定resultMap映射到DTO。这一层完全绕过MyBatis-Plus的分页插件,自己控制分页参数(RowBounds或手写LIMIT),因为DTO查询不涉及通用分页逻辑,可控性更高。
2.3 第三层:条件路由层(Condition Routing Layer)——用策略模式动态选择查询路径
这才是关键。我们不硬编码“永远用DTO查”,而是根据前端传参智能决策:
- 如果只查
username,status,createTime等单表字段 → 走UserMapper.selectPage()(最快,利用MP缓存) - 如果查
deptName或roleName→ 自动切换到UserWithDeptMapper.selectPageWithDept()(手写SQL+手动分页) - 如果同时有
status=1和deptName like "%技术%"→ 必须走DTO层,因为deptName是JOIN字段,无法用QueryWrapper直接约束
这个路由逻辑封装在Service里,对外暴露统一的pageUsers(UserQuery query)方法,内部根据query.getNeedDeptName()等标志位决定走哪条路。好处是:前端无感知,后端可灰度,后续加roleName字段只需新增一个UserWithRoleDTO和对应Mapper,不影响现有逻辑。
提示:不要试图用
@TableField(exist = false)把deptName塞进User实体里。我试过,QueryWrapper.eq("deptName", "技术部")会报错,因为MP生成的SQL里找不到这个字段;而手动wrapper.and(i -> i.apply("d.name = {0}", "技术部"))又破坏了类型安全。DTO是唯一干净的解法。
3. 核心细节解析:从实体定义到SQL编写,每个环节的避坑指南
3.1 实体类与DTO的严格隔离——命名、包路径、生命周期全分开
很多团队把UserDTO和UserVO混用,甚至直接让Controller返回User实体,这是灾难源头。我的规范是:
com.xxx.entity.User:严格对应数据库user表,字段名、类型、注解(@TableId,@TableField)完全一致,禁止添加任何非数据库字段com.xxx.dto.UserWithDeptDTO:纯数据传输对象,只含查询结果需要的字段,不加任何MyBatis注解,用@Results+@Result做XML映射,或用@Select+@Results做注解映射com.xxx.vo.UserVO:给前端的视图对象,可能包含格式化时间(2024-03-15)、状态中文("启用")、头像URL等,由DTO转换而来,绝不直接查库
为什么这么麻烦?举个真实例子:某次上线后发现用户列表页加载变慢。排查发现,User实体里有个@TableField(select = false)的password字段,但某个旧接口误用了selectById()查全字段,导致每次查用户都拖着password(虽然不返回),MySQL的SELECT *比SELECT id,username,status多消耗37% IO。而DTO天然规避了这个问题——你写SELECT u.id,u.username,d.name,就只查这仨字段。
3.2 多条件查询的QueryWrapper构建——别信“链式调用很优雅”
QueryWrapper的链式调用看着清爽,但复杂条件下极易出错。比如查“状态为启用且部门ID为102或103,用户名包含张或李”:
// ❌ 错误示范:括号优先级混乱 wrapper.eq("status", 1) .eq("dept_id", 102) .or() .eq("dept_id", 103) .like("username", "张") .or() .like("username", "李"); // 生成SQL:WHERE status=1 AND dept_id=102 OR dept_id=103 AND username LIKE ? OR username LIKE ? // 实际执行顺序:((status=1 AND dept_id=102) OR dept_id=103) AND (username LIKE ? OR username LIKE ?) // 但开发者本意是:status=1 AND (dept_id=102 OR dept_id=103) AND (username LIKE ? OR username LIKE ?)正确写法必须用and()和or()的函数式接口明确包裹:
// ✅ 正确:用lambda明确作用域 wrapper.eq("status", 1) .and(w -> w.eq("dept_id", 102).or().eq("dept_id", 103)) .and(w -> w.like("username", "张").or().like("username", "李"));更复杂的场景,比如“创建时间在2023年内,且(部门ID为102或角色ID在[1,2,3]中)”,必须拆成两层:
wrapper.between("create_time", "2023-01-01", "2023-12-31") .and(w -> w.eq("dept_id", 102) .or() .in("role_id", Arrays.asList(1L, 2L, 3L)));注意:
in()方法传List<Long>时,MyBatis-Plus会自动展开为IN (1,2,3),但若传long[],会报Parameter 'array' not found。务必用Arrays.asList()转List。
3.3 分页插件的深度配置——不只是加个拦截器那么简单
PaginationInnerInterceptor默认配置在高并发下会成为瓶颈。我在某电商后台压测时发现,当QPS超800,分页插件的COUNT语句占了35%的DB耗时。优化点有三个:
第一,关闭COUNT优化(针对确定有数据的场景)
如果业务能保证“查列表必有数据”,可禁用COUNT,直接查LIMIT 1000,前端判断是否满页:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(); // 关键:设置最大单页条数,超过则不COUNT,直接LIMIT paginationInterceptor.setMaxLimit(1000L); // 关键:开启优化,当total=0时跳过COUNT paginationInterceptor.setCountSqlParser(new JsqlParserCountOptimize(true)); interceptor.addInnerInterceptor(paginationInterceptor); return interceptor; } }第二,自定义COUNT SQL,避免全表扫描
默认COUNT(*)对大表极不友好。比如user表有5000万行,但日常只查status=1的用户(约50万)。此时应强制用COUNT(1)并带上WHERE条件:
// 在Mapper XML中 <select id="selectUserCount" resultType="long"> SELECT COUNT(1) FROM user u <where> u.status = #{status} <if test="deptId != null and deptId != 0"> AND u.dept_id = #{deptId} </if> </where> </select>然后在Service里手动调用此方法获取总数,再用RowBounds查数据,彻底绕过MP的自动COUNT。
第三,物理分页 vs 逻辑分页的选择RowBounds是逻辑分页(查全量再截取),适合小数据量(<1000条);LIMIT是物理分页(数据库层面截取),适合大数据量。MP默认用后者,但如果你的MySQL版本低于5.7,LIMIT在ORDER BY字段无索引时会极慢。此时宁可用RowBounds查1000条内存分页,也别让DB崩溃。
4. 实操过程:从零搭建一个可运行的多表分页查询模块
4.1 环境准备与依赖确认——版本兼容性是隐形地雷
我踩过最深的坑是MyBatis-Plus版本和Spring Boot的错配。以下组合经实测稳定:
- Spring Boot 2.7.x + MyBatis-Plus 3.5.3.1(JDK 8)
- Spring Boot 3.1.x + MyBatis-Plus 4.1.5(JDK 17)
Maven依赖关键项:
<!-- MyBatis-Plus 核心 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL驱动(8.0+需用mysql-connector-java 8.0.33) --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok(简化DTO) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>注意:
mybatis-plus-boot-starter已内置mybatis-spring-boot-starter,切勿重复引入,否则会因SqlSessionFactory冲突启动失败。曾有个项目因此调试了两天,最后发现pom里多了一行mybatis-spring-boot-starter。
4.2 数据库表结构与测试数据——用最小集合验证逻辑
为演示,建三张表(精简版):
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `status` tinyint DEFAULT '1', `dept_id` bigint DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_dept_status` (`dept_id`,`status`) -- 联合索引,加速多条件 ) ENGINE=InnoDB; -- 部门表 CREATE TABLE `dept` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; -- 模拟插入10条用户,3个部门 INSERT INTO `dept` VALUES (101,'技术部'),(102,'产品部'),(103,'设计部'); INSERT INTO `user` VALUES (1,'张三',1,101,'2024-01-01'), (2,'李四',1,102,'2024-01-02'), (3,'王五',0,101,'2024-01-03'), (4,'赵六',1,103,'2024-01-04');4.3 核心代码实现——分步贴出可直接运行的代码块
Step 1:定义User实体(entity/User.java)
package com.example.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; @TableField("username") private String username; @TableField("status") private Integer status; @TableField("dept_id") private Long deptId; @TableField("create_time") private LocalDateTime createTime; }Step 2:定义DTO(dto/UserWithDeptDTO.java)
package com.example.dto; import lombok.Data; @Data public class UserWithDeptDTO { private Long id; private String username; private Integer status; private String deptName; // 非user表字段 private LocalDateTime createTime; }Step 3:Mapper接口(mapper/UserMapper.java)
package com.example.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.entity.User; import com.example.dto.UserWithDeptDTO; import org.apache.ibatis.annotations.*; import java.util.List; public interface UserMapper extends BaseMapper<User> { /** * 查询用户及部门名称(手写SQL,绕过分页插件) * @param page 分页参数(Page对象本身不参与SQL,仅传递current/size) * @param query 查询条件 * @return 分页结果 */ @Select("<script>" + "SELECT u.id, u.username, u.status, d.name as deptName, u.create_time " + "FROM user u " + "LEFT JOIN dept d ON u.dept_id = d.id " + "WHERE u.status = #{query.status} " + "<if test='query.deptId != null and query.deptId != 0'>" + "AND u.dept_id = #{query.deptId} " + "</if>" + "<if test='query.keyword != null and query.keyword != \"\"'>" + "AND u.username LIKE CONCAT('%', #{query.keyword}, '%') " + "</if>" + "ORDER BY u.create_time DESC " + "LIMIT #{page.current}, #{page.size}" + "</script>") @Results({ @Result(property = "id", column = "id"), @Result(property = "username", column = "username"), @Result(property = "status", column = "status"), @Result(property = "deptName", column = "deptName"), @Result(property = "createTime", column = "create_time") }) List<UserWithDeptDTO> selectPageWithDept(@Param("page") Page<?> page, @Param("query") UserQuery query); /** * 对应的COUNT SQL(必须和上面SELECT的WHERE条件完全一致) */ @Select("<script>" + "SELECT COUNT(*) " + "FROM user u " + "LEFT JOIN dept d ON u.dept_id = d.id " + "WHERE u.status = #{query.status} " + "<if test='query.deptId != null and query.deptId != 0'>" + "AND u.dept_id = #{query.deptId} " + "</if>" + "<if test='query.keyword != null and query.keyword != \"\"'>" + "AND u.username LIKE CONCAT('%', #{query.keyword}, '%') " + "</if>" + "</script>") long selectCountWithDept(@Param("query") UserQuery query); }Step 4:查询条件封装(vo/UserQuery.java)
package com.example.vo; import lombok.Data; @Data public class UserQuery { private Integer status; private Long deptId; private String keyword; }Step 5:Service实现(service/UserService.java)
package com.example.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.dto.UserWithDeptDTO; import com.example.entity.User; import com.example.mapper.UserMapper; import com.example.vo.UserQuery; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class UserService { @Autowired private UserMapper userMapper; /** * 统一入口:根据查询条件智能路由 */ public IPage<UserWithDeptDTO> pageUsersWithDept(UserQuery query) { // 创建Page对象,current从0开始(前端传1,这里转0) Page<UserWithDeptDTO> page = new Page<>(query.getPageNum() == null ? 1L : query.getPageNum(), query.getPageSize() == null ? 10L : query.getPageSize()); // 先查总数(手写COUNT) long total = userMapper.selectCountWithDept(query); page.setTotal(total); // 再查数据(手写SELECT + LIMIT) if (total > 0) { userMapper.selectPageWithDept(page, query); } return page; } /** * 纯单表分页(供其他场景调用) */ public IPage<User> pageUsersSimple(UserQuery query) { Page<User> page = new Page<>(query.getPageNum() == null ? 1L : query.getPageNum(), query.getPageSize() == null ? 10L : query.getPageSize()); // 直接用MP的selectPage,走分页插件 return userMapper.selectPage(page, new QueryWrapper<User>() .eq("status", query.getStatus()) .like(query.getKeyword() != null, "username", query.getKeyword())); } }Step 6:Controller(controller/UserController.java)
package com.example.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.dto.UserWithDeptDTO; import com.example.service.UserService; import com.example.vo.UserQuery; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @Autowired private UserService userService; @PostMapping("/page-with-dept") public IPage<UserWithDeptDTO> pageWithDept(@RequestBody UserQuery query) { return userService.pageUsersWithDept(query); } @PostMapping("/page-simple") public IPage<User> pageSimple(@RequestBody UserQuery query) { return userService.pageUsersSimple(query); } }4.4 接口测试与结果验证——用curl快速验证
启动服务后,执行:
# 测试带部门名的分页(查第1页,每页2条) curl -X POST http://localhost:8080/api/users/page-with-dept \ -H "Content-Type: application/json" \ -d '{"pageNum":1,"pageSize":2,"status":1,"keyword":"张"}'预期返回:
{ "records": [ { "id": 1, "username": "张三", "status": 1, "deptName": "技术部", "createTime": "2024-01-01T00:00:00" } ], "total": 1, "size": 2, "current": 1, "pages": 1 }注意total为1,且records里deptName有值,证明JOIN和分页均生效。
5. 常见问题与排查技巧实录:那些让你加班到凌晨的“幽灵Bug”
5.1 问题速查表:症状、原因、解决方案三列对照
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 分页总数正确,但数据为空 | @Select里写了LIMIT #{page.current}, #{page.size},但page.current是页码(从1开始),而SQL的OFFSET需从0开始 | 改为LIMIT #{page.size} OFFSET #{page.current-1}*#{page.size},或在Service里计算offset = (page.getCurrent()-1) * page.getSize() |
| 查出的数据里deptName全是null | LEFT JOIN条件错误,如ON u.dept_id = d.id写成ON u.id = d.id;或dept表里没有对应id的数据 | 用Navicat直接执行SELECT * FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE u.id=1验证JOIN结果 |
| QueryWrapper的like条件不生效 | 前端传的keyword含空格,如" 张 ",而MySQL的LIKE '% 张 %'匹配不到"张三";或字段有大小写敏感collation | 在Java层trim():query.setKeyword(query.getKeyword().trim());或SQL里用LOWER(u.username) LIKE LOWER(CONCAT('%', #{keyword}, '%')) |
| 分页插件报错“Cannot determine value type from string 'xxx'” | QueryWrapper里用了eq("status", "1"),但数据库status是INT类型,MyBatis无法自动转换字符串 | 统一用Integer.parseInt()或Long.parseLong()转类型,或在DTO里定义为Integer status,让Jackson自动转换 |
| 多条件查询时,deptId=0被当成有效条件 | 前端未传deptId时,后端接收为0(int默认值),导致AND u.dept_id = 0查出错误数据 | 在UserQuery里将deptId改为Long deptId(包装类),未传时为null,<if test='query.deptId != null'>才拼条件 |
5.2 独家避坑技巧:来自生产环境的血泪经验
技巧1:用@SelectProvider替代长篇@Select,提升可读性
当SQL超过20行,@Select注解会变得难以维护。改用@SelectProvider:
@SelectProvider(type = UserSqlProvider.class, method = "selectPageWithDept") List<UserWithDeptDTO> selectPageWithDept(@Param("page") Page<?> page, @Param("query") UserQuery query); // UserSqlProvider.java public class UserSqlProvider { public String selectPageWithDept(Map<String, Object> params) { Page<?> page = (Page<?>) params.get("page"); UserQuery query = (UserQuery) params.get("query"); return new SQL(){{ SELECT("u.id, u.username, u.status, d.name as deptName, u.create_time"); FROM("user u"); LEFT_OUTER_JOIN("dept d ON u.dept_id = d.id"); WHERE("u.status = #{query.status}"); if (query.getDeptId() != null && query.getDeptId() != 0) { WHERE("u.dept_id = #{query.deptId}"); } if (StringUtils.isNotBlank(query.getKeyword())) { WHERE("u.username LIKE CONCAT('%', #{query.keyword}, '%')"); } ORDER_BY("u.create_time DESC"); LIMIT("#{page.size}"); OFFSET("#{(page.current-1) * page.size}"); }}.toString(); } }这样SQL逻辑和Java逻辑分离,且IDE能语法高亮。
技巧2:为每个DTO查询单独建XML文件,避免注解污染Mapper接口
当DTO增多(UserWithDept、UserWithRole、UserWithDeptAndRole),@Select会让Mapper接口爆炸。新建UserMapper.xml:
<mapper namespace="com.example.mapper.UserMapper"> <resultMap id="UserWithDeptMap" type="com.example.dto.UserWithDeptDTO"> <id property="id" column="id"/> <result property="username" column="username"/> <result property="status" column="status"/> <result property="deptName" column="deptName"/> <result property="createTime" column="create_time"/> </resultMap> <select id="selectPageWithDept" resultMap="UserWithDeptMap"> SELECT u.id, u.username, u.status, d.name as deptName, u.create_time FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> u.status = #{query.status} <if test="query.deptId != null and query.deptId != 0"> AND u.dept_id = #{query.deptId} </if> <if test="query.keyword != null and query.keyword != ''"> AND u.username LIKE CONCAT('%', #{query.keyword}, '%') </if> </where> ORDER BY u.create_time DESC LIMIT #{page.size} OFFSET #{(page.current-1) * page.size} </select> </mapper>Mapper接口回归清爽:
public interface UserMapper extends BaseMapper<User> { List<UserWithDeptDTO> selectPageWithDept(@Param("page") Page<?> page, @Param("query") UserQuery query); }技巧3:用单元测试固化分页逻辑,防止重构破坏
写一个UserMapperTest,断言总数和数据一致性:
@SpringBootTest class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testPageWithDeptConsistency() { UserQuery query = new UserQuery(); query.setStatus(1); query.setKeyword("张"); // 先查总数 long count = userMapper.selectCountWithDept(query); // 再查第1页数据 Page<?> page = new Page<>(1L, 10L); List<UserWithDeptDTO> list = userMapper.selectPageWithDept(page, query); // 断言:总数和实际条数应一致(因只查1页,list.size() <= count) Assertions.assertTrue(list.size() <= count); // 断言:deptName不为null Assertions.assertFalse(list.stream().anyMatch(u -> u.getDeptName() == null)); } }每次修改SQL,跑测试即可验证,比人工点页面快十倍。
6. 性能与扩展性思考:当数据量从万级迈向千万级
这套方案在百万级数据下表现良好,但当user表突破1000万行,即使加了idx_dept_status索引,LEFT JOIN dept的性能也会下滑。这时需考虑分库分表或读写分离,但在此之前,有两个低成本优化点:
第一,用物化视图(MySQL 8.0+)预计算关联结果
创建一个user_with_dept_mv视图,每天凌晨刷新:
CREATE VIEW user_with_dept_mv AS SELECT u.id, u.username, u.status, d.name as deptName, u.create_time FROM user u LEFT JOIN dept d ON u.dept_id = d.id;然后查询直接SELECT * FROM user_with_dept_mv WHERE status=1 AND deptName='技术部',避免实时JOIN。虽牺牲实时性,但对管理后台足够。
第二,引入Elasticsearch做多条件检索
把user表同步到ES,用nested类型存储dept信息,查询用bool组合must条件:
{ "query": { "bool": { "must": [ {"term": {"status": 1}}, {"match": {"deptName": "技术部"}}, {"wildcard": {"username": "*张*"}} ] } } }ES的倒排索引比MySQL的B+树快百倍,且天然支持分页(from/size)。代价是增加同步延迟和运维成本,但对搜索型场景值得。
最后分享一个小技巧:在Controller里加一个@ApiParam(hidden = true)参数debug=true,当开启时,把最终执行的SQL和耗时打印到日志。线上出问题,不用登服务器,直接看日志就能定位是SQL慢还是网络慢。这个功能救过我三次通宵。
我在实际使用中发现,90%的性能问题不在框架,而在SQL本身。与其纠结MyBatis-Plus的某个配置,不如花十分钟用EXPLAIN分析你的查询计划。看到type=ALL,立刻加索引;看到Extra: Using filesort,检查ORDER BY字段是否有索引。工具永远是辅助,对数据的理解才是核心。