你有没有碰到过这种情况:员工列表总共就几万条数据,用户翻到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的时候,最容易漏掉的是参数校验。很多同学只做非空判断,不做范围判断,于是线上就出现了pageSize=10000的请求,后端老老实实查了一万条,接口直接卡死。
我一般会在入口统一处理:
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 PageResult<T> { private List<T> 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条,说明还有下一页,hasMore=true。这个查询只在二级索引上做覆盖扫描,性能远好于先count再limit。
如果产品确实需要精确总数,那也要尽量把count查询走最小的二级索引。比如员工筛选条件是dept_id和status,就建一个(dept_id, status)的二级索引,让count只扫这个索引而不是主键索引,面积小很多,速度能快一大截。
3.5 排序字段防注入:别把前端参数直接拼进 ORDER BY
员工列表页通常允许用户点击表头排序,前端传sortField=hireDate&sortOrder=desc是很常见的。很多开发在这个地方会偷懒,直接把参数拼进SQL:
ORDER BY ${sortField} ${sortOrder}这种写法最大的问题不是性能,而是注入。ORDER BY后面拼接的内容无法通过预编译参数绑定,前端完全可以传一个sortField=(SELECT xxx)之类的东西。更稳的做法是做白名单映射,后端只承认预定义的几个字段:
private static final Map<String, 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_id=3 AND status=1的部分。explain的结果显示type=ref,key=idx_dept_status_emp_id,rows=2020,还算正常。
问题就在这里:explain显示的行数只有2020,好像一点都不吓人,但实际执行时间是2.8秒。看执行计划不能只看rows,还要看Extra里是否出现Using index condition这类回表信号,更要结合LIMIT的语义去理解。当SELECT emp_id这种覆盖查询变成SELECT *时,情况完全不同。
4.3 真正的瓶颈:LIMIT的"扫描并丢弃"叠加回表随机IO
深挖之后,瓶颈浮出水面。这条SQL的执行过程是:
- 从索引里定位到第一个满足
dept_id=3 AND status=1的索引项; - 沿着索引顺序往下扫描,因为这里要
ORDER BY emp_id,扫描顺序恰好就是emp_id递增的索引序; - 每扫到一行索引项,都要根据
emp_id回到主键索引去取整行的SELECT *数据,这个动作就是回表; - 前面扫过的2000行数据,全部回表取回来之后发现是"要被丢弃的",但因为SQL语义要求"先跳过再返回",它必须把这一路的开销全部付完,才能拿到第2000行之后的20行。
也就是说,这条慢SQL的耗时主体不是"查20条数据",而是"把2000条历史的完整行读了一遍然后扔掉"。回表是随机IO,2000行数据可能散落在几百个数据页里,每次访问一个页面对机械硬盘是一次寻址,对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优化、接口设计、数据安全、产品交互四个领域。最后分享我自己的一个习惯:接到任何一个列表需求,先回答三个问题——排序字段能不能稳定到唯一键?用户最多会翻到第几页?总数是不是必须精确?这三个问题想清楚了,分页方案基本就定下来了,后面走的弯路会少很多。