Skip to content
第 54 / 250 章后端⏱ 10 分钟阅读

第 54 章:条件构造器与分页

学习目标

  • 熟练使用 LambdaQueryWrapper
  • 掌握分页查询与多表关联
  • 学会动态条件拼装

一、为什么用 Lambda 而不是字符串?

java
// ❌ QueryWrapper:字段名写字符串,改字段名后编译不报错,运行时才炸
new QueryWrapper<User>().eq("user_name", "张三");

// ✅ LambdaQueryWrapper:方法引用,编译期检查,重构自动跟着改
new LambdaQueryWrapper<User>().eq(User::getUsername, "张三");

规约一律用 Lambda 版本QueryWrapper 只在需要写 SQL 片段(如 select("count(*) as num"))时才用。

二、常用条件方法

java
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 参数

java
// ❌ 大量 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 嵌套

java
// 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(...) 嵌套的坑

java
wrapper.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 的限制被绕过了!这是权限过滤最常见的漏洞。

五、分页查询

基础分页

java
@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);
}

不查总数(性能优化)

java
Page<User> page = new Page<>(current, size, false);   // ① 第三个参数:是否查 count

适用场景:瀑布流加载、只需要「有没有下一页」。省掉一次 count(*) 查询,大表上能省几百毫秒。

分页深度问题

sql
-- ❌ 深分页: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
// 游标分页的 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(推荐,可控性最强)

java
public interface UserMapper extends BaseMapper<User> {

    IPage<UserVO> selectUserPage(IPage<UserVO> page,
                                 @Param("query") UserPageQuery query);
}
xml
<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 注入漏洞。只有表名、字段名等无法参数化的地方才用 ${},且必须做白名单校验。

② 分步查询(推荐用于列表页)

java
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);
}

为什么分步查询常常更快?

  1. 避免大表 JOIN(MySQL 的 JOIN 在数据量大时性能不稳定)
  2. 关联数据可以走缓存(部门信息几乎不变,可以全量缓存到 Redis)
  3. 未来拆微服务时天然适配(部门数据在另一个服务)

⚠️ N+1 问题:如果在循环里逐个 deptMapper.selectById(),20 条数据就是 21 次查询。必须用 IN 批量查

③ MyBatis-Plus-Join(第三方插件)

java
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

java
// ① 部分字段更新,不用查出实体
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 是数据库层面的原子操作。

八、常见坑

java
// 坑 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 章:自动填充与枚举

本站基于 VitePress 构建 · 由 Codebook 团队维护