MyBatis-Plus Wrapper
Wrapper 是 MyBatis-Plus 的条件构造器。它让开发者用 Java API 组织查询、更新和排序条件,最后由 MyBatis-Plus 生成 SQL 片段,再交给 MyBatis 执行。
它的价值是提高简单单表 CRUD 的开发效率;它的风险是让 SQL 变得“看不见”。如果只会链式调用,不会看最终 SQL、不知道索引怎么配、不知道哪些条件会造成全表扫描,就很容易把简单 API 写成线上慢查询。
学习目标
学完本章要能说清楚:
QueryWrapper、LambdaQueryWrapper、UpdateWrapper、LambdaUpdateWrapper分别适合什么。- Wrapper 链式调用如何变成 SQL 条件和参数。
- 为什么推荐 LambdaWrapper,但它并不能提升 SQL 性能。
- 条件判断参数
condition为什么重要。 like、in、orderBy、last的风险边界。- Wrapper 更新为什么必须防止无条件更新。
- 商业列表查询如何设计 Wrapper、索引和分页。
- 面试怎么回答 Wrapper 原理、优缺点和线上排查。
Wrapper 解决什么问题
不用 Wrapper 时,简单条件查询也要写 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 写法:
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 总体执行流程
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 使用数据库字段名或字符串字段名。
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1)
.like("name", "Tom")
.orderByDesc("create_time");优点是直观;缺点是字段名是字符串,重构 Java 属性时不会自动报错。字段名写错,要到运行时或线上才发现。
LambdaQueryWrapper
LambdaQueryWrapper 使用方法引用。
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getStatus, 1)
.like(User::getName, "Tom")
.orderByDesc(User::getCreateTime);MyBatis-Plus 会把 User::getStatus 解析成实体属性 status,再根据实体和表字段映射转换成数据库列名。
flowchart TD
A["User::getStatus"] --> B["解析 Lambda 元信息"]
B --> C["得到实体属性 status"]
C --> D["根据 TableInfo 找到列名 status"]
D --> E["生成 SQL 条件 status = ?"]推荐优先使用 LambdaWrapper,因为它更适合重构。但要记住:LambdaWrapper 提高的是可维护性,不是数据库性能。最终 SQL 是否快,仍取决于索引、过滤条件、扫描行数和排序方式。
条件判断参数为什么重要
很多 Wrapper 方法都有一个重载,第一个参数是 condition。
wrapper.eq(status != null, User::getStatus, status);
wrapper.like(keyword != null && !keyword.isBlank(), User::getName, keyword);它的意思是:只有 condition 为 true 时,才拼接这个 SQL 条件。
如果不用它,代码可能写成:
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 为什么容易慢
wrapper.like(User::getName, keyword);通常生成:
name like '%监护仪%'这种包含匹配无法很好利用普通 B+Tree 索引,因为数据库不知道字符串前缀是什么,只能扫描更多数据再判断。
flowchart TD
A["name like '%keyword%'"] --> B["无法根据前缀定位索引范围"]
B --> C["扫描大量索引或数据行"]
C --> D["逐行判断是否包含 keyword"]
D --> E["数据越大越慢"]商业建议:
- 小后台、低频、数据量小可以接受。
- 大表关键字搜索优先考虑 Elasticsearch。
- 如果只需要前缀匹配,用
likeRight。 - 列表页必须限制分页和最大查询范围。
- 关键查询要看
EXPLAIN,不要只看代码是否优雅。
in 集合为什么要控制大小
wrapper.in(User::getId, ids);如果 ids 有 10 个,没问题;如果有 50000 个,SQL 会很长,参数很多,数据库解析、网络传输、优化器估算都会受影响。
空集合也要特别处理:
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
wrapper.in(User::getId, ids);不要让空集合生成异常 SQL,也不要因为“没有传 ids”变成无条件查询全表。
超大集合的替代方案:
- 分批查询。
- 写入临时表再 join。
- 使用中间表保存任务范围。
- 改成异步导出或离线任务。
动态排序必须白名单
前端常传:
{
"sortField": "createTime",
"sortType": "desc"
}不能直接把前端字符串拼到 SQL。推荐在后端做字段映射。
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:
wrapper.last("limit 1");它会把字符串直接追加到 SQL 末尾。它很方便,也很危险,因为它绕过了参数绑定。如果内容来自用户输入,就可能造成 SQL 注入或 SQL 结构被破坏。
使用原则:
- 只允许写固定常量。
- 不接收前端输入。
- 不在公共方法里随意暴露。
- 能用分页插件就不要手动拼分页。
UpdateWrapper:更新必须有条件
危险写法:
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
wrapper.set(User::getStatus, 0);
userMapper.update(null, wrapper);如果没有任何 where 条件,就可能更新全表。
正确写法:
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) 是状态机保护,避免把已经处理过的数据重复处理。
flowchart TD
A["准备更新用户状态"] --> B["必须带 id 或业务唯一键"]
B --> C["追加旧状态条件"]
C --> D["执行 update"]
D --> E{"影响行数是否为 1"}
E -- "是" --> F["更新成功"]
E -- "否" --> G["数据不存在、重复提交或并发修改"]商业 Demo:资产台账列表查询
需求:医院资产台账支持按医院、科室、状态、关键字、创建时间分页查询。
表结构和索引
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 对象
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 构造
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;
}注意 keyword 的 and 包裹。如果不包裹,or 可能破坏前面的医院、状态、删除条件,造成越权或查询结果扩大。
and / or 分组为什么重要
错误写法:
wrapper.eq(AssetDO::getHospitalId, hospitalId)
.eq(AssetDO::getDeleted, 0)
.like(AssetDO::getAssetName, keyword)
.or()
.like(AssetDO::getAssetCode, keyword);可能生成:
where hospital_id = ?
and deleted = 0
and asset_name like ?
or asset_code like ?SQL 中 and 优先级高于 or,实际含义可能变成:
(hospital_id = ? and deleted = 0 and asset_name like ?)
or asset_code like ?这可能查出其他医院的数据,属于严重数据权限问题。
正确写法:
wrapper.eq(AssetDO::getHospitalId, hospitalId)
.eq(AssetDO::getDeleted, 0)
.and(w -> w.like(AssetDO::getAssetName, keyword)
.or()
.like(AssetDO::getAssetCode, keyword));生成语义:
where hospital_id = ?
and deleted = 0
and (asset_name like ? or asset_code like ?)Wrapper 和分页插件
Page<AssetDO> page = new Page<>(query.getPageNo(), query.getPageSize());
Page<AssetDO> result = assetMapper.selectPage(page, buildAssetWrapper(query));分页插件通常会执行 count SQL 和分页 SQL:
select count(*) from asset where ...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 慢的问题。
线上排查流程
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["检查字段映射、插件改写和返回字段"]排查重点:
- 打印最终 SQL,而不是只看 Wrapper 代码。
- 检查
or是否破坏权限条件。 - 检查
condition是否导致条件没拼上。 - 检查
in集合为空或过大。 - 检查
like '%xx%'是否扫太多行。 - 检查分页 count 和深分页。
- 检查逻辑删除、多租户、数据权限插件是否追加条件。
常见坑
| 问题 | 后果 | 建议 |
|---|---|---|
| 复杂 SQL 硬写 Wrapper | 最终 SQL 难读难审查 | 复杂 join 和统计回到 XML |
| 忘记 condition | 空参数语义混乱 | 参数为空含义要明确 |
or 不分组 | 查询范围扩大,甚至越权 | 用 and(w -> ...) 包裹 |
last 拼用户输入 | SQL 注入 | 只允许固定常量 |
in 集合过大 | SQL 长、优化器估算差 | 分批或临时表 |
| 无条件 update | 全表更新事故 | Service 校验 + 阻断插件 |
| 只看 API 不看 SQL | 慢查询无法定位 | 打印 SQL + EXPLAIN |
| Wrapper 里混业务判断 | 数据访问层混乱 | 复杂业务放 Service |
面试标准回答
Wrapper 原理是什么
Wrapper 是 MyBatis-Plus 的条件构造器,会把 eq、like、in、orderBy 等链式调用记录成 SQL 片段和参数,最后拼接到 MyBatis-Plus 注入的通用 SQL 模板里,形成 MyBatis 能执行的 BoundSql。它没有改变 MyBatis 和 JDBC 的执行本质,最终仍然要看 SQL、参数和执行计划。QueryWrapper 和 LambdaQueryWrapper 区别
QueryWrapper 使用字符串字段名,写法直观但重构不安全;LambdaQueryWrapper 使用方法引用,MyBatis-Plus 会解析实体属性并转换成数据库列名,重构更安全。LambdaWrapper 提升的是可维护性,不代表 SQL 更快。Wrapper 有什么风险
Wrapper 容易让 SQL 变得不直观。常见风险包括复杂条件难读、or 分组错误导致越权、like 模糊查询扫表、in 集合过大、last 拼接 SQL 注入、无条件 update/delete 造成全表事故。生产中要打印最终 SQL、做 EXPLAIN,并对排序字段、更新条件和权限条件做严格控制。关联知识点
本章小结
Wrapper 的正确用法是:简单单表查询用它提高效率,复杂 SQL 不硬拼;动态条件用 condition 明确语义;排序和 last 不能接收用户原始输入;更新必须有明确条件;所有重要接口都要看最终 SQL 和执行计划。会写 Wrapper 只是入门,能判断它什么时候不该用,才是真正掌握。
