分页查询大概是我见过最容易被低估的数据库操作了。表面上一个LIMIT 10 OFFSET 20写下去好像没什么技术含量但等到线上真的出现“翻第二页能看到第一页的数据”“某些订单永远拉不出来”这类诡异现象时你才会意识到分页这件事里面全是坑。我最早踩到这个坑是在做一个订单导出功能的时候。需求很简单从订单表里按月分页拉取数据全部写给下游。结果对账的时候发现有一条订单既出现在了第一页又出现在了第二页还有两条订单从头到尾都没出现过。整个导出任务白白跑了一整夜数据却是脏的。从那之后我就把分页查询的稳定性当成一个正经的系统设计问题来对待而不是“写条SQL不就完事”。这篇文章我就把分页查询里会导致数据重复、数据遗漏的几种典型场景连同排查思路和稳定的设计方案一起梳理一遍。内容不绕弯子适合所有写过后端接口、碰过数据库分页的开发者参考尤其是那些正在做数据导出、定时同步、列表翻页功能的同学建议认真看完。1. 分页查询的基础offset分页与keyset分页的底层逻辑差异先把基础说清楚。我们平时说的分页查询其实可以分为两大流派一种是游标分页Keyset Pagination / Seek Method底层依赖排序键的位置来定位偏移量另一种就是搜索偏移量分页Offset Pagination底层依赖OFFSET跳行数。搜索偏移量分页在MySQL里的执行逻辑相对直接先把前OFFSET LIMIT行全部查出来然后把前面的OFFSET行直接丢弃。它在代码里通常写成LIMIT ? OFFSET ?或者LIMIT ?, ?。这类分页的优点是写起来简单前端也好控制显示“第 1 页”“第 2 页”非常直观。缺点是数据库需要扫描并丢弃大量无用的行翻页越深扫描的冗余数据就越多性能呈线性下滑。对比之下游标分页的逻辑就完全不同。它不依赖OFFSET而是每次带上上一页最后一条记录的位置通过WHERE条件约束只取排序键大于或小于这个位置的数据配合LIMIT限制行数。这类分页要求排序字段必须唯一且稳定。比如按主键ID排序时查询写法大概是WHERE id last_seen_id ORDER BY id LIMIT 10下一页就传上一页最小或最大的ID。这两种模式对“数据重复”和“数据遗漏”问题的态度截然不同。offset分页对数据变化的容忍度很低因为它的定位是行号不是行的属性而keyset分页只关心排序键的先后关系因此天然免疫很多动态数据变更。很多时候大家觉得分页查询出问题是因为写错SQL语法导致的但实际排查完你会发现语法根本没错真正出问题的是底层的定位逻辑与排序实现。2. 数据重复的根因排序字段不稳才是罪魁祸首2.1 缺失唯一排序键时数据库的“随机排序”引发跨页重复先看数据重复的问题。你说怪不怪前端翻页翻着翻着上一页的数据又出现在下一页里。如果连这季度的数据明明没有新增为何会出现这种“越界”重复答案往往在于排序字段不唯一、甚至根本没指定稳定顺序。比如一个订单表你图省事只写了ORDER BY created_at LIMIT 10 OFFSET 10这时候created_at就可能存在大量重复值。同一条时间戳下可能挂着好几条订单。数据库在遇到相同排序值时并不保证返回顺序的绝对稳定——尤其在不走索引、走临时文件排序filesort的场景下结果顺序可能随执行计划变化。于是上一页查询时这条记录排在第8位下一页查询时因为执行计划微调它被排到了第11位就又被第二轮查询捞出来了。这是“同一行数据被重复返回”最常见的来源。还有一种情况联合排序字段里包含一个低基数字段。比如ORDER BY status, id如果status只有“待支付、已支付、已取消”三个值数据库在排序时极大比例的数据会被分在同一组。当同组数据量大到一定程度且没有稳定的次级排序键兜底时各字段之间的相对顺序就会变得“随机”。我用一个真实例子解释某订单列表按status ASC, created_at DESC排列第1页最后几条恰好是某个状态下的边界记录。下一轮查询时同一批记录因为filesort算法选择的排序策略不同边界记录的相对位置发生了变化最终结果就出现了重复。2.2 数据并发写入导致offset偏移旧数据被“挤”进新页面另一个常见的重复来源是在翻页过程中有新的数据不断地插入到结果集前面。假设你正在按创建时间倒序查看订单列表每页10条。当一个新订单插入时所有旧订单的“显示位置”都会向后顺移一位。此时用户明明在第2页看到了一条订单等他翻到第3页由于位置偏移这条订单可能又被“挤”进了第3页的结果里。这听上去很好理解但实际影响面不小。尤其在数据同步、批处理、导出系统里分页拉取往往是一个循环脚本自动翻页。一旦有并发写入offset分页出来的数据就会在不经意间发生“既重复、又遗漏”的情况。我见过一个库存同步任务在主库不断写入新订单的夜间高峰期运行导出结果里同一订单重复出现多次下游因为重复数据直接产生了重复发券事故。2.3 大小页混用分页边界参数不一致还有一种比较隐蔽的重复来源分页参数在前后端传递过程中被篡改或者缓存。比如用户在页面停留了很久前端已经发送了page2size10但某个组件还在用page2size20的旧参数请求两个响应叠加后渲染当然会产生视觉上的重复。这类问题虽不是数据库层引起的但从“分页查询稳定性”的角度看也是必须覆盖的边界情况。2.4 数据库主从复制延迟与分页查询再补充一个偏架构层面的原因如果你用的是主从分离架构写操作在主库读操作在从库而从库的复制存在延迟那么你的分页查询每次命中的可能是不同状态的数据。上一页查询时数据A还没同步到从库没出现在结果里下一页查询时A同步过来了并且它排序位置恰好落在后续页次的范围内于是它出现了——而上一页里已有的数据B可能因为复制延迟的波动被挤出结果。“既有重复又有遗漏”的场景在这个环境下非常容易出现。所以排查分页重复问题第一件事不是改SQL而是先确认自己到底用了什么排序键、这个排序键是否唯一且稳定、以及基础数据是否在并发变动。3. 数据遗漏的深层问题为什么明明有数据却翻不到3.1 删除数据后的“位移陷阱”分页遗漏和分页重复通常是同一个机制的一体两面。其中典型的原因是删除操作导致的位移。假设第一页查询时总共有100条数据OFFSET 0 LIMIT 10返回了前10条。用户看完第1页时有一条数据被删除此时总数据变成99条。你再请求OFFSET 10 LIMIT 10数据库会从新的第11条开始取也就是说原本排在第11、12条的数据没变但原本排在第20条之后的数据向前补位导致第20条数据下一次查询时被算进“上一页”的范围从而被漏掉。这就好比你排队买饭队伍里有人中途离开了。你第一次数了前10个人等第二次去数时离开的人已经被消化掉了原来排在第11的人顶到了第10的位置。如果你还按“从第11个开始数”的规则旧的第11就会永远不会被数到。3.2 插入数据后的“压缩陷阱”如果删除是错误的偏移插入也一样。当你在查询过程中插入了一条新数据并且它的排序位置在当前分页点之前那么这条新数据会“顶走”原本应该出现在后续页的一条数据。新数据本身没有重复但被顶走的旧数据却因为整体偏移而延后最终可能被落到你已经查询过的区间之外造成遗漏。听起来好像只要接着翻总能翻到但对批量导出场景来说分页是一个有限循环——你固定翻100页每页100条导出完就收工。如果中途有任何一条数据被动态顶出窗口任务结束依然少数据。3.3 排序键重复且值域变化时边界记录直接消失还有一种非常隐蔽的遗漏由排序键的值域变化引起。比如你按updated_at排序做增量同步每轮查询时更新数据、并把上一轮最后一条的updated_at作为下一轮的下边界。如果同一秒内有多条数据更新而上一轮取到的最后一条恰好在边界上下一轮查询用WHERE updated_at 上次的updated_at就会重复用又会遗漏。这个看似只有“一个符号”的差别实际是很多增量同步丢失数据的幕后推手。更棘手的是如果那条边界记录正在被事务修改你去读取的瞬间它可能处于一个中间状态。事务提交后它的updated_at又变了于是它可能离开当前查询窗口等下轮再来时它又因为新时间排序到更前方导致同步任务永远看不到它。3.4 多表Join分页的“行膨胀”导致的漏数据再来聊一个更隐蔽的场景分页查询如果带着JOIN会把一对多关系也拉进来。比如一个订单表JOIN订单明细表一个订单对应多个明细。分页对象明明是订单但SQL查询的结果集行数是明细行数。此时用OFFSET去切必然会在订单边界处撕裂同一个订单的一部分明细被切到上一页一部分明细被切到下一页。如果服务端做了去重操作上一页去重后的订单会“凭空消失”下一页只剩明细而找不到订单头从而表现为数据遗漏。这类问题在分页查询里特别常见尤其是很多人习惯直接对带JOIN的查询结果分页而不是先对主表分页再关联取明细。4. 实操案例回放一次订单分页拉数踩坑实录光说不练容易让人觉得是纸上谈兵。我把一次典型的排查过程完整记录下来你们可以对比自己遇到的问题。4.1 现场信息与SQL模式当时我们的业务场景是一个订单导出任务每天早上从订单表按分页拉取昨天的订单全量发给数据团队做财务报表。表结构大致如下CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_created_at (created_at) ) ENGINEInnoDB;导出任务的SQL长这样SELECT id, order_no, user_id, status, created_at FROM t_order WHERE created_at 2025-01-01 00:00:00 AND created_at 2025-01-02 00:00:00 ORDER BY created_at ASC LIMIT 100 OFFSET ?;每一轮查询替换OFFSET的值依次翻页直到取不到数据为止。4.2 问题现象跑完一轮导出之后我做了个自检把导出的ID列表与数据库中查询条件覆盖的ID全集做对比发现两个问题。第一有7个订单ID重复出现在导出文件中第二有4个订单ID一条都没导出来。第一反应是SQL写错了翻来覆去检查语法没问题索引也命中了。然后又怀疑是不是事务隔离级别的问题把会话切到READ COMMITTED还是复现。最后才把怀疑点落到排序稳定性上。4.3 排查过程我提取了第一页和第二页的原始返回数据直接观察ID分布。结果发现第一页的最后三条数据和第二页的前三条数据它们的created_at完全相同都是2025-01-01 09:21:33。而ORDER BY created_at ASC在执行过程中因为同秒数据较多InnoDB在读取索引和回表时选择的顺序并不是严格固定的。于是同批次的数据在两次查询里相对位置发生了轻微变化有的被排进了上一页末尾有的被排进了下一页开头自然就重复了。至于遗漏的那4个订单它们的时间戳恰好落在两个分页窗口的交界处。被上一次查询排到“页尾”之外又被下一次查询因为顺序微调排到“页头”之前导致两边都没捞到。4.4 复现验证为了确认判断我做了一个小实验把ORDER BY created_at ASC改成ORDER BY created_at ASC, id ASC让排序键彻底唯一。重新跑同一套任务重复和遗漏全部消失。这个改动背后的原理很简单附加主键ID作为次级排序键后每一行记录在全表范围内都有唯一确定的排序位置不会再出现“同排序值内部的摇摆”。两次查询只要条件不变返回结果的行集合就不会乱。5. 稳定性分页的三种解法与取舍踩过坑之后我总结出三套能稳定落地的分页方案。它们各有优劣适合不同的业务阶段和场景。5.1 方案一给排序字段增加唯一次级键这是最小改动、最容易落地的方案。在排序中引入唯一键作为二级排序。比如SELECT id, order_no, user_id, status, created_at FROM t_order WHERE created_at 2025-01-01 00:00:00 AND created_at 2025-01-02 00:00:00 ORDER BY created_at ASC, id ASC LIMIT 100 OFFSET ?;用主键ID做次级排序保证了同一秒内多条记录的绝对先后顺序。由于ID本身不会变化数据在分页过程中不会被“随机重排”。这个方案的优点是代码改动量极小、逻辑直观、不需要修改接口协议缺点是它仍然依赖offset翻页到深层时性能依旧差。此外如果数据有删除或插入偏移问题依旧存在。所以它更适合“数据基本稳定、查询量可控”的内部系统场景。5.2 方案二游标分页Keyset Pagination想要从根本上规避动态数据带来的重复遗漏就得改用游标分页。它的核心设计是不记录“第几页”而是记录“上一页最后一条数据的位置”。比如按id升序翻页SQL如下SELECT id, order_no, user_id, status, created_at FROM t_order WHERE created_at 2025-01-01 00:00:00 AND created_at 2025-01-02 00:00:00 AND id ? ORDER BY id ASC LIMIT 100;每次取完一页把最后一条记录的id作为下一页的?参数。因为id是主键新插入记录的id一定比已有的大所以永远不会把已经查过的数据再拉出来删除数据也不会影响后续页的起点。游标分页几乎完美解决了重复和遗漏问题并且由于无需扫描并丢弃前面的行深度分页时性能也非常稳定。缺点是它不能自由跳页如果想直接跳到第100页就需要重复迭代99次。另外游标分页要求排序键本身是可比较的、唯一的、稳定的。比如按照created_at id联合排序游标就不能只记最后一个created_at必须同时记录created_at和id两个值WHERE (created_at ? OR (created_at ? AND id ?)) ORDER BY created_at ASC, id ASC LIMIT 100;写法稍微复杂一点但稳定性很高。只要排序键定义得好游标分页在绝大多数业务场景下都是最优解。5.3 方案三对主表分页再关联取明细对于前面提到的多表JOIN分页带来的行膨胀问题最稳妥的解法是拆分查询。先只对主表做分页查出主键集合再JOIN或IN查询带出明细字段。-- 第一步只查主键分页 SELECT id FROM t_order WHERE created_at 2025-01-01 00:00:00 AND created_at 2025-01-02 00:00:00 ORDER BY created_at ASC, id ASC LIMIT 100 OFFSET ?; -- 第二步用主键集合查明细 SELECT o.id, o.order_no, od.item, od.quantity FROM t_order o JOIN t_order_detail od ON od.order_id o.id WHERE o.id IN (...);这样每一行结果都对应一个主表记录分页窗口不会被“一对多”膨胀打乱。数据量较大时还可以用临时表或子查询缓存主键集合保证页与页之间口径一致。5.4 方案四数据同步场景的兜底校验如果分页查询的目的是导出数据或者增量同步那我额外建议加一道兜底校验。导出完成后按主键排序做一次聚合对比检查总数、去重数、最大值最小值是否匹配。最简单的校验方法是对主键集合做差集运算-- 导出的ID集合与库内全集做对比 SELECT COUNT(*) FROM t_order WHERE created_at 2025-01-01 00:00:00 AND created_at 2025-01-02 00:00:00; -- 再对结果集ID做 COUNT(DISTINCT id)如果两个数值不一致说明导出任务需要重跑或告警。校验逻辑虽然不能避免问题但能第一时间发现风险避免脏数据影响下游。下表是三套主流方案的适用场景对比我平时选型时基本就按这个框架来方案抗数据变更能力深层分页性能跳页能力适用场景offset 唯一次级排序较差仍受增删影响较差优秀后台列表、数据量小、变更少的场景keyset游标分页优秀只看排序键位置优秀较差数据同步、导出、C端翻页列表主表分页再关联明细取决于分页步中等中等存在一对多JOIN的分页场景6. 常见问题速查分页查询避坑清单把日常团队里最容易踩的几个问题汇总成一张速查表方便大家直接对号入座。现象可能原因解决方向翻页出现重复数据排序字段不唯一数据库排序不稳定增加主键作为二级排序键或改游标分页翻页出现遗漏数据查询过程中有删除/插入导致offset偏移改用keyset分页或对删除场景做补偿边界时间点数据重复/遗漏使用或边界时边界记录时间戳重复游标同时记录时间戳和主键避免纯时间边界JOIN后分页结果错乱一对多导致行数膨胀切页切到半条主记录先分页主表再取明细导出数量与库内不一致多页查询之间数据环境变化增加主键去重总数校验从库查询数据滞后主从复制延迟分页结果集前后不一致强制读主库或等待延迟追平排查这类问题时我个人的习惯是先在固定环境里用固定参数跑两遍查询对比两次结果集的差异。如果差异能稳定复现说明是排序不稳定如果差异偶发那大概率是并发写入或复制延迟。定好方向再动手远比无头苍蝇式改SQL高效。还有一个比较容易忽视的点分页查询和唯一索引约束其实是两回事。数据库不会因为你做了分页就保证你的导出结果是“某个时间点的快照”。想要拿到绝对一致的结果集最简单粗暴的方法是加上时间范围或者状态过滤条件尽量缩小动态数据的影响面。如果业务允许可以在导出期间把相关表设置为只读或者使用REPEATABLE READ隔离级别配合一致快照但这些方案都会带来额外的锁或性能开销需要根据业务忍痛取舍。最后聊点我自己的体会。以前我总觉得分页查询是“无脑查库”后来被线上数据对不上打了几次脸才明白凡是涉及“稳定遍历全量数据”的场景都值得在代码评审时多问一句这个排序键到底稳不稳分页边界被数据变更影响没有下游依赖幂等吗这几个问题能帮你挡掉绝大多数分页坑。后台管理界面的分页乱一下可能没人发现但数仓同步、资金对账、用户资产盘点这类场景数据重复或遗漏一次代价就完全不一样了。