多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Boot + MyBatis Plus菜品分页查询:原理、实现与性能优化

Spring Boot + MyBatis Plus菜品分页查询:原理、实现与性能优化 干餐饮后台的兄弟基本都绕不开菜品分页查询这个功能。菜单管理列表要分页、点餐端菜品浏览要分页、按分类筛选上下架状态也要分页看起来就是个普通得不能再普通的接口可真到落地的时候字段映射错位、count统计口径不对、Page对象失效这些问题一个接一个轻则联调加班重则线上点餐页面白屏。这篇东西是我从实际项目里摔打出来的经验汇总。技术栈以Spring Boot 2.7 MyBatis Plus 3.5为基准适合刚开始做餐饮管理、外卖点餐系统的后端同学也适合那些分页功能写过但没深究过原理的人。我会从分页原理、插件工作机制、完整实现代码、前端联调陷阱到性能优化全链路走一遍还会把几个典型的排查过程原样写出来方便你遇到类似问题时有迹可循。1. 菜品分页看着简单为什么还值得单独拿出来讲1.1 一张菜品表背后的真实查询压力很多人觉得菜品不就几百条数据嘛一次全查出来不就行了。有这种想法很正常但你去看看真实业务里的菜品表结构就不这么想了。一张菜品表除了主表字段往往还挂着分类名称、图片URL、规格选项、售卖状态、今日库存这些信息。尤其是图片URL一张高清菜品图能到几百个字符一次查500条就是几万字符的JSON再加上前端还要做列表渲染点餐页面的首屏时间基本就废了。更麻烦的是同一个分页接口要同时服务B端管理后台和C端点餐小程序B端要按分类、价格、上下架状态筛选C端要按销量、好评率排序这些条件组合在一起SQL的复杂程度会快速上升。还有一点容易被忽略菜品的状态是频繁变化的。早市过后一批菜售罄后台改状态C端菜单要立刻反映出来。如果不用分页而是一次性把全量数据推到前端状态字段更新的压力就会分散到每一次全量拉取中流量消耗和服务器压力都会明显变大。分页在这个场景里既是性能手段也是数据实时性的基础。1.2 手写LIMIT、PageHelper、MyBatis Plus插件三条路线怎么选做分页有三种常见路线我给出它们的核心区别和我实际选型的理由。方案实现原理优点缺点适合场景手写LIMIT每次拼接 LIMIT offset, size单独写count SQL灵活可控无框架绑定每个Mapper重复劳动条件变更容易漏改两处SQL接口极少且条件固定PageHelper基于ThreadLocal拦截SQL自动生成count和limit上手快老项目普及率高跨线程传递会失效嵌套查询可能统计错乱遗留项目维护MyBatis Plus分页插件拦截器解析SQL物理分页自动拼count和limit与IPage对象配套代码无侵入必须按规范返回IPage配置不对会静默失效新项目开发我现在的习惯是无脑选MyBatis Plus插件。原因很现实新项目基本都用了MyBatis Plus作为ORM框架分页插件只是加一个拦截器的事不需要额外依赖IPage对象天然封装了records、total、current、size这些分页元数据Controller层返回结构非常清爽配合LambdaQueryWrapper写条件查询拼接起来不用关心字符串逗号的问题。但选MyBatis Plus不等于高枕无忧它最大的坑是静默失效。所谓失效就是你代码写得很规范也传了Page对象但SQL执行后并没有LIMIT全表数据被一次性查了出来。这种问题比报错更难排查后面我会专门讲。2. 分页插件工作的底层逻辑一个Page对象的完整旅程2.1 offset怎么算pages怎么得分页SQL的前世今生分页查询的核心是两条SQL这是个数学问题。第一条是列表数据SQL。以每页10条、请求第3页为例MySQL里写成LIMIT 20, 10。这个20叫偏移量offset计算方式是(current - 1) * size也就是(3 - 1) * 10 20。注意offset从0开始第1页的offset是0LIMIT 0, 10表示从第0条开始取10条。第二条是总数SQL也就是SELECT COUNT(*) FROM dish_info WHERE ...用来告诉前端一共有多少条数据。有了总数total和每页数量size总页数pages就可以通过公式计算(total size - 1) / size。这个公式本质是向上取整。100条数据每页10条算出10页101条数据算出11页。很多新手会在总页数上踩坑用total / size这种整数除法结果是10而不是10.1导致101条数据时最后一页永远显示不了第11页的数据。我在项目中统一用向上取整的方式避免这类边界问题。2.2 插件拦截器内部做了什么count和limit是怎么自动生成的MyBatis Plus分页插件之所以能自动帮我们做这些事是因为它注册了一个拦截器会在SQL执行前进行拦截和改写。理解这个机制你才能真正知道为什么我传了Page却还是全量查询。我简单说下执行链路。当你调用selectPage(page, wrapper)方法时MyBatis Plus会走到MapperProxy代理最终到达分页拦截器。拦截器拿到原始的SQL后做三件事生成countSql。它把原SQL的外层查询直接包成SELECT COUNT(*) FROM (原SQL) AS total然后执行这个count查询把结果塞进Page对象的total字段。改写原SQL根据数据库方言拼接LIMIT语句。如果你配置的是MySQL方言它生成LIMIT offset, size如果是PostgreSQL生成LIMIT size OFFSET offset。执行改写后的SQL把查询结果封装进Page对象的records字段并把current、size、pages这些元数据填充完整。这个机制决定了两个关键约束第一方法入参里必须有Page对象且返回类型必须是IPage接口类型第二分页插件会从Page对象里读取current和size所以这两个值必须在调用Mapper之前就设置正确。2.3 返回类型和分页插件失效的几种经典现场我见过太多分页插件没生效的案例归纳起来就三类第一种Service层方法返回类型写成了List。分页插件只对返回IPage类型的方法生效如果你写了List list dishMapper.selectPage(page, wrapper)编译能通过警告都不会有但插件识别到返回类型不是IPage就直接放行了查询SQL没有LIMIT全量数据被查出来。这种问题在列表数据几千条时还不明显等数据量涨到几万条接口响应时间会突然飙升。第二种方法参数里根本没有Page对象。分页插件是从Mapper方法的第一个参数里找IPage类型的参数的。如果你把Page对象放在了一个自定义对象里比如PageQuery里有Page字段但Mapper方法入参是PageQuery插件找不到IPage参数同样不会执行分页。第三种数据库方言类型配置错了。我接手过一个项目配置写的是DbType.SQL_SERVER但实际数据库是MySQL。现象是列表接口偶尔能查出来偶尔报SQL语法错误日志里的SQL是SELECT TOP 10 * FROM dish_info这种SQL Server写法。MySQL自然不认。排查时检查配置文件里的数据库类型和实际连接的是不是一个。这三类问题排查思路都一样打开MyBatis的SQL日志看执行的SQL里有没有LIMIT关键字。没有LIMIT就从上面的三个方向逐个对照检查。3. 从建表到接口一套可复用的菜品分页查询实现3.1 表结构、索引与实体类的设计细节设计分页接口的第一步不是写代码而是把表结构和索引想清楚。这里我给出一份精简但完整的菜品表设计。CREATE TABLE dish_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, dish_name VARCHAR(64) NOT NULL COMMENT 菜品名称, category_id BIGINT NOT NULL COMMENT 菜品分类ID, price DECIMAL(10, 2) NOT NULL COMMENT 售价, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1上架 0下架, image_url VARCHAR(500) NOT NULL DEFAULT COMMENT 主图URL, stock INT NOT NULL DEFAULT 0 COMMENT 当日库存, create_time DATETIME NOT NULL COMMENT 创建时间, update_time DATETIME NOT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_category_status (category_id, status), KEY idx_update_time (update_time) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 菜品信息表;索引我刻意设计了两个idx_category_status应对按分类状态筛选这种最频繁的组合筛选条件idx_update_time应对按更新时间排序的分页场景。分页SQL最容易慢在ORDER BY LIMIT组合上没有索引支撑MySQL会把满足条件的所有行都查询出来再排序取前几条数据量大时极其拖节奏。实体类相对简单我习惯用Lombok减少样板代码字段和表字段保持一致Data TableName(dish_info) public class Dish { TableId(type IdType.AUTO) private Long id; private String dishName; private Long categoryId; private BigDecimal price; private Integer status; private String imageUrl; private Integer stock; private LocalDateTime createTime; private LocalDateTime updateTime; }这里有个小经验TableName注解一定要写别依赖默认的驼峰转下划线规则。比如dishName默认映射dish_name没问题但真实项目中表名、字段名经常不规则注解显式声明能避免后面一堆排查功夫。3.2 分页插件配置与查询对象设计分页插件配置是整个方案的核心。我给出的是生产环境可用的最小配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(200L); interceptor.addInnerInterceptor(pagination); return interceptor; } }这里我特别加了setMaxLimit(200L)。它的作用是限制单次最大查询条数防止有人恶意把size传成100000把数据库拖垮。这个值根据业务定菜品管理后台我一般限制单页最多100条操作日志类列表限制到500条都是合理的。查询对象的设计是我比较重视的部分。不要把current、size、name、categoryId、status这些散参数直接堆在Controller方法参数里包装成一个查询对象复用性和可读性都好很多Data public class DishPageQuery { private Integer current 1; private Integer size 10; private String name; private Long categoryId; private Integer status; private BigDecimal minPrice; private BigDecimal maxPrice; }注意默认值是在Java字段上直接赋的这样即使前端漏传参数后端也有兜底值。前端传了null时字段上的默认值不会生效这个要依赖Controller层的参数归一化处理后面我会写到。3.3 Service层、Controller层代码与统一响应结构Service层是分页逻辑的落脚点。我习惯把筛选条件用LambdaQueryWrapper的条件构造方法写出来而不是先判断再手动拼接SQL片段Override public IPageDishVO pageDish(DishPageQuery query) { PageDish page new Page(query.getCurrent(), query.getSize()); LambdaQueryWrapperDish wrapper new LambdaQueryWrapperDish() .like(StringUtils.hasText(query.getName()), Dish::getDishName, query.getName()) .eq(query.getCategoryId() ! null, Dish::getCategoryId, query.getCategoryId()) .eq(query.getStatus() ! null, Dish::getStatus, query.getStatus()) .ge(query.getMinPrice() ! null, Dish::getPrice, query.getMinPrice()) .le(query.getMaxPrice() ! null, Dish::getPrice, query.getMaxPrice()) .orderByDesc(Dish::getUpdateTime); return dishMapper.selectPage(page, wrapper).convert(this::toVO); }这段代码的逻辑是第一个参数是布尔表达式为true时该条件加入SQL为false时跳过。这样写比一堆if-else干净而且维护时不用来回看哪些条件被手动拼接过。convert方法把Dish实体转成DishVO。为什么转换因为Dish实体里的imageUrl、createTime这些字段不一定都要暴露给前端VO层可以做字段裁剪和格式化避免把实体直接返回。Controller层做两件事参数校验和统一响应。我的写法如下RestController RequestMapping(/api/dish) public class DishController { Resource private DishService dishService; GetMapping(/page) public ResultIPageDishVO page(DishPageQuery query) { if (query null) { query new DishPageQuery(); } if (query.getCurrent() null || query.getCurrent() 1) { query.setCurrent(1); } if (query.getSize() null || query.getSize() 100) { query.setSize(10); } return Result.success(dishService.pageDish(query)); } }current小于1时强制归为1这个归一化很重要。有些前端分页组件第一页传的是0如果直接拿给Page对象MyBatis Plus计算offset时会得到负数SQL变成LIMIT -10, 10MySQL直接报语法错误。这个问题我在前面提到的前端联调翻车事件里遇到过后面还会细说。统一响应结构如下{ code: 0, msg: success, data: { records: [], total: 100, size: 10, current: 1, pages: 10 } }这个结构里data字段就是IPage对象序列化后的样子。前端拿records渲染列表拿total和pages渲染分页器非常直观。4. 前端联调最容易翻车的三个地方4.1 字段命名错位records、total与list的战争后端和前端对分页数据结构的理解不一致这是联调第一天最常见的冲突。很多前端团队用element-ui的el-table或者antd的Table组件组件默认接收的列表字段是list或者rows而后端IPage序列化出来的字段叫records。前端如果不做适配拿到数据后list是undefined页面直接空白。解决方式两种要么在VO层做一个结构转换把records重新组装成前端期望的字段要么前端在拿到响应后做一次映射。我的建议是后端统一规范因为前端有多个页面每个页面都做映射太容易漏。而且全公司统一了分页响应结构后面新增接口就不用反复对齐这件事。total字段也会翻车。当数据总量过大时有的安全组件或者JSON序列化配置会把Long类型的total转成字符串前端用比较类型时会出现total是字符串而当前页是数字导致的分页逻辑错乱。解决方案是把total的类型明确为Long并检查Jackson全局配置中是否开启了Long转String的策略如果开了就得在前端明确用parseInt转换或者在后端单独为这个字段配置序列化方式。4.2 从0开始传页码导致LIMIT负数排查链路实录这是一个我印象很深的线上问题。点餐端小程序翻页到第二页之后页面突然空白接口报500。我第一反应是打开浏览器的Network面板看实际请求参数发现前端传的current字段不是2而是0。原来前端某个下拉加载组件内部把第一页的页码定义为0第二页为1我们在后端约定的是从1开始。当current0时MyBatis Plus计算offset (0 - 1) * size -10SQL生成LIMIT -10, 10MySQL直接报错。排查链路梳理如下看Network请求确认参数值current0,size10。看后端日志发现SQL中LIMIT关键字后面是负数。确认是前后端页码起始定义不一致导致的而不是SQL语句本身有问题。修复方案分两层前端组件配置pageStartIndex为1把起始页改为1后端做参数归一化current小于1时统一改成1。两层都做才能防止后续再有人踩这个坑。这类问题用Postman测试的时候完全暴露不了因为Postman里你按正常习惯传current1但真实前端组件的行为不在你掌控范围内。后端参数归一化是最可靠的防线。4.3 count统计口径不一致筛选条件失效的排查过程另一个高频问题筛选条件生效了列表数据没错但total始终是全表数量。你按分类川菜筛选列表只显示20条total却显示1000。这个问题的根源往往是使用了自定义count SQL。有人为了性能优化自己写了一个countDish方法但编写时只写了SELECT COUNT(*) FROM dish_info没有带上和列表SQL相同的where条件。我排查过一次现象是B端后台按分类筛选列表显示正常但分页器的总页数永远不对导致点最后一页时返回空列表。排查链路是复现筛选条件抓接口SQL日志。发现列表SQL是正常的带了WHERE category_id 5。再往下翻日志发现count SQL是SELECT COUNT(*) FROM dish_info没有任何where条件。定位到Service层手动调用了独立的count方法跟selectPage走的分页插件自动生成的count不是同一个逻辑分支。修复方式删掉自定义count方法直接让分页插件从原SQL自动生成count保证count和列表的条件一定一致。这是个很隐蔽的坑。只要count SQL和列表SQL是两套手工维护的代码时间一长一定会出现条件不同步。最佳方案是用分页插件的自动count机制让它从同一个wrapper里提取条件这样天然保证一致性。5. 菜品量级增长后的分页性能优化5.1 count别拖后腿轻量化统计与索引利用分页接口的性能瓶颈经常不在列表查询上而在count查询上。列表查询有LIMIT限制再怎么扫描也就那么多行但count需要扫描全部满足条件的行数据量大时往往比列表查询还要慢。菜品的total统计首先要保证走索引。当用户按category_id status筛选时如果没有复合索引MySQL需要全表扫描才能数出总量。所以前面建表的时我加了idx_category_status(category_id, status)这个复合索引count和列表查询都能受益。其次count查询不要去关联其他表。菜品列表可能要join分类表拿category_name但count只需要统计dish_info主表的行数join只会白白增加扫描成本。如果确实需要带条件关联统计考虑用EXISTS子查询替代JOIN或者在主表中冗余分类名字段避免为count服务。还有一个思路供参考在C端点餐场景total并不是必须精确的值。点餐页面的分页器只显示当前页码用户并不关心总共多少页。这时候可以把total逻辑简化成一个近似的估算值比如直接只count主表放弃一些复杂条件接口响应能快一个量级。当然这种优化要跟前端确认分页器的展示逻辑不能一厢情愿。5.2 大偏移量走不动游标分页的适用边界分页还有一个经典性能陷阱大偏移量。随着页码增大LIMIT后面的offset越来越大MySQL需要扫描offsetsize条数据再丢弃前面的offset条只保留最后size条。第10000页的SQL要扫描10万条数据耗时必然显著上升。解决大偏移量问题的标准做法是游标分页也叫keyset分页。它的核心不再是页码而是上一次看到哪里了。以菜品表为例按id排序时SQL写成SELECT id, dish_name, price FROM dish_info WHERE id #{lastId} ORDER BY id LIMIT 100;每次把上一页最后一条记录的id传给下一页数据库直接利用主键索引定位到lastId之后的位置不再需要大offset扫描。这种方式的数据量再大扫描成本都只和pageSize成正比。但它也有适用边界游标分页不适用于跳页。用户想从第1页跳到第50页你没有第49页的lastId就跳不过去。菜品管理后台通常需要跳页所以保留传统LIMIT分页而点餐端下拉加载是无限滚动的天然适合游标分页。我在这类场景的选型是B端用IPage传统分页C端点餐列表用游标分页两个接口分开维护各自用最合适的方案。5.3 点餐端菜单接口的缓存设计当点餐端并发量上来后即使分页做得很漂亮数据库压力也会越来越大。菜品数据有几个特点读多写少、实时性要求中等、内容相对稳定。这种特点适合用缓存来扛读压力。我的做法是对C端菜单分页结果做Redis缓存缓存key这样设计dish:menu:{categoryId}:{page}:{size}value直接存放序列化后的分页JSON。点餐页面的每个分页请求先查缓存命中就直接返回不命中再走数据库查询并回填缓存。缓存失效是这门手艺的关键。菜品上下架、价格调整、库存变化都需要让相关缓存失效。我之前吃过亏只做了缓存写入没有在后台修改菜品时主动清理缓存结果C端菜单显示的是旧价格用户下单时价格对不上运营直接炸毛。现在的方案是发布订阅机制后台任何菜品变更操作完成之后发送一个频道消息C端服务订阅到消息后清空该菜品所属分类下的全部分页缓存。虽然清理粒度粗一点但胜在逻辑简单不易漏。代价是缓存命中率会有轻微下降但对于点餐菜单这种数据量来说完全可接受。如果菜品有当日库存这种高频变化字段缓存时间别设置太长。我一般控制在60秒到5分钟之间根据业务对实时性的容忍度微调。售罄这种状态变化如果延迟5分钟用户点单时会发现菜下不了单体验就很难受。个人的一点实在话分页查询写多了以后你会发现真正考验功底的地方不在代码本身而在各种异常场景兜底、前后端契约一致性和数据量增长后的性能前瞻。我现在的习惯是凡新增一个分页接口先把maxLimit是否设置、参数归一化是否处理、count和列表条件是否同一套逻辑这三个骨架问题检查一遍再开始写业务代码。这套检查单看起来不起眼但每一项背后都对应着一个真实的线上教训。
返回列表