《互联网产品的服务端架构·规模化与架构演进》2.2 数据同步与冲突

围绕同一条记录在两个单元被同时修改的场景,拆解版本、时间与业务语义三层冲突判定,说明为什么时间戳不能替代版本号,复盘一次静默覆盖造成的数据事故,并用 Go 合并函数与针对性测试把冲突处理变成可审计的代码,最后给出冲突上报与人工裁决的明确边界。

同一条记录被改了两次

「回声」的用户资料在两个单元各有一份副本。平时用户只被路由到家单元,副本之间单向同步,冲突很少。某次华东单元故障,运维把流量切到备用单元,同时保留了双写:家单元恢复后,两边都继续接受写入,等数据追平再关掉备用写入。

切换当晚,一位用户先在家单元改了收货地址,十秒后又在一个尚未过期的旧会话里改了手机号。两份副本的版本号都从 6 自增到 7,时间戳分别是 22:14:03 和 22:14:13。合并程序按“时间戳新者胜”整条保留,结果备用单元的记录覆盖了家单元的记录:收货地址退回旧值,手机号更新了,但手机号验证状态字段还是旧的“未验证”。

用户第二天发现地址错了,客服查了半天才定位到合并逻辑。更麻烦的是,这次覆盖没有留下任何冲突记录,监控上一切正常,直到用户投诉才被发现。

这个场景说明,双向同步的难点不是“把数据传过去”,而是“两份都变过时,凭什么决定保留哪一份”。本节把冲突判定拆成三层:版本、时间、业务语义,并给出一个不静默覆盖的最小实现。

冲突判定的三层

第一层是版本。版本号回答的是“这份数据是不是在另一份之后又改过”。只要版本单调递增,比较版本就能判断谁更新,不需要相信任何机器的时钟。

第二层是时间。时间戳回答的是“两次修改发生的先后”。它只在时钟可信、且业务接受“后发生者胜”时才有意义。跨单元部署里,时钟偏差、时区配置错误和批量补数据都会让时间戳失真。

第三层是业务语义。它回答的是“这两份数据能不能自动合并”。有些字段可以按字段取新值,有些字段必须由用户确认,有些操作(例如扣款、核销)根本不允许两边同时成功。

判定依据能回答的问题不能回答的问题误判后果
版本号谁在之后修改过同版本下内容为何不同版本相同则无法裁决
更新时间哪次修改发生更晚时钟是否可信时钟偏差导致旧值覆盖新值
业务语义字段能否合并、操作能否重放冲突该由谁确认自动合并出用户没同意过的状态

三层不是替代关系,而是顺序关系:先用版本排除“明确更新”的情况;版本相同时再用时间做候选排序;最后用业务语义决定能不能自动落地。任何一层给出“不能确定”的结论,都应该把冲突暴露出来,而不是继续往下猜。

最危险的做法是只有一层。只用时间戳,会把时钟偏差变成数据错误;只用版本号,会在版本相同时束手无策;只靠业务语义,则要求每个写入路径都实现一套合并规则,迟早出现遗漏。

实际系统里,三层往往由不同组件实现:版本由写入路径维护,时间由数据库或消息队列附带,业务语义由领域服务判断。把它们混在一个 if-else 里,短期能跑,长期一定会出现某一层被绕过的情况。更稳妥的做法是让每一层都留下可观测的输出:版本比较结果、时间差、冲突分类,这样复盘时才能回答“当时为什么选了这一份”。

版本号不是时间戳

很多团队一开始会用 updated_at 当版本号:谁的时间晚,谁就是新版本。这个做法在单机、单时钟、低并发时勉强能用,一旦跨单元就漏洞百出。

  • 两台机器的时钟不可能完全一致。NTP 同步通常有毫秒级偏差,故障切换、容器迁移和虚拟化还会放大偏差。
  • 批量补数据、离线导入和回放任务会写入“过去的时间”,让后写入的记录看起来更旧。
  • 同一毫秒内的两次修改无法排序,时间戳相等时程序只能随便选一个。
  • 时间戳是业务字段,可能被用户或运营修改;版本号应该是系统字段,只由写入路径维护。

版本号的正确用法是“每次写入在同一个逻辑序列上自增”。单单元内可以用数据库的自增列或乐观锁版本;跨单元时,常见做法是引入全局逻辑时钟、按用户分片的版本分配器,或者让家单元统一分配版本。代价是写入多一次协调,但它换来的是可判定的先后关系。

案例假设:同步链路每秒处理 2000 条记录,版本分配多一次跨单元往返会增加约 3 毫秒。这个开销是否值得,要看业务能否接受“偶发冲突转人工”。对于用户资料这类低冲突数据,通常值得;对于点赞数这类可合并的计数,可以用另一套规则,不必强行全局排序。

还要区分“记录版本”和“字段版本”。整条记录一个版本号,合并时只能整条选一个;如果两个单元分别改了不同字段,整条覆盖就会丢掉其中一边的修改。字段级版本能减少误覆盖,但会让存储和同步协议复杂很多。选择哪一种,取决于业务能否接受“不同字段的修改互相覆盖”。

谁负责分配版本

版本号由谁分配,决定了它是否可信。最常见的是家单元分配:所有写入先到归属单元,由它递增版本,再异步同步到副本。这样版本只有一条序列,比较才有意义。

切换时必须显式转移分配权,而不是让两个单元各自继续。做法可以引入一个带租约的归属标记:只有持有租约的单元可以分配版本,旧单元在租约过期或收到 fencing 令牌后拒绝写入。否则一次网络分区就会产生两个“合法”的写入方。

分配权转移要留痕。至少记录转移时间、新旧单元和当时的最高版本,回切和排障时才能回答“这期间谁的版本有效”。没有这层记录,版本冲突只能靠猜。

版本相同、内容不同意味着什么

这是最容易被忽略的分支。版本相同、内容相同,说明两份副本一致,直接返回即可;版本相同、内容不同,说明两条写入路径各自从同一个版本自增,产生了分叉。此时没有“正确”的自动答案,只有业务上可接受的选择。

本章的实现选择保留 UpdatedAt 较新的一份,同时在返回值里给出冲突说明。注意,这里的“保留较新”只是让系统继续运行,不是宣布它正确。冲突说明必须进入日志、监控或待处理队列,由人或异步任务做最终裁决。

如果调用方把冲突说明丢掉,只使用返回的记录,系统就退化成了“静默覆盖”。这正是上一节事故的根因:合并函数其实可以返回说明,但调用方没有记录。

冲突率应该和同步延迟、双写开关一起看。冲突率高可能是版本分配有问题,也可能是两个单元在同一时间段都接收了写入;前者要修版本,后者要修路由或停止双写。只看冲突数量,容易把路由问题误判成合并问题。

删除、乱序与重复投递

双向同步里还有三类问题经常被忽略。它们不改变合并函数的核心规则,但会让“版本高者胜”在实际链路上失效。

  • 删除也是一种写入。如果删除只是把行删掉,另一份副本的旧记录同步过来时,系统会把它当成“新数据”重新插入。正确做法是写墓碑(tombstone),墓碑同样带版本,并在保留期内阻止旧版本复活。
  • 消息可能乱序。版本 8 先到、版本 7 后到时,接收方必须拒绝低版本,而不是无条件覆盖。合并函数只比较两份记录,落地层还要做一次“只接受更高版本”的判断。
  • 消息可能重复。同一版本、同一内容重复到达时,结果必须不变,否则计数、追加日志这类操作会被放大。幂等键和版本比较要一起用。
异常只靠版本比较的后果落地层需要的补充
删除后旧记录回流已删除数据复活墓碑 + 保留期
低版本消息晚到新值被旧值覆盖应用前拒绝低版本
同版本消息重放计数或日志重复幂等键 + 内容比较

这张表说明,合并函数和落地层是两件事。Merge 负责比较两份记录,落地层负责决定“这份结果要不要写、怎么写”。把两者混在一起,测试会变得很难写,线上也很难判断问题出在比较还是写入。

墓碑还有保留期问题。保留太久,存储成本上升;保留太短,离线两天的副本一回流,删除记录就复活。保留期应该覆盖“最慢副本可能落后多久”,并且可观测:一旦有副本落后超过保留期,应该停止自动同步并告警,而不是让它继续写。

同步链路的基本顺序

一个可解释的同步链路通常按五步执行,每一步都留下可检查的状态。

  1. 拉取:按 Key 或时间窗口读取待同步记录,记录来源单元的当前版本水位。
  2. 比较:调用合并函数,得到候选记录与冲突说明。
  3. 条件写入:只有候选版本高于本地版本时才写入,防止晚到的低版本覆盖新值。
  4. 冲突落库:冲突说明写入冲突表,带上双方版本、时间戳和来源单元。
  5. 推进水位:以上步骤全部成功后才推进同步水位,避免“数据没落地但水位前进了”。

顺序里最容易漏的是第三步和第五步。少了条件写入,合并函数的判断会被并发写入绕过;少了水位与落库的绑定,故障恢复后会跳过一段没有真正同步的数据。两者都不会在正常路径上暴露,只会在切换和回切时集中出现。

一次静默覆盖事故

案例假设:某内容平台把文章审核状态在两个单元间双向同步。审核员在 A 单元把文章标记为“通过”,同一时间风控任务在 B 单元把文章标记为“驳回”。两份记录的版本号相同,时间戳只差 800 毫秒。

合并程序按时间戳保留了“驳回”,但没有记录冲突。A 单元的审核员看到自己的操作“成功”了,页面上却显示驳回;他再次点击通过,版本号自增,系统又把状态改回通过。风控任务下一轮扫描再次驳回。两个系统来回拉锯,文章在通过和驳回之间反复切换,持续了六个小时。

事故的后果不只是状态错乱。期间有用户看到文章短暂上线,又突然消失;客服收到大量咨询;审核团队开始不信任系统,改为人工二次确认,吞吐量下降了一半。

复盘时发现三个问题:

  • 合并函数只返回一条记录,调用方没有渠道知道发生过冲突。
  • 冲突没有进入任何队列,监控只看同步成功率,不看冲突率。
  • 审核状态被当成普通字段整条合并,没有定义“通过”和“驳回”谁优先,也没有定义同一篇文章能否被两个单元同时审核。

修复方案不是换一个更聪明的合并算法,而是把冲突变成一等公民:Merge 返回冲突说明;调用方按说明写入冲突表;冲突表驱动告警和人工裁决;对于审核这类互斥操作,写入前先在家单元加锁,避免两个单元同时产生新版本。

这个事故还有一个隐蔽教训:同步成功率 100% 不代表数据正确。两份数据都成功写入、成功传输、成功合并,结果仍然是错的。监控必须同时看“同步是否完成”和“冲突是否发生”。

冲突之后由谁裁决

冲突被识别出来只是第一步,还要决定由谁给出最终结果。常见的处理方式有四类,选择哪一种取决于业务能不能承受自动判断的错误。

  • 自动合并:适用于不同字段各自修改、可加计数器、集合并集这类语义。前提是合并规则被领域专家确认过,并且有测试覆盖。
  • 人工队列:适用于审核状态、金额、权限、地址这类用户可感知且不能猜的字段。冲突进入队列,由运营或用户确认,系统只保证不丢数据。
  • 拒绝写入:适用于互斥操作,例如同一笔退款只能成功一次。写入前在归属单元加锁或做幂等检查,冲突直接返回失败,让上层重试或提示。
  • 补偿任务:适用于已经发生的错误,例如重复发货、重复加积分。系统记录冲突后由对账任务撤销多余的部分,而不是假装没发生。

四类方式可以组合。用户资料的不同字段可以自动合并,但“实名认证状态”进入人工队列;订单金额自动合并没有意义,应该直接拒绝第二次写入。关键不是选一个万能策略,而是每个字段都有明确归属。

最忌讳的是“所有冲突都按时间戳取新”。它看起来统一,实际上把业务判断偷偷换成了时钟判断。一旦时钟出错,错的不只是某条记录,而是整类字段的合并语义。

用 Go 实现合并规则

下面的代码来自本书配套的 webapp-lab。Merge 接收同一个 Key 的两份记录,按“版本高者胜、同版本同内容无冲突、同版本不同内容保留较新并报告冲突”的规则返回结果与说明。

// file: internal/cell/sync.go
// tier: L1
// verify: go test ./internal/cell/

package cell

import "fmt"

// Record 是双向同步的最小单位。Version 单调递增,UpdatedAt 是毫秒时间戳。
type Record struct {
	Key       string
	Version   int64
	UpdatedAt int64
	Payload   string
}

// Merge 合并同一个 Key 的两份副本。
// 版本高者胜;版本相同且内容相同视为无冲突;版本相同但内容不同时,
// 保留 UpdatedAt 较新者,并返回一条可审计的冲突说明。
func Merge(a, b Record) (Record, string) {
	if a.Key != b.Key {
		return a, fmt.Sprintf("cell: key mismatch %q vs %q, kept %q", a.Key, b.Key, a.Key)
	}
	if a.Version > b.Version {
		return a, ""
	}
	if b.Version > a.Version {
		return b, ""
	}
	if a.Payload == b.Payload {
		if b.UpdatedAt > a.UpdatedAt {
			return b, ""
		}
		return a, ""
	}

	winner, loser := a, b
	if b.UpdatedAt > a.UpdatedAt {
		winner, loser = b, a
	}
	return winner, fmt.Sprintf(
		"cell: conflict on %s version=%d, kept updated_at=%d over %d",
		a.Key, a.Version, winner.UpdatedAt, loser.UpdatedAt,
	)
}

几个边界值得说明。

  • Key 不一致时直接返回错误说明,并保留第一份记录。正常调用不应该出现这种情况,一旦出现通常意味着上游把两条不同的数据拼错了,早暴露比静默合并安全。
  • 版本不同时直接返回高版本,不比较时间。时间戳只用于同版本分叉时的候选排序,不能反过来覆盖版本判断。
  • 同版本同内容时,仍然选择 UpdatedAt 较新的一份。内容相同,选哪份都不影响正确性,但保留较新的时间戳可以让后续比较更稳定。
  • 冲突说明包含 Key、版本和两个时间戳,足够写进日志后定位,但不需要把完整 payload 打出来,避免日志里出现敏感数据。
  • 函数对 a、b 的顺序不敏感:交换输入后,胜者和说明文本一致。这一点由测试固定。

代码没有做字段级合并,也没有自动重试或人工裁决。这是有意的:Merge 只负责给出“能自动决定的结果”和“必须被看见的冲突”,把裁决留给上层。把这三件事混在一个函数里,会让它既难测试又难解释。

测试:高版本、平版本与冲突

测试要覆盖四种情况:版本不同、版本相同且内容相同、版本相同但内容不同、Key 不一致。其中冲突用例还要验证交换输入后结果不变,防止实现偷偷依赖参数顺序。

// file: internal/cell/sync_test.go
// tier: L1
// verify: go test ./internal/cell/

package cell

import (
	"strings"
	"testing"
)

func TestMergeHigherVersionWins(t *testing.T) {
	a := Record{Key: "k1", Version: 7, UpdatedAt: 100, Payload: "new"}
	b := Record{Key: "k1", Version: 6, UpdatedAt: 900, Payload: "old"}
	got, note := Merge(a, b)
	if got.Payload != "new" {
		t.Fatalf("payload = %q, want new", got.Payload)
	}
	if note != "" {
		t.Fatalf("note = %q, want empty", note)
	}
}

func TestMergeSameVersionSamePayload(t *testing.T) {
	a := Record{Key: "k2", Version: 3, UpdatedAt: 100, Payload: "same"}
	b := Record{Key: "k2", Version: 3, UpdatedAt: 200, Payload: "same"}
	got, note := Merge(a, b)
	if note != "" {
		t.Fatalf("note = %q, want empty", note)
	}
	if got.UpdatedAt != 200 {
		t.Fatalf("updated_at = %d, want 200", got.UpdatedAt)
	}
}

func TestMergeConflictKeepsNewerTimestamp(t *testing.T) {
	a := Record{Key: "k3", Version: 5, UpdatedAt: 100, Payload: "from-a"}
	b := Record{Key: "k3", Version: 5, UpdatedAt: 300, Payload: "from-b"}
	got, note := Merge(a, b)
	if got.Payload != "from-b" {
		t.Fatalf("payload = %q, want from-b", got.Payload)
	}
	if !strings.Contains(note, "conflict") || !strings.Contains(note, "version=5") {
		t.Fatalf("note = %q, want conflict with version", note)
	}

	swapped, swappedNote := Merge(b, a)
	if swapped.Payload != got.Payload || swappedNote != note {
		t.Fatalf("merge is not symmetric: %+v %q vs %+v %q", got, note, swapped, swappedNote)
	}
}

func TestMergeKeyMismatchIsReported(t *testing.T) {
	a := Record{Key: "k4", Version: 1, Payload: "a"}
	b := Record{Key: "k5", Version: 9, Payload: "b"}
	got, note := Merge(a, b)
	if got.Key != "k4" {
		t.Fatalf("key = %q, want k4", got.Key)
	}
	if !strings.Contains(note, "mismatch") {
		t.Fatalf("note = %q, want mismatch", note)
	}
}

TestMergeConflictKeepsNewerTimestamp 是核心用例。它同时检查胜者、说明文本和对称性。对称性很重要:如果 Merge(a, b) 和 Merge(b, a) 给出不同结果,说明实现里藏了顺序依赖,跨单元同步时两边的合并结果会不一致,最终形成两个永远对不上的副本。

TestMergeHigherVersionWins 故意让低版本的 UpdatedAt 更大,确认版本优先于时间。这个用例能挡住“为了处理某个线上问题,把版本判断改成时间判断”的改动。

TestMergeKeyMismatchIsReported 验证错误路径不会静默通过。它不要求函数返回 error,因为签名只有两个返回值;但它要求说明文本明确指出 mismatch,让调用方有机会记录和告警。

测试没有覆盖业务语义。比如“审核通过”和“审核驳回”谁优先,测试无法回答,因为那是产品规则,不是合并函数的职责。业务规则应该放在冲突处理层,用显式的状态机或人工队列实现。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/cell/ -count=1

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

ok  	webapp/internal/cell	0.233s

ok 表示包内所有测试通过。输出没有列出用例数量,是因为没有加 -v;需要逐个确认时可以加 -v 再跑一次。判定标准仍然是退出码为 0 且没有 FAIL。

小结

双向同步的冲突判定要分三层:版本回答“谁之后改过”,时间回答“哪次更晚”,业务语义回答“能不能自动合并”。三层按顺序使用,任何一层无法确定时都应该把冲突暴露出来,而不是继续猜。

版本号不能退化成时间戳。跨单元部署里时钟不可信,批量任务还会写入过去的时间;版本号必须是系统维护的逻辑序列,才能给出可判定的先后关系。版本相同、内容不同意味着分叉,系统可以选一个候选继续运行,但必须把冲突说明送到可被看见的地方。

下一节会把视角从单条记录拉到整个单元:流量切换、回切和演练为什么是三件事,以及“演练通过”为什么不能保证“回切安全”。

阅读导航:上一节:2.1 单元化与用户路由 · 下一节:2.3 切换、回切与演练 。

常见错误与边界

  • 用时间戳当版本号。时钟偏差、补数据和同毫秒写入都会让排序失真。
  • 版本相同就随便选一个。相同版本不同内容意味着分叉,必须报告冲突。
  • 合并函数只返回记录。调用方没有渠道知道冲突,系统必然退化为静默覆盖。
  • 把冲突说明写进日志却不告警。日志没人看,冲突等于没被发现。
  • 整条记录覆盖字段级修改。两个单元改不同字段时,后合并者会丢掉另一边的修改。
  • 认为同步成功就是数据正确。传输成功、合并成功,结果仍可能是错的。
  • 让合并函数承担业务裁决。审核、扣款这类互斥操作需要状态机或人工队列,不是通用合并能解决的。
  • 忽略参数顺序。合并结果不对称会让两个单元各自保留不同副本,永远无法追平。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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