1. 需求拆解与接口设计思路
先说下背景。我手上的项目是一个面向个人用户的成长管理类应用,里面有一个核心模块叫“学习计划”,用户可以创建自己的学习计划,比如“三个月完成英语词汇量突破”、“每天刷一道算法题”之类的,然后系统记录学习进度。现在开发到了第14天,这一节的任务很明确:做“我的学习计划”的查询接口,而且必须是分页的。
为什么分页这么重要?因为学习计划的数据会随着用户的使用越攒越多。你想,一个比较自律的用户,可能同时有七八个正在进行的计划,还有几十个已经结束的、归档的历史计划。如果一次性把所有数据全部返回,网络传输开销大,前端渲染也卡,体验很差。分页的意义不在于“显得专业”,而在于让接口的数据量可控,让用户在自己需要看的范围内完成操作。
再说“我的”这两个字。这决定了数据隔离的维度。接口不能查全表,必须强制加上当前用户的ID作为过滤条件。很多刚入行的同学容易漏掉这个,只做了分页,结果A用户把B用户的学习计划全查出来了,这就是安全事故。所以我在设计接口的第一时间就把用户维度钉死了:一切查询以当前登录用户的身份为前提。
这一天的标题里还有一个细节“25:47”,我理解是本节视频的时长,25分47秒。一个不长不短的时间,足以完整演示从接口定义到前后端联调的全过程。我在实际开发中,也给自己定了个节奏:一个接口的完整落地,包括需求确认、代码实现、自测、接口文档更新,控制在这个量级是比较健康的。如果超过这个时间,说明接口设计可能过于复杂,或者实现方式绕了远路。
回到正题,我下面把这节内容拆成几个核心部分,依次是:需求分析、参数设计、数据库查询实现、Service业务处理、Controller层封装,以及最后的分页插件坑点排查。整个流程走完,你就知道一个“分页数据查询接口”从零到一应该怎么写,以及哪些地方最容易踩坑。
2. 分页查询的参数设计与SQL底层逻辑
2.1 分页参数到底该怎么传
分页查询接口,最常见的传入参数就是两个:页码(pageNum)和每页条数(pageSize)。但真到了设计层面,很多细节需要定清楚。
第一,页码从0开始还是从1开始?这是前后端容易扯皮的点。我这边定的是从1开始,因为用户能感知的页码概念就是从第一页起步的,前端展示“第1页”对应pageNum=1,直译过来就好。如果从0开始,前端展示时还得加1,平白无故多了转换逻辑。
第二,每页条数有没有上限?必须有。你不能让用户随便传一个pageSize=100000,那和查全表没什么区别。我通常把上限设置为50,默认是10。这样既保证了用户能一次看到足够多的信息,也不会让单次请求的数据量失控。
第三,需要不需要返回总数(total)?一般情况下需要。因为前端要渲染分页组件,至少需要知道总页数,而总页数是根据总记录数pageSize算出来的。如果没有总数,前端就不方便展示“共XX页”,也不好在最后一页时禁用“下一页”按钮。所以查询接口要返回一个包含数据和总数两个部分的结果对象。
参数校验也是不能少的。pageNum不能小于1,pageSize不能小于1且不能大于50。有人觉得这是小事,但如果不校验,负数或0进入数据库查询,要么查不出来,要么MySQL报语法错误。更严重的可能是恶意构建一个超大pageSize导致数据库压力过大。我在代码里用注解校验或者手动判断,总之必须拦在外层。
2.2 LIMIT offset 与性能意识的建立
MySQL分页基础写法是:
SELECT * FROM study_plan WHERE user_id = #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize};offset = (pageNum - 1) * pageSize。比如第二页,pageNum=2,pageSize=10,offset=10,就跳过前10条,取第11到第20条。
这里我要多说一句。很多人在开发初期用的是PageHelper这类分页插件,框架自动帮我们生成count查询和limit语句,省事。但如果你不理解底层SQL,一旦遇到深分页问题,就会无从下手。因为传统的LIMIT offset方式,在offset非常大的时候有一个性能缺陷:MySQL会先把offset+pageSize条记录全部查出来,然后丢弃前offset条,只返回后面的pageSize条。比如你要查第10000页,offset是100000,MySQL可能还要扫描很多无用数据,查询效率会明显下降。
解决深分页的常见方式是“延迟关联”或“基于游标的分页”。所谓游标分页,就是不用页码,而是用上一次查询的最后一条记录的排序字段作为查询条件,比如按创建时间排序时,WHERE create_time < #{lastCreateTime} ORDER BY create_time DESC LIMIT #{pageSize}。这种方式特别适合移动端的下拉加载更多,但不太适合有“跳页”需求的后台管理系统。因为跳页需要随机访问第N页,游标做不到。
我这次做的是个人学习计划的列表,数据量通常不会大到十万级,所以还是采用传统的页码分页,但我在SQL里加了排序字段的索引,保证日常场景的查询速度。重要的是理解这个机制,等以后数据量真上来了,你才知道该往哪个方向优化。
2.3 排序规则怎么定才合理
分页查询还有一个隐藏问题:如果没有明确的排序规则,MySQL返回的顺序是不稳定的。比如用户在第一页看到了第5条记录,刷新一下可能这条记录跑到第二页去了,这就是因为没有稳定的排序字段。
我选择的是create_time DESC,即最新创建的计划排在最前面。不过这里有个小陷阱:如果同一秒内创建了多条计划,单靠create_time排序无法保证顺序完全确定。所以最佳实践是再加一个唯一字段作为次级排序。比如order by create_time desc, id desc。这样在同秒数据存在时,也能保证每次查询的顺序是一样的。
对于学习计划这种对象,排序字段还可以有:最近更新时间、计划开始时间、计划完成状态优先级等。但用户最直观的需求就是“看看我最近加了哪些计划”,所以创建时间倒序最合适。如果未来要支持“按截止日期最近”“按进度排序”,可以在查询参数里增加sortField和sortOrder,让前端自由控制,但要做好白名单校验,避免SQL注入。
3. 代码落地:从Mapper到ServiceImpl的完整实现
3.1 实体类与返回结构设计
这一节开发的核心数据结构有这么几个:StudyPlan实体、PlanQueryParam查询参数对象、PageResult<T>通用分页返回结构。
StudyPlan对应数据库表study_plan,关键字段有:id、user_id、plan_name、goal_desc、plan_status、progress_rate、create_time、update_time。其中progress_rate表示整体完成度,类型用整数,比如0-100。这里我踩过一个坑:如果用小数表示百分数,前后端换算比例的时候容易出精度偏差,整数最省事。
查询参数对象:
public class PlanQueryParam { private Integer pageNum = 1; private Integer pageSize = 10; private Integer planStatus; // 按状态筛选,可选 // getter setter 省略 }这个对象里可以不包含userId,因为userId要从当前登录态获取,不从前端传。如果从前端传,人家传别人的userId就能看别人的数据了,这是大忌。
返回结构:
public class PageResult<T> { private List<T> list; private long total; private int pageNum; private int pageSize; }为什么不直接返回List?因为前端需要total。为什么不直接把total塞到请求参数里?因为这是响应数据,需要独立结构。我见过有同学把total放进Map返回,看着灵活,但失去类型安全,前端也不容易约定格式。统一按照{ list, total, pageNum, pageSize }的结构返回,清晰稳定。
3.2 Mapper接口与XML的两种实现思路
写Mapper接口时,有两种选择:一是直接写方法返回一个包含总数和数据的对象,在SQL里同时写count查询和列表查询;二是用分页插件自动拦截count。我先说第一种,因为这种方式最透明,也是不依赖第三方框架的通用方案。
@Mapper public interface StudyPlanMapper { List<StudyPlan> selectPlanPage(@Param("userId") Long userId, @Param("offset") Integer offset, @Param("pageSize") Integer pageSize, @Param("planStatus") Integer planStatus); Long countPlanPage(@Param("userId") Long userId, @Param("planStatus") Integer planStatus); }XML里对应的两个SQL:
<select id="selectPlanPage" resultType="com.demo.entity.StudyPlan"> SELECT id, user_id, plan_name, goal_desc, plan_status, progress_rate, create_time, update_time FROM study_plan WHERE user_id = #{userId} <if test="planStatus != null"> AND plan_status = #{planStatus} </if> ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{pageSize} </select> <select id="countPlanPage" resultType="java.lang.Long"> SELECT COUNT(*) FROM study_plan WHERE user_id = #{userId} <if test="planStatus != null"> AND plan_status = #{planStatus} </if> </select>这种写法优点是清晰:总数查询和列表查询分开,SQL完全可控。缺点是业务上需要执行两次SQL。有些人为了省事,只写列表查询,然后用page.getTotal()之类的方式反推总数,但那通常是框架内置的功能,如果不用框架,就必须显式查count。
第二种方式是用PageHelper。如果你不想手动算offset,也不想写count查询,可以在Service里这样写:
PageHelper.startPage(pageNum, pageSize); List<StudyPlan> list = studyPlanMapper.selectPlanList(userId, planStatus); PageInfo<StudyPlan> pageInfo = new PageInfo<>(list);但PageHelper有一个经典的“失效”问题:如果你在调用startPage之后,紧接着执行了别的查询语句,比如查用户信息,那么分页会作用在这个错误的查询上。这是因为它底层是基于线程本地变量在下一句SQL上自动拼接limit。所以使用时要严格遵守“startPage之后第一条SQL必须是目标查询”的规则。
考虑到这是一节教学性质的开发,我建议你把第一种手动分页的逻辑吃透。因为你未来可能接触TDengine、MongoDB、ES等不同存储,分页手段各有差异,但核心思想都是“跳过的数据量、每页限制条数”。
3.3 Service层:业务校验与数据补全
Service层主要负责三件事:确认用户身份、计算offset、调用Mapper并组装结果。
@Service public class StudyPlanService { @Autowired private StudyPlanMapper studyPlanMapper; public PageResult<StudyPlan> queryMyPlans(PlanQueryParam param, Long currentUserId) { // 基础校验 if (param.getPageNum() == null || param.getPageNum() < 1) { param.setPageNum(1); } if (param.getPageSize() == null || param.getPageSize() < 1) { param.setPageSize(10); } if (param.getPageSize() > 50) { param.setPageSize(50); } int offset = (param.getPageNum() - 1) * param.getPageSize(); Long total = studyPlanMapper.countPlanPage(currentUserId, param.getPlanStatus()); List<StudyPlan> list = studyPlanMapper.selectPlanPage( currentUserId, offset, param.getPageSize(), param.getPlanStatus()); PageResult<StudyPlan> result = new PageResult<>(); result.setList(list); result.setTotal(total); result.setPageNum(param.getPageNum()); result.setPageSize(param.getPageSize()); return result; } }这里有个业务细节:当总数为0时,列表肯定就是空的。当pageNum超出最大页数时,比如总共有1条数据,每页10条,一共1页,前端却传了pageNum=99,那么查询返回的list是空的,但total仍为1。这种场景下,接口不应该报错,而是正常返回空列表。很多用户操作路径上不会出现这种情况,但如果前端页码状态没同步好,接口也要能宽容处理。
我还会在Service层对返回的列表做一层“数据脱敏或字段裁剪”。比如学习计划里的goal_desc如果是用户写的长文本,在列表页没必要返回全文,可以截断一部分,详情页再返回完整内容。这能降低响应体大小。不过要注意截断不能在前端做,否则用户可以直接查看全量数据,安全上有隐患。
3.4 Controller层的接口定义与统一响应
Controller层保持轻薄,只做参数绑定和统一返回。
@RestController @RequestMapping("/api/plan") public class StudyPlanController { @Autowired private StudyPlanService studyPlanService; @GetMapping("/my/list") public Result<PageResult<StudyPlan>> queryMyPlans(PlanQueryParam param) { Long userId = UserContext.getCurrentUserId(); return Result.success(studyPlanService.queryMyPlans(param, userId)); } }这里要注意,我用的是UserContext.getCurrentUserId(),而不是从参数里获取。在真实项目里,当前登录用户往往来自Token或者Session。之前做单体项目时我们使用ThreadLocal来存储用户ID,在拦截器里解析登录信息后放进去,这样Controller和Service都能拿到。这个方法很简单,但要注意线程池环境下ThreadLocal的清理问题,否则会串号。
关于统一返回结构Result<T>,团队成员需要事先约定success、code、message、data四个字段。即使接口内部出了问题,也要用这个结构包装后返回,不能直接把异常堆栈抛给前端。尤其分页查询可能因为SQL语法错误、数据库连接超时导致异常,统一的异常处理器负责转换为标准错误响应。
4. 联调阶段的常见问题与排查记录
4.1 分页插件不回显total的问题
我之前参与过的一个模拟项目里,同事用PageHelper写分页,发现返回的list数据是对的,但total永远为0。排查下来是因为他在XML里自定义了一个selectPlanPage并返回了List<StudyPlan>,但PageHelper生成count SQL的时候,会自动把查询语句改写为SELECT COUNT(0) ...,这时候如果原SQL里有复杂的JOIN或者DISTINCT,自动生成的count可能不准确。
解决办法有几种:一是手动使用PageHelper.startPage(pageNum, pageSize)后,直接用PageInfo对象的getTotal();二是如果自动count不理想,可以自己写count语句,并用PageHelper.count()传入自定义的count查询。但最省心的其实是我一开始说的方案:不要过度依赖插件,手动查count。反正业务也不复杂,两条SQL对我们这体量的应用没有任何压力。
4.2 前端翻页时数据重复或丢失
这个问题通常发生在排序不稳定时。如果SQL里只写了order by create_time desc,而同一时间创建了多条记录,MySQL有可能在两次相同查询里返回不同的顺序,于是翻页时出现同一条数据出现在上一页和下一页,或者某条数据被跳过去。我给出的解决方案是加id desc作为排序的二级条件。
还有一个可能的原因是pageNum从1开始,但前端在接收后端返回的pageNum后,给重新赋值时不小心做了pageNum - 1的操作,导致重复请求第一页数据。这种问题在联调时最容易出现,需要后端明确文档里写清楚:页面上的第一页对应pageNum=1。
4.3 数据权限被绕过
这是我反复强调的点。曾经有一个模拟项目,因为接口接收一个userId参数,用于查询不同的用户学习计划,测试阶段没发现什么问题,上线后被人用脚本遍历userId,把所有人的学习计划都拉走了。后来修复方式就是去掉接口参数,从Session里获取用户身份。这次开发我一开始就没把userId暴露给前端,所以从根上避免了这个问题。如果你接到一个老接口改造,记得一定要检查现有查询有没有这种越权漏洞。
4.4 慢查询排查:缺少索引
分页查询最怕的就是慢。我第一次给学习计划列表做压测时,发现随着数据量增长到五万条,接口响应时间从50毫秒涨到了1.5秒。排查后发现study_plan表只在主键id上建了索引,而查询条件是user_id。
原因很简单:MySQL执行WHERE user_id = #{userId}时,如果没有索引,就会全表扫描,把所有记录一行一行检查,再排序、跳过offset。加一个普通索引就解决了:
ALTER TABLE study_plan ADD INDEX idx_user_create (user_id, create_time);这个联合索引设计很划算:user_id用于精确过滤,create_time用于排序。最左前缀原则使得这个索引既能快速定位用户的数据,又能让数据库沿着索引顺序直接返回排序结果,避免额外filesort。加了索引后,接口响应时间又降回50毫秒以内。
如果你发现MySQL仍然选择全表扫描,可能情况是索引选择性不够(比如大部分数据都属于同一个user_id),或者统计信息过期。这时可以再结合业务调整,比如增加“删除标记”过滤条件,或者引入缓存。
4.5 前端“加载更多”与“分页组件”两种模式
分页接口写好后,需要明确前端的使用场景。学习计划列表在移动端往往采用“下拉加载更多”,这对应增量加载:每次请求pageNum递增,把新数据追加到列表尾部。而PC端后台通常用“页码跳转”,点击第N页直接请求那一页。
两种模式对后端接口来说是一样的,只需要正常返回总数。但我在实际开发中发现,移动端“加载更多”模式下,后端返回list为空时,前端要主动停止请求,否则用户滑到底部会不断发请求,产生大量无意义查询。前端判断依据就是if (res.data.list.length === 0) { 不再触发load },后端可以用hasNext字段辅助,但代码量要比简单封装多。目前我的建议是,先用基础JSON结构,如果后续有性能压力再优化。
5. 关于性能优化与下一步扩展的思考
分页查询接口做完了,并不代表一劳永逸。随着用户量变大,我通常会从三个方向继续优化。
第一个方向是局部缓存。对个人学习计划这类数据,一个用户可以同时维护的数据量很有限,几千条算多了。可以用userId作为缓存key,把第一页数据缓存到Redis里,设置短过期时间,比如2分钟。这样用户进入页面时可以秒开,同时如果数据变更,立刻删除缓存。但缓存更新有一个很麻烦的点:分页页数多了后,任何一条数据变化都可能影响很多页的缓存,所以通常只缓存第一页,其他页还是走数据库。
第二个方向是接口数据裁剪。我之前只返回了计划的基础字段。如果计划详情里包含学习记录列表、历史笔记等,绝对不能在列表接口里全部返回,必须拆成两个接口。列表接口只返回概要信息,详情接口再返回完整内容。这样可以避免一条学习计划的数据量过大。
第三个方向是异步化。如果列表页需要同时展示“学习计划”“今日任务”“学习统计”三个模块,不要在一个接口里全部做完了返回。否则这个接口就要分别查询数据库、聚合计算,响应时间会线性增长。正确做法是拆分成多个接口,前端并行请求。我见过模块化应用里有同事把统计功能硬塞进计划列表接口,结果一个列表查询背后带了好几个聚合SQL,代码难看,性能也差。这个教训值得记下来。
另外,关于这节内容本身的后续,还可以增加“按计划状态筛选”“按计划名称模糊搜索”“按完成度排序”等功能,本质上就是在查询参数里增加字段,在SQL里增加条件。分页查询是一个很基础的接口形态,但把基础功能做得可靠稳定,比堆砌一堆花哨功能更重要。
6. 实际开发中的一些心里话
Day03-14这节内容,从项目进度来看只是一个普通的功能接口。但我始终认为,越基础的接口越能体现一个开发者的工程素养。分页参数是否正确校验、排序是否稳定、查询是否能命中索引、接口能否防止越权,这些细节单独拎出来都不难,难的是每次写接口时都保持同样的警觉。
我记得自己刚开始接触分页查询时,只会调用PageHelper.startPage,完全没想过count从哪里来,也没想过offset的计算逻辑。后来有一次因为SQL问题导致生产环境列表接口报错,才开始认认真真把MySQL分页的机制研究明白。从那以后,我写任何一个分页接口,都会先问自己三个问题:查询条件是否都走了索引?排序是否稳定?总数查询是否高效?这三个问题想清楚了,接口基本就不会出大问题。
如果让我给正在学习的人一个建议,那就是亲自动手把一次完整的分页查询写下来,从建表到Mapper到Service到Controller,不要只跑通流程,而要理解每一步的取舍。比如为什么返回值里要有total,为什么LIMIT后边的offset要这样算,为什么不需要把userId作为请求参数。这些都理解透了,你才算真正掌握了这一小节的内容。
最后分享一个排查问题的小技巧:当你怀疑分页查询返回的数据不对时,先把Service里生成的SQL语句打出来,手动复制到数据库客户端里执行一遍,看看原始结果是什么。这是我百试百灵的排障手段。很多问题在代码层面看着逻辑没问题,但一放到真实的SQL执行环境里,真相就出来了。学会自己打印SQL、分析SQL,是后端开发最基本也最实用的能力。