菜品分页查询
做后台管理系统的时候,分页查询大概是最绕不开的需求之一了。前段时间接了个餐饮管理系统的活儿,里面菜品管理模块需要做一个列表页,要求支持按菜品名称、分类、售卖状态多条件筛选,还要分页展示。刚开始觉得这功能稀松平常,真正动手才发现分页查询从后端到前端有挺多细节值得抠。这篇文章就把“菜品分页查询”这个项目从需求拆解到代码落地完整复盘一遍,重点讲讲分页的原理、MyBatis Plus分页插件的使用、多条件组合查询的坑,以及我实测下来的一些排查经验。不管你是在做外卖系统、餐厅点餐后台,还是类似的管理类系统,这套东西都能直接参考复现。
1. 需求拆解:一个分页查询需求背后的真实业务考量
1.1 为什么不能一次性把菜品全查出来
很多刚入行的朋友可能会有个疑问:菜品表里不就几百行数据吗,一次性查出来,前端自己翻页不就行了?比如用JavaScript把全部数据切一下,一样能实现翻页效果。这种思路在小数据量的时候确实能用,但一旦数据量上来,或者业务规则一复杂,问题就全暴露了。
先说最直接的数据量问题。餐饮系统的菜品表看着不大,单店几百条,连锁的话几千条,听着不多。但真实业务里往往不止菜品主表,还有套餐子项、口味的规格选项、菜品图片、分类关联、库存信息,一做联表查询,数据量瞬间膨胀好几倍。我接过一个连锁餐饮项目,光菜品加规格的关联数据就有好几万条。一次性全量返回,接口响应时间从几十毫秒直接飙到一秒多,前端渲染几百个DOM节点也会明显卡顿。
再一个更关键的问题是实时性。菜品价格、上下架状态、库存余量,这些数据是动态变化的。如果前端一次性拉取全量数据然后做本地分页,用户停留在页面上翻到后面几页时,看到的可能是几分钟甚至几小时前的旧数据了。比如后厨把某道菜估清了,前端列表里还显示“可售”,用户下完单才发现没货,这就是事故。而服务端分页每次翻页都重新查一次数据库,拿到的始终是最新状态。
还有一点容易被忽略——大数据量下的传输损耗。全量接口返回几万行JSON,按每行200字节算,就是好几MB,用户用手机流量看个菜单列表,光这开销就不小。分页查询每次只返回当前页的10条或20条,网络传输量能节省99%以上。所以真正生产环境里的分页,基本都是服务端分页,数据库层面只查当前页需要的那几条。
1.2 分页查询的需求边界与设计取舍
这个项目里的分页查询需求,我整理下来核心是这几点。第一,列表要支持任意翻页,页码从1到N都要能直接跳转。第二,要支持多条件组合筛选:菜品名称支持模糊匹配、菜品分类多选、售卖状态按“起售/停售/估清”筛选。第三,查询结果需要按某个字段排序显示。第四,分页组件要回显总记录数,方便用户知道总共有多少条。
这些需求听着常规,真做起来有几个设计取舍要提前想清楚。
排序字段选哪个就是个问题。菜品列表最常用的是按“最近修改时间”倒序,这样后厨改了价格或者更新了图片,列表里能立刻看到变化。但单纯按修改时间排序有个隐患——如果大量数据修改时间相同,排序不稳定会导致翻页时数据重复或漏掉,这一点后面第四部分详细说。另一个选择是排序字段用主键id倒序,这个稳定性和查询性能都更好,但展示效果不如修改时间贴近业务直觉。最终我采用的是“修改时间倒序 + id倒序”的复合排序,既满足业务展示需求,又保证排序稳定。
还有一个取舍是搜索的粒度。菜品名称的模糊匹配,到底是“包含”还是“等于”?这里“包含”是更合理的,因为用户很可能只记得菜名的某几个字。但“包含”查询意味着没法走普通索引,大数据量下会全表扫描。像菜品表这种几千行的量级,全表扫也很快,可以接受;如果以后数据量上去了,就得考虑上Elasticsearch或者MySQL全文索引来做优化。
分页方式上也做了个统计。MySQL的分页写法有两种思路:一种是LIMIT offset, size的传统分页,另一种是“键集分页”,利用上次查询返回的最后一条记录的id,用WHERE id > lastId LIMIT size来取下一页。键集分页在深翻页场景下性能优势巨大,但缺点是用户没法自由跳页。这个项目里选择传统分页,主要是考虑到用户有跳页需求,而且数据量在可控范围内。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心技术方案选型与分页原理
2.1 三层架构下分页查询的职责划分
这个项目用的是标准的SSM架构升级版:Spring Boot做基础框架,MyBatis Plus做持久层,前端Vue + Element UI。为什么选这套组合?说白了就是省心。Spring Boot整合东西太方便了,MyBatis Plus帮我把单表CRUD、分页、条件构造这些最繁琐的样板代码全部干掉,我只用写业务部分就行。
分页查询的职责划分,我一般是这么拆的。Controller层只负责接收前端传过来的查询参数,校验参数合法性,然后调用Service层,把结果封装成统一响应返回。Service层承载业务逻辑,比如填充默认值、处理状态枚举的映射、计算总页数。Mapper层或者说DAO层,负责和数据库交互,执行真正的分页SQL。前端页面负责拼查询参数、渲染列表、控制分页组件状态。
这样拆的好处是每一层都可以独立测试。后端接口写完,直接用Swagger测;前端页面开发阶段可以mock假数据,不用等后端接口。后面要加缓存、加权限,也只在Service层动刀,不会牵扯到其他层。
这里有一个参数对齐的问题需要提前约定好。前端传给后端的参数是page(当前页码)、pageSize(每页条数)、name(菜品名称)、categoryId(分类ID)、status(状态)。后端返回给前端的是total(总记录数)、records(当前页数据列表)、current(当前页码)、size(每页条数)、pages(总页数)。前后端必须严格按照这个约定来对接,否则就是最常见的“接口通了但数据对不上”的联调事故。
2.2 MyBatis Plus分页插件:它到底做了什么
MyBatis Plus的分页功能是我在这个项目里最满意的部分。它的核心机制是通过一个分页插件(PaginationInnerInterceptor)拦截执行的SQL,然后自动生成一条COUNT查询语句和一条带LIMIT的分页查询语句。
拿这个项目里最简单的“查所有菜品”来说,我写的Mapper方法就是这样的:
java复制IPage<Dish> page = dishMapper.selectPage(page, wrapper);
MyBatis Plus底层拦截器做的事情,大致相当于帮我把这句SQL:
sql复制SELECT * FROM dish WHERE status = 1
拆成了两条。一条是统计总数:
sql复制SELECT COUNT(*) FROM dish WHERE status = 1
另一条是查询当前页数据:
sql复制SELECT * FROM dish WHERE status = 1 ORDER BY update_time DESC LIMIT 0, 20
左边那条拿到的count塞进IPage对象的total字段,右边那条的记录塞进records字段,一次方法调用就把分页数据全拿回来了。你可能会问,为什么不直接写两条SQL?因为MyBatis Plus帮你把这两条SQL的生命周期管理、参数绑定、结果集映射都封装好了,尤其当查询条件复杂、where条件里有动态拼的字段时,手写两条SQL意味着条件要拼两遍,维护成本直接翻倍。
关于分页插件有个配置细节必须注意。不能直接在MyBatis Plus里new一个分页插件就完事,需要告诉它当前用的是哪种数据库。因为不同数据库的分页语法不一样,MySQL用的是LIMIT offset, size,PostgreSQL和Oracle有各自的方言写法。PaginationInnerInterceptor里传入DbType.MYSQL,插件才能生成正确的分页SQL。如果这里配置错了,SQL执行直接报错,而且报错信息还挺隐蔽,后面排查那一节我会专门讲。
另一个常见操作是把分页插件加到MybatisPlusInterceptor拦截器链里,不是单独注册。这个拦截器链里可能还会有乐观锁插件、防全表更新插件等,顺序有讲究。一般把分页插件加在链条中靠前的位置,确保分页SQL的生成发生在其他SQL改写逻辑之前。
2.3 前端分页组件与后端参数的对接逻辑
前端用的是Element UI的el-pagination组件。这个组件的核心配置是current-page(当前页)、page-size(每页条数)、page-sizes(每页条数选项)、total(总条数)以及两个核心事件:current-change(页码改变时触发)和size-change(每页条数改变时触发)。
初次接触分页的人容易踩的坑是:分页组件里的total从哪来?我的答案是:从后端返回的total字段来,而且在拿到数据之前,总条数默认为0,分页组件先不渲染“共X条”的文案。这里还要注意一个细节:el-pagination绑定total值时,要把后端的total直接映射进去,不要自己做任何加减。
还有一个容易踩的坑是查询条件变化后页码要不要重置回第一页。比如用户当前在第6页,然后输入了一个关键词“鱼”重新搜索,这种情况下如果页码还停留在6,大概率查出来是空列表。正确的做法是:点击搜索按钮时,强制把currentPage重置为1。这个逻辑很小,但如果不做,用户就会莫名其妙地觉得“搜索坏了”。
前端的完整逻辑是切换页码或者改变每页条数后,重新调用查询接口,把新参数传给后端。每次操作本质上都是一次新的查询请求,而不是在本地数据上做切片。这是“服务端分页”和“前端假分页”最大的区别。
3. 实操实现:从零完成菜品分页查询
3.1 数据准备与实体类设计
先说数据库表结构。菜品表我用的核心字段如下:
sql复制CREATE TABLE `dish` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(64) NOT NULL COMMENT '菜品名称',
`category_id` bigint(20) NOT NULL COMMENT '分类ID',
`price` decimal(10, 2) NOT NULL COMMENT '价格',
`status` int(11) NOT NULL DEFAULT '1' COMMENT '状态:1起售 0停售 2估清',
`image` varchar(255) DEFAULT NULL COMMENT '图片地址',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
`update_user` bigint(20) DEFAULT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';
表设计阶段就要考虑到分页查询的过滤和排序会用到哪些字段。category_id和status字段在最开始就建了索引,因为这两个是高频的筛选条件。如果等数据量大了再补索引,改表结构不说,还得考虑线上平滑切换问题,成本高很多。
实体类直接用MyBatis Plus的注解风格:
java复制@Data
@TableName("dish")
public class Dish {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Long categoryId;
private BigDecimal price;
private Integer status;
private String image;
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
@TableField(fill = FieldFill.INSERT_UPDATE)这个注解其实背后有个很好用的能力。结合一个MetaObjectHandler的自动填充处理器,insert和update时会把create_time、update_time这些通用字段自动填上,我写业务代码时完全不用手动set时间,减少了很多重复劳动,也避免了漏填。
3.2 搭建分页查询的完整后端链路
先引入依赖。我的pom.xml里最关键的是MyBatis Plus的starter:
xml复制<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
等等,这里要停一下。我见过不少人因为分页插件选型踩坑,所以单独说一下。分页插件主流的方案有两个:一个是上面说的PageHelper,另一个是之前提到的MyBatis Plus自带的PaginationInnerInterceptor。PageHelper通过拦截器自动改写SQL,用法是直接调用PageHelper.startPage(pageNum, pageSize),之后的第一次查询自动分页。刚开始用觉得很方便,但用久了就会发现它的一个缺点:“侵入性”太强。有时一个方法里多写一句查询,分页就作用到错误的SQL上去了,排查起来很费劲。
这个项目里用的是MyBatis Plus自带的分页插件,配合MP的LambdaQueryWrapper,代码可读性比PageHelper高不少。分页插件配置如下:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
// 设置最大单页限制,防止有人传一个巨大的pageSize把数据库打崩
paginationInterceptor.setMaxLimit(500L);
// 溢出总页数后是否回到第一页,这里选择不处理,保持原页码
paginationInterceptor.setOverflow(false);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
为什么串联起来讲?因为查询本身用到的条件和分页插件之间是有协作关系的,分页插件负责把最终的SQL改写成限制条数;而查询条件由Wrapper构造,不用自己拼SQL字符串。两套机制配合使用,才能保证复杂条件查询的正确分页。
java复制public interface DishMapper extends BaseMapper<Dish> {
}
DishMapper继承BaseMapper后,就已经内置了selectPage、selectList、selectById等常用方法,单表查不用写任何SQL。唯一需要额外处理的场景是后面要讲的“多表关联查询菜品及分类名称”,那种场景才需要自定义XML里的SQL。
3.3 Service层核心代码:多条件组合查询
Service层是分页查询逻辑的核心。先看代码:
java复制@Override
public PageResult<DishVO> pageQuery(DishPageQueryDTO queryDTO) {
// 1. 构造分页参数
Page<Dish> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
// 2. 构造查询条件
LambdaQueryWrapper<Dish> wrapper = new LambdaQueryWrapper<>();
// 名称模糊查询
wrapper.like(StringUtils.isNotBlank(queryDTO.getName()), Dish::getName, queryDTO.getName());
// 分类精确查询
wrapper.eq(queryDTO.getCategoryId() != null, Dish::getCategoryId, queryDTO.getCategoryId());
// 状态查询
wrapper.eq(queryDTO.getStatus() != null, Dish::getStatus, queryDTO.getStatus());
// 排序:先按更新时间倒序,再按主键倒序,保证稳定
wrapper.orderByDesc(Dish::getUpdateTime);
wrapper.orderByDesc(Dish::getId);
// 3. 执行分页查询
Page<Dish> dishPage = dishMapper.selectPage(page, wrapper);
// 4. 转换VO,补充分类名称等信息
List<DishVO> records = dishPage.getRecords().stream().map(dish -> {
DishVO vo = new DishVO();
BeanUtils.copyProperties(dish, vo);
// 补充分类名称,这里要避免N+1问题
Category category = categoryService.getById(dish.getCategoryId());
if (category != null) {
vo.setCategoryName(category.getName());
}
return vo;
}).collect(Collectors.toList());
// 5. 组装分页结果
PageResult<DishVO> result = new PageResult<>();
result.setRecords(records);
result.setTotal(dishPage.getTotal());
result.setCurrent(dishPage.getCurrent());
result.setSize(dishPage.getSize());
result.setPages(dishPage.getPages());
return result;
}
这段代码里有几个细节值得说。
LambdaQueryWrapper的条件方法第一个参数是一个boolean值,当条件不成立时,MP会自动忽略这个查询条件。这里传的是StringUtils.isNotBlank(queryDTO.getName()),意思是:如果前端没传name参数,就不拼接name的like查询。这样做的好处是天然支持“可选条件”这个需求,不需要写if语句。
这里我踩过一个比较隐蔽的坑:prefix布尔参数一定要正确计算。比如查询状态时用queryDTO.getStatus() != null做条件,但status是一个Integer,前端如果传了0(停售状态),它是不等于null的,条件会正常生效。可是如果用StringUtils.isNotBlank去判断Integer类型,或者用queryDTO.getStatus() == 1这种写法来判断,就会出问题。基本原则是:包装类型判断用null判断,字符串类型判断用isNotBlank。
第4步的“补充分类名称”也值得展开说。如果我天真地在循环里调用categoryService.getById,一条一条查数据库,假如每页有20条菜,就要多查20次分类表,这就是经典的N+1查询问题。数据量小的时候没感觉,一旦菜品数量翻几倍,这个接口的响应时间会从几十毫秒慢到几百毫秒,性能瞬间劣化。
我简单估算过一个数据:假设单次查询分类需要耗时5毫秒,20条菜就要额外100毫秒,如果菜品数量到了几百条,光这额外开销就够把接口拖垮的。解决方案很简单:先把当前页菜品里的所有categoryId收集起来,用IN查询一次性查出所有涉及到的分类,然后转成Map,再在循环里map.get。这样最多执行两条SQL就把“补充分类名称”这件事做完了。
java复制// 收集去重后的分类ID
Set<Long> categoryIds = dishPage.getRecords().stream()
.map(Dish::getCategoryId)
.collect(Collectors.toSet());
// 批量查询一次
List<Category> categories = categoryService.listByIds(categoryIds);
Map<Long, String> categoryNameMap = categories.stream()
.collect(Collectors.toMap(Category::getId, Category::getName));
// 循环赋值
records.forEach(vo -> vo.setCategoryName(categoryNameMap.getOrDefault(vo.getCategoryId(), "未知分类")));
Controller层就比较薄了,主要做参数校验和统一响应:
java复制@GetMapping("/page")
public Result<PageResult<DishVO>> page(DishPageQueryDTO queryDTO) {
// 参数校验
if (queryDTO.getPage() == null || queryDTO.getPage() < 1) {
queryDTO.setPage(1);
}
if (queryDTO.getPageSize() == null || queryDTO.getPageSize() < 1) {
queryDTO.setPageSize(10);
}
return Result.success(dishService.pageQuery(queryDTO));
}
这里顺便说下DTO的设计。DishPageQueryDTO里除了page和pageSize,就是name、categoryId、status三个筛选条件。这种专门为某个接口定义的查询对象,和实体Dish分开,防止在层与层之间传递时多传了不该传的字段。
3.4 前端页面与接口对接
前端Vue页面的核心逻辑是维护一个查询参数对象,任何搜索条件变化,都会触发一次新的查询。我的核心代码分两部分:一是查询方法,二是分页事件绑定。
vue复制<template>
<div class="dish-page">
<!-- 搜索区域 -->
<el-form :inline="true" :model="queryParams">
<el-form-item label="菜品名称">
<el-input v-model="queryParams.name" placeholder="请输入菜品名称" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="菜品分类">
<el-select v-model="queryParams.categoryId" placeholder="请选择分类" clearable>
<el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="售卖状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="起售" :value="1" />
<el-option label="停售" :value="0" />
<el-option label="估清" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="name" label="菜品名称" />
<el-table-column prop="categoryName" label="分类" />
<el-table-column prop="price" label="价格" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleQuery"
@current-change="handleQuery"
/>
</div>
</template>
注意el-pagination上绑定的事件。很多人习惯把current-change和size-change分别绑定不同的处理函数,其实单独绑定同一个handleQuery函数就行,因为两个事件触发的本质都是“参数变了,重新拉数据”,v-model已经帮我把最新页码或每页条数同步到queryParams里了。
查询方法是这个逻辑:
javascript复制const handleQuery = async () => {
loading.value = true
try {
const res = await fetchDishPage(queryParams.value)
tableData.value = res.records
total.value = res.total
} finally {
loading.value = false
}
}
const handleSearch = () => {
// 搜索时强制回到第一页
queryParams.value.page = 1
handleQuery()
}
handleSearch里强制重置page为1,这个前面提过,是搜索功能的“灵魂”。没有这一行,用户在第5页点搜索,看到空结果后基本都会以为系统出bug了。
4. 常见问题与排查技巧实录
4.1 total总数对不上的典型场景
分页查询的第一个经典坑是:总数对不上。
我遇到过一种情况是列表数据量正确,但总数显示的是全部数据量。排查后发现是COUNT查询走了不同的条件分支。MyBatis Plus的selectPage方法内部自动生成的count语句,理论上会和查询语句共享同一个wrapper。但问题出在有些自定义SQL上,如果XML里手写了分页查询,同时还是用了@Select注解或者@Param传递参数,就需要手动确保COUNT语句和SELECT语句的条件一致。
我举一个实际例子。如果需求是“只查询已删除标记为0的菜品”,但我忘了在定义的基线上把deleteFlag过滤条件同步加到count语句里,就会出现查询结果是20条但total还是所有菜品总数的情况。
排查思路很直观:把打印出来的两条SQL拿日志记录下来,分别执行一遍,对比where条件是否一致。MyBatis Plus默认会打印SQL日志,在application.yml里配置:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
开启后控制台会输出生成的SQL。我每次排查分页类问题第一步就是开这个日志,先看SQL长什么样,再对比条件,90%的问题一眼就能看出来。
4.2 条件查询与分页混用时的参数穿透
第二个坑是:加了条件之后,分页数据对了,但“共X条”对不上了。
比如按名称模糊查询。如果只分了页但没有正确使用条件构造器,就可能在分页查询时把全部数据的分页结果返回,而不是按名称过滤后的分页结果。具体表象是:搜索出来的菜品分布在不同页里,第1页不是想要的那条。
我遇到的原因是某个老接口里分了页但write了硬编码SQL,直接是“where status=1”写死,然后外层又套了一个wrapper去传搜索关键词。实际上wrapper的条件根本没有被应用。生产排查时看到SQL里没有like条件,问题就很明确了。
所以自定义SQL场景下,用${ew.customSqlSegment}把wrapper条件拼进去是正解:
xml复制<select id="selectDishPageWithCategory" resultType="com.example.vo.DishVO">
SELECT d.*, c.name AS category_name
FROM dish d
LEFT JOIN category c ON d.category_id = c.id
${ew.customSqlSegment}
</select>
对应Mapper方法的写法是:
java复制IPage<DishVO> selectDishPageWithCategory(Page<DishVO> page, @Param(Constants.WRAPPER) Wrapper<DishVO> queryWrapper);
这样MyBatis Plus会自动把wrapper里的where条件和排序拼进SQL,而且count语句也会自动生成对应的条件版本,避免总数不一致的问题。可能有朋友好奇为什么这么麻烦,因为查询条件是动态的,用户可能搜名称、可能选分类、可能筛状态,我不能预先在XML里写死where条件,只能靠wrapper动态生成。
4.3 翻页重复和漏数据的排序问题
第三个坑比较隐蔽,翻页时出现了重复数据或漏数据。比如第一页显示了一条“宫保鸡丁”,翻到第二页,“宫保鸡丁”又出现了,或者第二页里少了一条本该出现的数据。
这个问题的根源是排序不稳定。如果SQL只用了ORDER BY update_time DESC,且多条数据的update_time完全相同,数据库返回这些记录的相对顺序是不确定的,每次查询都有可能不同。第一次查询走了索引A,第二次查询优化器选择了索引B,同一页可能就包含了上一次已经返回过的记录。
解决办法是加上一个不重复字段作为次级排序条件,最稳妥的就是主键id:
sql复制ORDER BY update_time DESC, id DESC
这也就是我在Service层代码里写两行orderBy的原因。很多人不看这个细节,直到线上出现“翻页翻出重复数据”才回头补,其实一开始就加上就没这些破事。
4.4 性能优化与深翻页问题
最后说性能。随着菜品数据增长,分页查询最明显的性能瓶颈出现在“深翻页”。例如用户在第10000页,MySQL是这样执行LIMIT 200000, 20的:
sql复制SELECT * FROM dish ORDER BY update_time DESC LIMIT 200000, 20
MySQL会先扫描出前200020条记录,然后丢弃前200000条,只返回最后20条。这种深翻页在数据量大时非常浪费时间,因为越往后翻,扫描的无用数据越多。
这个项目里菜品总量还好,但为了长远考虑,我做了两个优化。
第一,限定最大深度。当用户请求的页码超过总页数时,直接返回空列表,不让用户继续深翻。这个通过paginationInterceptor.setOverflow(false)实现:避免页码溢出后自动跳到第一页,造成用户困惑,给用户明确反馈列表为空。
第二,如果以后遇到“必须支持深翻页”的变态需求,比如菜品管理后台要导出全部数据,就改用键集分页,用“下一页”的游标方式,每次只查20条,通过记录上一页最后一条的id往下翻。这种方案无法自由跳页,但性能稳定,数据量再大也不怕。
另外关于COUNT查询的性能也需要留意。MyBatis Plus默认生成的count语句是SELECT COUNT(*) FROM dish WHERE ...,这会扫描所有满足条件的行。如果筛选条件没有走任何索引,数据量一大,光count就可能卡很久。实际处理方式是对高频筛选字段做了索引覆盖优化,同时也加了一个简单的Redis缓存:缓存菜品总数和对应筛选条件,设置短TTL比如5分钟,并在菜品增删改后主动清掉相关缓存,效果很显著。当然如果页面要实时性,这个缓存方案就要谨慎了。我是按需求来的,管理后台的菜品列表不需要秒级实时,所以这个方法完全够用。
4.5 前端分页组件不刷新问题的排查
还有一个和前端相关的坑。有次遇到分页组件上显示的总数一直不变,即使搜索条件改变了,总数还是旧值。排查后发现是total的响应式绑定写崩了。在Vue3的setup里如果直接这样写:
javascript复制let total = ref(0)
// 从接口赋值时忘了.value
total = res.total
就会把reactive对象变成普通值,页面自然不会更新。这个问题说白了就是Vue3 Composition API的响应式原理没吃透,赋值一定要用total.value = res.total,或者用reactive对象包裹整个查询结果。
另一个类似的问题是:表格数据更新了,但分页组件的页码高亮没变。原因通常是查询逻辑里用了queryParams.page但分页组件绑定的是另一个变量,两个值失去同步。要统一数据源,分页组件的current-page和查询参数里的page绑定同一个变量,不要各写各的。我在项目里统一用queryParams这个对象管理分页状态和筛选状态,表格、分页组件、查询方法都从这里读数据,从源头上避免同步问题。
4.6 常见问题排查速查表
为了方便快速定位,我把这个项目里遇到过的分页问题整理了一张表,可以当排查手册用。
| 问题现象 | 可能原因 | 排查方法与解决方式 |
|---|---|---|
| 总数比实际记录多 | COUNT语句条件漏拼了过滤条件 | 开启SQL日志,对比查询SQL和COUNT SQL的where条件是否一致 |
| 第一页数据对,第二页开始重复 | 排序不稳定,排序字段有重复值 | 排序字段末尾追加主键id条件:orderByDesc(updateTime, id) |
| 每页条数不生效 | 分页插件没配置或者配置顺序错误 | 检查MybatisPlusInterceptor是否注册了PaginationInnerInterceptor |
| 搜索条件无效 | 查询参数未绑定到wrapper的Lambda条件 | 检查prefix布尔参数是否正确,比如status传0时是否被isNotBlank过滤掉了 |
| 页码不重置 | 搜索点击时没有重置页码为1 | handleSearch里强制 queryParams.page = 1 再查询 |
| LIMIT后面数字异常大 | 前端传了超大pageSize | 分页插件里setMaxLimit,限制单页最大条数 |
| 接口响应很慢 | 深翻页或count查询太慢 | 考虑键集分页方案;优化筛选字段索引;必要时加短ttl缓存 |
| 数据库方言报错 | 分页插件没有设置DbType | PaginationInnerInterceptor构造函数传入DbType.MYSQL |
5. 一些个人实测下来的经验
整个菜品分页查询做下来,我最有感触的一点是:分页查询从来不只是“加一个LIMIT”那么简单。
一个看上去很普通的分页接口,背后牵扯到SQL改写、条件拼接、排序稳定性、参数对齐、性能优化这么多环节。任何一个细节没做到位,用户翻几页就能发现问题,尤其是重复数据、总数不对这种低级但又特别影响体验的bug。
如果让我总结几条最想让新手记住的经验,大概是这几条。排序一定加主键兜底,这一条能帮你避开翻页重复数据这个最大的坑。搜索触发时一定重置页码,这一条能让你的页面“手感”正常。复杂的动态查询条件尽量让wrapper来管理,不要自己拼SQL字符串,拼错一个空格就是事故。还有一条是老生常谈的——分页插件一定要配数据库类型,不然线上环境分页SQL直接就是错的。
最后再分享一个小技巧。如果你们的前端和后端是分开的团队维护,分页接口的参数和响应结构最好在接口文档里直接固定成模板,做成一个“标准分页模型”。后续所有列表页都按这个模型走,后端不用每个接口都重新设计,前端封装一个通用的分页请求工具函数,一套逻辑到处复用。我目前的新项目里就是这么干的,开发效率提升非常明显。
