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 有几个被低估的工程特性:

  1. 批量授时。客户端不是每次事务来一次 RPC,而是预先批量申请一段时间戳区间在本地发放(默认每次 3 秒的配额),把 TSO 的 QPS 压力降低一到两个数量级。
  2. start_ts 与 commit_ts 分离。事务的快照版本由 start_ts 决定,提交版本由 commit_ts 决定。这让"长事务读到的快照"与"提交时刻"解耦,从而实现 Snapshot Isolation(而非 Serializable)。
  3. 物理时间可对齐。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 的版本。流程是:

  1. 取 [key, start_ts] 范围内最大的 write 记录 → 拿到对应的 commit_ts;
  2. 用该 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 的现象——都能推导出来。

真正决定事务系统好坏的,往往不是提交协议本身,而是你对冲突率、事务大小、隔离级别边界这三者与业务之间关系的判断。协议是死的,取舍是活的。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部