《互联网产品的服务端架构·规模化与架构演进》4.3 数据迁移与双读

把十八亿行订单从单库迁到三十二分片集群时,「回声」团队用双读比对兜底,却在回填中途改了分片数、没有版本化路由,导致新旧集群各按一套规则分布,双读回退把错误全部掩盖。本节拆解迁移五个阶段的准入条件,并给出批次区间规划与覆盖校验的 Go 实现和测试。

迁移最危险的时刻,是看起来一切正常

「回声」要把 18 亿行订单(案例假设值)从单库迁到 32 分片的新集群。迁移方案写了三十页,核心思路很标准:先影子写入,再全量回填,然后双读比对,最后切写、下线旧库。团队还专门做了一个比对面板,实时显示新旧数据的差异率。

迁移进行到第三周,面板上的差异率一直低于 0.01%,所有人都觉得可以按计划切读。切读前一天,一位工程师在抽查历史订单时发现:同一笔两年前的订单,从新集群读出来是三年前的另一笔订单的金额。他以为是个例,写了个脚本抽样一万笔,发现约一半的历史订单在新集群里“张冠李戴”。

根因不在数据拷贝,而在路由。迁移开始时新集群按 16 分片规划,回填到一半,容量评估认为 16 片不够,团队把配置改成了 32 片。回填任务每次从配置中心读取分片数,于是前半段数据按 16 片写入,后半段按 32 片写入。在线读路径用的是最新配置,也按 32 片读。前半段数据全部读错片,读不到时双读逻辑回退到旧库,返回了正确结果,所以差异率一直很好看。

这是一次典型的“双读把错误变成了隐身”。双读的设计初衷是兜底,结果兜底路径太成功,掩盖了主路径的系统性错误。如果没有在切读前做独立的全量校验,旧库一下线,一半历史订单就会永久错位。

这一节把迁移拆成五个阶段,说明每个阶段的读写路径和准入条件;然后讲双读应该怎么设计,最后用一段批次规划与覆盖校验的代码,把“不重不漏”变成可执行的检查。

迁移不是一次切换,是五次边界移动

把迁移当成“某天晚上切一下”,几乎一定会出事。更稳的看法是:迁移是读写路径的五次边界移动,每次只移动一条边界,并且每次都有明确的进入条件、观察指标和回滚动作。

阶段写路径读路径进入下一阶段的条件回滚动作
影子写入旧库为准,新库异步写只读旧库新库写入成功率大于 99.9%,延迟可接受停止新库写入
全量回填旧库为准,新库继续写只读旧库回填覆盖率达到 100%,抽查差异低于阈值停止回填,保留已写入数据
双读灰度旧库为准,新库写先读新库,缺失或异常回退旧库回退率稳定,异步比对差异低于阈值读路径切回只读旧库
读切换新库为准,旧库反向同步先读新库,异常回退旧库新库承载主要读流量,错误预算充足读路径切回旧库,恢复旧库写
写切换与收尾只写新库只读新库观察期结束,旧库无写入重新打开旧库写,进入反向补偿

表里的阈值都是案例假设值。真实项目要根据业务对错误的容忍度来定:支付订单的差异阈值可能是零,内容浏览历史可以允许千分之一。关键不是数字大小,而是每个阶段都必须有数字,不能靠“感觉差不多了”。

五个阶段里,最容易偷工减料的是“双读灰度”和“写切换”。双读灰度需要同时维护两条读路径,代码复杂、延迟上升,团队容易想跳过;写切换之后旧库不再更新,一旦新库有问题,回滚需要反向补偿这段时间的写入,成本最高。因此这两个阶段的观察期应该最长,准入条件应该最严。

还要强调一个顺序问题:双读的“先读新、再回退旧”只适用于新库已经具备完整数据的前提。在回填未完成时先读新库,会把大量“新库还没有”误判为“数据不存在”,然后频繁回退,既增加延迟,也让回退率指标失去意义。所以双读灰度必须排在回填完成之后,不能为了“早点验证”提前打开。

双读不是读两次,而是定义一条主路径

很多团队把双读理解成“一次请求同时查新旧两个库,然后比较”。这样做有两个问题:延迟翻倍,且当两个结果不一致时,代码不知道应该相信谁。真正可运维的双读,应该明确定义四件事:

  • 主路径:默认读哪个库。按本节约定,先读新库。
  • 回退条件:什么情况下读旧库。包括记录不存在、超时、返回错误、版本号落后。
  • 比对方式:同步比对还是异步比对。同步比对会增加延迟,通常只用于小流量灰度;全量比对应该异步抽样。
  • 冲突裁决:不一致时以谁为准,以及如何记录。迁移期间通常以旧库为准,但要把差异写入比对队列,而不是静默吞掉。

“先读新、再回退旧”有一个容易被忽略的细节:新库返回“不存在”不一定代表数据真的不存在。它可能是回填还没覆盖到,也可能是路由算错。如果把所有“不存在”都当成回退条件,回退率会很高,但系统看起来仍然可用;一旦旧库下线,这些请求就会集体失败。因此回退必须带原因分类,并且对“路由 miss”单独告警,不能和“正常未回填”混在一起。

双读的比对也不能只比业务字段。至少要比对:主键是否存在、关键金额与状态、数据版本号或更新时间、以及路由到的分片号。只比金额,可能漏掉状态错位;只比存在性,可能漏掉内容错误。比对结果要能按数据版本、分片、时间段聚合,否则出了问题时无法判断影响范围。

还有一个成本问题:双读期间,新库要承受全量读流量,旧库还要承受回退流量和反向同步。容量规划要按“最坏情况”做,即大部分请求都回退到旧库。如果只按“新库能扛住”来扩容,回退风暴会把旧库打垮,而旧库恰恰是最后的正确性保障。

路由没版本化,双读会把错误藏起来

回到开头的事故。团队的错误不是“改了分片数”,而是改分片数时没有把路由规则版本化,也没有让回填任务和在线读路径使用同一个版本。

具体过程是这样的:回填任务按批次处理订单 ID 区间,每批开始前从配置中心读取一次 shard_count。前半段读到 16,数据按 hash(order_id) % 16 写入新集群;配置改成 32 后,后半段按 hash(order_id) % 32 写入。在线读路径每次请求都读最新配置,自然按 32 取模。对于前半段数据,hash % 32 和 hash % 16 大多指向不同分片,读新库大概率 miss。

miss 之后,双读回退到旧库,旧库数据是完整的,于是用户看到的结果一直正确。比对任务抽样的是“最近 30 天订单”,这些订单恰好都在后半段,按 32 写入、按 32 读取,差异率为零。三个保护机制同时失效:回退掩盖了主路径错误,抽样区间避开了问题数据,差异率指标又没有按数据年龄段拆分。

更隐蔽的是,这个错误在切读后不会立刻爆发。切读只是把“旧库为准”改成“新库为准”,读路径仍然是先读新、miss 回退旧。只要旧库还在,回退仍然能返回正确数据。真正爆发是在旧库下线的那一刻:所有前半段历史订单同时失去兜底,错误从“延迟暴露”变成“集中暴露”。

正确的做法是把路由版本写进迁移的每一层:

  • 回填任务在启动时固定一个路由版本,整个任务生命周期内不重新读取分片数;换版本必须开新任务,并从明确的位点开始。
  • 写入的数据带上版本标记,例如键前缀 v1: 或独立的 route_version 字段,读路径先解析版本再选择函数。
  • 新旧路由函数同时存在,且都能回答同一个键;迁移完成前不删除旧函数。
  • 双读比对按路由版本、数据年龄段、分片号分组统计,任何一组差异率异常都阻断切读。

这四条不是迁移的“加分项”,而是防止系统性错误被兜底路径掩盖的最低要求。

用区间规划把回填拆成不重不漏的批次

全量回填不能用一个 LIMIT OFFSET 从头扫到尾:偏移量越大越慢,任务失败后重试成本越高,多 worker 并行还容易重复处理。更稳的方式是按主键区间切批,每个批次处理一个左闭右开的范围,失败只重试当前批次。

配套代码 internal/shard/plan.go 提供两个函数:Plan 把总区间均匀切成若干段,余数分给前几片;Check 验证一组区间是否恰好覆盖整个范围,没有空洞、没有重叠。它不负责读取数据,只负责把“批次计划”变成可检查的对象。

// file: internal/shard/plan.go
// tier: L1
// verify: go test ./internal/shard/

package shard

import "fmt"

// Range 是左闭右开的键空间区间 [From, To)。
// 半开区间让相邻分片共享一个边界:前一片的 To 正好是后一片的 From,
// 既不会漏掉整数,也不会把同一个整数算两次。
type Range struct {
	From int
	To   int
}

// Plan 把 [0,total) 均匀切成 shards 段,余数依次分给前几片。
// 例如 Plan(10,3) 返回 [0,4) [4,7) [7,10):10/3=3 余 1,第一片多拿 1 个。
// 迁移任务常用它把一次全量扫描拆成可重试、可并行的批次。
func Plan(total, shards int) ([]Range, error) {
	if total <= 0 {
		return nil, fmt.Errorf("shard: total must be positive, got %d", total)
	}
	if err := ShardCount(shards); err != nil {
		return nil, err
	}
	if shards > total {
		return nil, fmt.Errorf("shard: shards %d exceeds total %d", shards, total)
	}

	base, rem := total/shards, total%shards
	out := make([]Range, 0, shards)
	from := 0
	for i := 0; i < shards; i++ {
		size := base
		if i < rem {
			size++
		}
		out = append(out, Range{From: from, To: from + size})
		from += size
	}
	return out, nil
}

// Check 验证一组区间恰好覆盖 [0,total):无空洞、无重叠、顺序递增。
// 迁移任务用它确认批次计划没有漏键,也没有把同一条记录处理两次。
func Check(rs []Range, total int) error {
	if total <= 0 {
		return fmt.Errorf("shard: total must be positive, got %d", total)
	}
	if len(rs) == 0 {
		return fmt.Errorf("shard: no ranges")
	}

	next := 0
	for i, r := range rs {
		if r.From != next {
			if r.From < next {
				return fmt.Errorf("shard: range %d overlaps at %d", i, r.From)
			}
			return fmt.Errorf("shard: range %d leaves hole [%d,%d)", i, next, r.From)
		}
		if r.To <= r.From {
			return fmt.Errorf("shard: range %d is empty or reversed", i)
		}
		next = r.To
	}
	if next != total {
		return fmt.Errorf("shard: coverage ends at %d, want %d", next, total)
	}
	return nil
}

Range 使用左闭右开区间 [From, To)。这个选择让相邻批次共享一个边界:前一批的 To 等于后一批的 From,既不会漏掉边界上的 ID,也不会让同一个 ID 被两个 worker 处理。如果使用闭区间 [From, To],每个边界都会被重复处理一次,回填任务必须在写入时做幂等,否则会产生重复记录。

Plan 把余数分给前几片,而不是全部塞给最后一片。假设 10 个 ID 分 3 批,结果是 4、3、3,而不是 3、3、4。这样做的好处是前面的批次稍大,整体完成时间更接近;坏处是如果批次大小必须严格一致,调用方需要自己处理余数。代码选择了“尽量均匀”,并在注释里写清楚,避免调用方猜。

Plan 拒绝 shards > total。把 3 条记录分给 4 个 worker,必然产生空批次;空批次在回填任务里通常意味着配置错误,而不是正常情况。与其让 worker 空转,不如在规划阶段报错。total <= 0 同样报错:没有数据时不应该启动回填。

Check 从 0 开始逐段核对,要求每一段的 From 等于上一段的 To。如果 From 小于期望值,说明区间重叠;如果大于期望值,说明中间有空洞;最后还要求覆盖终点等于 total。这三个判断分别对应迁移里最常见的三类错误:重复处理、漏数据、尾部截断。

代码没有解决“键空间不连续”的问题。订单 ID 可能有空洞,按 ID 区间切分时,某些批次可能一条记录都没有。这不算错误,但会让进度估算失真。实际任务应该同时统计“扫描过的 ID 数”和“实际处理的记录数”,用后者判断回填是否完成,而不是用前者。

测试:均匀切分与覆盖校验都要有反例

批次规划的正确性不难验证,难的是让错误配置在测试里显形。下面的测试覆盖了整除、有余数、单批次、边界输入,以及空洞、重叠、尾部截断和空区间。

// file: internal/shard/plan_test.go
// tier: L1
// verify: go test ./internal/shard/

package shard

import (
	"reflect"
	"testing"
)

func TestPlanEvenSplit(t *testing.T) {
	got, err := Plan(12, 4)
	if err != nil {
		t.Fatal(err)
	}
	want := []Range{{0, 3}, {3, 6}, {6, 9}, {9, 12}}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Plan(12,4) = %v, want %v", got, want)
	}
}

func TestPlanRemainderToFront(t *testing.T) {
	cases := []struct {
		total, shards int
		want          []Range
	}{
		{10, 3, []Range{{0, 4}, {4, 7}, {7, 10}}},
		{7, 3, []Range{{0, 3}, {3, 5}, {5, 7}}},
		{1, 1, []Range{{0, 1}}},
		{100, 3, []Range{{0, 34}, {34, 67}, {67, 100}}},
	}
	for _, c := range cases {
		got, err := Plan(c.total, c.shards)
		if err != nil {
			t.Fatalf("Plan(%d,%d): %v", c.total, c.shards, err)
		}
		if !reflect.DeepEqual(got, c.want) {
			t.Fatalf("Plan(%d,%d) = %v, want %v", c.total, c.shards, got, c.want)
		}
		if err := Check(got, c.total); err != nil {
			t.Fatalf("Check(Plan(%d,%d)): %v", c.total, c.shards, err)
		}
	}
}

func TestPlanRejectsBadInput(t *testing.T) {
	bad := []struct{ total, shards int }{
		{0, 1}, {-1, 1}, {10, 0}, {10, -2}, {3, 4},
	}
	for _, c := range bad {
		if _, err := Plan(c.total, c.shards); err == nil {
			t.Fatalf("Plan(%d,%d) = nil error", c.total, c.shards)
		}
	}
}

func TestCheckDetectsGapAndOverlap(t *testing.T) {
	cases := []struct {
		name string
		rs   []Range
	}{
		{"hole", []Range{{0, 3}, {5, 7}}},
		{"overlap", []Range{{0, 4}, {3, 7}}},
		{"short-tail", []Range{{0, 3}, {3, 6}}},
		{"empty-range", []Range{{0, 0}, {0, 6}}},
		{"no-ranges", nil},
		{"reversed", []Range{{0, 3}, {6, 3}}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if err := Check(c.rs, 7); err == nil {
				t.Fatalf("Check(%v,7) = nil, want error", c.rs)
			}
		})
	}
}

TestPlanRemainderToFront 是核心用例。Plan(10,3) 期望 [0,4) [4,7) [7,10),Plan(7,3) 期望 [0,3) [3,5) [5,7)。两个用例分别验证余数为 1 和 1 的不同分布,确保“余数给前几片”不是碰巧。每个用例还调用 Check,把“规划结果必须覆盖完整”变成断言。

TestCheckDetectsGapAndOverlap 用六个反例覆盖常见错误:中间空洞、区间重叠、尾部截断、空区间、没有区间、区间反向。这些反例的价值在于,它们对应的是迁移任务里真实会发生的配置错误,而不是为了凑覆盖率。

测试没有覆盖多 worker 并发写入的幂等性,也没有覆盖任务中断后的位点恢复。这些需要集成测试和故障注入。纯函数测试能保证“计划本身不重不漏”,但计划正确不等于执行正确;执行层还需要幂等键、位点记录和失败重试。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/shard/ -run 'TestPlan|TestCheck' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestPlanEvenSplit
--- PASS: TestPlanEvenSplit (0.00s)
=== RUN   TestPlanRemainderToFront
--- PASS: TestPlanRemainderToFront (0.00s)
=== RUN   TestPlanRejectsBadInput
--- PASS: TestPlanRejectsBadInput (0.00s)
=== RUN   TestCheckDetectsGapAndOverlap
=== RUN   TestCheckDetectsGapAndOverlap/hole
=== RUN   TestCheckDetectsGapAndOverlap/overlap
=== RUN   TestCheckDetectsGapAndOverlap/short-tail
=== RUN   TestCheckDetectsGapAndOverlap/empty-range
=== RUN   TestCheckDetectsGapAndOverlap/no-ranges
=== RUN   TestCheckDetectsGapAndOverlap/reversed
--- PASS: TestCheckDetectsGapAndOverlap (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/hole (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/overlap (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/short-tail (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/empty-range (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/no-ranges (0.00s)
    --- PASS: TestCheckDetectsGapAndOverlap/reversed (0.00s)
PASS
ok  	webapp/internal/shard	0.252s

四个顶层用例和六个子用例全部通过,退出码为 0。输出里的 short-tail 对应“覆盖终点小于 total”,hole 对应中间空洞,两者都会让迁移任务在启动前失败。

小结

数据迁移的本质是五次边界移动:影子写入、全量回填、双读灰度、读切换、写切换。每个阶段只移动一条边界,并且都有准入条件、观察指标和回滚动作。跳过任何一个阶段,错误都会在更晚、更贵的位置暴露。

双读不是读两次然后随便选一个。它必须定义主路径、回退条件、比对方式和冲突裁决,并且对回退原因分类。回退率低不代表主路径正确,可能只是旧库兜得太好。切读前必须做独立的全量校验,不能只依赖双读面板。

路由版本化是迁移的底线。分片数可以改,路由函数可以换,但历史数据必须知道自己是按哪个版本写的。回填任务固定版本、数据携带版本、新旧函数并存,这三件事做到,才能避免“双读把错误藏起来”。

阅读导航:上一节:4.2 冷热分离与归档 · 下一节:5.1 单位经济模型 。

常见错误与边界

  • 把迁移当成一次切换。没有影子写入、回填、双读、切读、切写的分段,任何一步失败都只能整体回滚。
  • 双读同时查两个库再比较。延迟翻倍,冲突时不知道该信谁;应该定义主路径和回退条件。
  • 用回退率低证明主路径正确。旧库兜底越成功,主路径的系统性错误越容易被掩盖。
  • 回填中途改分片数。任务每次重新读配置,会让前后批次按不同规则写入,历史数据分布错乱。
  • 路由函数没有版本。旧数据按旧规则写、新路径按新规则读,是“数据消失”的最常见根因。
  • 只按最近数据抽样比对。问题数据往往集中在早期年龄段,抽样区间必须覆盖全量并按版本分组。
  • 用 OFFSET 分页做全量回填。偏移越大越慢,失败重试成本高,多 worker 还容易重复处理。
  • 计划正确就认为执行正确。区间不重不漏只保证任务边界,执行层还需要幂等键、位点记录和失败重试。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查