44.20 分布式事务的实现

一句话

单机事务靠 WAL + 行锁;跨分片事务靠把“提交决定”写进数据本身——Percolator 用“主锁 + 提交记录”让崩溃后的任何读者都能自己判断“该提交还是该回滚”,而不需要协调者永远在线。

四种实现路线

方案 提交协议 崩溃后谁来做决定 典型
两阶段提交 prepare → commit 协调者(它挂了,参与者一直等) XA、早期分布式库
Percolator prewrite → commit primary → commit secondary 任意读者(查主锁对应行有没有提交记录) TiDB、早期 Bigtable 事务
TrueTime + 2PC 2PC + 提交等待 协调者,但用时间戳保证顺序 Spanner
Calvin(确定性) 先定序、再执行 不需要 2PC:所有副本按同一顺序执行 FaunaDB、部分新库

🔑 Percolator 的关键设计:主键(primary)的提交记录就是全局决定。只要主键有提交记录,所有从键都必须提交;主键没有,就必须回滚。这让“恢复”从“找协调者”变成“查一行数据”。

代码:主键提交后崩溃,读者如何把事务“补完”

package main

import "fmt"

type lockInfo struct {
	txn     string
	primary string
	startTS int64
}

type store struct {
	data  map[string]string    // 已提交的值
	lock  map[string]*lockInfo // 写锁(未提交)
	write map[string]int64     // 提交记录:key -> commitTS
}

func newStore() *store {
	return &store{data: map[string]string{}, lock: map[string]*lockInfo{}, write: map[string]int64{}}
}

func (s *store) prewrite(txn, primary string, keys []string, vals map[string]string) bool {
	for _, k := range keys {
		if _, ok := s.lock[k]; ok {
			return false // 写冲突
		}
		if ts, ok := s.write[k]; ok && ts > 0 {
			return false // 已被别的事务提交过(简化:不做快照读)
		}
	}
	for _, k := range keys {
		s.lock[k] = &lockInfo{txn: txn, primary: primary, startTS: 1}
		s.data[k] = vals[k] // 值先写进去,但对外不可见(靠锁/提交记录判断)
	}
	return true
}

// commitPrimary:写提交记录 + 删锁(此后事务一定成功)
func (s *store) commitPrimary(commitTS int64, primary string) {
	s.write[primary] = commitTS
	delete(s.lock, primary)
}

// 读者:遇到锁就查主键,决定 roll forward / roll back
func (s *store) get(k string) string {
	if l, ok := s.lock[k]; ok {
		if _, committed := s.write[l.primary]; committed {
			fmt.Printf("  [读 %s] 遇到锁,查主键 %s → 已提交,推进(roll forward)\n", k, l.primary)
			s.write[k] = s.write[l.primary]
			delete(s.lock, k)
		} else {
			fmt.Printf("  [读 %s] 遇到锁,查主键 %s → 未提交,回滚(roll back)\n", k, l.primary)
			delete(s.lock, k)
			return "<旧值>"
		}
	}
	if v, ok := s.data[k]; ok && s.write[k] > 0 {
		return v
	}
	return "<旧值>"
}

func main() {
	// 场景 A:prewrite 后崩溃,主键没提交 → 读者回滚
	fmt.Println("=== 场景 A:写完锁就崩了(主键未提交)===")
	a := newStore()
	a.prewrite("T1", "acct:2", []string{"acct:1", "acct:2"},
		map[string]string{"acct:1": "扣 100", "acct:2": "加 100"})
	fmt.Println("  T1 prewrite 完成(两行都有锁),随后崩溃")
	fmt.Printf("  acct:2 读到的值:%s\n", a.get("acct:2"))
	fmt.Printf("  acct:1 读到的值:%s\n\n", a.get("acct:1"))

	// 场景 B:提交主键后崩溃(从键还没提交记录)→ 读者补完
	fmt.Println("=== 场景 B:提交主键后崩溃(从键未落提交记录)===")
	b := newStore()
	b.prewrite("T2", "acct:2", []string{"acct:1", "acct:2"},
		map[string]string{"acct:1": "扣 100", "acct:2": "加 100"})
	b.commitPrimary(100, "acct:2") // 主键已提交,从键还没
	fmt.Println("  T2 提交主键 acct:2 后崩溃,acct:1 仍带锁")
	fmt.Printf("  acct:1 读到的值:%s(被读者推进提交)\n", b.get("acct:1"))
	fmt.Printf("  acct:1 再次读取:%s\n", b.get("acct:1"))

	fmt.Printf("\n最终一致性:场景 A 两行都回滚,场景 B 两行都提交 —— 不会出现“一半提交”\n")
}

本机实测(macOS,Apple M1,Go 1.27):

=== 场景 A:写完锁就崩了(主键未提交)===
  T1 prewrite 完成(两行都有锁),随后崩溃
  [读 acct:2] 遇到锁,查主键 acct:2 → 未提交,回滚(roll back)
  acct:2 读到的值:<旧值>
  [读 acct:1] 遇到锁,查主键 acct:2 → 未提交,回滚(roll back)
  acct:1 读到的值:<旧值>

=== 场景 B:提交主键后崩溃(从键未落提交记录)===
  T2 提交主键 acct:2 后崩溃,acct:1 仍带锁
  [读 acct:1] 遇到锁,查主键 acct:2 → 已提交,推进(roll forward)
  acct:1 读到的值:扣 100(被读者推进提交)
  acct:1 再次读取:扣 100

最终一致性:场景 A 两行都回滚,场景 B 两行都提交 —— 不会出现“一半提交”

读数:

场景 读者看到锁 主键状态 动作 结果
A 有 无提交记录 回滚从键 两行都是旧值(事务整体失败)
B 有 有提交记录 推进从键 两行都提交(事务整体成功)

🔑 关键点:读者在第一次读时就把“未完成的事务”补完了——这就是“无协调者的崩溃恢复”。代价是:读路径变重(读之前要查锁)、残留锁要被清理(lock TTL 或后台 GC)、以及“读已提交”需要额外的快照读(用 startTS 过滤更大的提交时间戳)。

恢复的三条兜底

兜底 作用 说明
锁 TTL / 心跳 防止崩溃事务的锁永久残留 超时后由后台清理线程判断该提交还是回滚
后台 GC 清理过期的旧版本与 write 记录 否则存储无限增长
提交记录(write 列) 恢复时判断方向的唯一依据 必须在删锁之前写,顺序不能反

⚠️ 顺序写反就是数据损坏:如果先删锁、后写提交记录,崩溃窗口里读者看不到锁、也看不到提交记录,会读到“未提交的值”或“旧值”,两种都可能错。

反模式

反模式 后果
用 XA 跨微服务做事务 协调者单点、连接长期占用、性能差(24.2 2PC 与恢复协议)
把“锁”放在缓存里而不是数据里 缓存一丢,没人知道事务该提交还是回滚
先删锁再写提交记录 崩溃窗口里读到不一致的数据
没有锁 TTL,靠人工介入 残留锁阻塞后续事务,故障恢复以小时计
跨分片事务里做长耗时操作(RPC/等用户) 锁持有时间过长,冲突与超时激增
以为“分布式数据库就不需要设计事务边界” 跨分片事务的延迟是单机的几倍,热点照样打满

自检

Q1 为什么 Percolator 需要“主键”这个概念,而不能每个键各自提交?

▸ 答案:因为需要一个唯一的决定。多行事务要么全提交、要么全回滚,如果每个键各自提交,崩溃后不同键可能处于不同状态。指定一个主键后,“主键有没有提交记录”就是整个事务的原子决定(它本身是一次单行写,天然原子)。任何读者/恢复线程只要查主键,就能推断整个事务的结局。

Q2 Percolator 的读为什么比普通读慢?

▸ 答案:因为读要检查“这一行有没有锁”:有锁要先解决(查主键、推进或回滚),等于把“恢复逻辑”放到了读路径上。此外为了读已提交,还要用 startTS 过滤掉“提交时间戳大于我快照”的版本。所以 Percolator 适合“写冲突少、读多写少”的场景;高冲突场景下,读会被大量锁阻塞。

Q3 Spanner 也是 2PC,为什么它不需要 Percolator 那种“读者补完”的机制?

▸ 答案:因为 Spanner 用 TrueTime 让协调者的状态可判定:提交时间戳 s 一旦分配,系统保证所有后续事务的时间戳都大于 s,且参与者可以通过“提交记录是否存在 + 时间戳”判断。更关键的是,Spanner 的 2PC 协调者本身是高可用的 Paxos 组,还能借助时间戳做“无锁读”(读快照)。Percolator 诞生在没有 TrueTime 的环境,只能用“主锁 + 读者补完”模拟同样的确定性。

Q4 Percolator 的锁清理为什么可能 “误删别人的锁”?

▸ 答案:因为锁清理是异步判断:清理者看到 “锁已过期” 就去删,但原持有者可能只是慢(GC/网络),它其实还在提交。若清理者删锁后新事务写入,原事务再提交就会覆盖。防护是 “提交前校验锁归属(primary lock 的 token)” 与 “时间戳/租约”。

Q5 为什么 “大事务” 在 Percolator 里代价特别高?

▸ 答案:因为它要写大量 lock 列 + 提交时逐行清理:事务越大,锁表越大、冲突窗口越长、失败重试成本越高。所以分布式事务普遍限制 “单事务行数/字节数”,大事务拆批或用异步补偿。

关联