跨分片分页:翻到第 50 页,每个分片要交出 1000 行
订单表按用户分了 4 个分片,运营后台要按下单时间倒序列出所有订单。第一版用 offset 分页,前几页很快,越往后越慢;改成「按时间做游标」之后快了,却有人发现翻完所有页,总数比实际少了几千条。
单库分页的代价和游标写法,在数据库设计与调优里讨论过。数据分到多个分片后,问题被放大了一层:没有任何一个分片知道全局的第 N 行是谁,结果只能由各分片的局部结果归并出来。这时 offset 的代价按分片数成倍增长,而游标的正确性完全取决于排序键是否唯一。
本文用固定种子生成 4 个分片、每片 5000 行的数据(created_at 只到秒,大量重复;id 全局唯一),按 created_at 倒序、id 倒序分页,每页 20 行。这是纯计算的模拟,读取量以「各分片返回的行数」计。实验见文末。
created_at 做排他游标,遍历完漏掉 4022 行;复合游标 (created_at, id) 得到全部 20000 行,与全局排序逐行一致。一、offset:每个分片都要取 offset + limit 行
全局的第 981—1000 行可能全部来自同一个分片,也可能平均分布。为了不出错,每个分片都必须返回自己的前 1000 行,由中间层归并后再丢掉前 980 行:
第 1 页:每个分片取前 20 行(不足则取全部),共读取 80 行、归并后丢弃 60 行;结果正确:True
第 50 页:每个分片取前 1000 行(不足则取全部),共读取 4000 行、归并后丢弃 3980 行;结果正确:True
第 500 页:每个分片取前 10000 行(不足则取全部),共读取 20000 行、归并后丢弃 19980 行;结果正确:True读取量是「分片数 ×(offset + limit)」,第 500 页已经把所有数据读了一遍。Elasticsearch 用 index.max_result_window(默认 10000)限制 from + size,原因就在这里:每个分片都要排序并返回这么多条,再由协调节点归并。
一个常见的「优化」是把 offset 平均分给各分片:
把第 50 页的 offset 平均分给 4 个分片、每片取 5 行:与正确结果相同的行 5 / 20它假设数据在各分片间按排序键均匀交错,这几乎从不成立,结果自然是错的。
二、单列游标:排序键有重复就会漏或重
游标分页记住上一页最后一行的排序值,下一页只取「在它之后」的行,每个分片只需返回 limit 行。问题在于「之后」怎么定义:
只按 created_at,条件 created_at < 上一页最后一行:遍历得到 15978 行,遗漏 4022 行,重复 0 行
只按 created_at,条件 created_at <= 上一页最后一行(排除那一行本身):遍历得到 40020 行,遗漏 17195 行,重复 37215 行;已在同值的行之间反复翻页,实验在遍历量超过总行数两倍时停止created_at 只到秒,同一秒里有多行。用严格小于,页边界上与最后一行同一秒、但还没返回的行全部被跳过;开头那几千条的遗漏就是这么来的。用小于等于,同一秒里已经返回过的行又会再出现,同值的行足够多时甚至会在原地打转。
三、复合游标:排序键加上唯一的决胜键
复合游标 (created_at, id) < 上一页最后一行:遍历得到 20000 行,遗漏 0 行,重复 0 行;与全局排序逐行一致:True
复合游标每页每个分片最多取 20 行,遍历 1000 页共读取 79865 行;同样遍历用 offset 需要读取 17510000 行排序键加上一个唯一的列(这里是 id),排序就变成了严格的全序,「上一页最后一行之后」有了唯一的含义。MySQL 和 PostgreSQL 里可以写成行构造器 (created_at, id) < (?, ?),配合 (created_at, id) 上的索引。每个分片每页只返回 limit 行,读取量与翻到第几页无关。
决胜键必须全局唯一。各分片各自的自增 id 在分片之间会重复,这时要用全局唯一的 id(雪花 id 等),或者把分片号也加进排序键:(created_at, shard, id)。
四、翻页期间有新数据写入
读完第 1 页后插入一条更新的数据:offset 取第 2 页,第一行与第 1 页最后一行重复:True
复合游标取第 2 页:与第 1 页重复的行 0 个,紧接在第 1 页之后:Trueoffset 指的是「第几行」,前面插入一行,后面所有行的位置都往后挪一位,于是第 2 页的第一行就是第 1 页的最后一行。游标指的是「某一行之后」,前面插入数据不影响它。
五、代价:游标不能跳页
复合游标只能「下一页」,不能直接跳到第 37 页,也不容易给出总页数。常见的取舍是:
- 列表与无限滚动用游标;需要跳页的后台查询限制最大页数,或者引导用户加筛选条件。
- 总数用单独的计数,允许近似或延迟更新,而不是每次翻页都对所有分片做
COUNT。 - 需要任意排序、任意跳页的复杂查询,交给专门的搜索或分析系统,而不是让业务分片承担。
六、检查清单
| 问题 | 做法 |
|---|---|
| 深分页的读取量 | offset 的代价是分片数 ×(offset + limit);限制最大页数 |
| 排序键有重复 | 加上全局唯一的决胜键,组成复合游标 |
| 游标条件 | 用行构造器或展开的 (a < ?) OR (a = ? AND b < ?),并有对应的复合索引 |
| 决胜键的唯一性 | 全局唯一 id,或把分片号加进排序键 |
| 翻页期间的写入 | 游标不受影响;offset 会重复或遗漏 |
| 跳页与总数 | 游标不支持跳页;总数单独维护 |
配套实验
- codesphere-labs/system-design/cross-shard-pagination:Python 3.14.8,固定种子的 4 分片数据:offset 归并的读取量、平均分摊 offset、单列游标的遗漏与重复、复合游标、翻页期间插入(验证记录)
参考资料