多条件搜索与分页查询优化——MyBatis-Plus QueryWrapper 深度解析
部分效果

一、引言:为什么搜索和分页是核心需求
在二手闲置交易平台中,用户最常用的功能就是搜索商品和浏览商品列表。一个好的搜索体验能让用户快速找到心仪的商品,而分页则是处理大量数据的必备手段。
作为商品模块的负责人,我在开发中遇到了以下挑战:
- 如何支持多条件组合搜索(分类、关键词、用户ID)?
- 如何实现高效的分页查询?
- 如何处理动态条件(某些条件可能为空)?
- 如何保证查询性能?
本文将详细讲解如何使用 MyBatis-Plus 的 QueryWrapper 实现灵活的多条件搜索,并结合分页插件实现高效的分页查询。
二、QueryWrapper:MyBatis-Plus 的查询利器
2.1 什么是 QueryWrapper
QueryWrapper 是 MyBatis-Plus 提供的一个查询条件构造器,它允许我们通过链式调用的方式构建 SQL 查询条件,而不需要手写 SQL。
核心优势:
- 类型安全:编译期检查字段名,避免拼写错误;
- 链式调用:代码简洁,可读性强;
- 动态条件:支持条件的动态添加和移除;
- 防 SQL 注入:自动使用预编译语句。
2.2 QueryWrapper 基本用法
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1) // WHERE status = 1
.like("title", "iPhone") // AND title LIKE '%iPhone%'
.eq("category", "数码产品") // AND category = '数码产品'
.orderByDesc("create_time"); // ORDER BY create_time DESC
对应的 SQL:
SELECT * FROM shangpin
WHERE status = 1
AND title LIKE '%iPhone%'
AND category = '数码产品'
ORDER BY create_time DESC
2.3 常用条件方法
| 方法 | 功能 | 对应 SQL |
|---|---|---|
eq(column, value) | 等于 | column = value |
ne(column, value) | 不等于 | column != value |
gt(column, value) | 大于 | column > value |
ge(column, value) | 大于等于 | column >= value |
lt(column, value) | 小于 | column < value |
le(column, value) | 小于等于 | column <= value |
like(column, value) | 模糊查询 | column LIKE '%value%' |
likeLeft(column, value) | 左模糊 | column LIKE '%value' |
likeRight(column, value) | 右模糊 | column LIKE 'value%' |
isNull(column) | 为空 | column IS NULL |
isNotNull(column) | 不为空 | column IS NOT NULL |
in(column, values) | IN 查询 | column IN (value1, value2, ...) |
between(column, val1, val2) | BETWEEN | column BETWEEN val1 AND val2 |
2.4 排序方法
| 方法 | 功能 | 对应 SQL |
|---|---|---|
orderByAsc(column) | 升序 | ORDER BY column ASC |
orderByDesc(column) | 降序 | ORDER BY column DESC |
orderBy(true, true, column) | 排序 | ORDER BY column ASC/DESC |
三、多条件搜索实现:商品列表接口
3.1 接口设计
@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String category,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Integer user_id) {
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1);
if (user_id != null) {
wrapper.eq("user_id", user_id);
}
if (category != null && !category.isEmpty()) {
wrapper.eq("category", category);
}
if (keyword != null && !keyword.isEmpty()) {
wrapper.like("title", keyword);
}
wrapper.orderByDesc("create_time");
IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
return Result.success(iPage);
}
3.2 动态条件构建技巧
技巧1:条件判空
if (keyword != null && !keyword.isEmpty()) {
wrapper.like("title", keyword);
}
为什么要判空?
- 如果
keyword为空,like("title", "")会生成title LIKE '%%',匹配所有记录; - 这会导致搜索结果不符合预期;
- 判空后,只有当用户输入关键词时才添加搜索条件。
技巧2:默认条件
wrapper.eq("status", 1); // 默认只查在售商品
设计意图:
- 商品列表默认只显示在售商品;
- 已下架商品不应该出现在列表中;
- 这个条件始终生效,不需要判空。
技巧3:多条件组合
// 用户可以同时选择分类和输入关键词
if (category != null && !category.isEmpty()) {
wrapper.eq("category", category);
}
if (keyword != null && !keyword.isEmpty()) {
wrapper.like("title", keyword);
}
效果:
- 只选分类:按分类筛选;
- 只输入关键词:按关键词搜索;
- 两者都选:分类 + 关键词的交集。
3.3 接口测试
3.3.1 查询所有在售商品
GET /goods/list?page=1&size=10
返回:
{
"code": 200,
"msg": "操作成功",
"data": {
"records": [...],
"total": 100,
"size": 10,
"current": 1,
"pages": 10
}
}
3.3.2 按分类筛选
GET /goods/list?page=1&size=10&category=数码产品
3.3.3 按关键词搜索
GET /goods/list?page=1&size=10&keyword=iPhone
3.3.4 组合条件
GET /goods/list?page=1&size=10&category=数码产品&keyword=iPhone
3.3.5 查询用户发布的商品
GET /goods/list?page=1&size=10&user_id=1
四、分页查询:MyBatis-Plus 分页插件
4.1 为什么需要分页
当商品数量很多时(比如上千件),一次性返回所有数据会:
- 占用大量内存:服务器需要加载所有数据到内存;
- 传输速度慢:大量数据传输耗时;
- 前端渲染慢:浏览器渲染大量 DOM 元素耗时;
- 用户体验差:用户需要滚动很长时间才能找到目标商品。
分页查询的解决方案:
- 每次只返回一页数据(比如 10 条);
- 用户通过翻页浏览更多数据;
- 服务器只查询当前页的数据,减轻压力。
4.2 分页插件配置
在 MybatisPlusConfig.java 中注册分页插件:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
关键配置:
MybatisPlusInterceptor:MyBatis-Plus 的拦截器链;PaginationInnerInterceptor(DbType.MYSQL):MySQL 分页拦截器;- 拦截器会自动在 SQL 末尾追加
LIMIT ?, ?。
4.3 分页查询实现
@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String category,
@RequestParam(required = false) String keyword) {
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1);
if (category != null && !category.isEmpty()) {
wrapper.eq("category", category);
}
if (keyword != null && !keyword.isEmpty()) {
wrapper.like("title", keyword);
}
wrapper.orderByDesc("create_time");
// 分页查询
IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
return Result.success(iPage);
}
4.4 Page 对象详解
new Page<>(page, size)
参数说明:
page:页码(从 1 开始);size:每页条数。
返回的 IPage 对象包含:
| 字段 | 类型 | 说明 |
|---|---|---|
records | List<T> | 当前页的数据列表 |
total | long | 总记录数 |
size | int | 每页条数 |
current | int | 当前页码 |
pages | long | 总页数 |
hasNext | boolean | 是否有下一页 |
hasPrevious | boolean | 是否有上一页 |
4.5 分页查询的 SQL 原理
MyBatis-Plus 分页插件会自动执行两条 SQL:
- 查询总记录数:
SELECT COUNT(*) FROM shangpin WHERE status = 1 AND category = '数码产品'
- 查询当前页数据:
SELECT * FROM shangpin
WHERE status = 1 AND category = '数码产品'
ORDER BY create_time DESC
LIMIT 0, 10
注意:LIMIT offset, size 中 offset = (page - 1) * size。
五、收藏模块的多条件查询
5.1 收藏列表接口
@GetMapping("/list")
public Result list(HttpSession session, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Integer user_id) {
Integer userId = user_id;
if (userId == null) {
User user = (User) session.getAttribute("user");
if (user == null) {
return Result.unauthorized();
}
userId = user.getId();
}
QueryWrapper<Favorite> wrapper = new QueryWrapper<>();
wrapper.eq("user_id", userId);
wrapper.orderByDesc("create_time");
IPage<Favorite> iPage = favoriteService.page(new Page<>(page, size), wrapper);
return Result.success(iPage);
}
5.2 双重用户ID获取机制
Integer userId = user_id;
if (userId == null) {
User user = (User) session.getAttribute("user");
if (user == null) {
return Result.unauthorized();
}
userId = user.getId();
}
设计意图:
- 优先使用 URL 参数
user_id:个人中心统计接口需要传入用户ID; - 回退到 Session:正常操作时从 Session 获取当前登录用户;
- 未登录返回 401:没有用户信息时返回未登录状态。
5.3 检查是否收藏接口
@GetMapping("/check/{goodsId}")
public Result check(HttpSession session, @PathVariable Integer goodsId) {
User user = (User) session.getAttribute("user");
if (user == null) {
return Result.success(false);
}
QueryWrapper<Favorite> wrapper = new QueryWrapper<>();
wrapper.eq("user_id", user.getId()).eq("goods_id", goodsId);
return Result.success(favoriteService.getOne(wrapper) != null);
}
QueryWrapper 链式调用:
wrapper.eq("user_id", user.getId()).eq("goods_id", goodsId);
对应的 SQL:
SELECT * FROM shoucang WHERE user_id = ? AND goods_id = ? LIMIT 1
六、高级查询技巧
6.1 批量查询(IN 查询)
// 查询多个分类的商品
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.in("category", Arrays.asList("数码产品", "图书教材"));
对应的 SQL:
SELECT * FROM shangpin WHERE category IN ('数码产品', '图书教材')
6.2 范围查询(BETWEEN)
// 查询价格在 100-500 之间的商品
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.between("sale_price", 100, 500);
对应的 SQL:
SELECT * FROM shangpin WHERE sale_price BETWEEN 100 AND 500
6.3 排序优先级
// 先按价格降序,再按创建时间降序
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.orderByDesc("sale_price").orderByDesc("create_time");
对应的 SQL:
SELECT * FROM shangpin ORDER BY sale_price DESC, create_time DESC
6.4 分组查询
// 按分类统计商品数量
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.select("category", "COUNT(*) as count");
wrapper.groupBy("category");
对应的 SQL:
SELECT category, COUNT(*) as count FROM shangpin GROUP BY category
6.5 自定义 SQL 片段
// 使用 apply 方法添加自定义 SQL
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.apply("DATE(create_time) = CURDATE()");
对应的 SQL:
SELECT * FROM shangpin WHERE DATE(create_time) = CURDATE()
七、性能优化策略
7.1 索引优化
为常用查询字段添加索引:
-- 用户ID索引(查询用户发布的商品)
INDEX `idx_user_id`(`user_id`),
-- 分类索引(按分类筛选)
INDEX `idx_category`(`category`),
-- 状态索引(筛选在售商品)
INDEX `idx_status`(`status`)
索引的作用:
- 没有索引时,数据库需要全表扫描;
- 有索引时,数据库可以快速定位到目标数据;
- 索引可以将查询时间从秒级降到毫秒级。
7.2 避免 SELECT *
当前代码使用的是 SELECT *,在生产环境中建议只查询需要的字段:
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.select("id", "title", "image", "sale_price", "category", "create_time");
好处:
- 减少数据传输量;
- 减少内存占用;
- 提高查询速度。
7.3 分页查询优化
问题:MySQL 的 LIMIT offset, size 在 offset 很大时性能较差。
原因:数据库需要扫描 offset + size 条记录,然后丢弃前 offset 条。
优化方案:
- 使用游标分页:记录上一页最后一条数据的 ID,查询时使用
WHERE id > lastId LIMIT size; - 限制最大页码:禁止用户跳转到太大的页码;
- 使用缓存:对热门查询结果进行缓存。
7.4 模糊查询优化
问题:LIKE '%keyword%' 无法使用索引。
原因:索引是按前缀排序的,%keyword% 无法确定前缀。
优化方案:
- 使用全文索引:MySQL 支持全文索引,使用
MATCH ... AGAINST查询; - 使用 Elasticsearch:对于复杂的搜索场景,引入专门的搜索引擎;
- 限制关键词长度:太短的关键词不进行搜索。
八、前端分页组件实现
8.1 分页数据渲染
function renderPagination(pageInfo) {
var total = pageInfo.total;
var current = pageInfo.current;
var pages = pageInfo.pages;
var size = pageInfo.size;
var html = '';
// 上一页
if (current > 1) {
html += '<button onclick="loadGoodsList(' + (current - 1) + ')" class="btn btn-outline-primary">上一页</button>';
} else {
html += '<button disabled class="btn btn-outline-secondary">上一页</button>';
}
// 页码
for (var i = 1; i <= pages; i++) {
if (i === current) {
html += '<button class="btn btn-primary">' + i + '</button>';
} else {
html += '<button onclick="loadGoodsList(' + i + ')" class="btn btn-outline-primary">' + i + '</button>';
}
}
// 下一页
if (current < pages) {
html += '<button onclick="loadGoodsList(' + (current + 1) + ')" class="btn btn-outline-primary">下一页</button>';
} else {
html += '<button disabled class="btn btn-outline-secondary">下一页</button>';
}
// 统计信息
html += '<span class="ml-3">共 ' + total + ' 条记录,第 ' + current + '/' + pages + ' 页</span>';
$('#pagination').html(html);
}
8.2 分类筛选
$('#categoryFilter').change(function() {
loadGoodsList(1); // 切换分类时回到第一页
});
8.3 关键词搜索
$('#searchBtn').click(function() {
loadGoodsList(1); // 搜索时回到第一页
});
$('#keyword').keypress(function(e) {
if (e.which === 13) { // 回车键
loadGoodsList(1);
}
});
九、常见问题与解决方案
9.1 问题1:分页插件不生效
现象:调用 page() 方法后返回全部数据,没有分页。
原因:分页插件没有注册或注册方式错误。
解决方案:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
9.2 问题2:QueryWrapper 条件不生效
现象:添加了条件但查询结果没有过滤。
原因:字段名写错或条件值为 null。
解决方案:
- 检查字段名是否与数据库一致;
- 检查条件值是否为 null;
- 使用
isNotNull()方法检查。
9.3 问题3:分页查询性能差
现象:翻到后面的页码时查询很慢。
原因:LIMIT offset, size 在 offset 很大时需要扫描大量数据。
解决方案:
- 限制最大页码;
- 使用游标分页;
- 添加合适的索引。
9.4 问题4:模糊查询结果不准确
现象:搜索关键词后返回的结果不符合预期。
原因:LIKE '%keyword%' 匹配范围太广。
解决方案:
- 使用全文索引;
- 限制关键词长度;
- 增加搜索字段(如描述字段)。
十、代码优化建议
10.1 使用 LambdaQueryWrapper
LambdaQueryWrapper 是 QueryWrapper 的 Lambda 版本,可以避免手写字符串字段名:
LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Goods::getStatus, 1)
.like(Goods::getTitle, keyword)
.eq(Goods::getCategory, category);
优势:
- 编译期检查字段名,避免拼写错误;
- 字段名重构时自动更新;
- 代码更简洁。
10.2 抽取查询条件构建方法
private QueryWrapper<Goods> buildQueryWrapper(String category, String keyword, Integer userId) {
QueryWrapper<Goods> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1);
if (userId != null) {
wrapper.eq("user_id", userId);
}
if (category != null && !category.isEmpty()) {
wrapper.eq("category", category);
}
if (keyword != null && !keyword.isEmpty()) {
wrapper.like("title", keyword);
}
wrapper.orderByDesc("create_time");
return wrapper;
}
使用:
@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String category,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Integer user_id) {
QueryWrapper<Goods> wrapper = buildQueryWrapper(category, keyword, user_id);
IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
return Result.success(iPage);
}
10.3 添加查询缓存
对于热门查询,可以添加 Redis 缓存:
@GetMapping("/list")
public Result list(...) {
String cacheKey = "goods:list:" + page + ":" + size + ":" + category + ":" + keyword;
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return Result.success(cached);
}
QueryWrapper<Goods> wrapper = buildQueryWrapper(category, keyword, user_id);
IPage<Goods> iPage = goodsService.page(new Page<>(page, size), wrapper);
redisTemplate.opsForValue().set(cacheKey, iPage, 5, TimeUnit.MINUTES);
return Result.success(iPage);
}
十一、总结
多条件搜索和分页查询是二手交易平台的核心功能,本文详细讲解了如何使用 MyBatis-Plus 的 QueryWrapper 和分页插件实现这些功能。
核心收获:
- QueryWrapper 的强大:通过链式调用构建动态查询条件,无需手写 SQL;
- 分页插件的便捷:一行配置即可实现分页查询,自动处理
LIMIT; - 条件判空的重要性:避免空条件导致的意外查询结果;
- 性能优化的必要性:索引、缓存、避免
SELECT *等优化手段。
作为组员,我深刻体会到:好的查询设计不仅要功能正确,还要考虑性能和用户体验。
更多推荐




所有评论(0)