ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分页查询从原理到实战:深翻页优化与稳定排序的工程指南

分页查询从原理到实战:深翻页优化与稳定排序的工程指南 你有没有碰到过这种情况员工列表总共就几万条数据用户翻到100页以后页面开始明显卡顿甚至接口直接超时又或者翻到第10页时发现第9页已经出现过一条记录数据还重复了。我做过的几个后台项目里员工分页查询这种看起来最基础的CRUD功能反而是埋坑最多的地方之一。多数人写分页就是一条LIMIT 10 OFFSET 20但分页背后的排序稳定性、总数统计、深翻页性能、敏感字段隔离每一样都能让一个上线了两年没动过的接口突然变成事故制造机。这篇文章不讲那种分页查询是什么的入门概念而是从一个实际维护者的角度把员工分页查询从SQL原理拆到工程落地再到一次真实的深翻页超时排查过程。适合正在做企业后台、HR系统、OA系统或者接手了老系统又不敢乱动分页代码的同学看完应该能直接对着自己的列表接口做一轮体检。1. 从员工列表加载慢说起全量查询为什么撑不住1.1 我接手的那套老系统三万人列表把浏览器拖垮了有一年老系统改造员工模块一共三万多条记录原来的页面不做分页一次请求把所有员工全拉回来前端脚本再自己截取分页。当时页面长啥样我不说你也知道首屏等两秒滚动条一拉就开始卡崩溃之前先用Ajax拉回一个好几MB的JSON浏览器解析完DOM节点已经大几千个了。这件事的本质不是页面写得太烂而是全量返回这个模式在企业数据场景下注定不可持续。员工表三万条只是个开始等组织机构、历史离职员工、兼职人员陆续并入可能就是几十万行。你要明白接口返回多少数据不是你在数据库里执行了多快的SQL就能决定的它是一条链路的整体损耗数据库扫描和传输、网络带宽、应用服务序列化、前端渲染。任何一环被大结果集击中体感上都会变成系统卡。1.2 全量查询的四个代价我后来总结过全量返回至少付了四份账单第一份是数据库账单。一次性把符合条件的全部行都读出来InnoDB需要扫描并返回大量数据页缓冲池里的热数据被不断换出其他业务SQL跟着受影响。第二份是网络账单。数据量越大网络传输耗时越高内网可能还好一旦系统对公网开放或者有用户在偏远地区一个3MB响应的体感丝毫不亚于一次慢SQL。第三份是应用账单。查出来的结果要映射成DTO、序列化成JSON、放进响应对象几十万个对象的创建堆出来GC都压不住。第四份是前端账单。几千行数据同时渲染成DOM节点哪怕用了虚拟滚动首屏数据量过大也是浪费。这四个代价叠加在一起就是用户嘴里那句系统很慢。很多人以为分页只是为了少传几条数据给前端看其实正确的分页是在保护整条链路的每一环。1.3 分页真正要解决的目标比取10条多得多一个合格的分页接口至少应该做到四件事每次请求的耗时可控不能随着页码增大而线性恶化翻页结果稳定同一条件下不会出现重复或者漏掉某条数据总数展示合理总数 每页条数 × 总页数这一整套交互要立得住返回字段受控尤其是员工这种敏感数据量大的实体该挡住的字段必须挡住。你会发现查询第N页的M条数据只是最表层的一步。真正的复杂度藏在排序字段如何处理、深翻页怎么办、count怎么算、数据变更时怎么保证不串页。这也是我把这篇文章重点放在原理和工程落地上的原因。2. LIMIT分页与游标分页两种方案的执行原理与选型2.1 传统LIMIT offset, size的真实语义大部分系统的分页SQL长这样SELECT * FROM employee WHERE dept_id ? ORDER BY emp_id LIMIT 20, 10;这表示跳过前面20行返回接下来的10行。很多人把这条SQL理解得很简单先查出来30行然后丢掉前面20行。但从数据库执行角度看它做的事是通过二级索引和主键索引定位到第一条满足条件的记录然后顺着B树的顺序一路往后读把前20行当成垃圾一样读出来又扔掉再继续读10行返回给用户。这个扫描并丢弃的动作在处理浅页时无所谓。但当你写LIMIT 200000, 20的时候数据库必须实打实地扫描20万行索引项并且多数情况下还要对这20万行做回表才能扔掉它们。这跟你去一本厚厚的书里找第1000页是一个道理无论你最终只看第1000页那一页你还是得把前999页翻过去。2.2 深翻页为什么慢三层开销叠加深翻页慢从来不是一个原因导致的而是三层开销叠在一起扫描量随着offset增大需要扫描的索引项数线性增长回表量如果查询列不在当前索引里每扫描一行都要回到主键索引去取完整行这就是一次随机IO排序成本如果排序字段和筛选条件组合得不好数据库还要额外做filesort把符合条件的行先全部放到临时文件排好序再执行limit逻辑。假设一次回表在普通机械磁盘上要0.1ms左右SSD上这个数字会低一些但频繁随机读仍然不便宜。一个LIMIT 2000, 20的查询单单回表2000行理论上就已经是2000 * 0.1ms 200ms的纯IO成本这还没算扫描和网络传输。这个数字放在单请求上看起来还能接受但放在高并发下就完全不是一回事了——几十个用户同时翻到深页数据库立刻被打穿。2.3 游标分页keyset pagination为什么能做到指哪打哪深翻页的痛点本质上是位置定位扔给了扫描。游标分页换了一种思路我不告诉你我要第2000页的数据我告诉你从上次看到的最后一条记录开始继续往下取20条。SELECT emp_id, emp_no, name, hire_date FROM employee WHERE (hire_date, emp_id) (2022-05-01, 10086) ORDER BY hire_date, emp_id LIMIT 20;像这种SQL的执行路径是B树根据hire_date和emp_id的值直接定位到上次结束的位置然后顺序往后取20条。不管库里有多少历史数据它的扫描量基本恒定在定位 20条这个级别。这也是为什么即时通讯的消息列表、Feed流、操作日志这些动辄百万行、又不允许跳页的场景几乎都采用游标分页。但游标分页不是万能的它有几个硬限制不支持任意跳页只能一页一页往前翻游标里必须带一个唯一键作为兜底否则排序不唯一时照样出问题另外前端交互得配合下一页/加载更多这种模式传统的页码组件和跳转到第N页的输入框都不再适用。2.4 实际选型员工列表两种方案怎么取舍放到员工分页场景里我的选型习惯是这样对比项offset/LIMIT分页keyset游标分页任意跳页支持不支持深翻页性能随offset增长退化恒定扫描量实现复杂度低传页码即可中需要回传游标新增数据影响可能导致页内数据偏移不受插入影响典型场景管理后台员工列表、前100页自助查询、无限滚动、大文件导出含义很明确传统的后台员工管理页面用户确实有跳到第50页这种诉求那你就老实走offset分页但同时必须做好页码深度限制和深翻页优化如果产品形态是加载更多或者上下滑下一页优先用keyset。两者不是互斥的甚至可以在同一个接口里做阈值切换前100页走页码超过100页提示用户改用条件筛选或者直接切换到游标模式。这种设计一开始就要想好不要等到线上出事故了再补。3. 把员工分页接口做成可上线的样子参数、排序与数据边界3.1 请求参数pageNum、pageSize从哪里来怎么约束员工列表接口最常见的参数组合就是pageNum和pageSize。第一次封分页DTO的时候最容易漏掉的是参数校验。很多同学只做非空判断不做范围判断于是线上就出现了pageSize10000的请求后端老老实实查了一万条接口直接卡死。我一般会在入口统一处理int pageNum (param.getPageNum() null || param.getPageNum() 1) ? 1 : param.getPageNum(); int pageSize (param.getPageSize() null || param.getPageSize() 1) ? 20 : param.getPageSize(); pageSize Math.min(pageSize, MAX_PAGE_SIZE); // 建议100或200MAX_PAGE_SIZE的设置不是拍脑袋而是根据自己的数据库规模和用户使用习惯来定。员工列表一次看200条已经够多了真有人要一次性导出一万条走的是专门的导出接口而不是分页接口。分页接口的每一项设计都应该以让单次请求的成本可控为底线。3.2 排序不稳定导致翻页重复这是最常见的隐性坑有段时期我们线上的员工列表被用户反馈翻到后面数据乱了我排查了一天最后发现罪魁祸首是排序不稳定。当时列表页默认不传排序条件开发图省事直接把数据库自然返回顺序当作列表顺序。但员工表里同名的、同一天入职的、同一职级的人太多了InnoDB自然顺序并不是一个稳定的逻辑顺序。用户在第一页看到了张三翻到第二页又出现一个张三很难判断到底是不同的人还是同一批数据被重复返回。实际上当排序键不唯一时数据库可能在同一范围查询里因为并发插入或索引扫描策略不同返回了轻微不同的顺序于是翻页就出现了重复和漏掉。解决办法其实很简单分页查询的ORDER BY必须给出一个绝对确定的顺序通常在业务排序字段后面追加唯一键比如ORDER BY hire_date DESC, emp_id DESC;emp_id是主键全局唯一这样排序结果在逻辑上就是确定的。这个原则看着很基础但我在维护过的系统里见过太多只写ORDER BY create_time的情况等数据量大了之后全炸出来。3.3 分页结果必须走DTO尤其员工这种敏感数据员工表里的字段有多敏感做过HR系统的人都懂身份证号、银行卡号、薪资、社保基数、家庭住址全在实体类里躺着。分页接口如果直接把数据库实体丢到JSON里返回等于把员工隐私全部暴露给所有能访问这个接口的人。哪怕前端页面不展示响应体里也已经漏出去了。所以员工分页接口的返回结构必须做DTO隔离public class PageResultT { private ListT records; private long total; private int pageNum; private int pageSize; private boolean hasMore; // 构造方法、getter/setter省略 }EmployeeDTO 只暴露empId、empNo、name、deptName、positionName、hireDate、status这种页面真正需要的字段。这一步的收益不光是安全还有一个工程上的好处数据库表结构哪天改了只要DTO不变前端无感知接口契约更稳。3.4 count(*) 的姿势以及总数是不是永远必须精确这个我要多说一句因为太多系统把总数查询当成附带送的查询从来没算过账。在InnoDB里COUNT(*)需要扫描索引来统计行数即使MySQL 8.0做了优化本质上还是要读一遍所有索引项表量一大比如几十万员工数据count本身可能就要几百毫秒到一秒多。列表接口每请求一次都先把全部数据数一遍这个成本非常可观。我的工程习惯是能不走count就不走count。无限滚动、加载更多的场景根本不关心总数是多少只关心还有没有下一页。这种情况用一个取巧的方案SELECT emp_id FROM employee WHERE dept_id ? ORDER BY emp_id LIMIT 21;查21条如果返回结果大于20条说明还有下一页hasMoretrue。这个查询只在二级索引上做覆盖扫描性能远好于先count再limit。如果产品确实需要精确总数那也要尽量把count查询走最小的二级索引。比如员工筛选条件是dept_id和status就建一个(dept_id, status)的二级索引让count只扫这个索引而不是主键索引面积小很多速度能快一大截。3.5 排序字段防注入别把前端参数直接拼进 ORDER BY员工列表页通常允许用户点击表头排序前端传sortFieldhireDatesortOrderdesc是很常见的。很多开发在这个地方会偷懒直接把参数拼进SQLORDER BY ${sortField} ${sortOrder}这种写法最大的问题不是性能而是注入。ORDER BY后面拼接的内容无法通过预编译参数绑定前端完全可以传一个sortField(SELECT xxx)之类的东西。更稳的做法是做白名单映射后端只承认预定义的几个字段private static final MapString, String SORTABLE_FIELDS Map.of( empId, emp_id, empNo, emp_no, name, name, hireDate, hire_date );排序方向也可以用枚举校验只允许asc和desc字面量。看起来多写了几行代码但你从此不用再担心有人通过排序参数给你的员工接口使坏。4. 踩坑实录一次员工列表深翻页超时的完整排查链路4.1 现象第101页开始接口响应从80ms涨到2.8s先把背景交代清楚。某个管理后台的员工列表支持按部门、在职状态筛选默认按工号emp_id排序每个HR模块大概四个部门加起来十几万条员工记录浅页响应一直很稳80ms上下。某天用户反馈列表跳转页数超过100以后页面转圈半天甚至直接报超时。我一开始也怀疑是不是筛选字段缺索引但看了下表结构(dept_id, status, emp_id)这个组合索引是存在的理论上筛选排序都应该能扛住。于是我从慢查询日志开始查。4.2 定位慢SQL慢查询日志和explain双重核对慢查询日志捞出来的是长这样的一条SELECT * FROM employee WHERE dept_id 3 AND status 1 ORDER BY emp_id LIMIT 2000, 20;ORDER BY emp_id正好是组合索引(dept_id, status, emp_id)的最后一个字段所以不需要额外filesort可以直接从索引里按照顺序读取满足dept_id3 AND status1的部分。explain的结果显示typerefkeyidx_dept_status_emp_idrows2020还算正常。问题就在这里explain显示的行数只有2020好像一点都不吓人但实际执行时间是2.8秒。看执行计划不能只看rows还要看Extra里是否出现Using index condition这类回表信号更要结合LIMIT的语义去理解。当SELECT emp_id这种覆盖查询变成SELECT *时情况完全不同。4.3 真正的瓶颈LIMIT的扫描并丢弃叠加回表随机IO深挖之后瓶颈浮出水面。这条SQL的执行过程是从索引里定位到第一个满足dept_id3 AND status1的索引项沿着索引顺序往下扫描因为这里要ORDER BY emp_id扫描顺序恰好就是emp_id递增的索引序每扫到一行索引项都要根据emp_id回到主键索引去取整行的SELECT *数据这个动作就是回表前面扫过的2000行数据全部回表取回来之后发现是要被丢弃的但因为SQL语义要求先跳过再返回它必须把这一路的开销全部付完才能拿到第2000行之后的20行。也就是说这条慢SQL的耗时主体不是查20条数据而是把2000条历史的完整行读了一遍然后扔掉。回表是随机IO2000行数据可能散落在几百个数据页里每次访问一个页面对机械硬盘是一次寻址对SSD也是一次读请求叠加起来自然就是秒级。4.4 修复延迟关联 覆盖索引定位到原因之后修复方案就明确了既然开销集中在回表取2000行废弃数据那就让扫描丢弃的过程不要回表。改法如下SELECT e.* FROM employee e INNER JOIN ( SELECT emp_id FROM employee WHERE dept_id 3 AND status 1 ORDER BY emp_id LIMIT 2000, 20 ) t ON e.emp_id t.emp_id;子查询只查emp_id这刚好能完全走(dept_id, status, emp_id)这个覆盖索引索引扫描过程不需要回表等子查询确定好最终需要的20个主键之后再用INNER JOIN去主键索引回表回表次数从2020次直接降到20次。这个技术叫延迟关联又叫派生表优化是解决深分页性能问题的经典手法。我改完之后做了压测同样的翻页深度接口从2.8s降到120ms左右。前100页和深页的响应曲线也变成了平缓上升基本一个量级。4.5 根治思路延迟关联不是终点限制深度或换游标才是延迟关联确实解决了当时2.8s的问题但我不建议把延迟关联当终极方案。原因很简单它的本质是把扫描回表变成了纯索引扫描索引扫描虽然不开销大可offset到一万、五万的时候还是要扫描一万、五万条索引项SQL整体耗时依然会缓慢增长。后续我做两件事产品层面把分页最大翻页深度限制在500页以内超过这个深度前端提示数据量过大请使用精确筛选条件技术上保留keyset游标分页的接口给导出和自助查询场景使用。这样才算是把深翻页的问题从根上处理掉而不是修一次等下一次爆。5. 员工分页的进阶优化索引、产品约束与导出思维5.1 员工常用筛选条件的组合索引怎么设计员工列表页最常见的筛选是什么部门、在职状态、入职时间范围、姓名关键字、工号精确查询。对应的组合索引我在实际项目里比较推荐这样设计高频等值筛选部门 状态加唯一键分页(dept_id, status, emp_id)如果列表默认按入职时间排序并且经常做入职时间范围筛选(status, hire_date, emp_id)姓名模糊搜索这类LIKE %xx%场景索引基本帮不上忙数据量中等时老老实实全表扫数据量大了得上搜索引擎或者专门的外接分词方案不要指望普通索引。一个比较容易忽略的原则是组合索引的字段顺序要按等值条件放前面、范围条件放中间、排序字段放最后来排。比如(dept_id, status, hire_date, emp_id)里如果用户把入职时间做成范围查询而不是等值查询那么后面的emp_id排序字段在索引里就不再连续有序可能反而触发filesort。所以索引并不是列越全越好要根据真实筛选组合来设计。5.2 存量的SQL改成游标游标怎么构造才不踩坑游标分页在很多代码库里是留着没用上的部分原因就是大家不知道多条件筛选的游标怎么传。其实关键就一条游标必须和ORDER BY的排序键严格对齐。假设当前查询是WHERE dept_id ? AND status ? ORDER BY hire_date DESC, emp_id DESC那么上一页最后一条员工记录有两个排序键hire_date和emp_id。下一页的游标就是这一页最后一条的这两个值数据库拿着它们在索引上直接定位续传WHERE dept_id ? AND status ? AND (hire_date, emp_id) (2023-08-01, 10086) ORDER BY hire_date DESC, emp_id DESC LIMIT 20;注意hire_date可以重复但加上emp_id之后这个复合游标就是唯一的不会漏也不会重复。另外如果某个员工在翻页过程中被修改了hire_date游标锚点可能会受影响所以在员工这种数据会被后台编辑的场景里常见的做法是用自增字段emp_id或者没有业务含义的updated_seq做游标主键稳定性和抗变更好。5.3 翻页深度不只是技术问题更是产品问题我说句直白的话让用户在一个列表里翻到几百页这个交互本身就不太合理。员工列表是查信息的工具不是逛微博的信息流。真需要找一个人正确操作是输入工号、姓名去搜索而不是花半小时翻到第300页。所以在设计分页接口时技术手段要把深翻页的性能兜住产品手段再给一记刹车。常见的做法是传统页码模式限制最大翻页数超过限制后引导用户去用条件筛选新式列表改用加载更多用游标分页平滑撑住大数据量伙伴用数据看板或者异步导出。产品约束和技术优化不是二选一是双保险。5.4 Excel导出业务里藏着分页思维的另一种用法说到导出这里也顺带提一笔。后台系统几乎都有导出当月员工列表这个功能。如果导出逻辑上来就是一次SELECT * FROM employee WHERE ...十几万行数据一次性塞进内存再来一个POI把整个大Excel对象build出来服务端内存基本当场告警接口也得等到超时。正确做法是把导出当作高速分页遍历来处理异步启动一个导出任务先把任务记录写库前端轮询状态用游标分页或者emp_id lastId的keyset方式每批读取800到1000条写完文件再读下一批记录导出的断点位置万一任务中断下次可以续跑不用从头开始。这样既不会把列表页分页接口压垮也不会让一个导出请求吃掉整个JVM堆。我见过太多导出慢、导入慢的问题根源都是没把大数据量的操作拆成一批一批来看待。分页这个功能表面上是数据库的章节实际上横跨了SQL优化、接口设计、数据安全、产品交互四个领域。最后分享我自己的一个习惯接到任何一个列表需求先回答三个问题——排序字段能不能稳定到唯一键用户最多会翻到第几页总数是不是必须精确这三个问题想清楚了分页方案基本就定下来了后面走的弯路会少很多。
返回列表