后端系统工程 23:分片扩容时的数据交接
系列导航:上一篇 22 非分片键查询与全局分页 | 本篇 23 | 下一篇 24 在线变更的数据兼容
两个分片扩到三个,需要把一部分数据从旧分片搬到新分片,同时写入不停。这件事拆开看有三个难点。复制需要时间,复制期间源端还在变化,复制完的副本一出来就是旧的。路由表改了之后,已经拿着旧路由的写入方不会立刻知道,它们的写会继续打到源端。写入方遇到超时会重试,一次请求可能在源端提交了、重试却落到目标端。任何一处处理不当,结果都是某些已经告诉调用方“成功”的写,在迁移完成后不见了,或者已经删除的行又回来了。
本篇实现按桶迁移:版本化路由、快照复制、按事务水位追赶、冻结栅栏、按键与版本校验、切流。三个独立的写入进程在迁移全程持续写入、删除与重试,迁移结束后逐键核对“所有已确认写”。代码在 examples/23-shard-rebalance/,输出在 evidence/23/output.txt。
需求与不变量
数据是合成的键值记录 items(item_id, bucket, val, row_version),写操作有三种:创建(值为 0,版本 1)、增量(val += delta,版本加 1)、删除。每个请求带请求 ID,同一请求 ID 重复提交返回第一次的结果。bucket = floorMod(hashCode(item_id), 8);初始桶 0–3 在分片 s0,4–7 在 s1,s2 是新加入的空分片,迁移把桶 3 和桶 7 搬到 s2。
迁移需要保持的不变量:
- 迁移结束后,每个键在其当前所属分片上的值和版本,等于该键所有已确认写依次作用的结果。
- 已确认删除的键不会重新出现。
- 同一请求 ID 无论重试多少次、落在哪个分片,只生效一次,且每次得到相同的结果。
- 任何时刻一个桶最多只有一个分片接受写入方的写。
- 切流之前可以随时放弃迁移,回到源分片且不丢写;切流之后不再承诺可回退。
规模是教学假设:8 个桶,3 个写入进程,每个进程维持约 40 个存活键,每次操作后休眠 1ms。一个场景内每个进程确认约 1000–1700 次写,迁移时每个桶只有十几到几十个存活键、几百条请求记录。这个规模足以制造交错,不足以说明大桶的复制耗时。
状态与归属
图 23-1:迁移涉及的状态与持久化位置
flowchart TB
subgraph META[b2_23_meta 路由元数据]
R[(bucket_route<br/>bucket, shard, epoch, state, target_shard)]
end
subgraph SRC[源分片 s0]
G0[(bucket_guard 本地守卫<br/>ACTIVE / MIGRATING / FROZEN / MOVED)]
D0[(items / item_tombstone / request_log<br/>权威数据, 直到切流)]
L0[(change_log<br/>触发器同事务写入, 带 txid)]
end
subgraph DST[目标分片 s2]
G2[(bucket_guard: IMPORTING → ACTIVE)]
D2[(items / item_tombstone / request_log)]
end
WR[写入进程 ×3<br/>内存中缓存路由与 epoch] -->|读| R
WR -->|FOR SHARE 守卫 + 写, 同一事务| G0
WR --> D0
D0 -->|AFTER 行触发器| L0
M[迁移器] -->|快照 / 追赶 / 排空| L0
M -->|按版本条件应用| D2
M -->|改状态与 epoch| R
M -->|改状态与 epoch| G0
M --> G2
路由表是写入方查找分片的依据,但它只是一份提示:写入方缓存了它,缓存可能是旧的。真正决定一次写是否被接受的,是分片本地的守卫行 bucket_guard。每个写事务的第一条语句是:
1 | |
状态不是 ACTIVE 或 MIGRATING 时拒绝,返回状态名;守卫的 epoch 与写入方持有的路由版本不同时拒绝,返回 STALE_EPOCH。检查和后续的业务写在同一个本地事务里,所以“检查时允许写、提交时已冻结”这种间隙不存在。
FOR SHARE 的选择决定了冻结怎样与在途写事务互动。PostgreSQL 文档说明,共享行锁“blocks other transactions from performing UPDATE, DELETE, SELECT FOR UPDATE or SELECT FOR NO KEY UPDATE on these rows, but it does not prevent them from performing SELECT FOR SHARE”(PostgreSQL 16 显式锁定)。写事务之间互不阻塞;迁移器把守卫改成 FROZEN 的 UPDATE 要等所有持有共享锁的写事务结束。反过来,在冻结 UPDATE 之后才来锁守卫行的写事务会等它提交,然后按 READ COMMITTED 的规则拿到更新后的行:“In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client”(PostgreSQL 16 事务隔离)。它看到 FROZEN,被拒绝。冻结提交的那一刻,可以断定所有写过这个桶的事务都已经提交或回滚,之后不会再有新的写被接受。
图 23-2:桶的状态迁移(括号内为守卫与路由的变化;虚线为拒绝路径)
stateDiagram-v2
[*] --> ACTIVE: 初始 epoch=1
ACTIVE --> MIGRATING: 源守卫 UPDATE(等在途写)\n路由 MIGRATING, epoch 不变
MIGRATING --> FROZEN: 快照与追赶完成\n源守卫 FROZEN epoch+1(等在途写)\n路由 FROZEN epoch+1
FROZEN --> ACTIVE_ON_TARGET: 排空且按键与版本校验通过\n目标守卫 ACTIVE epoch+2 → 路由指向目标 → 源守卫 MOVED
FROZEN --> ACTIVE: 校验失败或主动回退\n目标清空, 源守卫 ACTIVE epoch+1
MIGRATING --> ACTIVE: 主动回退
ACTIVE_ON_TARGET --> ACTIVE_ON_TARGET: rollback 请求 → PAST_CUTOVER(拒绝)
note right of FROZEN: 写入方: FROZEN / STALE_EPOCH 拒绝\n刷新路由, 同一请求 ID 重试
路由表里预留的 MOVED 状态在实现中没有使用:切流后路由行直接是 ACTIVE 并指向新分片。MOVED 只出现在源分片的守卫行上,作为迟到写的拒绝依据。
变更捕获与写在同一事务
迁移开始时,源守卫先切到 MIGRATING。从这一刻起,items 与 request_log 上的 AFTER 行触发器把本桶的每一次插入、更新、删除写进 change_log。触发器与触发它的语句属于同一个事务,文档的原话是“a trigger is executed as part of the same transaction as the statement that triggered it, so if either the statement or the trigger causes an error, the effects of both will be rolled back”(PostgreSQL 16 触发器行为概述)。业务写提交了,变更记录一定也提交了;业务写回滚了,变更记录也不存在。
1 | |
切到 MIGRATING 的那次 UPDATE 同样要等在途写事务结束,所以不存在“在 ACTIVE 状态下开始、在 MIGRATING 状态下提交、却没有被捕获”的写:MIGRATING 提交之前的写全部已经提交,会出现在随后的快照里;之后的写全部被捕获。
change_log.txid 的默认值是 pg_current_xact_id(),即写入事务的事务 ID。它在追赶时决定读到哪里为止。
快照与追赶的水位
快照复制在源端开一个 REPEATABLE READ 事务,读出本桶的 items、墓碑与请求记录,写入目标。REPEATABLE READ 的查询“sees a snapshot as of the start of the first non-transaction-control statement in the transaction”(同一事务隔离文档),三张表来自同一个时间点。
快照之后要把 change_log 中快照没有包含的变更追上。最直接的做法是记住读到的最大 seq,下次读 seq 更大的记录。这个做法有缺口:seq 在触发器执行时分配,不在提交时分配。事务 A 先拿到 seq=100,事务 B 后拿到 seq=101,B 先提交;迁移器此时读到 101,把游标推到 101;A 随后提交,seq=100 永远落在游标后面。
本实现改用事务水位。快照的 xmin 是“Lowest transaction ID that was still active. All transaction IDs less than xmin are either committed and visible, or rolled back and dead”(PostgreSQL 16 系统信息函数)。每一轮追赶先取当前的 pg_snapshot_xmin(pg_current_snapshot()) 作为上界 H’,再读 txid >= H and txid < H' 的记录。低于 H’ 的事务都已结束,不会再有迟到的提交落进已读区间;读完把 H 推进到 H’。起点 H 取快照的 xmin,快照之后提交的事务 ID 都不低于它。这个区间可能包含快照里已经有的变更,重复应用由下一节的版本规则消化。
代价是水位会被无关的长事务压住:同一个库里有一个运行很久的事务,xmin 就停在它那里,追赶会停滞,但不会遗漏。实验里没有制造长事务,停滞时间没有测量。
冻结之后的排空不再需要上界。冻结提交意味着所有写过本桶的事务都已提交,此后本桶没有新的提交,直接读 txid >= H 的全部剩余记录即可。
应用规则:版本与墓碑
追赶记录可能重复,可能与快照重叠,也可能因为跨轮次而与提交顺序不同。目标端的应用规则因此设计成与顺序无关、可重复执行:
1 | |
墓碑是删除不复活的保证。没有墓碑时,一条更早的 U 记录在删除之后被重放,会把行重新插入。写入方的删除路径本身也写墓碑(与删除同事务),所以墓碑随快照复制到目标,切流后目标上的新删除同样留下墓碑。
replay_old_changes_idempotent 在切流、写入方又在目标上写了一段时间之后,把桶 3 从头到尾的 60 条变更记录重放到目标:
1 | |
目标状态的摘要不变,没有键复活。
请求去重表 request_log 也是桶数据,随快照与变更日志一起迁移。一个请求在源端提交、写入方没有收到响应,冻结后它按新路由把同一请求 ID 发到目标,目标的 request_log 里已经有这条记录,返回第一次的结果。写入进程按 5% 的概率把已经提交的请求当作响应丢失,原样重发:
1 | |
冻结、排空、校验、切流
图 23-3:桶 3 的一次迁移(时间取自 evidence/23/output.txt 的 timeline)
sequenceDiagram
participant W as 写入进程
participant S as 源分片 s0
participant M as 迁移器
participant T as 目标分片 s2
participant R as 路由 meta
M->>S: 守卫 MIGRATING(11ms)
M->>S: REPEATABLE READ 快照
M->>T: 23 个键, 407 条请求(126ms)
loop 追赶 3 轮, 共 56 条(193ms)
M->>S: txid ∈ [H, xmin)
M->>T: 按版本应用
end
M->>S: 守卫 FROZEN epoch=2(等在途写, 198ms)
M->>R: FROZEN epoch=2
W->>S: 写(epoch=1)
S-->>W: 拒绝 FROZEN
W->>R: 刷新路由, 10ms 后用同一请求 ID 重试
M->>S: 排空 txid ≥ H, 2 条(504ms)
M->>M: 校验 24 键 / 436 请求, 无差异(726ms)
M->>T: 守卫 ACTIVE epoch=3
M->>R: shard=s2, epoch=3
M->>S: 守卫 MOVED epoch=3(729ms)
W->>T: 重试(epoch=3)
T-->>W: 确认
冻结窗口里额外停留了 300ms(freezeHoldMs),是为了让写入方的拒绝与重试能被观察到,生产实现会尽量缩短这段时间。排空之后、切流之前,迁移器逐键比较源与目标的 (item_id, val, row_version) 和 (request_id, outcome, row_version),任何缺失、多余或不一致都阻止切流并触发回退。
切流的三步有顺序:先把目标守卫设为 ACTIVE、epoch=3,再改路由,最后把源守卫设为 MOVED。三步之间源守卫一直是 FROZEN,写入方无论持有哪个版本的路由,都不可能同时在两边写成功。
正常场景的结果:
1 | |
5026 次已确认写作用于 917 个键,迁移后全部与期望一致。期望终态由写入进程自己计算:每个键只由一个进程写、按顺序写,进程记下每次确认的结果,结束时写出每个键应有的值和版本。
210 次 FROZEN 拒绝只对应 6 个请求。每个写入进程在每次冻结中恰好被挡住一个请求(它在等这个请求成功后才发下一个),每 10ms 左右重试一次,两次冻结合计每个进程重试约 70 次。6 次 MOVED 是切流后仍持有旧路由的写入方打到源分片,被守卫拒绝后刷新路由、写到目标。stale_route_write_fenced 则直接构造了四种持旧路由的写:发往源分片的写无论带什么 epoch 都得到 MOVED,发往目标分片的旧 epoch 写得到 STALE_EPOCH。
回退条件
切流前,源分片从未停止作为权威副本,目标上只有迁移器写入的副本。此时回退很简单:清空目标,源守卫回到 ACTIVE、epoch 再加 1,路由同步更新。rollback_before_cutover 在桶 1 冻结并校验通过后主动回退,每个写入进程被拒一次 STALE_EPOCH 后刷新路由,继续在 s0 上写:
1 | |
切流后,目标开始接受源端没有的新写。要无损地切回源端,需要把这些新写从目标同步回源端,也就是反向变更捕获。Vitess 的 Reshard 默认在切写时启动一条反向复制流以支持 ReverseTraffic,并写明关掉它之后“you will not be able to rollback once you have switched write traffic over to the target”;它的 cancel 也“can only be called if no traffic has been switched”(Vitess v24.0 Reshard、MoveTables)。本实现没有反向捕获,越过切流点就不能无损回退,rollback 对已切流的桶返回 PAST_CUTOVER,不做任何改动。
三个错误方案
只做快照、不追赶。为了模拟大桶的复制耗时,快照之后等待 500ms 再冻结:
1 | |
切流前的校验已经发现 54 项缺失、3 项多余、9 项不一致。强行切流后,桶 3 有 14 个键与已确认写不符,其中 3 个是快照之后删除、却随快照复制到目标的键,删除“复活”了。写入进程统计到 36 次异常结果:对一个已确认创建、尚未删除的键做增量或删除,目标却返回不存在。
不冻结、直接改路由。路由刷新间隔设为 300ms,追赶一轮后立即切换:
1 | |
源守卫一直停在 MIGRATING、epoch=1,持旧路由的写入方在刷新之前又被源分片接受了 24 次写。这些写对调用方是“成功”的,目标上却不存在,桶 3 有 13 个键不符。迁移器在切换之前做过一次排空,丢失全部来自切换之后的那段时间。
只比行数的校验。迁移器注入一个应用缺陷:排空时跳过某个键的最后一条更新记录。桶 3 用只比行数的校验,桶 7 用按键与版本的校验:
1 | |
丢掉一条更新不改变行数,两边都是 13 行、212 条请求,只比行数的校验放行。冻结期内读到的那个键,源端是 15:5,目标是 14:4:一次已确认的增量在切流时丢了。这次运行的终态检查没有在桶 3 发现问题,因为切流后写入进程又删除了这个键,删除把差异抹掉了;这个巧合说明事后核对不能代替切流前的校验。桶 7 遇到同样的缺陷,按键与版本的校验报告 diff=1,迁移自动回退,第二次迁移通过校验后切流。Vitess 的 VDiff 同样是逐行比较,报告缺失、多余与不一致的行数,并建议在 SwitchTraffic 之前执行(Vitess v24.0 VDiff)。
与 VReplication 的对应
Vitess 的 VReplication 先做一致复制,复制完成后“continuously replicate the data from the source to destination”(Vitess v24.0 VReplication);切写时“we temporarily make the source databases read-only, and wait for the targets to catchup. This means that the application can effectively be partially down for this cutover period as writes will pause or error out”(Reshard 文档)。本章的快照、追赶、冻结排空与它在结构上对应,差异在机制:Vitess 读 MySQL binlog,本章用触发器写 change_log;Vitess 在 VTGate 层缓冲切流期间的查询,本章把拒绝交给写入方重试;本章的栅栏放在分片本地的守卫行上,要求每一条写都经过它。
触发器方案的代价是每次业务写多一次 change_log 插入和一次守卫行查询,且捕获只覆盖装了触发器的表。用逻辑解码代替触发器可以去掉写路径上的额外开销,但需要复制槽与解码插件,本章没有实现。
用例与证据
| 用例 | 类别 | 观察值(evidence/23/output.txt) |
|---|---|---|
migrate_under_concurrent_writes |
正常 | 5026 次确认写、917 个键全部一致,切流后源端接受 0 次 |
frozen_writes_rejected_then_retried |
边界 | FROZEN 拒绝 210 次、MOVED 6 次,6 个请求重试后确认 |
retry_same_request_id_idempotent |
重试 | 261 次重发全部返回一致的第一次结果 |
stale_route_write_fenced |
栅栏 | 源端 MOVED,目标端旧 epoch 为 STALE_EPOCH |
replay_old_changes_idempotent |
恢复 | 重放 60 条记录,目标不变,无复活 |
rollback_before_cutover / _after_cutover_refused |
回退 | 冻结后回退终态一致;切流后返回 PAST_CUTOVER |
negative_snapshot_without_catchup |
负向对照 | 校验 54 缺失;强行切流后 14 键不符,3 个删除复活 |
negative_switch_without_fence |
负向对照 | 切换后源端又接受 24 次写,13 键不符 |
negative_count_only_verification / key_version_verify_blocks_cutover |
负向对照 / 校验 | 行数相同放行分歧;按键与版本校验拦下并回退 |
运行入口 examples/23-shard-rebalance/run.sh,环境见 evidence/23/env.txt(PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4、4 核)。四个库都在同一个 PostgreSQL 实例上;写入数量、拒绝次数、排空条数随调度变化,每次运行不同,断言只检查不变量。
结论边界
实验支持的结论:写路径在同一本地事务里检查分片守卫、写业务数据、写变更记录,冻结 UPDATE 等待在途写事务结束,这三点合起来让“冻结之后不再有写被接受”成为可检查的事实;按事务水位追赶不会因为提交顺序与分配顺序不同而漏读;按版本与墓碑应用,使重放、重叠和乱序都无害;请求去重表随桶迁移后,跨越切流点的重试仍然只生效一次;按键与版本的校验能发现行数校验看不到的分歧。在这些条件下,多进程持续写入时的迁移没有丢失已确认写,也没有复活删除。
实验不支持的说法:迁移器崩溃后能自动续传(未实现进度持久化);栅栏在写入方绕过守卫直接连库时仍然有效(它依赖所有写都经过守卫行);冻结窗口、追赶速度或业务延迟的任何数值(未测量,300ms 与 500ms 都是人为停留);切流后可以回退(未实现反向捕获,明确拒绝)。跨机器部署、网络分区和分片主备切换都没有覆盖。
迁移搬的是数据,表结构没有变。如果迁移的同时还要给表加字段、回填,新旧两版代码在一段时间里同时读写,兼容性怎样保证、回填怎样不覆盖新值,是第 24 章(在线变更的数据兼容)的主题。






