第 54 章:条件构造器与分页
学习目标
- 熟练使用 LambdaQueryWrapper
- 掌握分页查询与多表关联
- 学会动态条件拼装
一、为什么用 Lambda 而不是字符串?
// ❌ QueryWrapper:字段名写字符串,改字段名后编译不报错,运行时才炸
new QueryWrapper<User>().eq("user_name", "张三");
// ✅ LambdaQueryWrapper:方法引用,编译期检查,重构自动跟着改
new LambdaQueryWrapper<User>().eq(User::getUsername, "张三");规约:一律用 Lambda 版本。
QueryWrapper只在需要写 SQL 片段(如select("count(*) as num"))时才用。
二、常用条件方法
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
// 比较
wrapper.eq(User::getStatus, 1); // =
wrapper.ne(User::getStatus, 0); // !=
wrapper.gt(User::getAge, 18); // >
wrapper.ge(User::getAge, 18); // >=
wrapper.lt(User::getAge, 60); // <
wrapper.le(User::getAge, 60); // <=
wrapper.between(User::getAge, 18, 60); // BETWEEN 18 AND 60
wrapper.notBetween(User::getAge, 18, 60);
// 模糊
wrapper.like(User::getUsername, "张"); // LIKE '%张%'
wrapper.likeLeft(User::getUsername, "张"); // LIKE '%张'
wrapper.likeRight(User::getUsername, "张"); // LIKE '张%' ← 能走索引
wrapper.notLike(User::getUsername, "test");
// 空值
wrapper.isNull(User::getDeptId);
wrapper.isNotNull(User::getDeptId);
// 集合
wrapper.in(User::getStatus, List.of(1, 2));
wrapper.notIn(User::getStatus, List.of(0));
// 排序
wrapper.orderByDesc(User::getCreateTime);
wrapper.orderByAsc(User::getId);
wrapper.orderBy(true, false, User::getCreateTime); // (是否生效, 是否升序, 字段)
// 分组聚合
wrapper.groupBy(User::getDeptId);
wrapper.having("count(*) > 5");
// 限制字段
wrapper.select(User::getId, User::getUsername); // 只查这两个字段
likeRight才能走索引。like '%xxx%'和like '%xxx'都会全表扫描。搜索功能数据量大时应该用 Elasticsearch。
三、动态条件:condition 参数
// ❌ 大量 if 判断,代码臃肿
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(query.getUsername())) {
wrapper.like(User::getUsername, query.getUsername());
}
if (query.getStatus() != null) {
wrapper.eq(User::getStatus, query.getStatus());
}
if (query.getDeptId() != null) {
wrapper.eq(User::getDeptId, query.getDeptId());
}
// ✅ 每个方法的第一个参数可以是 boolean condition,false 时该条件不拼接
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(query.getUsername()), User::getUsername, query.getUsername())
.eq(query.getStatus() != null, User::getStatus, query.getStatus())
.eq(query.getDeptId() != null, User::getDeptId, query.getDeptId())
.between(query.getStartTime() != null && query.getEndTime() != null,
User::getCreateTime, query.getStartTime(), query.getEndTime())
.orderByDesc(User::getCreateTime);这是 MyBatis-Plus 最实用的设计。它把 XML 里成堆的
<if test="...">变成了一个布尔参数。
四、复杂条件:and / or 嵌套
// SQL: WHERE status = 1 AND (username LIKE '%张%' OR phone LIKE '%138%')
wrapper.eq(User::getStatus, 1)
.and(w -> w.like(User::getUsername, "张")
.or()
.like(User::getPhone, "138"));
// SQL: WHERE (dept_id = 1 AND status = 1) OR (dept_id = 2 AND status = 2)
wrapper.and(w -> w.eq(User::getDeptId, 1).eq(User::getStatus, 1))
.or(w -> w.eq(User::getDeptId, 2).eq(User::getStatus, 2));
// SQL: WHERE NOT (status = 0 OR deleted = 1)
wrapper.not(w -> w.eq(User::getStatus, 0).or().eq(User::getDeleted, 1));⚠️ 不加
and(...)嵌套的坑:javawrapper.eq(User::getStatus, 1).like(...).or().like(...); // SQL: WHERE status=1 AND username LIKE '%张%' OR phone LIKE '%138%' // AND 优先级高于 OR,实际变成:(status=1 AND username LIKE) OR (phone LIKE) // status=1 的限制被绕过了!这是权限过滤最常见的漏洞。
五、分页查询
基础分页
@GetMapping("/page")
public PageResult<UserVO> page(UserPageQuery query) {
Page<User> page = new Page<>(query.getCurrent(), query.getSize());
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(query.getKeyword()),
User::getUsername, query.getKeyword())
.orderByDesc(User::getCreateTime);
IPage<User> result = userMapper.selectPage(page, wrapper);
return PageResult.of(result, UserConvert.INSTANCE::toVO);
}不查总数(性能优化)
Page<User> page = new Page<>(current, size, false); // ① 第三个参数:是否查 count适用场景:瀑布流加载、只需要「有没有下一页」。省掉一次
count(*)查询,大表上能省几百毫秒。
分页深度问题
-- ❌ 深分页:LIMIT 1000000, 10 会先扫描 1000010 行再丢弃前面 100 万行
SELECT * FROM sys_user ORDER BY id LIMIT 1000000, 10; -- 慢!
-- ✅ 方案 1:游标分页(记住上一页最后一个 ID)
SELECT * FROM sys_user WHERE id > 1000000 ORDER BY id LIMIT 10;
-- ✅ 方案 2:延迟关联(子查询只扫索引)
SELECT u.* FROM sys_user u
INNER JOIN (SELECT id FROM sys_user ORDER BY id LIMIT 1000000, 10) t
ON u.id = t.id;// 游标分页的 Java 实现
public List<User> scroll(Long lastId, int size) {
return userMapper.selectList(new LambdaQueryWrapper<User>()
.gt(lastId != null, User::getId, lastId)
.orderByAsc(User::getId)
.last("LIMIT " + size));
}产品层面的解法更好:限制最大页数(如只能翻到 100 页),引导用户用筛选条件缩小范围。淘宝、百度都是这么做的。
六、多表关联查询
MyBatis-Plus 只解决单表。多表有三种做法:
① 自定义 XML(推荐,可控性最强)
public interface UserMapper extends BaseMapper<User> {
IPage<UserVO> selectUserPage(IPage<UserVO> page,
@Param("query") UserPageQuery query);
}<select id="selectUserPage" resultType="com.taskflow.modules.system.vo.UserVO">
SELECT
u.id, u.username, u.phone, u.status, u.create_time,
d.name AS deptName
FROM sys_user u
LEFT JOIN sys_dept d ON u.dept_id = d.id AND d.deleted = 0
<where>
u.deleted = 0
<if test="query.keyword != null and query.keyword != ''">
AND (u.username LIKE CONCAT('%', #{query.keyword}, '%')
OR u.phone LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.deptId != null">
AND u.dept_id = #{query.deptId}
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
</where>
ORDER BY u.create_time DESC
</select>分页插件对自定义 SQL 也生效:只要方法第一个参数是
IPage,插件会自动改写成分页 SQL 并生成 count 查询。
⚠️ 必须用
#{}不能用${}。${}是字符串直接拼接,存在 SQL 注入漏洞。只有表名、字段名等无法参数化的地方才用${},且必须做白名单校验。
② 分步查询(推荐用于列表页)
public PageResult<UserVO> page(UserPageQuery query) {
// ① 先分页查主表
IPage<User> userPage = userMapper.selectPage(page, wrapper);
List<User> users = userPage.getRecords();
if (users.isEmpty()) return PageResult.empty();
// ② 批量查关联数据(一次 IN 查询,避免 N+1)
Set<Long> deptIds = users.stream()
.map(User::getDeptId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> deptNameMap = deptMapper.selectBatchIds(deptIds).stream()
.collect(Collectors.toMap(Dept::getId, Dept::getName));
// ③ 组装
List<UserVO> vos = users.stream().map(u -> {
UserVO vo = UserConvert.INSTANCE.toVO(u);
vo.setDeptName(deptNameMap.get(u.getDeptId()));
return vo;
}).toList();
return PageResult.of(userPage, vos);
}为什么分步查询常常更快?
- 避免大表 JOIN(MySQL 的 JOIN 在数据量大时性能不稳定)
- 关联数据可以走缓存(部门信息几乎不变,可以全量缓存到 Redis)
- 未来拆微服务时天然适配(部门数据在另一个服务)
⚠️ N+1 问题:如果在循环里逐个
deptMapper.selectById(),20 条数据就是 21 次查询。必须用 IN 批量查。
③ MyBatis-Plus-Join(第三方插件)
MPJLambdaWrapper<User> wrapper = new MPJLambdaWrapper<User>()
.selectAll(User.class)
.select(Dept::getName)
.leftJoin(Dept.class, Dept::getId, User::getDeptId);
List<UserVO> list = userMapper.selectJoinList(UserVO.class, wrapper);方便但有代价:SQL 不直观、复杂场景生成的 SQL 性能难以调优。简单关联可用,复杂查询老实写 XML。
七、UpdateWrapper
// ① 部分字段更新,不用查出实体
userService.lambdaUpdate()
.eq(User::getId, 1L)
.set(User::getStatus, 0)
.set(User::getUpdateTime, LocalDateTime.now())
.update();
// ② 字段自增(原子操作,避免并发问题)
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setSql("login_count = login_count + 1")
.update();
// ③ 批量条件更新
userService.lambdaUpdate()
.in(User::getDeptId, deptIds)
.set(User::getStatus, 0)
.update();② 为什么用
setSql而不是先查再改?select count → count+1 → update是三步,并发时会丢更新。SET count = count + 1是数据库层面的原子操作。
八、常见坑
// 坑 1:selectOne 结果多于一条会抛异常
userMapper.selectOne(wrapper); // ❌ TooManyResultsException
userService.getOne(wrapper, false); // ✅ 返回第一条
// 坑 2:updateById 不更新 null 字段(默认 update-strategy=not_null)
user.setPhone(null);
userMapper.updateById(user); // ❌ phone 不会被置空
// ✅ 想置空要用 UpdateWrapper
userService.lambdaUpdate().eq(User::getId, id).set(User::getPhone, null).update();
// 坑 3:wrapper 为空时 delete/update 会操作全表
userMapper.delete(new LambdaQueryWrapper<>()); // ❌ 删光全表!
// 用 BlockAttackInnerInterceptor 兜底(见上一章)
// 坑 4:in 传空集合
wrapper.in(User::getId, emptyList); // ❌ 生成 IN () 语法错误
wrapper.in(!ids.isEmpty(), User::getId, ids); // ✅ 加 condition
// 坑 5:last 方法有 SQL 注入风险
wrapper.last("LIMIT " + userInput); // ❌ 用户输入直接拼接
wrapper.last("LIMIT " + Math.min(size, 100)); // ✅ 校验后使用
// 坑 6:Wrapper 复用
LambdaQueryWrapper<User> w = new LambdaQueryWrapper<>();
w.eq(User::getStatus, 1);
list1 = userMapper.selectList(w);
w.eq(User::getDeptId, 1); // ❌ 条件累加了,不是替换
list2 = userMapper.selectList(w); // 实际是 status=1 AND dept_id=1九、本章小结
| 要点 | 关键 |
|---|---|
| Lambda | 一律用 LambdaQueryWrapper |
| 动态条件 | 第一个参数传 boolean condition |
| or 嵌套 | 必须用 and(w -> ...) 包住,否则权限会被绕过 |
| 分页 | 不需要总数时传 false |
| 深分页 | 游标分页 / 限制最大页数 |
| 多表 | 自定义 XML 或分步查询 |
| N+1 | 批量 IN 查询 |
#{} vs ${} | 一律用 #{},防注入 |
| 空 wrapper | 会操作全表,开启防护插件 |
动手练习
练习 1:基础题
实现用户列表分页查询,支持:关键词模糊搜索(用户名或手机号)、状态筛选、部门筛选、创建时间范围、按创建时间倒序。全部条件可选。
练习 2:进阶题
对比三种方式查询「用户 + 部门名称」列表的性能:JOIN 查询、分步查询、循环查询(N+1)。用 10 万条数据测试,记录耗时。
下一章:第 55 章:自动填充与枚举 →