1. 项目整体设计与思路拆解
外卖平台的菜品管理模块里,“分页查询” 这个功能单看起来不算复杂,但实际落地时会牵扯出不少问题。比如菜品数据量大了以后,一次查几百条数据丢给前端,页面渲染会卡顿,网络传输也会变慢;又比如需要按名称模糊搜、按分类过滤、按售卖状态筛选,这些条件组合在一起时,SQL 怎么拼、参数怎么传、返回结构怎么定,都直接影响接口的稳定性和后续维护成本。
“苍穹外卖-菜品分页查询” 这个标题所指的,其实就是外卖后台管理系统中最常见的一个能力:菜品列表的分页展示与多条件检索。它承担的不只是把数据“一页页切出来”,更重要的是在数据量增长之后,依然能保证接口响应够快、查询结果够准、前端交互够顺滑。
做这个功能之前,我先把需求拆成了几个核心问题:
- 分页参数怎么设计,才能兼顾简单和灵活?是固定的 page、pageSize,还是允许前端自定义排序规则?
- 条件过滤怎么做,才能既支持组合查询,又不至于让 SQL 变成一团乱麻?
- 数据量大了以后,分页查询会不会慢?慢的话是索引的问题、还是统计总数的方式不对?
- 后端返回的数据结构怎么定,才能让前端拿到就能直接用,不需要二次组装?
这些问题想清楚之后,整个项目的方向就很明确了。我最终选定的技术方案是:Spring Boot 2.x + MyBatis + MySQL 的经典组合,分页插件用 PageHelper,前端通过 RESTful API 请求,后端返回统一格式的 JSON 数据。这套方案在中小型项目中非常实用,社区文档多、上手快、排查问题也相对容易。
我见过不少开发者一上来就追求高并发、微服务、分布式那一套,其实对于外卖后台这种业务场景,数据量级往往停留在百万以内,QPS 也不会高到离谱。使用合理设计的分页查询,加上适当的数据库索引,完全能支撑日常运营需求。技术选型要匹配业务规模,这是我反复强调的,也是我在这个项目里坚持“轻量、实用”的原因。
1.1 为什么分页查询不是简单“LIMIT 10”
新手最容易犯的一个错误,是把分页查询理解为“SQL 里加一行 LIMIT 10 OFFSET 20”。这确实能实现分页,但它只是分页的“结果形态”,不是分页的系统设计。
分页查询在真实项目里的完整闭环是这样的:前端告诉后端“我要第几页、每页多少条”,后端根据条件去查数据,同时还要告诉前端“总共有多少条数据、一共有多少页”。没有总数,前端就没有办法渲染出分页器;没有页数,用户就没法跳转到底部去翻页。
具体到我实现的“苍穹外卖-菜品分页查询”,大致流程是:
- 前端发送 GET 请求,携带页码 page、每页条数 pageSize,以及可选的查询条件(菜品名称 name、分类 ID categoryId、售卖状态 status)。
- 后端 Controller 接收参数,将查询条件封装为 DTO 对象。
- Service 层调用分页插件,执行查询,得到当前页数据和总记录数。
- 后端将结果封装为统一响应体,包括数据列表、总条数、总页数、当前页码、每页大小。
- 前端拿到响应后渲染表格和分页器。
这里面有一个容易被忽略的点:条件过滤和分页是两件独立的事,但必须配合好。如果先查全部数据,再在内存里做分页,数据量一大就会 OOM;如果只做分页不处理过滤条件,用户看到的列表就永远是全部菜品而不是搜索结果。
所以在设计查询逻辑时,需要把过滤条件注入到 SQL 查询之前,让数据库在扫描数据时就已经把不符合条件的数据排除了。分页只是对“过滤后的结果”做切片,而不是对全量结果做切片。
1.2 分页方案选型:自己封装还是用 PageHelper
开发这个功能时,我面临一个选择:是手写 LIMIT/OFFSET 自己封装分页,还是引入 MyBatis 的分页插件 PageHelper。
先说说我为什么没有选择完全手写。手写分页其实并不复杂,Dao 层传两个参数,SQL 末尾加上“limit #{offset}, #{pageSize}”,再单独写一条 count 查询语句统计总数。但这样做的痛点在于:
- 每写一个分页方法,都要写两条 SQL(一条查数据、一条查总数),重复劳动量大。
- 总数 SQL 和列表 SQL 的条件必须保持一致,一旦两个人各改一处,条件就发生不同步。
- 返回结构还得自己包装 PageResult 对象,代码量虽然不大,却是纯体力活。
如果用 PageHelper,它能自动拦截 MyBatis 执行的 SQL,帮你生成 count 查询和分页 SQL,对开发者透明。我只需要在 Service 层调用 PageHelper.startPage(page, pageSize),然后执行正常的列表查询,PageHelper 就会自动拼接分页条件,还能通过返回的 Page 对象获得总条数。
不过 PageHelper 也不是没有坑。它的分页原理是基于 ThreadLocal 的,startPage 调用之后,紧接着执行的第一个查询会被拦截并分页。如果业务逻辑里有多个查询,而第一个查询不是你想要分页的那个,就会产生诡异的 Bug。这个我后面会专门写一节来排查。
对于这个项目来说,PageHelper 的优势更多,毕竟需求和业务都比较标准,引入插件能极大提升开发效率。如果是那种 SQL 极端复杂、需要分库分表、或者对 SQL 执行计划有严格要求的系统,我反而建议手写分页,因为插件自动生成的 count 语句不一定最优。
2. 菜品表设计与查询条件配置
做分页查询,第一步要把表结构理清楚。菜品的核心属性和查询条件字段,决定了你后续是能轻松应对需求变化,还是三天两头改代码。
2.1 菜品表字段结构
在“苍穹外卖”这个项目里,我设计的菜品表(dish)核心字段大致如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 ID |
| name | varchar(32) | 菜品名称 |
| category_id | bigint | 分类 ID,关联分类表 |
| price | decimal(10,2) | 售价 |
| image | varchar(255) | 图片地址 |
| description | varchar(255) | 菜品描述 |
| status | int | 售卖状态 1起售 0停售 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| create_user | bigint | 创建人 ID |
| update_user | bigint | 更新人 ID |
| is_deleted | int | 逻辑删除标记 0未删 1已删 |
这个设计里特别要注意的是逻辑删除字段 is_deleted。物理删除在后台管理的菜品维护中风险很高,一旦菜品被订单引用,物理删除会导致历史订单数据无法追溯。我使用逻辑删除,让所有查询 SQL 默认带上 and is_deleted = 0,既能保证列表不出问题,又保留了数据完整性。
分页查询的核心 SQL 就建立在 dish 表之上,但仅有 dish 表还不够。前端列表页通常需要显示“菜品分类名称”,而分类名称存储在 category 表中。这里就要求分页查询必须关联分类表查出分类名称。
2.2 查询条件组合的场景拆解
菜品分页查询并不是一个只有 page 和 pageSize 两个参数的接口,实际开发中经常会遇到以下查询场景:
- 用户只想看“川菜分类下正在售卖”的菜品,此时需要同时过滤 categoryId 和 status。
- 运营人员在搜索框输入“鱼”,希望能模糊匹配出酸菜鱼、水煮鱼、糖醋鱼等菜品。
- 用户按价格区间筛选,或者想只看某个时间段新增的菜品。
如果一次性把所有这些条件都设计进接口,代码会变得非常笨重。所以我在实现时采用“可选条件”策略:请求参数中带上什么,SQL 就动态拼接什么条件;不带则不做限制。
我用一个 DTO(Data Transfer Object)来接收查询参数:
@Data public class DishPageQueryDTO { private Integer page; private Integer pageSize; private String name; private Long categoryId; private Integer status; }page 和 pageSize 是必传项,其他全部可选。前端不传 name,后端就不做模糊查询;不传 categoryId,就不按分类过滤。这样接口的通用性会好很多,后续扩展也方便。
可能有人会问:为什么不在 DTO 里直接加一个 priceMin 和 priceMax?理论上可以加,但要根据实际需求来。如果当前需求没有价格筛选,加了就是冗余设计。等哪天产品提出需求了,再扩展 DTO 和 SQL 也不迟,改动成本并不高。我倾向于“按需设计”,而不是一次做全。
3. 后端分层实现与代码落地
分页查询的代码实现,核心逻辑按 Controller、Service、Mapper 三个层级来拆分。每一层职责清晰,后期维护时定位问题也更快。
3.1 Controller 层:参数接收与响应封装
Controller 层的代码比较薄,主要做三件事:接收请求参数、调用 Service、返回响应结果。
@RestController @RequestMapping("/admin/dish") public class DishController { @Autowired private DishService dishService; @GetMapping("/page") public Result<PageResult<DishVO>> page(DishPageQueryDTO dishPageQueryDTO) { PageResult<DishVO> pageResult = dishService.pageQuery(dishPageQueryDTO); return Result.success(pageResult); } }这里有个细节值得注意:查询参数直接用 DTO 对象接收,Spring MVC 会自动把请求参数绑定到 DTO 的字段上。比如请求路径是 /admin/dish/page?page=1&pageSize=10&name=鱼,那么 name 字段就会自动被赋值为“鱼”。
统一响应体 Result 是项目里的通用设计,结构一般是:
@Data public class Result<T> { private Integer code; // 1 成功,0 失败 private String msg; // 提示信息 private T data; // 数据实体 }分页结果我封装了一个 PageResult 对象,包含当前页数据列表以及分页元数据:
@Data public class PageResult<T> { private long total; // 总记录数 private List<T> records; // 当前页数据 private Integer page; // 当前页码 private Integer pageSize;// 每页大小 }之所以把 total 和 records 放一起,而不使用 PageHelper 自带的 PageInfo,是因为 PageInfo 里字段太多,很多前端用不上。轻量化的响应结构既节省流量,也让前端同事拿到数据后不需要层层取属性,直接 pageResult.total 和 pageResult.records 就能用。
3.2 Service 层:分页插件与条件组合
Service 层是分页查询的核心逻辑层,也是 PageHelper 发挥作用的地方。这段代码我踩过不少坑,写出来供大家参考:
@Override public PageResult<DishVO> pageQuery(DishPageQueryDTO dto) { int page = dto.getPage() == null ? 1 : dto.getPage(); int pageSize = dto.getPageSize() == null ? 10 : dto.getPageSize(); PageHelper.startPage(page, pageSize); List<DishVO> dishVOList = dishMapper.pageQuery(dto); Page<DishVO> pageResult = (Page<DishVO>) dishVOList; return new PageResult<>(pageResult.getTotal(), dishVOList, page, pageSize); }第 6 行的 PageHelper.startPage 是关键。它告诉 MyBatis:“接下来的第一条 SQL 查询要做分页。” PageHelper 会自动在原始 SQL 后面拼接 LIMIT,并生成一条 count 语句来统计总条数。
第 8 行的 dishVOList 虽然是 List 类型,但实际运行时它是 Page 类的实例,所以可以在第 10 行强转回 Page 类型,调用 getTotal() 获取总数。
有同学可能会问:为什么不在 Controller 层设置默认 page 和 pageSize?我习惯在 Service 层做参数兜底,因为理论上 Controller 可能被多个入口调用,Service 层做默认值处理可以保证无论谁调用,分页参数都是合法的。当然,如果公司规范要求参数校验必须在 Controller 层,那就按项目统一规范来。
3.3 Mapper 层:动态 SQL 的关键写法
Mapper 层是分页查询的“技术含量”所在。既要支持多条件动态拼接,又要保持 SQL 清晰可控。我使用的是 MyBatis 的 XML 映射文件方式,这样 SQL 的可读性和维护性都比注解方式更好。
<select id="pageQuery" resultType="com.sky.vo.DishVO"> select d.*, c.name as categoryName from dish d left join category c on d.category_id = c.id <where> and d.is_deleted = 0 <if test="name != null and name != ''"> and d.name like concat('%', #{name}, '%') </if> <if test="categoryId != null"> and d.category_id = #{categoryId} </if> <if test="status != null"> and d.status = #{status} </if> </where> order by d.update_time desc </select>这段 SQL 有几个细节值得展开讲讲。
第一,is_deleted 为什么要放在<where>标签内而不是写成 where d.is_deleted = 0?其实放在哪都行,关键是保证 SQL 始终有至少一个固定条件,避免出现“where 1=1”这种写法。<where>标签会自动处理开头的 and 或 or,省去很多拼接烦恼。
第二,模糊查询的写法:concat('%', #{name}, '%'),而不是直接用 '%${name}%'。使用 ${} 进行字符串拼接存在 SQL 注入风险,而 concat 和 #{} 是参数化查询,能有效防注入。这一点在涉及用户输入时尤其重要。
第三,order by d.update_time desc。菜品更新后应该排在列表前面,这是后台管理系统的常见需求。同时,update_time 字段在后续优化时可以考虑加索引。
第四,left join 关联分类表,目的是查出 category_name。这里我使用 left join 而不是 inner join,是因为菜品可能没有配置分类(比如测试数据没关联分类),用 left join 可以保证菜品本身依然能查出来,分类名为 null 而已。
3.4 VO 对象的设计思路
DishVO 是专门给前端展示用的视图对象,和实体类 Dish 的区别在于它额外包含了分类名称等非 dish 表字段。我在设计时让 DishVO 直接继承 Dish,然后加一个 categoryName 字段:
@Data @EqualsAndHashCode(callSuper = true) public class DishVO extends Dish { private String categoryName; }这样设计的好处是:MyBatis 的查询结果可以直接映射到 DishVO 对象中,既包含菜品所有字段,又包含关联出的分类名称字段。前端拿到这个对象后,表格的“分类”列直接用 categoryName 渲染,不需要自己做关联查询。
有同学可能会说,不是应该尽量减少字段冗余吗?但这是查询结果显示场景,不是写入场景,VO 本来就是为了满足前端展示需求而存在的,多一个字段完全正常。真正的禁忌是把 VO 用在新增或修改的接口里接受参数,那样会导致接收到不该接收的字段,产生安全风险。
4. 前端联调与接口对接
后端接口写完之后,不能直接说“开发完了”,必须与前端联调,确保数据结构、参数命名完全对齐。
4.1 前端如何调用分页接口
在“苍穹外卖”项目中,前端使用 Vue + Element UI 的组件库来搭建后台管理系统。菜品管理页面会用到 el-table 展示列表,el-pagination 做分页。
前端核心逻辑大致如下:
getDishPageData(pageNum, pageSize) { const params = { page: pageNum, pageSize: pageSize } if (this.searchForm.name != null && this.searchForm.name != '') { params.name = this.searchForm.name } if (this.searchForm.categoryId != null && this.searchForm.categoryId != '') { params.categoryId = this.searchForm.categoryId } if (this.searchForm.status != null && this.searchForm.status != '') { params.status = this.searchForm.status } getDishPage(params).then(res => { this.dishList = res.data.records this.total = res.data.total }) }这里有个很容易踩坑的地方:参数的 key 必须和后端 DTO 的字段名保持一致。比如后端定义的是 pageSize,前端如果传的是 page-size、size、limit,Spring MVC 会绑定不上,导致分页大小一直是默认值。
后端接口返回的数据结构,前端在使用时要特别留意 data 字段里的内容是一个对象,而不是数组。很多新手以为 res.data 就是列表,其实 res.data 是 PageResult 对象,列表在 res.data.records 里。
4.2 分页器参数对齐与常见设计问题
前端分页组件通常会有以下属性:
<el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next" :total="total"> </el-pagination>sync 好当前页和每页条数,确保用户切换 pagination 时触发新的查询请求即可。
联调时我遇到过一个问题:前端在切换 pageSize 时忘记重置 currentPage,导致用户在最后一页时把每页条数从 10 改成 50,结果当前页码已经超出总页数,接口返回空列表。解决办法是在 pageSize 变更的回调里把当前页重置为 1:
handleSizeChange(newSize) { this.pageSize = newSize this.currentPage = 1 this.getDishPageData() }这个细节虽然小,但真实使用中非常影响体验。如果后端接口能支持 page 超界时返回空列表,那前端可以不用管;但如果后端会直接报错或返回异常数据,前端的重置动作就必不可少。
5. 常见问题排查与避坑指南
分页查询在实际开发和运行阶段遇到的问题,远比想象中的多。挑几个典型的场景,记录完整排查过程和解决思路。
5.1 PageHelper startPage 被“吞掉”的问题
这是 PageHelper 使用中最经典的一个坑。在启动分页后,如果执行的第一个 MyBatis 查询不是你想分页的查询,分页就会作用在错误的地方。比如:
PageHelper.startPage(1, 10); List<User> users = userMapper.selectAll(); // 这句会被分页 List<Dish> dishes = dishMapper.pageQuery(dto); // 这句反而没有分页我当时排查这个问题时,发现查询结果是全量的,总数也不正确。原因就是业务代码在调用 pageQuery 之前额外执行了一次查询,导致 PageHelper 把分页拦截到了那条无关的查询上。
解决思路有两个:一是确保 startPage 和第一条查询语句之间不要掺杂其他查询逻辑;二是调整代码结构,让分页查询尽可能独立。如果在一个方法里必须执行多个查询,可以把无关查询放到 startPage 之前,或者用 ExecutorUtil 手动清理 ThreadLocal。
5.2 SQL 注入风险与 concat 的正确用法
在实现菜品名称模糊查询时,我曾经见过一个反面教材:SQL 直接写成 name like '%${name}%'。
假如用户在搜索框输入了一个单引号,比如“酸'菜”,最终执行的 SQL 会变成:
where name like '%酸'菜%'轻则 SQL 语法错误,重则被构造出恶意条件,导致查询范围被扩大甚至数据泄露。
正确的做法是用 concat 函数加 #{} 参数占位:
where d.name like concat('%', #{name}, '%')#{} 在 MyBatis 中会生成预编译占位符 ?,由 JDBC 驱动做参数转义,从根上避免注入问题。这个知识点我写进来,就是希望读者不要在实战中踩同样的坑。
5.3 总数与列表数据不一致的问题
分页查询的 count 语句和列表查询语句,如果没有保持同一套条件,就会导致一个现象:列表页显示 3 条数据,总数却显示 100 条。
这个问题的根源一般有三种:
- 列表 SQL 和 count SQL 是两条分开写的 SQL,改了一条忘改另一条。
- 列表查询里有动态条件,但 count 查询是固定条件,条件不一致。
- 过滤条件中有随机函数或者依赖当前上下文的函数,导致两次查询结果不一致。
使用 PageHelper 时,它自动生成的 count 语句是基于原始查询 SQL 改造的,一般不大会出现这类问题。但如果你手写分页,就必须自己保证条件同步。
我的经验是:不要在查询字段中使用数据库无关的随机函数或会话变量,保持 count 和 list 的查询逻辑同源。
5.4 MySQL 深分页性能问题的优化思路
页面越往后翻,OFFSET 越大,SQL 执行就越慢。比如 pageSize=10,翻到第 1000 页时 OFFSET 就是 9990,MySQL 需要扫描并丢弃前 9990 条记录,再取 10 条,效率很低。
这种深分页在日常后台管理中未必会触发,但一旦运营人员翻到尾部数据,接口延迟就会变得非常明显。我通常会做两类优化:
第一,限制最大翻页深度。超过某个阈值后,提示用户使用条件筛选或搜索,而不是无限翻页,这是产品层面最简单有效的方法。
第二,使用基于索引的延迟关联或游标分页。核心思路是用主键或唯一索引定位起始位置:
select d.*, c.name as categoryName from dish d left join category c on d.category_id = c.id where d.id > #{lastId} and d.is_deleted = 0 order by d.id asc limit #{pageSize}这种方案适合“加载更多”的交互模式,翻页时记录上一页最后一条数据的 id,然后查询大于这个 id 的数据。性能比 OFFSET 翻页要好得多,因为 MySQL 可以直接走主键索引,避免回表扫描大量无关记录。
不过游标分页不支持随机跳页,只能顺序翻页,所以它更适合移动端列表的“下拉加载更多”场景。后台管理系统的“跳转到第 N 页”需求,还是用 OFFSET 方案更合适。
5.5 参数校验与默认值兜底
在实际开发中,我见过接口因为参数不规范而报错的情况。比如 page=0 或 page=-1,有的分页插件会在解析时抛异常;pageSize 传成 10000,一次返回上万条数据,给数据库造成不小的压力。
我处理的方式是在 Service 层加一个过滤:
int page = dto.getPage() == null ? 1 : Math.max(dto.getPage(), 1); int pageSize = dto.getPageSize() == null ? 10 : Math.min(dto.getPageSize(), 100);这样保证 page 至少为 1,pageSize 最大不超过 100。后台管理系统里的列表查询,单页最大 100 条在绝大多数场景下是够用的。如果前端确实需要更大的 pageSize,可以单独约定,而不是直接放开限制。
6. 项目测试方法与验证清单
功能实现之后,不能直接交付,要通过手工和接口测试来验证分页查询是否符合预期。下面是我在这个项目中使用的测试方法和验证清单。
6.1 接口测试的关键用例
我用浏览器直接访问接口来验证基本功能,同时也用接口测试工具去覆盖各种组合条件。核心测试用例如下:
| 用例编号 | 请求参数 | 预期结果 |
|---|---|---|
| 1 | page=1&pageSize=10 | 返回第一页 10 条数据,total 为总条数 |
| 2 | page=2&pageSize=10 | 返回第二页数据,且与第一页不重复 |
| 3 | name=鱼 | 返回名称包含“鱼”的菜品,分页正常 |
| 4 | categoryId=1&status=1 | 返回分类 1 下起售状态的菜品 |
| 5 | name=鱼&categoryId=1&status=1 | 三个条件同时生效,结果正确 |
| 6 | page=999&pageSize=10 | 返回空列表,total 不变,接口不报错 |
| 7 | page=-1&pageSize=10 | 被默认为第 1 页,接口不报错 |
| 8 | pageSize=1000 | 被限制为 100,接口不报错 |
| 9 | name=不存在的内容 | 返回空列表,total 为 0 |
| 10 | page=1&pageSize=10&categoryId=999 | 返回空列表,total 为 0 |
表里最后两组用例很容易被忽略。当过滤条件匹配不到任何数据时,接口应该返回空列表而不是抛出异常。这个逻辑如果在 SQL 层没写对,往往会在前端渲染时报错,影响整页显示。
6.2 SQL 日志查看与执行计划分析
开发环境中我会打开 MyBatis 的 SQL 日志,观察分页查询实际执行时的语句:
logging: level: com.sky.mapper: debug日志中会看到两条 SQL:一条是 count 查询,一条是分页查询。重点检查两条 SQL 的 where 条件是否一致。如果 count 条件比查询条件少,总数就会偏大;如果多,总数就会偏小。
如果确认某条查询比较慢,可以用 EXPLAIN 分析执行计划:
EXPLAIN SELECT d.*, c.name as categoryName FROM dish d LEFT JOIN category c ON d.category_id = c.id WHERE d.is_deleted = 0 ORDER BY d.update_time DESC LIMIT 0, 10;重点看 type 列是否为 index 或者 ref,看 key 列是否用了索引,看 Extra 列是否出现 Using filesort 或 Using temporary。如果出现 filesort,就需要评估是否要为排序字段加索引。菜品表这个量级通常无所谓,但我见过很多项目在数据量涨起来后才意识到排序字段没有索引,性能断崖式下滑,这是需要提前评估的点。
7. 从分页查询到菜品管理模块的扩展思路
分页查询不是一个孤立的接口,它在整个菜品管理模块中属于“列表查询”一环。完成这个功能之后,你会发现其他接口的代码结构也可以复用这一套设计。
7.1 新增、修改、删除与分页查询的联动
菜品管理里有一个很常见的需求:修改菜品状态,然后返回列表页时状态能即时反映出来。这里的核心是缓存刷新。如果没有缓存,修改状态后列表自然查的是最新数据;但如果项目使用了 Redis 做菜品缓存,那么状态变更时必须主动删除或更新缓存,否则分页查询会一直返回旧数据。
我在项目里设计了一个简单规则:所有写操作(新增、修改、删除、状态变更)完成之后,清理对应分类下的缓存。清理的方式可以精确到 key 匹配,也可以简单粗暴地删除该分类前缀的缓存,视项目规模而定。这个规则也适用于分页查询的数据实时性维护。
7.2 条件扩展与字段索引调整
分页查询做了一段时间后,产品可能会提出新需求:按菜品价格区间筛选,按创建人筛选,按起售时间排序等等。这时候 DTO 和 SQL 的修改成本很低,但要同时评估新条件的索引情况。
比如新增 price 范围查询,SQL 会变成:
<if test="priceMin != null"> and d.price >= #{priceMin} </if> <if test="priceMax != null"> and d.price <= #{priceMax} </if>如果这个字段经常出现在 where 条件里,就要在 dish 表的 price 字段上建立索引;如果仅仅是列表展示辅助条件,全表扫描也未必不可接受。索引不是越多越好,关键是看查询模式。
7.3 分页查询的代码复用价值
认真实现一次分页查询,后面所有列表查询(分类列表、套餐列表、订单列表、用户列表)都能复用同样的设计模式:DTO 接收参数、Service 开启分页、Mapper 写动态 SQL、返回 PageResult。不同列表的差异集中在表结构、查询条件和返回 VO 上,骨架几乎一致。
我曾经把一套写好的分页模板整理到一个基类中,将重复的 startPage 和 PageResult 组装逻辑抽出来,业务代码就只需要关注查询本身。如果有兴趣,这部分可以做成自己的代码生成器,进一步提升开发效率。
8. 一些实际操作中的心里话
唠到这儿,我把自己在“苍穹外卖-菜品分页查询”这个模块上的一些真实体会总结一下,没有高大上的理论,全是实际操作后沉淀下来的东西。
PageHelper 是好用,但不要无脑用。它最核心的依赖是“startPage 之后第一条查询必须是你想分页的查询”,这个前提一旦被打破,问题非常隐蔽。我建议团队约定:分页查询方法内部没有任何多余查询,startPage和下一页上的mapper查询之间只隔一个方法调用。
分页条件的参数校验一定要做。page 小于 1、pageSize 过大这类问题,在前端正常操作时不会出现,但一旦出现就会很闹心。在 Service 层做一层兜底,成本极低,收益立竿见影。
动态 SQL 里凡是用户输入的值,一律用 #{} 传入。菜品名称、备注、套餐描述等等,全部不能免除这个规则。有人觉得内部系统不需要防注入,但安全问题是底线,等出事了再补救就没意义了。
菜品的逻辑删除字段 is_deleted,是查所有数据列表的前置条件。如果哪次新增了查询 SQL 却漏了这个条件,后台管理页就会出现一堆“已删除”的脏数据。我建议把 is_deleted 过滤器做成 MyBatis 拦截器级别的通用能力,从机制上防止遗漏,而不是靠每个开发人员写 SQL 时自觉。
最后,分页查询是整个菜品管理模块的“门面”,前端第一眼看到的就是这个列表页,它的响应速度、数据准确性直接影响用户对系统的第一印象。把分页查询做扎实,后续的所有功能迭代都会轻松很多。这些经验在我实际项目里带来的收益是实打实的,希望也能帮到正在开发这个功能的你。