Skip to content

跨分片分页:翻到第 50 页,每个分片要交出 1000 行 ​

订单表按用户分了 4 个分片,运营后台要按下单时间倒序列出所有订单。第一版用 offset 分页,前几页很快,越往后越慢;改成「按时间做游标」之后快了,却有人发现翻完所有页,总数比实际少了几千条。

单库分页的代价和游标写法,在数据库设计与调优里讨论过。数据分到多个分片后,问题被放大了一层:没有任何一个分片知道全局的第 N 行是谁,结果只能由各分片的局部结果归并出来。这时 offset 的代价按分片数成倍增长,而游标的正确性完全取决于排序键是否唯一。

本文用固定种子生成 4 个分片、每片 5000 行的数据(created_at 只到秒,大量重复;id 全局唯一),按 created_at 倒序、id 倒序分页,每页 20 行。这是纯计算的模拟,读取量以「各分片返回的行数」计。实验见文末。

offset 第 50 页分片 1前 1000 行分片 2前 1000 行分片 3前 1000 行分片 4前 1000 行归并 4000 行丢弃 398020 行游标的下一页created_at < 上一页最后一行同一秒里还没返回的行被跳过:漏 4022 行created_at <= 上一页最后一行同值的行反复出现,原地打转(created_at, id) < (?, ?)每片只取 20 行,不漏不重决胜键必须全局唯一:分片内各自的自增 id 会重复,用全局 id 或 (shard, id)
图 1 · 实测(纯计算模拟,4 个分片、每片 5000 行):offset 第 50 页每个分片交出 1000 行,共读 4000 行、只用 20 行。只按秒级 created_at 做排他游标,遍历完漏掉 4022 行;复合游标 (created_at, id) 得到全部 20000 行,与全局排序逐行一致。

一、offset:每个分片都要取 offset + limit 行 ​

全局的第 981—1000 行可能全部来自同一个分片,也可能平均分布。为了不出错,每个分片都必须返回自己的前 1000 行,由中间层归并后再丢掉前 980 行:

text
第 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 平均分给各分片:

text
把第 50 页的 offset 平均分给 4 个分片、每片取 5 行:与正确结果相同的行 5 / 20

它假设数据在各分片间按排序键均匀交错,这几乎从不成立,结果自然是错的。

二、单列游标:排序键有重复就会漏或重 ​

游标分页记住上一页最后一行的排序值,下一页只取「在它之后」的行,每个分片只需返回 limit 行。问题在于「之后」怎么定义:

text
只按 created_at,条件 created_at < 上一页最后一行:遍历得到 15978 行,遗漏 4022 行,重复 0 行
只按 created_at,条件 created_at <= 上一页最后一行(排除那一行本身):遍历得到 40020 行,遗漏 17195 行,重复 37215 行;已在同值的行之间反复翻页,实验在遍历量超过总行数两倍时停止

created_at 只到秒,同一秒里有多行。用严格小于,页边界上与最后一行同一秒、但还没返回的行全部被跳过;开头那几千条的遗漏就是这么来的。用小于等于,同一秒里已经返回过的行又会再出现,同值的行足够多时甚至会在原地打转。

三、复合游标:排序键加上唯一的决胜键 ​

text
复合游标 (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)。

四、翻页期间有新数据写入 ​

text
读完第 1 页后插入一条更新的数据:offset 取第 2 页,第一行与第 1 页最后一行重复:True
复合游标取第 2 页:与第 1 页重复的行 0 个,紧接在第 1 页之后:True

offset 指的是「第几行」,前面插入一行,后面所有行的位置都往后挪一位,于是第 2 页的第一行就是第 1 页的最后一行。游标指的是「某一行之后」,前面插入数据不影响它。

五、代价:游标不能跳页 ​

复合游标只能「下一页」,不能直接跳到第 37 页,也不容易给出总页数。常见的取舍是:

  • 列表与无限滚动用游标;需要跳页的后台查询限制最大页数,或者引导用户加筛选条件。
  • 总数用单独的计数,允许近似或延迟更新,而不是每次翻页都对所有分片做 COUNT。
  • 需要任意排序、任意跳页的复杂查询,交给专门的搜索或分析系统,而不是让业务分片承担。

六、检查清单 ​

问题做法
深分页的读取量offset 的代价是分片数 ×(offset + limit);限制最大页数
排序键有重复加上全局唯一的决胜键,组成复合游标
游标条件用行构造器或展开的 (a < ?) OR (a = ? AND b < ?),并有对应的复合索引
决胜键的唯一性全局唯一 id,或把分片号加进排序键
翻页期间的写入游标不受影响;offset 会重复或遗漏
跳页与总数游标不支持跳页;总数单独维护

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权