TiDB 分布式事务深度实战:从 Percolator 两阶段提交到 TSO 全局授时的工程全解
单机数据库的事务,难点在"并发";分布式数据库的事务,难点在"时间"。当一行数据可能落在上海机房的某个 Region,另一行落在深圳机房的另一个 Region 时,"这两行是不是同一个时刻写入的"这个问题本身就没有答案——除非你人为造出一个全局时钟。TiDB 选择的答案是 Google Percolator 模型:用一个中心化的时间戳服务(TSO, Timestamp Oracle)把全集群的因果顺序压缩成两个 64 位整数,再在其上叠加一套改良的两阶段提交。
这篇文章从工程视角拆开这套机制:TSO 怎么发号、Prewrite/Commit 两级写如何保证原子性、Primary Lock 为什么是整个设计的枢纽、读路径遇到锁时的 Resolve Lock 到底在干什么,以及生产环境里那些真正会咬人的边界。
一、为什么不能直接用单机 2PC
经典 XA 两阶段提交需要一个协调者,Prepare 阶段锁住所有参与节点的资源,Commit 阶段统一提交。问题在于协调者宕机时,参与节点会卡在 in-doubt 状态,只能靠人肉或恢复日志兜底。对于要支撑百万级 QPS 的 OLTP 系统,这种"协调者单点 + 资源长期锁死"的组合是不可接受的。
Percolator 的取巧之处在于:它把协调者的角色下沉到了数据本身。任何一个事务,在写入时从所有被修改的 key 中挑一个作为 primary,其余的作为 secondary。事务的最终命运,只由 primary 那一行说了算:
- primary 提交了 → 整个事务提交(secondary 可以被异步推进);
- primary 回滚了 → 整个事务回滚;
- primary 还带着锁没处理 → 事务处于悬挂状态,由后来者按需裁决。
协调者不再是必须存活的进程,而是持久化的一个 key。客户端可以在任意时刻崩溃,剩下的工作交给"下一个来读这行数据的人"顺手完成。这就是所谓的 commit-on-read 惰性恢复。
二、TSO:把全序压缩成两个整数
Percolator 依赖一个单调递增的全局时间戳发生器。TiDB 里由 PD(Placement Driver)leader 承担,实现方式在工程上很朴素但极其有效:
// 简化版 TSO 逻辑(概念示意,非 TiDB 源码)
type TSO struct {
mu sync.Mutex
physical int64 // 毫秒级 unix time
logical int64 // 同一毫秒内的自增计数,18 bit 上限 262144
}
// 返回 int64:高 46 位 physical,低 18 位 logical
func (t *TSO) Get() int64 {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now().UnixMilli()
if now > t.physical {
t.physical, t.logical = now, 0
} else {
t.logical++
if t.logical >= 262144 { // 逻辑位溢出,等待下一毫秒
time.Sleep(time.Until(time.UnixMilli(t.physical + 1)))
t.physical, t.logical = time.Now().UnixMilli(), 0
}
}
return t.physical<<18 | t.logical
}
TSO 有几个被低估的工程特性:
- 批量授时。客户端不是每次事务来一次 RPC,而是预先批量申请一段时间戳区间在本地发放(默认每次 3 秒的配额),把 TSO 的 QPS 压力降低一到两个数量级。
start_ts与commit_ts分离。事务的快照版本由start_ts决定,提交版本由commit_ts决定。这让"长事务读到的快照"与"提交时刻"解耦,从而实现 Snapshot Isolation(而非 Serializable)。- 物理时间可对齐。TSO 的高位是真实墙上时钟,因此
tidb_snapshot可以直接填一个人类可读的时间点:
-- 回到 10 分钟前读数据(Stale Read)
SET @@tidb_snapshot = '2026-09-30 08:00:00';
SELECT balance FROM accounts WHERE id = 42;
SET @@tidb_snapshot = '';
这个能力在误删数据后的应急恢复里非常实用,也是单机数据库很难做到的。代价是:PD leader 挂掉后要等新一轮选举和新 leader 的时钟窗口推进,期间 TSO 不可用——所以 PD 的高可用是 TiDB 事务能力的硬前提。
三、Prewrite 与 Commit:两级写的真实语义
下面用一段精简的 Go 伪实现,把客户端的两阶段写流程写清楚:
func (c *Client) Commit(ctx context.Context, mutations []Mutation) error {
startTS := c.tso.Get()
primary := mutations[0].Key
// ---- 阶段一:Prewrite ----
for _, m := range mutations {
// 1. 冲突检测:若存在 commit_ts > start_ts 的记录,写写冲突,直接失败
if existing, _ := c.readLatestCommit(m.Key); existing > startTS {
return ErrWriteConflict
}
// 2. 若已有锁(无论谁的),等待或中止
if lock, ok := c.readLock(m.Key); ok {
if !c.tryResolve(ctx, lock, startTS) {
return ErrKeyIsLocked
}
}
// 3. 写 data + lock,同一行原子写入
// data: <key, startTS> -> value
// lock: <key> -> {startTS, primary, ttl, op}
c.write(ctx, m.Key, startTS, m.Value, lockMeta{
Primary: primary, TTL: 20 * time.Second,
})
}
// ---- 阶段二:Commit ----
commitTS := c.tso.Get()
// 先提交 primary:这一步成功,事务即"已提交"
c.write(ctx, primary, commitTS, nil, nil) // 写 write 记录
c.deleteLock(ctx, primary, startTS)
// secondary 异步提交:即使客户端此刻崩溃也没关系
for _, m := range mutations[1:] {
go c.commitSecondary(m.Key, startTS, commitTS)
}
return nil
}
几个关键点值得停下来看:
为什么 primary 的提交是原子分界线? 因为任何后续读到 secondary 上残留锁的读者,都会回头去查 primary 的状态——primary 有 write 记录则帮它补提交,primary 锁没了也没 write 记录则判定为回滚并帮它清理。secondary 的清理因此变成了纯幂等的辅助操作。
为什么 Prewrite 要同时写 data 和 lock? data 是真正的值,但它在没有对应 write 记录之前不可见;lock 是"这行有事务在飞"的公告板。二者必须原子写入同一行(TiKV 用单行事务保证),否则读者可能看到值但看不到锁。
TTL 是防悬挂的最后一道保险。客户端崩溃后锁会一直留着,所以每个锁带一个 TTL(默认 3 秒~20 秒)。其他事务等待超时后会去裁决它——但裁决必须谨慎:只有确认 primary 已回滚,才能安全地回滚 secondary;否则只能等 TTL 自然过期。
四、读路径:Resolve Lock 到底在做什么
读操作用 start_ts 去读最近一个 commit_ts <= start_ts 的版本。流程是:
- 取
[key, start_ts]范围内最大的 write 记录 → 拿到对应的commit_ts; - 用该
commit_ts去读 data → 得到值。
如果第 1 步之前发现 [key, start_ts] 之间存在锁,读不能简单忽略它——因为这个锁可能属于一个已经提交但还没清理的事务。此时进入 Resolve Lock:
发现锁 L(startTS=100)
└─ 查 primary 的状态
├─ primary 有 write 记录(commitTS=105) → 事务已提交 → 帮 secondary 补写并清锁 → 返回 105 版本
├─ primary 的锁还在且 TTL 未过期 → 事务仍在进行 → 返回 KeyIsLocked,让客户端重试
└─ primary 的锁已消失且无 write → 事务已回滚 → 清理 secondary 锁与 data → 继续读旧版本
这个"读者顺手做清理"的设计,让系统不需要一个后台 GC 进程也能自愈。当然 TiKV 里仍有独立的 Lock Resolver 与 GC 模块做兜底,但主路径上的自愈是 Percolator 最漂亮的地方。
五、生产环境真正会咬人的几个点
1. 乐观锁默认"写冲突即失败",对热点行是灾难。 TiDB 从 3.0 起默认改用悲观事务(tidb_txn_mode = pessimistic),Prewrite 阶段对写操作提前上悲观锁,把冲突检测提前到 SQL 执行时:
BEGIN PESSIMISTIC;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- 立刻抢锁,而不是等 COMMIT
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
秒杀、扣库存这种热点行场景必须走悲观锁,否则乐观重试会把 TSO 和 CPU 全部烧在无效重试上。但反过来,批量导入、宽表扫描这类"几乎不会冲突"的场景用乐观锁吞吐更高。判断标准是冲突率而不是直觉。
2. 大事务有硬限制。 单事务默认上限 10 万条 DML / 100MB 数据(可通过 tidb_txn_entry_size_limit 等调整,但不建议)。原因很现实:Prewrite 阶段所有锁都同时存活,事务越大,锁占用的内存越多、TTL 续期压力越大、崩溃后残留锁的清理窗口越长。真正的批量变更应该拆成分批事务:
-- 反例:一条 DELETE 删掉 5000 万行
DELETE FROM logs WHERE created_at < '2025-01-01';
-- 正例:循环分批,每批 1000~10000 行
DELETE FROM logs WHERE created_at < '2025-01-01' LIMIT 5000;
3. Snapshot Isolation 不是 Serializable,写偏斜(write skew)真实存在。 两个事务读同一快照,各自写不同的行,互不冲突,但组合起来破坏不变式。经典例子:两个医生同时请假,各自检查"剩余医生数 >= 1"都通过,结果一起请假导致无人值班。解法是在 SQL 层面显式加锁:
BEGIN PESSIMISTIC;
-- 用 FOR UPDATE 把不变式的判断基准锁住,强制串行
SELECT COUNT(*) FROM oncall WHERE status = 'available' FOR UPDATE;
UPDATE doctors SET status = 'leave' WHERE id = ?;
COMMIT;
不要指望 SI 帮你兜住业务不变式,这是应用层责任。
4. start_ts 一旦取定就不再变,长事务会拖住 GC。 GC 只能回收 safe point 之前的旧版本,safe point 由最老的活跃事务决定。一个跑了几小时的只读大查询,会让所有历史版本都无法回收,Region 体积膨胀、读放大加剧。所以 OLAP 型大查询应该走 TiFlash 或者显式设置较短的 tidb_gc_life_time 之外的独立副本,而不是裸跑在 TiKV 上。
六、什么时候该放弃分布式事务
Percolator 很优雅,但它不是免费的:每次写要两次 TSO RPC、两次 Raft 复制(Prewrite + Commit),跨节点事务的延迟天然比单机高一档。工程上的取舍原则其实很朴素:
- 能按同一个 shard key 分片、让事务退化为单 Region 事务的,就别用分布式事务。TiDB 里这表现为单 Region 的一阶段提交优化,代价只有一次 Raft 写。
- 跨服务的数据一致性,用 Saga/TCC/Outbox 而不是分布式事务。分布式事务只解决同构存储之间的原子性,跨系统的原子性靠它反而会把可用性拖死。
- 接受最终一致的读场景,用 Stale Read / follower read 把读压力从 leader 上摘走,比强行做全局强一致划算得多。
小结
Percolator 的核心贡献不是"两阶段提交"这个老概念,而是三个具体而微的设计决策:用 TSO 把时间问题变成发号问题、用 Primary Lock 把协调者持久化进数据、用读者的惰性 Resolve 把恢复成本摊薄到后续访问。理解这三点,TiDB 事务的绝大多数行为——包括那些看起来像是 bug 的现象——都能推导出来。
真正决定事务系统好坏的,往往不是提交协议本身,而是你对冲突率、事务大小、隔离级别边界这三者与业务之间关系的判断。协议是死的,取舍是活的。

发表评论 取消回复