Skip to content

MyBatis-Plus Wrapper

Wrapper 是 MyBatis-Plus 的条件构造器。它让开发者用 Java API 组织查询、更新和排序条件,最后由 MyBatis-Plus 生成 SQL 片段,再交给 MyBatis 执行。

它的价值是提高简单单表 CRUD 的开发效率;它的风险是让 SQL 变得“看不见”。如果只会链式调用,不会看最终 SQL、不知道索引怎么配、不知道哪些条件会造成全表扫描,就很容易把简单 API 写成线上慢查询。

学习目标

学完本章要能说清楚:

  1. QueryWrapperLambdaQueryWrapperUpdateWrapperLambdaUpdateWrapper 分别适合什么。
  2. Wrapper 链式调用如何变成 SQL 条件和参数。
  3. 为什么推荐 LambdaWrapper,但它并不能提升 SQL 性能。
  4. 条件判断参数 condition 为什么重要。
  5. likeinorderBylast 的风险边界。
  6. Wrapper 更新为什么必须防止无条件更新。
  7. 商业列表查询如何设计 Wrapper、索引和分页。
  8. 面试怎么回答 Wrapper 原理、优缺点和线上排查。

Wrapper 解决什么问题

不用 Wrapper 时,简单条件查询也要写 XML:

xml
<select id="selectPage" resultType="User">
    select id, name, status, create_time
    from user
    <where>
        <if test="status != null">
            and status = #{status}
        </if>
        <if test="name != null and name != ''">
            and name like concat('%', #{name}, '%')
        </if>
    </where>
    order by create_time desc
</select>

Wrapper 写法:

java
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(status != null, User::getStatus, status)
       .like(name != null && !name.isBlank(), User::getName, name)
       .orderByDesc(User::getCreateTime);

List<User> users = userMapper.selectList(wrapper);

Wrapper 适合减少这种“简单单表条件”的样板代码。但它并没有消灭 SQL,只是把 SQL 片段生成过程从 XML 换到了 Java 对象里。

Wrapper 总体执行流程

mermaid
flowchart TD
    A["业务代码创建 Wrapper"] --> B["调用 eq / like / in / orderBy"]
    B --> C["Wrapper 记录条件片段和参数"]
    C --> D["MyBatis-Plus 注入通用 SQL 模板"]
    D --> E["把 Wrapper 条件拼进模板"]
    E --> F["形成 MyBatis MappedStatement 和 BoundSql"]
    F --> G["MyBatis ParameterHandler 绑定参数"]
    G --> H["JDBC 执行 SQL"]
    H --> I["数据库返回结果"]

所以排查 Wrapper 问题的核心不是盯着链式调用,而是看最终 SQL、参数、执行计划。

QueryWrapper 和 LambdaQueryWrapper

QueryWrapper

QueryWrapper 使用数据库字段名或字符串字段名。

java
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1)
       .like("name", "Tom")
       .orderByDesc("create_time");

优点是直观;缺点是字段名是字符串,重构 Java 属性时不会自动报错。字段名写错,要到运行时或线上才发现。

LambdaQueryWrapper

LambdaQueryWrapper 使用方法引用。

java
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1)
       .like(User::getName, "Tom")
       .orderByDesc(User::getCreateTime);

MyBatis-Plus 会把 User::getStatus 解析成实体属性 status,再根据实体和表字段映射转换成数据库列名。

mermaid
flowchart TD
    A["User::getStatus"] --> B["解析 Lambda 元信息"]
    B --> C["得到实体属性 status"]
    C --> D["根据 TableInfo 找到列名 status"]
    D --> E["生成 SQL 条件 status = ?"]

推荐优先使用 LambdaWrapper,因为它更适合重构。但要记住:LambdaWrapper 提高的是可维护性,不是数据库性能。最终 SQL 是否快,仍取决于索引、过滤条件、扫描行数和排序方式。

条件判断参数为什么重要

很多 Wrapper 方法都有一个重载,第一个参数是 condition

java
wrapper.eq(status != null, User::getStatus, status);
wrapper.like(keyword != null && !keyword.isBlank(), User::getName, keyword);

它的意思是:只有 condition 为 true 时,才拼接这个 SQL 条件。

如果不用它,代码可能写成:

java
wrapper.eq(User::getStatus, status);

status = null 时,是否生成 status = null 或者产生不符合预期的条件,就会影响查询结果。商业列表接口必须明确:参数为空表示“不筛选”,还是表示“筛选空值”。这两个语义完全不同。

常用条件和 SQL 结果

Wrapper 写法可能生成的 SQL注意点
eq(User::getStatus, 1)status = ?最常用,适合索引过滤
ne(User::getStatus, 0)status <> ?选择性差时可能不走索引
gt(User::getAge, 18)age > ?范围条件后面的复合索引列可能受影响
between(User::getAge, 18, 30)age between ? and ?注意边界包含
like(User::getName, "Tom")name like ?通常是 %Tom%,可能无法有效走普通 B+Tree
likeRight(User::getName, "Tom")name like 'Tom%'前缀匹配更容易利用索引
in(User::getId, ids)id in (?, ?, ?)集合不能太大,空集合要处理
isNull(User::getDeletedTime)deleted_time is null和索引选择性有关
orderByDesc(User::getCreateTime)order by create_time desc排序字段最好和过滤条件组成索引

like 为什么容易慢

java
wrapper.like(User::getName, keyword);

通常生成:

sql
name like '%监护仪%'

这种包含匹配无法很好利用普通 B+Tree 索引,因为数据库不知道字符串前缀是什么,只能扫描更多数据再判断。

mermaid
flowchart TD
    A["name like '%keyword%'"] --> B["无法根据前缀定位索引范围"]
    B --> C["扫描大量索引或数据行"]
    C --> D["逐行判断是否包含 keyword"]
    D --> E["数据越大越慢"]

商业建议:

  1. 小后台、低频、数据量小可以接受。
  2. 大表关键字搜索优先考虑 Elasticsearch。
  3. 如果只需要前缀匹配,用 likeRight
  4. 列表页必须限制分页和最大查询范围。
  5. 关键查询要看 EXPLAIN,不要只看代码是否优雅。

in 集合为什么要控制大小

java
wrapper.in(User::getId, ids);

如果 ids 有 10 个,没问题;如果有 50000 个,SQL 会很长,参数很多,数据库解析、网络传输、优化器估算都会受影响。

空集合也要特别处理:

java
if (ids == null || ids.isEmpty()) {
    return Collections.emptyList();
}
wrapper.in(User::getId, ids);

不要让空集合生成异常 SQL,也不要因为“没有传 ids”变成无条件查询全表。

超大集合的替代方案:

  1. 分批查询。
  2. 写入临时表再 join。
  3. 使用中间表保存任务范围。
  4. 改成异步导出或离线任务。

动态排序必须白名单

前端常传:

json
{
  "sortField": "createTime",
  "sortType": "desc"
}

不能直接把前端字符串拼到 SQL。推荐在后端做字段映射。

java
private static final Map<String, SFunction<User, ?>> SORT_FIELD_MAP = Map.of(
        "createTime", User::getCreateTime,
        "id", User::getId
);

public LambdaQueryWrapper<User> buildWrapper(UserQuery query) {
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(query.getStatus() != null, User::getStatus, query.getStatus());

    SFunction<User, ?> sortColumn = SORT_FIELD_MAP.getOrDefault(
            query.getSortField(), User::getCreateTime);

    boolean asc = "asc".equalsIgnoreCase(query.getSortType());
    wrapper.orderBy(true, asc, sortColumn);
    return wrapper;
}

这样前端只能选择你允许的字段,不能传任意 SQL 片段。

last 为什么危险

MyBatis-Plus 有 last

java
wrapper.last("limit 1");

它会把字符串直接追加到 SQL 末尾。它很方便,也很危险,因为它绕过了参数绑定。如果内容来自用户输入,就可能造成 SQL 注入或 SQL 结构被破坏。

使用原则:

  1. 只允许写固定常量。
  2. 不接收前端输入。
  3. 不在公共方法里随意暴露。
  4. 能用分页插件就不要手动拼分页。

UpdateWrapper:更新必须有条件

危险写法:

java
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
wrapper.set(User::getStatus, 0);
userMapper.update(null, wrapper);

如果没有任何 where 条件,就可能更新全表。

正确写法:

java
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(User::getId, userId)
       .eq(User::getStatus, 1)
       .set(User::getStatus, 0)
       .set(User::getUpdateTime, LocalDateTime.now());

int rows = userMapper.update(null, wrapper);
if (rows == 0) {
    throw new IllegalStateException("数据不存在或状态已变化");
}

这里的 eq(User::getStatus, 1) 是状态机保护,避免把已经处理过的数据重复处理。

mermaid
flowchart TD
    A["准备更新用户状态"] --> B["必须带 id 或业务唯一键"]
    B --> C["追加旧状态条件"]
    C --> D["执行 update"]
    D --> E{"影响行数是否为 1"}
    E -- "是" --> F["更新成功"]
    E -- "否" --> G["数据不存在、重复提交或并发修改"]

商业 Demo:资产台账列表查询

需求:医院资产台账支持按医院、科室、状态、关键字、创建时间分页查询。

表结构和索引

sql
create table asset (
    id bigint primary key auto_increment,
    hospital_id bigint not null,
    dept_id bigint not null,
    asset_code varchar(64) not null,
    asset_name varchar(128) not null,
    status tinyint not null,
    deleted tinyint not null default 0,
    create_time datetime not null,
    update_time datetime not null,
    key idx_hospital_dept_status_time(hospital_id, dept_id, status, create_time),
    key idx_hospital_status_time(hospital_id, status, create_time)
);

为什么索引要这样设计?因为商业列表常见过滤条件是租户或医院必传,科室和状态可选,最后按时间排序。索引要服务真实查询路径,而不是所有字段随便建一个。

Query 对象

java
public class AssetQuery {
    private Long hospitalId;
    private Long deptId;
    private Integer status;
    private String keyword;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private Integer pageNo = 1;
    private Integer pageSize = 20;
}

Wrapper 构造

java
public LambdaQueryWrapper<AssetDO> buildAssetWrapper(AssetQuery query) {
    if (query.getHospitalId() == null) {
        throw new IllegalArgumentException("hospitalId 不能为空");
    }

    LambdaQueryWrapper<AssetDO> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(AssetDO::getHospitalId, query.getHospitalId())
           .eq(query.getDeptId() != null, AssetDO::getDeptId, query.getDeptId())
           .eq(query.getStatus() != null, AssetDO::getStatus, query.getStatus())
           .ge(query.getStartTime() != null, AssetDO::getCreateTime, query.getStartTime())
           .lt(query.getEndTime() != null, AssetDO::getCreateTime, query.getEndTime())
           .eq(AssetDO::getDeleted, 0)
           .orderByDesc(AssetDO::getCreateTime);

    if (query.getKeyword() != null && !query.getKeyword().isBlank()) {
        wrapper.and(w -> w.like(AssetDO::getAssetName, query.getKeyword())
                         .or()
                         .like(AssetDO::getAssetCode, query.getKeyword()));
    }
    return wrapper;
}

注意 keywordand 包裹。如果不包裹,or 可能破坏前面的医院、状态、删除条件,造成越权或查询结果扩大。

and / or 分组为什么重要

错误写法:

java
wrapper.eq(AssetDO::getHospitalId, hospitalId)
       .eq(AssetDO::getDeleted, 0)
       .like(AssetDO::getAssetName, keyword)
       .or()
       .like(AssetDO::getAssetCode, keyword);

可能生成:

sql
where hospital_id = ?
  and deleted = 0
  and asset_name like ?
   or asset_code like ?

SQL 中 and 优先级高于 or,实际含义可能变成:

sql
(hospital_id = ? and deleted = 0 and asset_name like ?)
or asset_code like ?

这可能查出其他医院的数据,属于严重数据权限问题。

正确写法:

java
wrapper.eq(AssetDO::getHospitalId, hospitalId)
       .eq(AssetDO::getDeleted, 0)
       .and(w -> w.like(AssetDO::getAssetName, keyword)
                  .or()
                  .like(AssetDO::getAssetCode, keyword));

生成语义:

sql
where hospital_id = ?
  and deleted = 0
  and (asset_name like ? or asset_code like ?)

Wrapper 和分页插件

java
Page<AssetDO> page = new Page<>(query.getPageNo(), query.getPageSize());
Page<AssetDO> result = assetMapper.selectPage(page, buildAssetWrapper(query));

分页插件通常会执行 count SQL 和分页 SQL:

sql
select count(*) from asset where ...
sql
select id, hospital_id, dept_id, asset_code, asset_name, status, create_time
from asset
where ...
order by create_time desc
limit ?, ?

如果接口只需要“是否还有下一页”,不需要总数,应该评估是否可以不用精确 count,或者改成游标分页。Wrapper 不能解决深分页和 count 慢的问题。

线上排查流程

mermaid
flowchart TD
    A["Wrapper 查询结果或性能异常"] --> B["打开 SQL 日志拿最终 SQL 和参数"]
    B --> C{"结果数量不对吗"}
    C -- "是" --> D["检查 condition、and/or 分组、空集合、逻辑删除、租户条件"]
    C -- "否" --> E{"查询慢吗"}
    E -- "是" --> F["拿最终 SQL 做 EXPLAIN"]
    F --> G["检查索引、扫描行数、排序、like、in、深分页"]
    E -- "否" --> H["检查字段映射、插件改写和返回字段"]

排查重点:

  1. 打印最终 SQL,而不是只看 Wrapper 代码。
  2. 检查 or 是否破坏权限条件。
  3. 检查 condition 是否导致条件没拼上。
  4. 检查 in 集合为空或过大。
  5. 检查 like '%xx%' 是否扫太多行。
  6. 检查分页 count 和深分页。
  7. 检查逻辑删除、多租户、数据权限插件是否追加条件。

常见坑

问题后果建议
复杂 SQL 硬写 Wrapper最终 SQL 难读难审查复杂 join 和统计回到 XML
忘记 condition空参数语义混乱参数为空含义要明确
or 不分组查询范围扩大,甚至越权and(w -> ...) 包裹
last 拼用户输入SQL 注入只允许固定常量
in 集合过大SQL 长、优化器估算差分批或临时表
无条件 update全表更新事故Service 校验 + 阻断插件
只看 API 不看 SQL慢查询无法定位打印 SQL + EXPLAIN
Wrapper 里混业务判断数据访问层混乱复杂业务放 Service

面试标准回答

Wrapper 原理是什么

text
Wrapper 是 MyBatis-Plus 的条件构造器,会把 eq、like、in、orderBy 等链式调用记录成 SQL 片段和参数,最后拼接到 MyBatis-Plus 注入的通用 SQL 模板里,形成 MyBatis 能执行的 BoundSql。它没有改变 MyBatis 和 JDBC 的执行本质,最终仍然要看 SQL、参数和执行计划。

QueryWrapper 和 LambdaQueryWrapper 区别

text
QueryWrapper 使用字符串字段名,写法直观但重构不安全;LambdaQueryWrapper 使用方法引用,MyBatis-Plus 会解析实体属性并转换成数据库列名,重构更安全。LambdaWrapper 提升的是可维护性,不代表 SQL 更快。

Wrapper 有什么风险

text
Wrapper 容易让 SQL 变得不直观。常见风险包括复杂条件难读、or 分组错误导致越权、like 模糊查询扫表、in 集合过大、last 拼接 SQL 注入、无条件 update/delete 造成全表事故。生产中要打印最终 SQL、做 EXPLAIN,并对排序字段、更新条件和权限条件做严格控制。

关联知识点

  1. MyBatis-Plus 核心全过程原理
  2. MyBatis 核心全过程原理
  3. MyBatis 动态 SQL
  4. MyBatis 拦截器
  5. MySQL EXPLAIN 执行计划
  6. MySQL 大表覆盖索引优化
  7. ORM 面试题

本章小结

Wrapper 的正确用法是:简单单表查询用它提高效率,复杂 SQL 不硬拼;动态条件用 condition 明确语义;排序和 last 不能接收用户原始输入;更新必须有明确条件;所有重要接口都要看最终 SQL 和执行计划。会写 Wrapper 只是入门,能判断它什么时候不该用,才是真正掌握。