部分效果

在这里插入图片描述

一、引言:为什么搜索和分页是核心需求

在二手闲置交易平台中,用户最常用的功能就是搜索商品浏览商品列表。一个好的搜索体验能让用户快速找到心仪的商品,而分页则是处理大量数据的必备手段。

作为商品模块的负责人,我在开发中遇到了以下挑战:

  • 如何支持多条件组合搜索(分类、关键词、用户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)BETWEENcolumn 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 对象包含

字段类型说明
recordsList<T>当前页的数据列表
totallong总记录数
sizeint每页条数
currentint当前页码
pageslong总页数
hasNextboolean是否有下一页
hasPreviousboolean是否有上一页

4.5 分页查询的 SQL 原理

MyBatis-Plus 分页插件会自动执行两条 SQL:

  1. 查询总记录数
SELECT COUNT(*) FROM shangpin WHERE status = 1 AND category = '数码产品'
  1. 查询当前页数据
SELECT * FROM shangpin 
WHERE status = 1 AND category = '数码产品' 
ORDER BY create_time DESC 
LIMIT 0, 10

注意LIMIT offset, sizeoffset = (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 条。

优化方案

  1. 使用游标分页:记录上一页最后一条数据的 ID,查询时使用 WHERE id > lastId LIMIT size
  2. 限制最大页码:禁止用户跳转到太大的页码;
  3. 使用缓存:对热门查询结果进行缓存。

7.4 模糊查询优化

问题LIKE '%keyword%' 无法使用索引。

原因:索引是按前缀排序的,%keyword% 无法确定前缀。

优化方案

  1. 使用全文索引:MySQL 支持全文索引,使用 MATCH ... AGAINST 查询;
  2. 使用 Elasticsearch:对于复杂的搜索场景,引入专门的搜索引擎;
  3. 限制关键词长度:太短的关键词不进行搜索。

八、前端分页组件实现

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。

解决方案

  1. 检查字段名是否与数据库一致;
  2. 检查条件值是否为 null;
  3. 使用 isNotNull() 方法检查。

9.3 问题3:分页查询性能差

现象:翻到后面的页码时查询很慢。

原因LIMIT offset, size 在 offset 很大时需要扫描大量数据。

解决方案

  1. 限制最大页码;
  2. 使用游标分页;
  3. 添加合适的索引。

9.4 问题4:模糊查询结果不准确

现象:搜索关键词后返回的结果不符合预期。

原因LIKE '%keyword%' 匹配范围太广。

解决方案

  1. 使用全文索引;
  2. 限制关键词长度;
  3. 增加搜索字段(如描述字段)。

十、代码优化建议

10.1 使用 LambdaQueryWrapper

LambdaQueryWrapperQueryWrapper 的 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 和分页插件实现这些功能。

核心收获

  1. QueryWrapper 的强大:通过链式调用构建动态查询条件,无需手写 SQL;
  2. 分页插件的便捷:一行配置即可实现分页查询,自动处理 LIMIT
  3. 条件判空的重要性:避免空条件导致的意外查询结果;
  4. 性能优化的必要性:索引、缓存、避免 SELECT * 等优化手段。

作为组员,我深刻体会到:好的查询设计不仅要功能正确,还要考虑性能和用户体验

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐