后端系统工程 22:非分片键查询与全局分页
系列导航:上一篇 21 有限能力的数据库路由代理 | 本篇 22 | 下一篇 23 分片扩容时的数据交接
订单按买家 user_id 分片之后,“某个买家的订单”只落在一个库里,查询直接路由即可。“某个商户的订单,按下单时间倒序分页”就不同了:同一商户的订单散在每一个分片上,路由层手里没有能把它收窄到单个分片的条件。上一篇的路由代理对这类语句的处理是拒绝。业务上这个查询不能不做,于是有两条路:每次都问遍所有分片,再在协调方合并;或者额外维护一张按商户组织的全局索引,先查索引再回分片取数。
两条路都要回答同一组问题:翻页时同一行会不会出现两次,会不会有行永远翻不到;翻页期间有新订单写入时,边界划在哪里;索引和主数据不一致时返回什么;一次查询最多允许打到几个分片、等多久。代码在 examples/22-shard-query/,输出在 evidence/22/output.txt。
查询契约与规模假设
查询只有一种:给定 merchant_id,按 (created_at desc, order_id desc) 返回一页订单,以及一个用于取下一页的不透明游标。对它的承诺如下:
- 翻页过程中始终存在且没有被修改的行,恰好返回一次。
- 任何一行在一次完整翻页中最多出现一次。
- 翻页期间新插入的行,排序键排在当前游标之后的会在后续页出现;排序键落在已读区间里的不会出现。
- 一次分页触达的分片数不超过调用方给出的扇出预算,每个分片单次返回的行数不超过行数预算;超出预算在发出任何语句之前拒绝。
- 某个分片在截止时间内没有返回时,要么整页失败,要么返回标记为不完整的结果;不完整的结果不签发下一页游标。
不承诺跨分片的一致快照。每个分片执行的是各自的语句,PostgreSQL 在 READ COMMITTED 下“只看到语句开始前已提交的数据”(PostgreSQL 16 事务隔离),三个分片的三个语句没有共同的时间点。上面的第三条因此按排序键定义,而不按提交时刻定义。
教学规模:3 个分片(b2_22_s0..s2),12000 个合成订单,300 个用户,10 个商户。created_at 取秒级时间、落在 2 小时窗口内,平均每秒约 1.7 行,重复时间很常见;商户 7 另有 400 行的 created_at 完全相同,用来放大重复排序值的问题。order_id 先生成再打乱,与时间顺序无关,否则 tie-breaker 的作用看不出来。数据由固定种子 22 生成,每组用例前重建。
数据模型
每个分片上的订单表带一个复合索引,列顺序与查询的等值条件和排序一致:
1 | |
多列 B-tree 索引在“前导列上的等值条件,加上第一个没有等值条件的列上的不等式”时能限定扫描范围(PostgreSQL 16 多列索引);索引顺序与 ORDER BY 一致时,LIMIT n 可以直接取前 n 行而不扫描其余部分(索引与 ORDER BY)。本章没有检查执行计划,这两条只作为建索引的依据。
方案 B 另有一个独立的索引库 b2_22_idx:
1 | |
一个订单只属于一个商户,所以 order_id 唯一,维护时可以按订单号先删后插。
排序必须是全序
PostgreSQL 文档在 LIMIT 一节写得很直接:“When using LIMIT, it is important to use an ORDER BY clause that constrains the result rows into a unique order. Otherwise you will get an unpredictable subset of the query’s rows.”(PostgreSQL 16 LIMIT 与 OFFSET)。只按 created_at 排序时,同一秒的几行之间没有确定的先后,两次执行可能给出不同的排列;分页又依赖“上一页最后一行”来决定下一页从哪里开始,这一行本身不确定,边界也就不确定。
加上唯一的 order_id 作为第二排序键,排序变成全序。游标保存最后一行的 (created_at, order_id),下一页的条件用行构造器比较写成 (created_at, order_id) < (?, ?)。行构造器的 < 按元素从左到右比较,遇到第一对不相等的元素就由它决定结果(PostgreSQL 16 行比较),所以这个条件恰好等价于“在全序中排在游标之后”。
负向对照把游标退化为只有 created_at。在商户 7 上(1560 行,其中 400 行同一时刻,页大小 37):
1 | |
严格小于时,第一页在 400 行重复值中间停下(这 400 行时间最新,排在最前),下一页从“早于这一秒”开始,同一秒剩下的 363 行全部跳过;其余 1 行丢在另一个落在重复秒内的页边界上,合计 364 行。改成小于等于,第二页又从同一秒开头取,重复了第一页的 37 行,之后游标不再前进。两种写法都没有报错,结果只是悄悄地错了。
方案 A:分散-汇聚
协调方对每个分片执行同一条语句,取各自的前 pageSize 行,再按全局顺序做 k 路归并。全局前 n 行一定落在各分片各自的前 n 行之内:如果全局第 k 行(k ≤ n)来自某个分片,那么它在该分片内部的名次不会超过 k。每个分片多取一行也没有必要。
图 22-1:分散-汇聚的一次分页(共享截止时间、超时取消、不完整结果不签发游标)
sequenceDiagram
participant C as 调用方
participant Q as 协调方 ScatterGather
participant S0 as 分片 s0
participant S1 as 分片 s1
participant S2 as 分片 s2
C->>Q: byMerchant(m, cursor, n, budget)
Q->>Q: 预算检查 n ≤ maxRowsPerShard, 分片数 ≤ maxFanout,否则拒绝(不发语句)
par 共享截止时间 shardTimeoutMs
Q->>S0: keyset 条件 + LIMIT n
Q->>S1: keyset 条件 + LIMIT n
Q->>S2: keyset 条件 + LIMIT n
end
S0-->>Q: ≤ n 行
S1-->>Q: ≤ n 行
Note over Q,S2: s2 超过截止时间 → Statement.cancel(),等语句结束
alt allowPartial = false
Q-->>C: SHARD_TIMEOUT
else allowPartial = true
Q-->>C: 归并结果, complete=false, missingShards=[2], nextCursor=null
end
归并部分的实现(src/ScatterGather.java):
1 | |
最后一个判断定义了不完整结果的契约。缺席的分片里可能有排序靠前的行;如果按已到达的行签发游标,调用方用它翻下一页,这些行就排在游标之前,再也不会出现。不签发游标,调用方只能用原游标整页重试,结果最多是慢,不会错。
游标编码了商户号。拿商户 3 的游标去查商户 4,或者传入无法解码的字符串,返回 BAD_CURSOR。游标没有签名,调用方可以伪造一个排序键,但伪造的结果只是从另一个位置开始翻页,不会越过商户条件。
商户 3(1217 行,页大小 50)与商户 7 的完整翻页结果,与直接读全部分片后在内存中排序得到的真值逐行一致:
1 | |
商户 7 的 43 个页边界中有 13 个落在同一 created_at 的重复值内部,游标靠 order_id 在重复值中间准确续上。代价也写在输出里:25 页共发出 75 条语句,每页从三个分片取回 150 行,只用其中 50 行。扇出系数等于分片数,与结果大小无关。
翻页期间的插入与删除
keyset_concurrent_insert 在翻页的同时开一个线程,持续以当前时间为商户 3 插入新订单;在第 2 页之后,再做四个确定的动作:插入一行排序键在游标之后的订单、插入一行排序键落在已读区间的订单、删除一行尚未读到的初始订单、删除一行已经读过的初始订单。
1 | |
并发插入的 34 行时间都比数据集新,排在第一页之前,后续页一行也看不到;初始行没有遗漏,也没有重复。另外四个动作的结果对应契约的第三条:排在游标之后的回填行出现了,落在已读区间里的行没有出现,删除的未读行不再出现,删除前已读过的行已经在早先的页里返回。
“落在已读区间里的行不会出现”在实际系统中有一个具体来源:created_at 由应用服务器赋值,服务器之间有时钟偏差,或者事务在赋值之后很久才提交。一行订单可能带着早于当前游标的时间戳晚到。keyset 分页对这种行的处理是确定的,它会在下一次从头翻页时出现,本次翻页看不到。需要“翻页期间不遗漏任何晚到行”的场景,排序键必须在提交顺序上单调,例如由单一序列在提交时分配,这超出了本章的数据模型。
全局 OFFSET 分页是常见的错误对照:每个分片取 offset + size 行,归并后跳过前 offset 行。数据静止时它是对的。同样在第 1 页后插入 3 行新订单、第 3 页后删除 1 行已读订单:
1 | |
新插入的 3 行把所有已有行的名次推后 3 位,第 2 页开头重复了第 1 页的最后 3 行;删除一行已读订单又让后面的行整体前移 1 位,一行未读订单滑进已经跳过的区间,从未返回。OFFSET 按名次定位,名次随写入变化;keyset 按排序键定位,排序键不随其它行的写入变化。效率上 OFFSET 还有一个问题,被跳过的行仍需在服务端计算(同一文档),而全局 OFFSET 让每个分片都计算 offset + size 行,第 25 页时每个分片要排出 1250 行。
预算与超时
扇出预算和行数预算在入口检查,不满足时一条语句都不发:
1 | |
分散-汇聚的扇出在查询之前就已知(全部分片),可以提前拒绝。索引方案的扇出要读完一批索引项才知道,IndexQuery 在回分片取数之前检查本页触达的分片数,同样在发出分片语句之前拒绝。
超时用一个慢分片验证:测试先在分片 2 上用管理员连接执行 LOCK TABLE orders IN ACCESS EXCLUSIVE MODE,让该分片的查询一直等锁,再以 300ms 截止时间查询:
1 | |
到期时 ShardCalls 对该分片的语句调用 Statement.cancel(),并等待语句真正结束后才返回,以免这条连接上残留一个仍在等锁的查询。lockWaitersAfterCancel=0 是在服务端 pg_stat_activity 中查到的等锁会话数。解锁后同一组连接正常完成查询。服务端也可以用 statement_timeout 作为兜底(PostgreSQL 16 客户端连接参数),本实验没有使用。
方案 B:全局二级索引
Vitess 把这类结构叫作 Lookup Vindex:“A Lookup Vindex is implemented as a MySQL lookup table that maps a column value to keyspace IDs”,文档也说明它们“informally referred to as cross-shard indexes”(Vitess v24.0 Vindexes)。同一页面指出,没有这类二级 Vindex 时,VTGate 只能把查询分散到所有分片;非唯一的 lookup 表要带上能唯一标识原行的列,通常就是原表主键。merchant_order_idx 带上 order_id 与 shard,正是为了从索引项定位到一行确切的订单。
索引项与订单不在同一个库里,维护它就是跨库写。Vitess 文档对 owner 表自动维护 lookup 行的描述是“These essentially result in distributed transactions, which traditionally require 2PC to guarantee atomicity”,它的一致 lookup 用加锁和提交顺序来避免 2PC。本章不实现这种方案,只比较两种更朴素的维护方式。
图 22-2:索引方案的状态归属与维护路径(实线为同步,虚线为异步)
flowchart LR
W[写路径 OrderStore] -->|同一本地事务| O[(分片 sN: orders<br/>权威数据)]
W -->|同一本地事务| X[(分片 sN: order_outbox<br/>待同步变更)]
X -.->|FOR UPDATE SKIP LOCKED 取批| I[Indexer]
I -.->|先删后插, 提交①| IDX[(b2_22_idx: merchant_order_idx<br/>派生数据, 可滞后)]
I -.->|删除已应用记录, 提交②| X
R[读路径 IndexQuery] -->|1 读索引项| IDX
R -->|2 按 order_id 取行并复核| O
F[修复任务 Repair] -->|扫描比对, 点查后修正| IDX
F --> O
同步双写(DoubleWriter)先提交分片上的订单,再写索引库。两个库没有共同的提交点,进程在两次提交之间退出,订单存在而索引项不存在。实验用 Runtime.halt(137) 在这个位置终止两个双写进程,第三个正常完成:
1 | |
调换顺序、先写索引,崩溃留下的就是指向不存在订单的索引项。两种顺序都有残留,区别只是残留的种类。
发件箱(OrderStore + Indexer)把“需要更新索引”这件事写进订单所在分片的同一本地事务,它与订单要么一起提交,要么一起回滚。维护器每轮在分片上用 FOR UPDATE SKIP LOCKED 取一批记录(“any selected rows that cannot be immediately locked are skipped”,PostgreSQL 16 SELECT),在索引库的一个事务里按 order_id 先删后插,提交索引库,再删除发件箱记录并提交分片。第二次提交之前崩溃,发件箱记录还在,下次再应用一遍。先删后插对重复应用不敏感:
1 | |
同一订单的两条发件箱记录(例如先插入、后改商户)由订单行锁串行化:第二个事务要等第一个提交才能更新这一行,它的发件箱记录也就在之后才写入,按 seq 顺序应用即可保持先后。发件箱方案的代价是滞后:订单提交与索引更新之间有一段时间差,长短取决于维护器的轮询频率和积压量,本章没有测量。
读路径必须复核
索引是派生数据,读路径不能把它当作事实。IndexQuery 读一批索引项,按分片分组,用 order_id = any(?) 回主分片取行,再检查三件事:行存在,merchant_id 仍是查询的商户,created_at 与索引项一致。不满足就丢弃,继续读下一批,最多 4 轮:
1 | |
游标推进到最后一个已消费的索引项,被丢弃的项也算消费过,否则下一页会再次读到它们。4 轮读完仍凑不满一页时返回短页并签发游标,短页不表示结束,只有索引读完才不签发游标。
index_lag_with_recheck 在发件箱未消费时,把商户 3 的 5 个订单改到商户 4、删除 5 个、新插入 4 个:
1 | |
复核挡住了 10 个过期索引项,返回结果里没有一行不属于商户 3。复核挡不住的是遗漏:4 个新订单的索引项还没写入,商户 3 暂时查不到它们;5 个改到商户 4 的订单在商户 4 那里同样暂时查不到。去掉复核,同一时刻会返回 5 个已经属于商户 4 的订单和 5 个已删除订单的残影。发件箱消费完之后,两个商户的结果都与真值一致。
复核把索引方案的错误限定为一种:过期项可以被检测并丢弃,缺失项只能等待同步或修复。
修复任务
缺失项在读路径上无法察觉,需要独立的修复任务把分片数据与索引对账。Repair.run 按商户扫描三个分片的订单与索引库中的项,分出 missing(分片有、索引无或不符)与 stale(索引有、分片无或不符)。扫描与写入并发时,扫描结果可能已经过时,所以动手前对每个候选按 order_id 回分片点查一次,仍然成立才修正。对上面两个双写崩溃留下的漏项,再加一个绕过发件箱直接删除订单留下的过期项:
1 | |
修复任务读全部相关数据,代价与分散-汇聚的一次全量翻页相当,适合低频运行或按商户触发,不适合放在每次查询的路径上。
两个方案的取舍
| 维度 | 分散-汇聚 | 全局索引 + 发件箱 |
|---|---|---|
| 每页语句数 | 分片数 | 1 次索引查询 + 本页涉及的分片数 |
| 每页取回行数 | 分片数 × pageSize | pageSize(加上被复核丢弃的) |
| 新鲜度 | 与分片同步 | 滞后于发件箱消费 |
| 错误形态 | 慢分片拖慢整页 | 过期项被复核过滤;缺失项要靠修复 |
| 额外状态 | 无 | 索引库、发件箱、维护器、修复任务 |
| 扇出 | 恒等于分片数 | 由数据分布决定,本实验平均每页 3.00 |
最后一行值得多看一眼:index_full_scan_m3 的 avgShardsPerPage=3.00。商户的订单按买家分片,同一商户在每个分片上都有订单,一页 50 行几乎必然覆盖全部 3 个分片。索引方案省掉的是“每个分片多取的行”和“不相关分片上的空查询”,在本数据分布下语句数并没有减少。如果商户的订单集中在少数分片,或者分片数远大于页大小,索引方案的扇出优势才会显现。分片数少、新鲜度要求高时,分散-汇聚的实现与运维成本都更低;分片数多、单页扇出成为瓶颈时,才值得承担索引的同步与修复成本。
用例与证据
| 用例 | 类别 | 观察值(evidence/22/output.txt) |
|---|---|---|
scatter_full_scan |
正常 | 1217 行,25 页,与真值逐行一致 |
scatter_duplicate_sort_values |
边界 | 400 行同一时刻,13 个页边界落在重复值内,无重漏 |
negative_ts_only_cursor_* |
负向对照 | < 漏 364 行;<= 重复 37 行并停滞 |
keyset_concurrent_insert |
并发 | 并发插入 34 行,可见 0 行,重复 0,遗漏 0 |
negative_offset_concurrent_insert |
负向对照 | 重复 3 行,遗漏 1 行 |
fanout_and_size_budget / bad_cursor_rejected |
拒绝 | 预算超限不发语句;游标商户不符被拒 |
shard_timeout_contract |
失败 | 300ms 截止,322ms 返回超时;部分结果不签发游标 |
index_full_scan_m3 / _m7 |
正常 | 与真值一致,复核过滤 0 |
index_lag_with_recheck / 对照 |
失效索引 | 过滤 10 个过期项;不复核时返回 10 行错误订单 |
index_missing_found_by_repair |
恢复 | 双写崩溃漏项 2 个、过期项 1 个,修复后一致 |
indexer_crash_replay_idempotent |
恢复 | 维护器崩溃后重放 30 条,与真值一致 |
运行入口 examples/22-shard-query/run.sh,环境见 evidence/22/env.txt(PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4、4 核)。三个分片与索引库都在同一个 PostgreSQL 实例上,延迟数字不代表跨机器部署,本章也不以它们作性能结论。
结论边界
实验支持的结论:用唯一的 tie-breaker 构成全序、用行构造器比较实现 keyset 游标,分散-汇聚在重复排序值和并发插入下没有重复、没有遗漏存续行,晚到的行按排序键而非提交时刻决定是否出现;全局 OFFSET 与只按时间的游标在同样的条件下确实出错。扇出与行数预算可以在发出语句前执行;慢分片要么让整页失败,要么得到不签发游标的不完整结果。发件箱维护的全局索引配合读时复核,错误只剩可修复的遗漏,修复任务能发现并补齐它们。
实验不支持的说法:多个分片的结果来自同一时间点(没有跨分片快照);索引在任何时刻都完整(发件箱只保证最终一致,滞后时间未测);排序键可变时游标仍然稳定(本章排序键不可变);多个索引维护器并发时的正确性(只以单个维护器运行)。
本章的分片映射是固定的。按 user_id 增加分片、把一部分桶搬到新分片时,订单要在两个库之间交接,正在写入的事务、迟到的写入和重试都要有确定的去处,这是下一篇 23 分片扩容时的数据交接 的内容。






