切换前夜,双写差异看板全是零
基线通过之后,「回声」的 Feed 重写进入第二阶段:新链路已经能跑通,但还不能直接承接流量。团队在发布、删除、关注、取关四条写路径上都加了双写,旧存储与新存储各写一份,再起一个小时级任务比对两份数据。差异看板挂在大屏上,连续三周都是零。
零差异让大家很有信心。灰度从 1% 开到 5%、10%、30%,每一步都看错误率与延迟,指标都很平稳。直到灰度到 30% 的第三天,内容安全团队找上门:有用户举报,已经被作者删除的帖子仍然出现在一部分人的首页里,而创作者后台显示删除成功。
排查发现,双写只覆盖了“发布”事件。“删除”“拉黑”“负反馈”三条写路径仍然只写旧存储。新链路的收件箱里,被删除的帖子从来没有被移除;比对任务也只比较“两边都存在的 key”,新存储里多出来的帖子根本不在比对范围内。看板上的零,不是没有差异,而是差异没有被看见。
这一节讨论双写与灰度:双写不是把同一份数据写两遍就结束,灰度也不是把流量比例调大就安全。两者都需要一个可执行的判据,告诉团队“现在能不能切”。
双写要比的是“同一件事”,不是“两条 SQL”
双写比对最常见的失败,是把“两条写入都成功”当成“两份数据一致”。写入成功只说明请求没有报错,不说明写进去的内容相同。要比较,必须先约定一个规范形式:
- 字段集合固定。旧存储有、新存储没有的字段,要么明确忽略,要么补齐,不能一边比一边改规则。
- 时间精度统一。旧存储存秒,新存储存毫秒,直接比字符串会得到 100% 差异。
- 空值与缺省统一。
null、空字符串、字段缺失在业务上可能等价,比对前要归一。 - 集合顺序统一。关注列表、标签列表的顺序如果不影响语义,比对前要排序。
- 覆盖所有事件类型。发布只是其中一种,删除、拉黑、负反馈同样会改变 Feed 结果。
常见双写方式的对比如下:
| 双写方式 | 写路径 | 差异何时发现 | 主要代价 | 适用阶段 |
|---|---|---|---|---|
| 应用层同步双写 | 业务事务内依次写新旧存储 | 写入返回后即可比对 | 任一存储慢都会拖长请求 | 小流量验证 |
| 应用层异步双写 | 写旧存储后发消息,由消费者写新存储 | 消息消费后比对 | 消息积压与重复消费 | 中等流量灰度 |
| CDC 订阅 | 订阅旧存储变更日志回放 | 回放延迟之后 | 日志格式与事务边界 | 大规模迁移 |
| 影子读比对 | 双写之外再用读请求抽样比对 | 读时实时发现 | 额外读放大 | 切流前验收 |
选择哪一种,取决于团队能承受多少写放大,以及差异需要多快被发现。同步双写最容易实现,也最容易把新存储的故障传导给用户;CDC 对业务代码侵入最小,但要求团队理解事务日志的边界,尤其是批量更新和级联删除。
表里没有“最优解”。真正要固定的是两件事:所有会改变 Feed 结果的写事件都要进入双写;比对必须能发现“只存在于一边”的 key,而不只是比较两边都有的 key。
为什么差异要按 key 聚合
同一条内容可能在一次请求里被写多次:发布写一次、计数更新写一次、审核状态更新再写一次。如果逐条比对,重复 key 会产生大量噪声,值班同学无法判断到底有几条数据坏了。按 key 聚合成“这个 key 是否存在差异”,报告才可读。
聚合还有一个副作用:只要有一次不一致,这个 key 就算不一致。这正是我们想要的。重试、补偿、乱序消费都可能让同一条数据在短时间内出现不同版本,最后一次写入正确不代表中间状态没有影响过读路径。
覆盖率是切流的必要条件,不是充分条件
覆盖率回答的是“有多少流量已经走过新链路”,不回答“新链路的结果对不对”。30% 覆盖且差异为零,只能说明这 30% 里没有发现问题;它不能证明剩下的 70% 也会没问题,更不能替代质量门禁。
因此灰度要同时看三个数:覆盖率、差异率、业务质量指标。覆盖率达到阈值只说明“样本够了”,差异率达标说明“数据对得上”,业务质量达标说明“用户感受没有变差”。三者都过,才轮到 9.3 的门禁做最终决策。
双写期间读路径怎么选
双写期间,读路径仍然应该走旧链路。新链路的数据还没有被完整验证,把它暴露给用户等于把比对任务变成线上实验。需要验证新链路时,用影子读:按一定比例复制请求,去新链路读一份,只记录结果和耗时,不影响返回给用户的内容。
影子读的代价是额外的读放大。如果新链路本身依赖旧存储回源,影子读会把回源流量也放大一倍。更稳妥的比例是从 1% 开始,确认新链路没有放大下游压力后再逐步提高。影子读的结果要和真实返回做字段级比对,而不是只比 HTTP 状态码。
双写本身也会改变旧链路
双写不是免费的。同步双写会把新存储的延迟和错误带入用户请求;异步双写会增加消息队列、消费者和重试逻辑。团队经常只评估新链路的容量,忘了旧链路的写入耗时也变长了。
一个实用的做法是给双写加旁路开关:新存储异常时,双写降级为只写旧存储并记录一条待补记录,而不是让整个发布请求失败。旁路必须被监控,否则“临时降级”会变成长期静默漏写,差异报告又会回到零。
差异率告警怎么定阈值
差异率的阈值不能拍脑袋。先看历史峰值:正常情况下,消息乱序造成的短暂差异可能是万分之几,超过这个量级就值得看一眼。再把阈值分成两级:一级只记录,二级才停止扩大灰度。一级阈值可以设得低一些,用来积累样本;二级阈值要设得足够高,避免一次消息积压就让整个发布冻结。
阈值还必须带时间窗。单点一分钟的差异率没有意义,连续三个五分钟窗口都超标才触发动作,这样既能过滤瞬时抖动,也不会让问题拖过整个晚高峰。每次调整阈值都要留下记录和到期时间,否则“临时放宽”会变成永久失效。
一次“零差异”的假象
回到开头的事故。根因可以拆成三层,每一层单独看都像小问题。
第一层是事件覆盖不全。双写代码只挂在发布服务上,删除、拉黑、负反馈分别由另外三个服务处理,没有接入双写。团队的检查清单写的是“核心写路径已双写”,但“核心”没有被定义成事件列表,于是漏掉的恰好是影响推荐质量最大的那几条。
第二层是比对口径只做内连接。比对任务从旧存储拉一批 key,再从新存储查这些 key,比较值是否相同。新存储里多出来的帖子不在旧存储的 key 集合里,永远不会被检查。正确做法是按事件流做全量对照,或者至少对两边做抽样外连接,统计“只在旧”“只在新”“两边都有但不同”三类。
第三层是告警被“已知抖动”掩盖。上线初期确实有过几次因消息重复导致的短暂差异,值班同学把差异率告警阈值调高,并加了一条“差异会自动收敛”的备注。后来真正的删除缺失也被这条备注掩盖了三天。
修复方案并不复杂:把写事件列成清单逐项接入;比对任务改为按事件流对照并统计三类差异;差异率连续 N 个窗口超过阈值就自动停止扩大灰度。真正难的是承认“看板为零”本身可能是一个需要验证的结论,而不是一个可以庆祝的结果。
修复上线后,团队加了一条验证:故意在测试环境删除一条已经双写的内容,确认差异报告在一个比对周期内出现对应 key。这个演练比再加十个仪表盘更有用,因为它验证的是“差异能不能被看见”,而不是“系统看起来是否正常”。回退预案里也应该有同样的一步:先证明监控能发现坏数据,再证明回退能修好它。
用两个纯函数固定比对与覆盖率
配套代码 internal/feedmig/dualwrite.go 提供两个函数:Divergence 从一批双写快照里找出不一致的 key,Canary 判断覆盖率是否达到阈值。两者都不碰数据库,也不发消息,方便在单测里把边界锁死。
// file: internal/feedmig/dualwrite.go
// tier: L1
// verify: go test ./internal/feedmig/
package feedmig
import (
"fmt"
"sort"
)
// Write 是一次双写的结果快照。
// Key 是业务键(例如 user:1001),Old 与 New 分别是两条链路写出的规范化值。
type Write struct {
Key string
Old string
New string
}
// Divergence 返回所有双写不一致的 key,按字典序排序并去重。
// 同一个 key 出现多次时,只要有一次不一致就算不一致;这样重试不会掩盖问题。
func Divergence(ws []Write) []string {
seen := make(map[string]struct{})
for _, w := range ws {
if w.Old != w.New {
seen[w.Key] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Canary 判断迁移覆盖率是否达到切流阈值。
// total 是目标总量,migrated 是已在新链路验证过的数量,threshold 取值在 (0,1]。
func Canary(total, migrated int, threshold float64) (bool, error) {
if total <= 0 {
return false, fmt.Errorf("feedmig: total must be positive, got %d", total)
}
if migrated < 0 || migrated > total {
return false, fmt.Errorf(
"feedmig: migrated %d out of range [0,%d]", migrated, total)
}
if threshold <= 0 || threshold > 1 {
return false, fmt.Errorf(
"feedmig: threshold must be in (0,1], got %v", threshold)
}
return float64(migrated)/float64(total) >= threshold, nil
}
Divergence 的输入是已经规范化过的快照。规范化放在上游,是因为不同存储的序列化方式不同,把转换逻辑塞进比对函数会让它同时承担解析和判断两种职责,测试也会变得臃肿。函数只回答一个问题:这条快照的旧值和新值是否相同。
返回结果排序并去重,是为了让报告稳定。map 的迭代顺序在 Go 里是随机的,如果直接把 key 列表返回给告警系统,同一批数据每次生成的告警内容顺序都不同,值班同学很难判断是新增还是重复。
Canary 用浮点除法计算覆盖率,而不是先乘后除的整数运算。整数运算容易在边界上差一,例如 899/1000 与 0.9 的比较,稍不注意就会因为截断变成“未达标”。函数同时校验 total、migrated 和 threshold:总量必须为正,已迁移数必须落在 [0,total],阈值必须在 (0,1]。覆盖率只是输入,函数不判断业务质量。
测试:排序、去重与阈值边界
双写比对的测试要覆盖三类情况:重复 key、全等数据、以及阈值刚好卡在边界。下面的用例把这些行为固定下来。
// file: internal/feedmig/dualwrite_test.go
// tier: L1
// verify: go test ./internal/feedmig/
package feedmig
import (
"reflect"
"testing"
)
func TestDivergenceSortedAndDeduped(t *testing.T) {
ws := []Write{
{Key: "user:3", Old: "v1", New: "v2"},
{Key: "user:1", Old: "same", New: "same"},
{Key: "user:2", Old: "a", New: "b"},
{Key: "user:3", Old: "v1", New: "v9"},
{Key: "user:1", Old: "same", New: "same"},
}
got := Divergence(ws)
want := []string{"user:2", "user:3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Divergence = %v, want %v", got, want)
}
}
func TestDivergenceEmpty(t *testing.T) {
if got := Divergence(nil); len(got) != 0 {
t.Fatalf("Divergence(nil) = %v, want empty", got)
}
ws := []Write{{Key: "user:1", Old: "x", New: "x"}}
if got := Divergence(ws); len(got) != 0 {
t.Fatalf("Divergence(all equal) = %v, want empty", got)
}
}
func TestCanary(t *testing.T) {
cases := []struct {
total int
migrated int
threshold float64
want bool
}{
{1000, 900, 0.9, true},
{1000, 899, 0.9, false},
{1000, 1000, 1.0, true},
{10, 1, 0.1, true},
{10, 0, 0.1, false},
}
for _, c := range cases {
got, err := Canary(c.total, c.migrated, c.threshold)
if err != nil {
t.Fatalf("Canary(%d,%d,%v): %v", c.total, c.migrated, c.threshold, err)
}
if got != c.want {
t.Fatalf("Canary(%d,%d,%v) = %v, want %v",
c.total, c.migrated, c.threshold, got, c.want)
}
}
}
func TestCanaryRejectsBadInput(t *testing.T) {
bad := []struct {
total int
migrated int
threshold float64
}{
{0, 0, 0.5},
{-1, 0, 0.5},
{100, -1, 0.5},
{100, 101, 0.5},
{100, 50, 0},
{100, 50, -0.1},
{100, 50, 1.1},
}
for _, c := range bad {
if _, err := Canary(c.total, c.migrated, c.threshold); err == nil {
t.Fatalf("Canary(%d,%d,%v) = nil error, want error",
c.total, c.migrated, c.threshold)
}
}
}
TestDivergenceSortedAndDeduped 里 user:3 出现了两次,一次不一致、一次更不一致,结果只保留一个 key。这模拟了重试场景:同一业务键被写多次,只要有一次对不上,就必须进入差异报告。
TestCanary 覆盖了 899/1000 与阈值 0.9 的边界。这个用例的价值在于防止有人把 >= 改成 >,或者在计算时先做整数除法。覆盖率恰好等于阈值时应当允许切流,否则阈值就变成了一个永远差一点的数字。
TestCanaryRejectsBadInput 覆盖了总量为零、迁移数为负、迁移数超过总量、阈值越界四类输入。这些输入在正常情况下不应该出现,但一旦出现,静默返回 false 会让调用方以为“还没准备好”,从而掩盖上游的计算错误。
测试没有覆盖“规范化是否正确”。那属于数据管道的责任,应该用真实样本做回放比对,而不是在单元测试里构造一个理想化的 JSON。单元测试只保证比较和阈值逻辑本身没有方向性错误。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/feedmig/ -run 'TestDivergence|TestCanary' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestDivergenceSortedAndDeduped
--- PASS: TestDivergenceSortedAndDeduped (0.00s)
=== RUN TestDivergenceEmpty
--- PASS: TestDivergenceEmpty (0.00s)
=== RUN TestCanary
--- PASS: TestCanary (0.00s)
=== RUN TestCanaryRejectsBadInput
--- PASS: TestCanaryRejectsBadInput (0.00s)
PASS
ok webapp/internal/feedmig 0.610s
四个用例全部通过,退出码为 0。-run 只筛选了双写相关的用例,9.1 的基线比较与 9.3 的门禁测试没有被执行。
小结
双写的目标不是“写两份”,而是“让两份的差异可见”。事件覆盖率、规范化规则、外连接式的差异统计,三者缺一个,看板上的零就可能是假象。
灰度用覆盖率决定“样本够不够”,用差异率决定“数据对不对”,用业务指标决定“用户是否满意”。三个问题不能用一个百分比回答。覆盖率函数只做它该做的事:把边界算准,把坏输入挡在门外。
下一节讨论结果与回退条件:即使双写和灰度都通过,也要提前写好“什么情况下退回去”,并且验证回退本身是有效的。
阅读导航:上一节:9.1 旧链路的问题与基线 · 下一节:9.3 结果与回退条件 。
常见错误与边界
- 只双写发布事件。删除、拉黑、负反馈同样改变 Feed 结果,遗漏任何一类都会让新链路“记得”不该记得的内容。
- 只比较两边都存在的 key。内连接会天然隐藏“只在新存储出现”和“只在旧存储出现”的数据。
- 直接比较原始序列化结果。字段顺序、时间精度、空值表示不同会产生大量假差异,必须先规范化。
- 差异逐条告警。同一 key 的多次写入会制造噪声,应按 key 聚合并去重排序。
- 把覆盖率当成质量证明。覆盖率高只说明样本多,不说明结果对。
- 阈值边界用整数除法。899/1000 很容易被截断成 0,导致永远达不到阈值。
- 对坏输入静默返回 false。总量为零、迁移数越界都应该报错,而不是伪装成“还没准备好”。
- 告警阈值被“已知抖动”长期放宽。每一次放宽都应该有到期时间,否则真正的故障会被备注掩盖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。