
1. 需求拆解与接口设计思路先说下背景。我手上的项目是一个面向个人用户的成长管理类应用里面有一个核心模块叫“学习计划”用户可以创建自己的学习计划比如“三个月完成英语词汇量突破”、“每天刷一道算法题”之类的然后系统记录学习进度。现在开发到了第14天这一节的任务很明确做“我的学习计划”的查询接口而且必须是分页的。为什么分页这么重要因为学习计划的数据会随着用户的使用越攒越多。你想一个比较自律的用户可能同时有七八个正在进行的计划还有几十个已经结束的、归档的历史计划。如果一次性把所有数据全部返回网络传输开销大前端渲染也卡体验很差。分页的意义不在于“显得专业”而在于让接口的数据量可控让用户在自己需要看的范围内完成操作。再说“我的”这两个字。这决定了数据隔离的维度。接口不能查全表必须强制加上当前用户的ID作为过滤条件。很多刚入行的同学容易漏掉这个只做了分页结果A用户把B用户的学习计划全查出来了这就是安全事故。所以我在设计接口的第一时间就把用户维度钉死了一切查询以当前登录用户的身份为前提。这一天的标题里还有一个细节“25:47”我理解是本节视频的时长25分47秒。一个不长不短的时间足以完整演示从接口定义到前后端联调的全过程。我在实际开发中也给自己定了个节奏一个接口的完整落地包括需求确认、代码实现、自测、接口文档更新控制在这个量级是比较健康的。如果超过这个时间说明接口设计可能过于复杂或者实现方式绕了远路。回到正题我下面把这节内容拆成几个核心部分依次是需求分析、参数设计、数据库查询实现、Service业务处理、Controller层封装以及最后的分页插件坑点排查。整个流程走完你就知道一个“分页数据查询接口”从零到一应该怎么写以及哪些地方最容易踩坑。2. 分页查询的参数设计与SQL底层逻辑2.1 分页参数到底该怎么传分页查询接口最常见的传入参数就是两个页码pageNum和每页条数pageSize。但真到了设计层面很多细节需要定清楚。第一页码从0开始还是从1开始这是前后端容易扯皮的点。我这边定的是从1开始因为用户能感知的页码概念就是从第一页起步的前端展示“第1页”对应pageNum1直译过来就好。如果从0开始前端展示时还得加1平白无故多了转换逻辑。第二每页条数有没有上限必须有。你不能让用户随便传一个pageSize100000那和查全表没什么区别。我通常把上限设置为50默认是10。这样既保证了用户能一次看到足够多的信息也不会让单次请求的数据量失控。第三需要不需要返回总数total一般情况下需要。因为前端要渲染分页组件至少需要知道总页数而总页数是根据总记录数pageSize算出来的。如果没有总数前端就不方便展示“共XX页”也不好在最后一页时禁用“下一页”按钮。所以查询接口要返回一个包含数据和总数两个部分的结果对象。参数校验也是不能少的。pageNum不能小于1pageSize不能小于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。比如第二页pageNum2pageSize10offset10就跳过前10条取第11到第20条。这里我要多说一句。很多人在开发初期用的是PageHelper这类分页插件框架自动帮我们生成count查询和limit语句省事。但如果你不理解底层SQL一旦遇到深分页问题就会无从下手。因为传统的LIMIT offset方式在offset非常大的时候有一个性能缺陷MySQL会先把offsetpageSize条记录全部查出来然后丢弃前offset条只返回后面的pageSize条。比如你要查第10000页offset是100000MySQL可能还要扫描很多无用数据查询效率会明显下降。解决深分页的常见方式是“延迟关联”或“基于游标的分页”。所谓游标分页就是不用页码而是用上一次查询的最后一条记录的排序字段作为查询条件比如按创建时间排序时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查询参数对象、PageResultT通用分页返回结构。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 PageResultT { private ListT 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 { ListStudyPlan 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里对应的两个SQLselect idselectPlanPage resultTypecom.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 testplanStatus ! null AND plan_status #{planStatus} /if ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{pageSize} /select select idcountPlanPage resultTypejava.lang.Long SELECT COUNT(*) FROM study_plan WHERE user_id #{userId} if testplanStatus ! null AND plan_status #{planStatus} /if /select这种写法优点是清晰总数查询和列表查询分开SQL完全可控。缺点是业务上需要执行两次SQL。有些人为了省事只写列表查询然后用page.getTotal()之类的方式反推总数但那通常是框架内置的功能如果不用框架就必须显式查count。第二种方式是用PageHelper。如果你不想手动算offset也不想写count查询可以在Service里这样写PageHelper.startPage(pageNum, pageSize); ListStudyPlan list studyPlanMapper.selectPlanList(userId, planStatus); PageInfoStudyPlan 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 PageResultStudyPlan 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()); ListStudyPlan list studyPlanMapper.selectPlanPage( currentUserId, offset, param.getPageSize(), param.getPlanStatus()); PageResultStudyPlan result new PageResult(); result.setList(list); result.setTotal(total); result.setPageNum(param.getPageNum()); result.setPageSize(param.getPageSize()); return result; } }这里有个业务细节当总数为0时列表肯定就是空的。当pageNum超出最大页数时比如总共有1条数据每页10条一共1页前端却传了pageNum99那么查询返回的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 ResultPageResultStudyPlan queryMyPlans(PlanQueryParam param) { Long userId UserContext.getCurrentUserId(); return Result.success(studyPlanService.queryMyPlans(param, userId)); } }这里要注意我用的是UserContext.getCurrentUserId()而不是从参数里获取。在真实项目里当前登录用户往往来自Token或者Session。之前做单体项目时我们使用ThreadLocal来存储用户ID在拦截器里解析登录信息后放进去这样Controller和Service都能拿到。这个方法很简单但要注意线程池环境下ThreadLocal的清理问题否则会串号。关于统一返回结构ResultT团队成员需要事先约定success、code、message、data四个字段。即使接口内部出了问题也要用这个结构包装后返回不能直接把异常堆栈抛给前端。尤其分页查询可能因为SQL语法错误、数据库连接超时导致异常统一的异常处理器负责转换为标准错误响应。4. 联调阶段的常见问题与排查记录4.1 分页插件不回显total的问题我之前参与过的一个模拟项目里同事用PageHelper写分页发现返回的list数据是对的但total永远为0。排查下来是因为他在XML里自定义了一个selectPlanPage并返回了ListStudyPlan但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的操作导致重复请求第一页数据。这种问题在联调时最容易出现需要后端明确文档里写清楚页面上的第一页对应pageNum1。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是后端开发最基本也最实用的能力。