
分页查询这四个字在单库时代几乎是送分题一个 LIMIT 加一个 OFFSET顶多再琢磨一下排序字段要不要建索引。可一旦表被拆到多个库、多个分片同样是查“第 10001 到 10020 条”成本可能直接翻几十倍甚至引发内存溢出和线上事故。这篇我想好好聊聊分库分表之下的分页查询它为什么难、主流方案有哪些、各自的代价是什么以及我在生产环境里沉淀下来的一套落地清单。无论你刚调研分库分表还是已经被深分页慢查询折磨过这篇文章都值得读完。1. 分页查询为什么到分库分表这里就水土不服了1.1 单库分页的思维定式先回到最简单的单库场景。比如订单表要按创建时间倒序分页SELECT * FROM orders ORDER BY create_time DESC LIMIT 20 OFFSET 10000;这条 SQL 的语义很直接先按create_time全局排序再跳过前 10000 条取接下来 20 条。单库之所以能这么写是因为所有数据都在同一个节点上排序的“全集”是可见的数据库可以顺着索引扫描跳过 offset 条记录后开始返回。这也是大多数后端开发对分页的直觉分页 LIMIT OFFSET翻得越深越慢但慢得有限加个索引就能扛。但请注意这个直觉隐含了一个前提排序全集可见。一旦数据被水平拆到多个库、多张表这个前提就不成立了。1.2 跨库取数的本质矛盾limit 不等于只查 N 条分库分表之后一张逻辑表被拆成 M 个物理分片。每个分片各自有数据各自有局部排序。问题来了“全局第 10001 到 10020 条”到底在哪几个分片上答案是在给出全部数据之前协调节点根本无法确定。既然无法确定最笨但也最正确的做法就是让每个分片都把可能出现目标记录的候选数据交上来。要拿到全局第 offset 到 offsetlimit 条协调节点必须保证候选集至少覆盖“全局前 offsetlimit 条”。因此每个分片至少要交出自己“局部前 offsetlimit 条”。这意味着你查 10000 条往往要从每个分片各取 10020 条然后汇总排序、内存归并。打个比方你要从 8 个班里找出全年级成绩排第 101 到 120 名的学生。你不可能让每个班只报“第 101 到 120 名”因为每个班的前 120 名合在一起才是全年级前 120 名的候选池。你必须让每个班都报出前 120 名再统一排名。这就是分库分表下分页查询的本质矛盾数据库的LIMIT只能约束单分片的读取量却约束不了全局候选集的大小。1.3 先确认一件事你真的会遇到跨分片分页吗说到这必须先泼一盆冷水很多团队一听“分库分表分页很难”就盲目搞复杂方案。但业务里的分页往往分两种按分片键查询比如订单表按user_id分片用户端查询“我的订单列表”WHERE user_id ?能直接路由到单个分片。单分片内就是普通 LIMIT 分页完全不存在跨分片问题。不带分片键的全局列表比如运营后台要查“全平台最近 1 万条订单”这时没有任何过滤条件能把请求收敛到单个分片才需要做跨分片归并。所以第一步永远是问产品“这个列表的查询条件里有没有天然的分片键”只有确认了是全局跨分片列表才需要继续看下面的方案。2. 主流的四类跨分片分页方案与取舍2.1 方案一全局捞取 内存归并——最直接也最容易被深分页打爆最朴素的做法也是很多分库分表中间件默认实现的思路-- 中间件把逻辑 SQL 改写后发给每个分片 SELECT * FROM orders_${shard} ORDER BY create_time DESC LIMIT 20 OFFSET 10000; -- 实际下发的可能是 SELECT * FROM orders_${shard} ORDER BY create_time DESC LIMIT 10020;协调节点拿到所有分片的 10020 条后再在内存里按create_time统一排序跳过前 10000 条取出 20 条返回。这套方案正确性没问题但有两个致命软肋内存占用假设有 8 个分片每个分片返回 10020 条整行数据总共 8 万条。如果单行 500 字节就是 40MB。如果 offset 变成 100 万内存直接奔着几个 GB 去。DB 扫描量每个分片都要扫描并排序前 10020 条即使有索引也得做深度遍历。所以它只适合一个场景offset 小、分片少、总量可控。比如内部管理页只翻前 3 页8 个以内的分片勉强能扛。线上用户只要翻到几十页以后这套方案基本就不能用了。2.2 方案二二次查询法——省流量不省扫描量的折中二次查询法本质上是把“传输大量整行数据”优化成“只传最小定位信息”。流程整体是这样的第一步每个分片按全局排序规则取 offsetlimit 条但只返回主键 排序字段 第二步协调节点汇总这些最小元组按全局排序规则归并 第三步定位到目标窗口第一条记录记录它在哪个分片、局部序号是多少 第四步目标分片重新查询取该局部序号之后的 limit 条 其他分片各取 limit 条 第五步所有分片结果再次汇总全局归并排序取 limit 条为什么二次查询会省资源因为第一次查询只需要返回主键和排序字段单条可能只有 20 字节相比整行字段动辄几百字节网络传输和内存占用能降一个量级。第二次查询则把数据量收敛到每个分片只取 limit 条。但这里必须说清楚它的局限第一次查询时每个分片在数据库里仍然要扫描 offsetlimit 条记录。省的是网络和内存不是数据库的扫描量。对于 1 万以内的 offset这个方案很实用但 offset 一旦到百万级单分片扫描本身就是灾难。2.3 方案三keyset 业务分页——放弃页码换取恒定成本如果你愿意在产品交互上让步放弃“任意跳页”改成“上一页 / 下一页 / 加载更多”那么所有问题都会迎刃而解。这就是 keyset 分页也叫游标分页、seek 分页。核心思想是不要用 offset 去跳过而是用上一页最后一条记录的排序字段值作为下一页的过滤条件。比如上一页最后一条订单是create_time 2024-06-01 12:00:00, id 10086下一页就写SELECT * FROM orders WHERE create_time 2024-06-01 12:00:00 OR (create_time 2024-06-01 12:00:00 AND id 10086) ORDER BY create_time DESC, id DESC LIMIT 20;这样每个分片都能利用联合索引直接定位到游标位置再往后取 20 条每页成本恒定为 O(limit)和翻了多少页完全无关。代价也是明确的用户没法直接输入“第 5 页”业务必须接受流式加载或者严格的上一页/下一页。对移动端 Feeds、订单流水、消息列表来说这个交互几乎是零成本但对“运营要跳到第 100 页”这类需求产品就得重新设计了。2.4 方案四搜索引擎/OLAP 加速——从查数据库变成查索引当数据量到了 TB 级过滤条件又特别灵活多字段组合、范围、全文搜索方案一、二、三都不够优雅。这时候主流架构是分库分表继续承担 OLTP 写入和点查分页查询走异步同步出来的搜索引擎或分析型数据库。比如订单数据通过 CDC 同步到 Elasticsearch 或 ClickHouse运营后台的复杂筛选分页直接查分析引擎。这里有个容易被忽视的坑ES 的from size默认最大只能到 10000也就是说即便换到搜索引擎深分页依然要做search_after原理和 keyset 一模一样。所以方案四不是“分页难题消失了”而是把复杂查询的性价比重新拉高。它解决的是“任意组合过滤 快速统计 中等深度翻页”而不是万能药。2.5 方案选型对照表方案适合场景最大风险改造成本全局捞取 内存归并分片少、页码浅、数据量可控深分页内存和扫描量爆炸低二次查询法浅中深度翻页需最小化传输量数据库扫描量仍与 offset 线性相关中keyset 业务分页流式加载、上一页/下一页产品必须放弃任意跳页中搜索引擎/OLAP大宽表复杂筛选、后台分析同步延迟、运维成本、深分页仍需游标高提示不管用中间件还是自研先确认它底层是哪种实现。很多中间件默认就是“全局捞取 内存归并”用起来很爽深翻页时突然把内存打满这种事我见过不止一次。3. 深分页优化从 offset 到 keyset 的切换实战3.1 深分页的定量账为什么第 10 万页比第 1 页贵 5 位数用数字说话。假设逻辑订单表 2 亿行分 8 个分片每片 2500 万行。用户想翻到第 50000 页每页 20 条offset (50000 - 1) * 20 999980 ≈ 100 万用方案一每个分片要取前 1000020 条。8 个分片总共要取出大约800 万条记录。如果每条只取主键 create_time 约 20 字节传输数据就有 160MB如果取整行几个 GB 也不是不可能。协调节点内存里做一次 800 万条的全排序单次请求就可能把应用服务器的堆压穿。用 keyset 方案上一页游标确定后每个分片通过联合索引定位各取 20 条总共最多 160 条传输不到几千字节成本与第 1 页几乎相同。这个对比就是很多人说的“深分页性能相差万倍”的由头。本质上不是 keyset 用了什么魔法而是它彻底消除了偏移量带来的线性扫描。3.2 keyset 分页的 SQL 写法和边界条件写 keyset 分页时最容易犯的错误是漏掉唯一键。先看推荐的写法-- 全局列表场景游标是 (lastCreateTime, lastId) SELECT * FROM orders WHERE (create_time #{lastCreateTime}) OR (create_time #{lastCreateTime} AND id #{lastId}) ORDER BY create_time DESC, id DESC LIMIT #{limit};这段 SQL 背后有两条硬性要求排序字段必须和唯一键组合。只按create_time排序不够因为同一秒可能有大量订单只有加上id才能让全序确定。游标条件必须和排序规则完全同构。条件里的(create_time, id) (lastCreateTime, lastId)展开成 SQL 就是上面这种 OR 写法。在 MyBatis 里如果第一页没有游标直接走普通排序即可select idpageByCursor resultTypeOrder select * from orders where if testlastCreateTime ! null create_time lt; #{lastCreateTime} or (create_time #{lastCreateTime} and id lt; #{lastId}) /if /where order by create_time desc, id desc limit #{limit} /select如果是跨分片全局查询协调节点把同样的条件广播给所有分片各分片取limit条汇总后再全局排序、取前limit条。注意这里每片返回的候选集是 limit 条而不是 offsetlimit 条因为游标本身已经取代了 offset 的定位作用。3.3 排序唯一性如何在分片边界上不漏数据、不重复数据很多团队从 offset 切到 keyset 后依然会遇到“数据漏了”或“重复了”的问题。最典型的原因是排序字段不是唯一键。假设订单表按create_time desc排序上一页最后一条是create_time 2024-06-01 12:00:00下一页条件是create_time 2024-06-01 12:00:00。恰好有 100 条订单都创建在这一秒上一页取了 20 条下一页却把这一秒剩下的所有记录都跳过了——因为下一页的条件是严格小于等于的一律过滤掉。修复方式就是上一节说的排序键 业务排序字段 主键查询条件用复合元组比较。只要主键全局唯一就能保证排序是“全序”而不是“偏序”分片边界上不会出现可排序并列项。注意如果主键不是全局唯一例如每个分片独立自增跨分片时两个分片的id可能相同。这种情况下需要把分片键或分布式 ID 一并纳入排序唯一键否则归并时依然可能乱序。这也是很多系统坚持用雪花 ID 作为全局主键的原因之一。4. 分页排序稳定性与分布式一致性那些容易被忽略的小坑4.1 排序字段重复值带来的幽灵重复先讲一个我实际踩过的现象接口用 offset 分页排序只按create_time desc其中一页的某条订单在下一页又出现了一次更诡异的是上一页末尾出现了一条本应在下一页的订单。排查到根因后发现问题出在归并排序的“选择性”上。跨分片场景下每个分片各自返回offsetlimit条协调节点做多路归并。如果排序键只在分片内唯一、全局不唯一那么“哪些记录排在前 20、哪些排在后 20”完全取决于协调节点如何挑拣重复值。单库场景下数据库的LIMIT OFFSET会严格按既定索引顺序返回重复值之间也有隐式顺序跨分片归并时不同分片来源的数据先后顺序由归并算法决定而算法通常不会聪明到“按主键补全全序”。结果就是同一排序值在不同请求之间可能被分到不同的页用户看着就像数据在漂移。解决的办法没有任何花活排序必须加上主键把偏序变成全序。4.2 写入并发下的页间漂移怎么处理即使排序全序解决了还有一个更朴素的难题数据一直在变。用户翻第 1 页时还没有新订单插入翻第 2 页时恰好有一条新订单插到了排行的最前面。于是第 1 页最后一条在第 2 页可能又出现一次同时原来第 2 页的某条被顶到第 3 页。对单库来说可以通过事务隔离级别拿到一致性快照但跨分片没有全局事务快照所以跨分片分页无法做到绝对的一致性视图。实际项目中我见过几种缓解手段接受轻微漂移对用户 Feeds 流来说新内容插入导致旧页重复体验上尚可接受配合前端去重即可。增加过滤窗口只允许查询create_time在某个固定时间范围内的数据新写入如果不在窗口内就不会影响分页结果。读取阶段去重协调节点按主键去重后再返回减少“同一条记录出现两次”的体感。如果业务真的无法接受任何漂移比如财务对账列表那不应该实时分页而应该在某个时间点把数据快照导出来再在快照上分页。4.3 一个真实故障的完整排查链路之前帮一个团队查过线上问题运营后台订单列表翻到第 20 页后出现两条重复订单再往后翻又少了一条本该出现的订单。第一反应是分页缓存或者前端渲染问题但排查下来发现没那么简单。排查链路大概是这样的抓取连续两次翻页的请求参数对比响应里的订单 ID 集合确认存在交集和缺集。查看业务 SQL发现排序条件是ORDER BY create_time DESC没有主键参与排序。看分片返回情况4 个分片里有两个分片在同一个秒级时间戳内大量命中数据量分布严重倾斜。手工模拟协调节点归并过程对相同create_time的记录不同分片交替被选入两页复现了重复和遗漏现象。修复 SQL 排序ORDER BY create_time DESC, id DESC。顺手把接口从 offset 分页改成 keyset 分页用游标替代页码。改完后“上一页/下一页”的重复问题消失运营再翻几十页也没有异常。后续产品提出“直接跳第 100 页”时我直接拿第 3 节的定量账去说服产品改成“加载更多”因为跳页在千万级数据规模的全局列表里性价比实在太低。5. 我在生产环境里的分页方案落地清单5.1 按业务场景选型前台、后台、导出完全不同分页方案不是一套走天下我的经验是先按三类场景拆用户前台列表如果查询条件天然带分片键比如user_id直接单分片 LIMIT 分页不要人为制造跨分片难题。如果做全站 Feeds 流改成 keyset 流式加载绝不支持任意跳页。运营后台查询过滤条件通常很乱又没有分片键。基础方案是 keyset 分页支持条件组合一旦数据量上了亿级、筛选维度很多直接把查询迁移到搜索引擎或分析型数据库用search_after代替from size。批量导出不要用页面意义上的“分页”而是开一个游标循环每次按(create_time, id)递增取 1000 条全程保持排序键严格递增直到取完。这样导出几百万行也不会拖垮数据库。5.2 配套改造点幂等、缓存、只读路由决定用 keyset 后接口设计也要跟着变。返回结构里应该携带游标而不是pageNum。比如{ list: [...], nextCursor: { lastCreateTime: 2024-06-01 12:00:00, lastId: 10086 }, hasMore: true }这样天然具备一定幂等性只要数据不变同样的游标永远返回同样的下一页不会因为中间有别的请求把页码顶乱。缓存方面我不建议直接缓存“整个分页结果集”尤其是跨分片列表。因为分页数据和源表的一致性本来就难以保证缓存一引入漂移问题会更难排查。如果确实要缓存短 TTL 游标结果是最稳妥的组合比如 Redis 里以“请求参数 hash 游标”作为 key过期时间控制在 30 秒以内。路由层面跨分片大查询一定要绑定只读数据源。从库延迟可以接受但不能让后台一个深分页请求把主库连接池占满进而拖垮线上写入。很多分库分表中间件支持读写分离在分页场景下这项配置不是可选项是必选项。5.3 最后的建议不要在一个事务里做跨分片分页这是个我听来的真实事故也差点发生在自己项目里。某个运营导出功能代码里把“查询分页列表”和“写导出日志”包在了一个大事务里查询跨 4 个分片每页 1000 条循环 50 次。结果事务长时间持有多个数据源连接连接池被打满线上订单查询直接超时。跨分片分页本身就是一个耗时操作在事务里做会把锁和连接的占用时间拉长放大故障半径。正确的姿势是分页查询不放在事务里查询完直接提交事务或者干脆不要事务。导出类操作走异步任务限制并发数设置 SQL 超时时间。如果业务允许分页查询使用独立的只读账号和应用实例避免影响主链路。我在目前的项目里定下的原则很简单前台列表一律 keyset 或单分片查询后台复杂筛选走分析引擎导出走异步游标循环。这套组合撑住了多个核心列表页的稳定性也希望这些经验能帮你在分库分表的分页问题上少走弯路。