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 列 + 提交时逐行清理:事务越大,锁表越大、冲突窗口越长、失败重试成本越高。所以分布式事务普遍限制 “单事务行数/字节数”,大事务拆批或用异步补偿。