bigywater.tech
分布式事务 · 23

Percolator 风格的事务:主锁与次锁

Percolator 用“主锁 + 次锁”让一个事务跨多行(多个分片)原子提交,不需要独立的协调者进程。它怎么提交?客户端崩溃后谁来收尾?读到别人的锁怎么办?这篇自己实现一个最小版本(单机 MySQL 模拟存储),用 kill -9 和压力测试把这些路径逐个跑出来。

1486 ms
预写后客户端 kill -9,读到遗留的锁的新事务等到主锁过期(TTL 1500 ms)后回滚所花的时间
0.7 ms
主锁已提交、次锁遗留时,读到次锁的新事务前滚所花的时间(不用等)
0 / 215 vs 11 / 3339
压力下总额不是 1000 的快照读次数:会处理锁的读 vs 不管锁的读

主锁与次锁

实测 · 本机执行输出
  初始(已提交):
    data(k, ts, v):[('A', 101, 100), ('B', 101, 0)]
    lock(k, ts, 主锁):[]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('B', 102, 101)]
  预写之后(start_ts=103,主锁 = A,B 上的锁指向 A):
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[('A', 103, 'A'), ('B', 103, 'A')]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('B', 102, 101)]
  提交主锁 A 之后(commit_ts=104):A 有了提交记录、锁没了;B 还有锁 —— 此刻这个事务已经算提交了:
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[('B', 103, 'A')]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('A', 104, 103), ('B', 102, 101)]
  提交次锁 B 之后(锁全部清掉):
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('A', 104, 103), ('B', 102, 101), ('B', 104, 103)]
  新事务(start_ts=105)读到 A = 50,B = 50

预写时每个 key 写 data 和 lock,次锁记着主锁在哪;提交主锁的那一次本地事务就是整个事务的提交点。

快照与冲突

实测 · 本机执行输出
  T1(start_ts=103)读 x = 1(T2 在 commit_ts=105 提交了 x = 2,T1 的快照看不到);新事务读 x = 2
  T1 提交:预写失败 —— x:有比 start_ts 更新的提交记录(写写冲突);锁 / 数据都已清理:lock 行数 0

读只看 start_ts 之前提交的版本;预写时发现更新的提交记录或别人的锁,就中止(先提交者赢)。

客户端死在提交之前

实测 · 本机执行输出
  kill -9 之后:
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[('A', 103, 'A'), ('B', 103, 'A')]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('B', 102, 101)]
  新事务读 B:等了 1486 ms(锁的 TTL 是 1500 ms,过期之前主锁还可能属于一个正在进行的事务,只能等)才读到 B = 0;等待轮询 107 次,回滚了 1 把锁
  再读 A = 100(A 的锁在回滚 B 时已经一并清掉);lock 行数 0,data 行 [('A', 101, 100), ('B', 101, 0)]

锁遗留在库里,碰到的人看主锁:未过期就等,过期就回滚整个事务。

客户端死在提交主锁之后

实测 · 本机执行输出
  kill -9 之后(A 已提交、B 的锁还在):
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[('B', 103, 'A')]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('A', 104, 103), ('B', 102, 101)]
  只看提交记录、不管锁的读:A = 50,B = 0,合计 50(应该是 100)—— 看到了转账的一半
  会处理锁的读:B = 50(用时 0.7 ms,没有等待:前滚了 1 把锁),A = 50,合计 100
  读之后:B 的锁被换成了提交记录:
    data(k, ts, v):[('A', 101, 100), ('A', 103, 50), ('B', 101, 0), ('B', 103, 50)]
    lock(k, ts, 主锁):[]
    write(k, commit_ts, start_ts):[('A', 102, 101), ('A', 104, 103), ('B', 102, 101), ('B', 104, 103)]

事务已经提交,碰到遗留次锁的人看一眼主锁就前滚,不用等。

压力下的一致性

实测 · 本机执行输出
  600 次转账用时 4.0 s:{'committed': 310, 'conflict': 260, 'crash_pre': 15, 'crash_primary': 15};读线程处理锁的次数:前滚 69、回滚 34、等待轮询 2016
  会处理锁的快照读:215 次检查,总额 ≠ 1000 的 0 次;不管锁的读:3339 次检查,总额 ≠ 1000 的 11 次
  全部处理完之后的最终快照:[12, 88, -30, 30, 408, 29, 10, 209, 44, 200],合计 1000;lock 行数 0

每个读都先处理遇到的锁,快照读的总额始终正确;只看提交记录不管锁的读会看到半个事务。

分步演示

Percolator
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇是我自己写的最小实现(tools/articles/percolator.py,Python + MySQL 26.7.0,READ COMMITTED 隔离级别),不是 Google 的 Percolator,也不是 TiKV / TiDB;存储是同一个 MySQL 里的 data / lk / wr 三张表,时间戳来自 tso 表里一行的原子自增,锁 TTL 第 3 节是 1500 ms、第 5 节是 300 ms;“崩溃”有两种:真正的 kill -9 子进程(第 3、4 节)和代码里直接不再往下做(第 5 节);提交主锁、回滚抢锁靠“谁删到锁行谁说了算”
    • 没有验证:TiKV / TiDB 的实际实现(异步提交、1PC、悲观事务、大事务的处理);存储分布在多个节点、TSO 是独立服务时的延迟;锁 TTL 的续期(这里没有 heartbeat);读到锁时的等待策略(这里是轮询);初版压力测试里最终总额是 894(不是 1000),说明实现有错;我据此改了两处——预写时先加锁再检查写冲突、改用读已提交并按“先读主锁、后读提交记录”的顺序判断——改后总额才对,最终数字是改后的结果;这两处改动各自起了多大作用,我没有分别验证
    京ICP备2026067502号-1京公网安备11010502063487号