1. 概述与背景

1.1 Project4 整体目标

在 TinyKV 的前三个 Project 中,我们构建了一个具有 Multi-Raft 能力的分布式 KV 存储:每个 Region 内部通过 Raft 协议保证了数据的强一致性与高可用性。然而,这个系统只提供了单 key 的原子性操作,无法保证跨 key、跨 Region 的原子提交。

Project4 的目标是在 Multi-Raft KV 之上实现分布式事务,让多个 key 的读写操作能够以原子、隔离的方式执行。具体地,TinyKV 采用 Percolator 协议(Google 2010 年提出)实现了一套基于 MVCC(Multi-Version Concurrency Control) 的两阶段提交(2PC)分布式事务。

1.2 为什么需要分布式事务

考虑一个银行转账场景:

账户 A: 扣减 100 元
账户 B: 增加 100 元

如果这两个操作不是原子的,可能出现:

  • A 扣减成功,B 增加失败 → 钱凭空消失
  • 中间状态被其他事务读到 → 脏读
  • 两个并发转账同时读到旧值 → 丢失更新

在分布式系统中,账户 A 和账户 B 可能分布在不同的 Region(不同的 Raft Group),这使得原子性保证更加复杂。

1.3 TinyKV 事务架构概览

┌─────────────────────────────────────────────────────────────┐
│                        Client SDK                           │
│  (获取 TSO, 构造 PrewriteRequest/CommitRequest, 重试逻辑)    │
└───────────────────────────┬─────────────────────────────────┘
                            │ gRPC
┌───────────────────────────▼─────────────────────────────────┐
│                    TinyKV Server (server.go)                 │
│  KvGet | KvPrewrite | KvCommit | KvScan                     │
│  KvCheckTxnStatus | KvBatchRollback | KvResolveLock         │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│                  MVCC Layer (transaction.go)                 │
│  MvccTxn: GetLock/PutLock | GetValue/PutValue               │
│           CurrentWrite | MostRecentWrite                    │
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────▼─────────────────────────────────┐
│              RaftStore (Multi-Raft KV Storage)               │
│  三个列族: CfLock | CfWrite | CfDefault                     │
│  底层: badger (LSM-Tree)                                     │
└─────────────────────────────────────────────────────────────┘

1.4 Percolator 协议来源与适用场景

Google Percolator(2010)最初是为了支持 Google 网页索引的增量更新而设计的。传统批处理方式(MapReduce)每次需要重新处理所有网页,效率低下。Percolator 通过在 BigTable 之上实现跨行事务,支持对索引的增量修改。

Percolator 的核心设计思路:

  • 利用底层存储(BigTable/RocksDB)已有的单行原子性
  • 通过 Primary Key 充当事务协调者,避免独立协调者的单点问题
  • 基于 MVCC 实现快照隔离,读操作不需要锁
  • 乐观并发控制:先尝试加锁,冲突时回滚重试

Percolator 特别适合:

  • 读多写少的场景(读不加锁)
  • 跨行、跨节点的原子更新
  • 需要快照隔离的 OLTP 工作负载

2. 单机事务理论基础

2.1 ACID 四要素深度解析

事务是数据库的基本操作单位,ACID 是衡量事务质量的四个属性:

2.1.1 Atomicity(原子性)

定义:事务中的所有操作要么全部成功,要么全部失败回滚,不存在部分执行的中间状态。

实现机制

  1. Undo Log(回滚日志):在修改数据之前,先将原始数据写入 undo log。若事务需要回滚,通过 undo log 恢复数据到修改前的状态。

    • InnoDB 的 undo log 存储在系统表空间(或独立的 undo 表空间)
    • 每条记录包含:事务 ID、操作类型(INSERT/UPDATE/DELETE)、旧值
    • MVCC 也依赖 undo log 实现历史版本的读取
  2. WAL(Write-Ahead Log):所有数据修改在写入磁盘前,必须先将日志写入 redo log。这保证了崩溃恢复时能够重放已提交事务的修改。

    • InnoDB 的 redo log 以循环方式写入(ib_logfile0, ib_logfile1)
    • innodb_flush_log_at_trx_commit 控制刷盘时机:
      • 0:每秒刷盘,性能最好但最多丢失 1 秒数据
      • 1(默认):每次提交都刷盘,最安全
      • 2:写入 OS 缓存但每秒 fsync,折中方案

两段式提交(在单机 InnoDB 中)

事务执行阶段: 修改 buffer pool + 写 undo log + 写 redo log buffer
提交阶段:
  1. 写 binlog(如果开启)
  2. 写 redo log(prepare 状态)
  3. 写 binlog(commit 标记)
  4. 写 redo log(commit 状态)

:InnoDB 内部的 2PC 是为了保证 redo log 和 binlog 的一致性,与跨节点的分布式 2PC 概念不同。

2.1.2 Consistency(一致性)

定义:事务执行前后,数据库从一个合法的状态转变到另一个合法的状态,满足所有预定义的约束和规则。

关键理解

  • 一致性是事务的目的,而 AID 是实现一致性的手段
  • 一致性包含两个层面:
    • 数据库层面:主键唯一性、外键约束、非空约束等
    • 业务层面:账户余额不能为负、库存不能超卖等(由应用程序保证)
  • 某些文献中,C 被认为是 AID 的结果,而非独立属性

注意:CAP 定理中的 Consistency 是指线性一致性(Linearizability),与 ACID 中的 Consistency 含义不同,需要加以区分。

2.1.3 Isolation(隔离性)

定义:并发执行的多个事务之间相互隔离,一个事务的中间状态对其他事务不可见。

并发异常类型

并发异常描述示例
脏读(Dirty Read)读到未提交的数据T1 修改 x=10(未提交),T2 读到 x=10,T1 回滚
不可重复读(Non-Repeatable Read)同一事务内两次读同一行,值不同T1 读 x=5,T2 提交 x=10,T1 再读 x=10
幻读(Phantom Read)同一事务内两次范围查询,结果集不同T1 查询 age>18 得 10 条,T2 插入新行,T1 再查得 11 条
丢失更新(Lost Update)两事务同时读旧值,先后写入,后者覆盖前者T1 读 x=5,T2 读 x=5,T1 写 x=6,T2 写 x=7(T1 的更新丢失)
读偏斜(Read Skew)读到不一致的数据组合T1 先读 x,T2 修改 x 和 y,T1 再读 y,读到不一致的 x, y 组合
写偏斜(Write Skew)基于快照做出的决策,破坏了业务约束见下方详解

写偏斜(Write Skew)详解

约束: 值班医生数 >= 1
T1: 查询在岗医生 = 2,决定让 A 下班,修改 A.on_call = false
T2: 查询在岗医生 = 2,决定让 B 下班,修改 B.on_call = false
结果: A 和 B 都下班,值班医生 = 0,违反约束

写偏斜在快照隔离(Snapshot Isolation)下也会发生,需要串行化快照隔离(SSI)才能解决。

2.1.4 Durability(持久性)

定义:已提交的事务对数据的修改是永久性的,即使系统崩溃也不会丢失。

实现机制

  1. WAL(Write-Ahead Log):已提交事务的 redo log 必须在数据页刷盘之前先持久化到磁盘。崩溃后可通过重放 redo log 恢复未刷盘的数据。

  2. fsync 系统调用:确保数据真正写入磁盘(而非仅在 OS 缓冲区)。innodb_flush_log_at_trx_commit=1 时每次提交都会调用 fsync。

  3. Checkpoint 机制:定期将 buffer pool 中的脏页刷盘,减少崩溃恢复时需要重放的 redo log 量。

  4. Double Write Buffer:防止"部分写"(Partial Write)问题——当 16KB 的数据页只写了一半时系统崩溃,通过 doublewrite buffer 保证页面完整性。

2.2 事务隔离级别

SQL 标准定义了四个事务隔离级别,从低到高依次为:

2.2.1 四种隔离级别定义

READ UNCOMMITTED(读未提交)

  • 允许读到其他事务未提交的修改
  • 存在脏读、不可重复读、幻读问题
  • 几乎不使用,无实际价值

READ COMMITTED(读已提交)

  • 只能读到已提交的数据,防止脏读
  • 仍存在不可重复读、幻读问题
  • SQL Server、Oracle 的默认级别
  • 实现:每次读操作都创建新的 Read View(最新快照)

REPEATABLE READ(可重复读)

  • 同一事务内多次读同一行,结果相同,防止不可重复读
  • 在某些实现中可防止幻读(InnoDB 通过间隙锁)
  • MySQL InnoDB 的默认级别
  • 实现:事务开始时创建 Read View,之后的读操作都使用同一个快照

SERIALIZABLE(可串行化)

  • 最强隔离,事务完全串行执行(或等价于串行)
  • 防止所有并发异常,包括幻读和写偏斜
  • 性能最差,通常使用 2PL(两阶段锁)实现

2.2.2 各级别允许/禁止的并发异常

隔离级别脏读不可重复读幻读写偏斜
READ UNCOMMITTED✓(允许)
READ COMMITTED✗(禁止)
REPEATABLE READ✓(标准)/ ✗(InnoDB)
SERIALIZABLE

  • InnoDB 的 REPEATABLE READ 通过间隙锁(Gap Lock)和临键锁(Next-Key Lock)在大多数场景下防止了幻读,但严格来说并非完全等价于 SQL 标准的 SERIALIZABLE
  • 写偏斜(Write Skew)在快照隔离(SI)下依然存在,需要 SSI 才能防止

2.2.3 MySQL InnoDB 如何防止幻读

InnoDB 在 REPEATABLE READ 级别下通过**间隙锁(Gap Lock)**防止幻读:

-- 事务 T1
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- 对查询范围加 Next-Key Lock(临键锁 = 行锁 + 间隙锁)
-- 其他事务无法在此范围内插入新行

-- 事务 T2(被阻塞)
INSERT INTO orders (amount) VALUES (150);  -- 阻塞!

Next-Key Lock = 行锁 + 该行之前的间隙锁,范围为 (previous_key, current_key]

注意

  • SELECT ... FOR UPDATESELECT ... IN SHARE MODE当前读,会加锁
  • 普通 SELECT快照读,使用 MVCC,不加锁

2.3 MVCC 多版本并发控制

2.3.1 核心思想

MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:为数据维护多个历史版本,使读操作不需要等待写操作,写操作也不需要等待读操作

传统锁机制的问题:

  • 读写互斥:读需要等写完成,写需要等读完成
  • 高并发下锁竞争严重,吞吐量下降

MVCC 的解决方案:

  • 写操作不修改原有数据,而是创建新版本
  • 读操作根据事务开始时的快照版本读取对应版本,不受其他并发写的影响
  • 读写并发,互不阻塞

2.3.2 快照读 vs 当前读

快照读(Snapshot Read)

  • 读取某个时间点的数据快照,可能不是最新版本
  • 不加锁,不影响其他事务
  • SQL:SELECT * FROM table WHERE ...(普通 SELECT)
  • InnoDB 通过 undo log 版本链实现历史版本的访问

当前读(Current Read)

  • 读取最新版本的数据
  • 加锁(共享锁或排他锁)
  • SQL:
    • SELECT ... FOR UPDATE(加排他锁)
    • SELECT ... IN SHARE MODE(加共享锁)
    • INSERT, UPDATE, DELETE(也是当前读,因为要修改最新数据)

两者的本质区别:快照读通过 MVCC 实现非阻塞读,当前读需要通过锁保证读到最新且一致的数据。

2.3.3 InnoDB MVCC 实现

InnoDB 的每行数据都有两个隐藏字段:

字段说明
DB_TRX_ID最后修改该行的事务 ID
DB_ROLL_PTR回滚指针,指向 undo log 中的旧版本
DB_ROW_ID隐藏主键(如果表没有显式主键)

Undo Log 版本链

当前版本(row):  trx_id=100, name="Alice", roll_ptr ──►
undo log v1:  trx_id=80, name="Bob", roll_ptr ──────────►
undo log v2:  trx_id=50, name="Charlie", roll_ptr ──────► NULL

每次更新都会将旧版本写入 undo log,形成链式结构。

2.3.4 Read View 与可见性判断

Read View 是事务在执行快照读时创建的一个"视图",包含:

type ReadView struct {
    m_ids          []uint64  // 创建时活跃的事务 ID 列表
    m_low_limit_id uint64    // 创建时最大事务 ID + 1(>=此值的事务不可见)
    m_up_limit_id  uint64    // m_ids 中最小的事务 ID(<此值的事务可见)
    m_creator_trx_id uint64  // 创建此 Read View 的事务 ID
}

可见性判断规则(对某行的 trx_id 进行判断):

1. trx_id == m_creator_trx_id  → 可见(自己修改的,当然能读)
2. trx_id < m_up_limit_id      → 可见(创建 Read View 前已提交)
3. trx_id >= m_low_limit_id    → 不可见(创建 Read View 后才开始的事务)
4. m_up_limit_id <= trx_id < m_low_limit_id:
   - trx_id 在 m_ids 中 → 不可见(创建时仍活跃,未提交)
   - trx_id 不在 m_ids 中 → 可见(已提交)

如果当前版本不可见,沿 roll_ptr 链找到旧版本,重复判断,直到找到可见版本或链尾(返回空)。

2.3.5 RC 和 RR 的 Read View 创建时机差异

隔离级别Read View 创建时机效果
READ COMMITTED每次执行 SELECT 都创建新的 Read View能读到已提交的最新数据
REPEATABLE READ事务内第一次执行 SELECT 时创建,之后复用整个事务期间读到相同的数据快照

这就是为什么:

  • RC 存在不可重复读(每次读视图不同,可以看到新提交的数据)
  • RR 防止了不可重复读(整个事务使用同一快照)

2.4 并发控制机制对比

2.4.1 悲观并发控制(PCC / 2PL)

两阶段锁(Two-Phase Locking, 2PL)

  • 加锁阶段:事务可以加锁,不能释放锁
  • 释放阶段:事务开始释放锁后,不能再加新锁

锁的类型

  • 共享锁(S Lock):允许并发读,阻塞写
  • 排他锁(X Lock):阻塞所有并发读写

锁兼容矩阵

S LockX Lock
S Lock兼容 ✓不兼容 ✗
X Lock不兼容 ✗不兼容 ✗

优缺点

  • 优点:实现简单,能防止写冲突
  • 缺点:读写互斥,高并发下性能差;可能死锁(需要死锁检测)

适用场景:写多、冲突率高的场景

2.4.2 乐观并发控制(OCC)

三个阶段

  1. 读取(Read):读取数据,记录版本号
  2. 验证(Validate):提交前检查是否有冲突(版本号是否改变)
  3. 写入(Write):无冲突则提交,有冲突则回滚重试

优缺点

  • 优点:读操作不加锁,高并发下吞吐量高
  • 缺点:冲突时需要回滚重试,冲突率高时性能差

适用场景:读多写少、冲突率低的场景(Percolator 就是乐观并发控制)

2.4.3 时间戳排序(TO)

基于全局时间戳对事务进行排序,保证等价于某个串行执行顺序。

  • 每个事务分配一个唯一的时间戳
  • 读操作检查:不能读到更新时间戳的写
  • 写操作检查:不能写已被更大时间戳读取的数据

Percolator 中的 MVCC 本质上就是时间戳排序的一种变体,通过 startTS 和 commitTS 确定事务的"位置"。

2.4.4 选择策略总结

冲突率高(写密集型)  → 悲观锁(2PL)减少无效重试
冲突率低(读密集型)  → 乐观锁(OCC/MVCC)提高并发
长事务              → 避免持锁太久,考虑乐观锁
短事务/点查询        → 悲观锁开销可接受

3. 分布式事务理论

3.1 分布式系统的挑战

在单机数据库中,ACID 的实现相对简单:undo log、redo log、锁机制已经足够。但在分布式系统中,面临更多挑战:

3.1.1 网络不可靠

  • 消息丢失:包括请求和响应都可能丢失
  • 消息延迟:网络延迟不确定,超时不意味着失败
  • 消息乱序:路由变化可能导致包乱序到达
  • 网络分区:节点间可能完全断联

一个常见的困境:协调者发送 Commit 消息后网络分区,参与者不知道该提交还是回滚。

3.1.2 节点故障

  • 崩溃故障:节点宕机后无限期不响应(fail-stop)
  • 拜占庭故障:节点发送错误或恶意消息(更难处理)

TinyKV 只考虑崩溃故障(非拜占庭),但崩溃期间的状态恢复仍然复杂。

3.1.3 时钟不一致

分布式系统中每个节点的时钟可能不同步:

  • 时钟漂移:石英晶振频率略有差异,随时间累积误差
  • NTP 同步误差:通常 1-100ms,无法保证毫秒级精确排序

这意味着简单地用本地时钟作为事务 ID 无法保证全局唯一和单调递增,需要特殊的时钟方案(TSO、HLC、TrueTime)。

3.1.4 CAP 定理与 BASE 理论

CAP 定理(Brewer, 2000):在分布式系统中,以下三个属性最多只能同时满足两个:

  • C(Consistency):所有节点看到相同的数据(线性一致性)
  • A(Availability):每个请求都能得到响应(不一定是最新数据)
  • P(Partition Tolerance):网络分区时系统继续运行

在实际中,网络分区(P)是不可避免的,所以需要在 C 和 A 之间取舍:

  • CP 系统(如 ZooKeeper、HBase):分区时牺牲可用性,保证一致性
  • AP 系统(如 Cassandra、DynamoDB):分区时牺牲一致性,保证可用性

BASE 理论(对 CAP 的一种工程实践):

  • Basically Available(基本可用):允许降级(响应延迟、部分数据不可用)
  • Soft State(软状态):允许系统处于中间状态(数据同步中)
  • Eventually Consistent(最终一致性):经过一定时间后,所有副本数据最终一致

BASE 是对强一致性(ACID)的放宽,适合对一致性要求不那么严格的场景。

3.2 两阶段提交(2PC)

2PC 是实现分布式事务的经典协议,也是 XA 协议和 Percolator 的理论基础。

3.2.1 角色与流程

角色

  • Coordinator(协调者):发起事务,协调所有参与者的提交/回滚决策
  • Participant(参与者):执行具体的数据修改,接受协调者的指令

Phase 1:Prepare(准备阶段)

Coordinator                    Participant A     Participant B
     │                               │                │
     │─── Prepare ──────────────────►│                │
     │─── Prepare ────────────────────────────────────►│
     │                               │                │
     │      执行事务操作,写 undo log + redo log,加锁    │
     │                               │                │
     │◄── Vote Yes/No ───────────────│                │
     │◄── Vote Yes/No ────────────────────────────────│
  • 协调者向所有参与者发送 Prepare 消息
  • 参与者执行事务操作(写日志、加锁),但不提交
  • 参与者回复 Yes(准备好了)或 No(无法执行)
  • 此时参与者处于"就绪"状态,等待最终决定

Phase 2:Commit/Abort(提交/回滚阶段)

所有 Yes:  Coordinator ─── Commit ───► 所有参与者 ─── Ack ────► 协调者
有 No:     Coordinator ─── Abort ────► 所有参与者 ──No Ack ────► 协调者
  • 若所有参与者回复 Yes:协调者发送 Commit 消息,参与者提交并释放锁
  • 若有任一 No:协调者发送 Abort 消息,参与者回滚并释放锁

3.2.2 2PC 的问题

问题 1:同步阻塞

  • Prepare 阶段后,参与者持锁等待协调者的 Commit/Abort 决定
  • 等待期间,持锁资源无法被其他事务使用,导致吞吐下降

问题 2:协调者单点故障

情景:
1. 协调者向所有参与者发送 Commit
2. 参与者 A 收到并提交,协调者此时宕机
3. 参与者 B 没收到 Commit,进入无限等待

结果:A 已提交,B 处于未知状态 → 数据不一致
  • 协调者宕机后,参与者处于"悬挂(in-doubt)“状态
  • 必须等待协调者恢复才能解决(可能导致长时间阻塞)

问题 3:网络分区

情景:
1. 协调者发送 Commit 后网络分区
2. 参与者 A 收到 Commit 并提交
3. 参与者 B 超时,不知道是 Commit 还是 Abort
  • 即使参与者有超时机制,也不知道是否该单方面提交

问题 4:“最后提交者宕机”

  • 协调者 Prepare 阶段收到所有 Yes 后,向第一个参与者发送 Commit
  • 第一个参与者提交后,协调者宕机
  • 其他参与者不知道整体事务是否提交

3.2.3 2PC 的优化

预提交日志:协调者在发送 Commit 前将决定持久化,崩溃恢复后可继续发送。

超时机制:参与者等待超时后,可主动询问协调者或其他参与者。但如果无法联系协调者,仍然无法确定(不一致风险)。

假设提交(Presumed Commit):默认所有决定未记录的事务都是提交状态,减少日志写入。

3.3 三阶段提交(3PC)

3PC 是对 2PC 的改进,增加了一个 CanCommit 阶段,并引入超时自动提交机制。

3.3.1 三个阶段

Phase 1:CanCommit

  • 协调者询问各参与者是否可以执行事务(只是询问,不实际执行)
  • 参与者回复 Yes/No(不加锁)

Phase 2:PreCommit

  • 所有回复 Yes:协调者发送 PreCommit,参与者执行操作并加锁(写日志)
  • 有 No:协调者直接发送 Abort

Phase 3:DoCommit

  • 所有参与者确认 PreCommit:协调者发送 DoCommit,参与者最终提交
  • 如果参与者超时未收到 DoCommit:自动提交(假设协调者已决定提交)

3.3.2 3PC 的改进与局限

改进

  • CanCommit 阶段降低了无效等待(快速失败)
  • 超时自动提交减少了阻塞时间
  • 协调者宕机时,参与者不会永久阻塞

局限性

  • 仍无法解决网络分区下的脑裂问题
  • 假设场景:
    1. PreCommit 后网络分区
    2. 协调者决定 Abort(因为某个参与者返回 No)
    3. 分区的参与者超时后自动提交
    4. 结果:部分提交,部分回滚 → 数据不一致

3PC 在实践中并不比 2PC 更受欢迎,主要原因是增加了网络往返次数,且仍无法处理分区+超时的极端情况。

3.4 Paxos/Raft 与分布式共识

3.4.1 分布式共识与事务的关系

**分布式共识(Consensus)**解决的问题:让多个节点就某个值达成一致(即使有节点故障)。

Raft/Paxos 解决的是日志复制问题,而不是跨分区的事务协调问题:

  • 单个 Raft Group 内:保证日志的线性一致性(所有副本有相同的日志序列)
  • 跨 Raft Group:需要额外的协调协议(如 2PC/Percolator)

3.4.2 Raft + 2PC 的协作

在 TiKV/TinyKV 的架构中,两者协作如下:

分布式事务(跨 Region)
    2PC 协调(Percolator)
    │           │
    ▼           ▼
Region A     Region B
(Raft Group A) (Raft Group B)
    │           │
    ▼           ▼
  持久化       持久化
(Raft 保证每个 Region 内的强一致性)
  • 2PC/Percolator 负责:跨 Region 的原子性协调(Who commits? When?)
  • Raft 负责:每个 Region 内部的日志复制和持久化(确保单节点的 D 和 C)

这两个机制是互补的,缺一不可。

3.5 分布式事务隔离与一致性

3.5.1 外部一致性(External Consistency)

定义:如果事务 T1 在事务 T2 开始之前提交,则 T2 必须能看到 T1 的所有修改。更正式地:事务的提交顺序必须与实际时钟顺序一致(即线性化)。

为什么难以实现

  • 需要全局精确的时钟
  • 分布式节点之间的时钟不可能完全同步

Spanner 的解决方案(TrueTime)

  • 使用 GPS 天线 + 原子钟提供有界不确定性的时间(ε 区间)
  • 提交时等待 确保时间戳唯一且正确顺序

3.5.2 快照隔离(Snapshot Isolation, SI)

定义:每个事务在开始时读取一个一致的数据快照,事务的写操作在提交前对其他事务不可见。

快照隔离能防止:脏读、不可重复读、大多数幻读

快照隔离不能防止:写偏斜(Write Skew)

Percolator 实现的是快照隔离(通过 startTS 实现快照读,通过 commitTS 的可见性控制)。

3.5.3 串行化快照隔离(SSI)

SSI 在 SI 的基础上,通过检测读写冲突环来识别并阻止写偏斜:

  • 如果事务 T1 读了某个数据项,T2 写了该数据项,且 T1 也写了 T2 读的数据项 → 形成冲突环 → 中止一个事务

PostgreSQL 的 SERIALIZABLE 级别就是基于 SSI 实现的。

3.5.4 全局时钟方案对比

方案原理精度实现难度
TSO(中心化时间戳)专用服务器分发单调递增 ID取决于网络延迟
HLC(混合逻辑时钟)物理时钟 + 逻辑时钟结合有界误差
TrueTimeGPS + 原子钟±数毫秒高(专用硬件)
Lamport Clock纯逻辑时钟仅保证偏序

3.6 分布式事务常见协议

3.6.1 XA 协议

XA 是 Open Group 制定的跨数据库分布式事务标准,基于 2PC:

-- 应用程序通过 XA 接口控制多个数据库
XA START 'xid';
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
XA END 'xid';
XA PREPARE 'xid';    -- Prepare 阶段

XA COMMIT 'xid';     -- 或 XA ROLLBACK 'xid'

特点

  • 标准化接口,支持多种数据库混用
  • 基于 2PC,有协调者单点问题
  • 性能较差(同步阻塞、持锁时间长)
  • 实际应用:JTA(Java Transaction API)

3.6.2 TCC(Try-Confirm-Cancel)

TCC 是一种业务层面的补偿事务协议:

阶段操作说明
Try资源预留冻结库存/余额,不实际扣减
Confirm确认执行正式扣减,释放预留资源
Cancel取消执行释放预留资源,回到原始状态

示例(电商下单)

Try: 冻结库存 1 件,冻结余额 100 元
Confirm: 扣减库存,扣减余额
Cancel: 解冻库存,解冻余额

特点

  • 侵入性强:需要为每个操作实现 Try/Confirm/Cancel 三个方法
  • 没有数据库锁,性能好
  • 适合核心业务(订单、支付)

3.6.3 Saga 长事务

Saga 将一个长事务分解为一系列本地事务,每个本地事务都有对应的补偿事务

正向流程: T1 → T2 → T3 → ... → Tn
补偿流程: C1 ← C2 ← C3 ← ... ← Cn

失败处理

  • 如果 Ti 失败,执行 Ci-1, Ci-2, …, C1 进行补偿回滚
  • 最终数据可能经过一段时间才达到一致(最终一致性)

实现方式

  • Orchestration(编排):有一个 Saga Orchestrator 协调所有步骤
  • Choreography(编舞):每个服务在完成本地事务后发布事件,其他服务监听事件执行

特点

  • 适合长事务(跨多个微服务、可能持续数分钟)
  • 最终一致性,不提供隔离性
  • 需要业务上接受"暂时不一致"状态

3.6.4 本地消息表

一种基于消息队列的最终一致性方案:

1. 本地事务:业务操作 + 写消息到本地消息表(原子操作)
2. 异步服务:扫描本地消息表,发送到消息队列
3. 消费者:消费消息,执行目标操作,幂等处理
4. 确认机制:消费成功后删除/标记消息

特点

  • 最终一致性,适合异步场景
  • 实现简单,依赖消息队列可靠性
  • 不适合强一致性要求的场景

3.6.5 各协议对比

协议一致性协调者侵入性性能适用场景
XA/2PC强一致需要少量跨库
TCC强一致需要金融核心业务
PercolatorSI无(Primary Key)通用 KV 事务
Saga最终一致可选微服务长事务
本地消息表最终一致异步通知
Spanner外部一致TSO(TrueTime)全球数据库

4. Percolator 事务模型深度解析

4.1 Percolator 背景与设计哲学

4.1.1 论文背景

Google Percolator(2010 年 OSDI 论文《Large-scale Incremental Processing Using Distributed Transactions and Notifications》)最初是为了解决网页索引的增量更新问题:

  • 问题:Google 每天有数十亿个网页更新,用 MapReduce 重新处理整个索引效率太低
  • 方案:只处理更新的部分,需要支持跨行原子更新

4.1.2 核心设计哲学

去中心化:传统 2PC 有一个独立的协调者节点,是单点故障风险所在。Percolator 通过将事务状态存储在Primary Key 所在的行,利用底层存储(BigTable/RocksDB)的单行原子性来替代协调者角色。

乐观并发控制

  • Prewrite 阶段检测冲突(乐观地假设没有冲突)
  • 发现冲突时回滚重试,而非提前加锁等待

MVCC 支持快照读

  • 读操作不加锁,使用快照版本读取
  • 写操作通过 lock 防止并发修改

依赖的底层能力

  • 单行原子性(对同一 key 的多列读写是原子的)
  • 单调递增时间戳服务(TSO)

4.2 三列族设计

Percolator 使用三个列族(Column Family)来实现分布式事务:

4.2.1 CfDefault(data 列族)

存储内容:用户数据的实际值

键格式EncodeKey(userKey, startTS)(userKey, startTS)

值格式:用户数据的原始字节

语义:在 startTS 时刻,某个写事务对 userKey 写入的值。注意是用 startTS 而非 commitTS 作为版本,这样读操作可以通过 Write CF 的 commitTS 找到对应的 startTS,再去 Default CF 中读取。

CfDefault:
Key                          Value
(Alice, startTS=100)    ─►   "Bob"       ← 事务 100 写入的值
(Alice, startTS=80)     ─►   "Alice"     ← 事务 80 写入的值
(Bob, startTS=95)       ─►   "100"       ← 事务 95 写入的值

4.2.2 CfLock(lock 列族)

存储内容:事务在 Prewrite 阶段写入的锁,表示事务正在进行中

键格式userKey(不含时间戳,每个 key 同时只能有一个锁)

值格式(Lock 结构体):

message Lock {
    LockType   lock_type   // PUT / DELETE / ROLLBACK / LOCK
    bytes      primary     // Primary Key(用于崩溃恢复时查找事务状态)
    uint64     ts          // startTS(用于区分哪个事务的锁)
    uint64     ttl         // TTL,超时后可强制回滚
    uint64     txn_size    // 事务大小(决定 TTL)
}

语义:key 上有锁 = 有事务正在修改这个 key,还未提交。

CfLock:
Key      Value
Alice ─► Lock{type=PUT, primary="Alice", ts=100, ttl=3000}
Bob   ─► Lock{type=PUT, primary="Alice", ts=100, ttl=3000}

注意:同一事务对 Alice 和 Bob 的锁都指向同一个 primary(“Alice”),这样通过任何一个锁都能找到事务的决策点。

4.2.3 CfWrite(write 列族)

存储内容:事务提交记录,记录哪个 commitTS 对应哪个 startTS

键格式EncodeKey(userKey, commitTS)(userKey, commitTS)

值格式(Write 结构体):

message Write {
    WriteKind kind           // PUT / DELETE / ROLLBACK
    uint64    start_ts       // 对应的 startTS(指向 CfDefault 中的值)
}

语义:在 commitTS 时刻,userKey 的值来自 startTS 时刻写入的 CfDefault 数据。读操作通过扫描 CfWrite 找到最新的可见版本,再通过 startTS 去 CfDefault 取值。

CfWrite:
Key                          Value
(Alice, commitTS=105)   ─►   Write{kind=PUT, startTS=100}
(Alice, commitTS=85)    ─►   Write{kind=PUT, startTS=80}
(Bob, commitTS=98)       ─►   Write{kind=PUT, startTS=95}

4.2.4 三列族时间线图

时间线(数字代表 TS):

事务 80(已提交): startTS=80, commitTS=85
事务 100(进行中): startTS=100, commitTS 待定

CfDefault:
  Alice: TS=80 → "Alice"(旧值),  TS=100 → "Bob"(新值,尚未提交)

CfLock:
  Alice: Lock{ts=100, primary="Alice", ...}  ← Prewrite 写入,Commit 后删除

CfWrite:
  Alice: TS=85 → {PUT, startTS=80}  ← 事务80 Commit 后写入

读事务(startTS=90)读取 Alice:
  1. 查 CfLock[Alice]:有锁 ts=100 > 90,忽略(锁的事务在我的快照之后)
  2. 查 CfWrite[Alice]: 找 commitTS <= 90 的最新记录 → commitTS=85 → startTS=80
  3. 查 CfDefault[Alice, TS=80] → "Alice"  ✓

读事务(startTS=110)读取 Alice:
  1. 查 CfLock[Alice]:有锁 ts=100 <= 110,需要等待或清理
  2. 等待或通过 CheckTxnStatus 判断是否可清理

4.3 时间戳服务(TSO)

4.3.1 TSO 的作用

TSO(Timestamp Oracle,时间戳服务) 是 Percolator 的核心组件之一,提供:

  • 全局单调递增的时间戳:确保每个时间戳唯一且单调递增
  • 因果一致性:如果 T1 在 T2 开始之前提交,则 T1.commitTS < T2.startTS

在 TiDB/TinyKV 中,TSO 由 PD(Placement Driver)提供。

4.3.2 startTS 与 commitTS 的语义

startTS(事务开始时间戳)

  • 在事务开始时从 TSO 获取
  • 定义了事务的读快照:只能看到 commitTS <= startTS 的已提交数据
  • 用作 CfDefault 的键(数据版本号)

commitTS(事务提交时间戳)

  • 在事务准备提交时从 TSO 获取,commitTS > startTS
  • 定义了事务的提交时序:其他事务的 startTS > commitTS 才能看到此事务的数据
  • 用作 CfWrite 的键(提交版本号)

4.3.3 时间戳保证全局一致快照

假设 T1 提交(commitTS=100),T2 开始(startTS=105):

  • T2 的快照 = 所有 commitTS <= 105 的事务 → T2 能看到 T1 的修改 ✓

假设 T2 开始(startTS=95),T1 提交(commitTS=100):

  • T2 的快照 = 所有 commitTS <= 95 的事务 → T2 看不到 T1 的修改 ✓(快照隔离)

这个保证依赖于 TSO 的全局单调递增性质:一旦 T2 获取了 startTS=95,之后 T1 获取的 commitTS 必然 > 95(因为时间戳单调递增)。

4.4 Primary Key 机制

4.4.1 为什么需要 Primary Key

传统 2PC 依赖独立的协调者节点来做 Commit/Abort 决策,协调者是单点故障风险。

Percolator 的创新:利用事务的某一个写 key(Primary Key)来充当协调者

关键观察:

  • 底层存储(BigTable/RocksDB)支持单行(单 key)的原子操作
  • 对 Primary Key 的 Commit(写 Write CF + 删 Lock CF)是原子的
  • 因此:Primary Key 的 Write CF 有 Commit 记录 = 事务已提交
  • 任何观察者通过检查 Primary Key 的状态,就能确定整个事务的结果

4.4.2 Primary Key 的工作原理

事务状态决策点

事务状态判断(通过 PrimaryKey):
  - CfWrite[PrimaryKey] 有 WriteKind_Put 记录 → 事务已提交
  - CfWrite[PrimaryKey] 有 WriteKind_Rollback 记录 → 事务已回滚
  - CfLock[PrimaryKey] 存在 → 事务仍在进行(根据 TTL 决定是否清理)
  - CfLock[PrimaryKey] 不存在 且 CfWrite 无记录 → 事务已回滚(锁被清理)

崩溃恢复场景

场景 1:Prewrite 完成,Commit 前崩溃

CfLock[Alice] 存在(Primary),CfLock[Bob] 存在(Secondary)
→ 检查 CfWrite[Alice]:无记录
→ 检查 Alice.lock 的 TTL:如果超时 → 清理所有锁,视为回滚
→ 如果未超时 → 等待,原事务可能还在执行

场景 2:Primary Key Commit 成功,Secondary Key 未 Commit,崩溃

CfWrite[Alice] 有 Write{PUT, startTS=100}(Primary 已提交)
CfLock[Bob] 存在(Secondary 未提交)
→ 其他事务遇到 Bob 的锁 → 检查 Primary Key → 已提交
→ 替原事务提交 Bob:DeleteLock[Bob] + PutWrite[Bob, TS=105]

4.4.3 Primary Key 的选择

TinyKV/TiKV 中,Primary Key 通常是第一个写入的 key(或随机选择)。

Primary Key 的选择会影响热点:如果所有事务都以某个热门 key 作为 Primary,该 key 会成为瓶颈。

4.5 Prewrite 阶段详解

Prewrite 是事务的第一阶段,负责:

  1. 检测冲突(写冲突 + 锁冲突)
  2. 写入数据(CfDefault)
  3. 写入锁(CfLock)

4.5.1 完整流程

客户端发起 Prewrite(mutations, primaryKey, startTS, ttl):

FOR each mutation in mutations:
    // 步骤1: 检查写冲突
    write, commitTS = MostRecentWrite(mutation.key)
    IF write != nil AND commitTS >= startTS:
        RETURN WriteConflict{commitTS, startTS, key}

    // 步骤2: 检查锁冲突
    lock = GetLock(mutation.key)
    IF lock != nil:
        RETURN KeyError{locked: lock}  // 提示客户端等待或清理

    // 步骤3: 写入数据(值)
    PutValue(mutation.key, mutation.value)

    // 步骤4: 写入锁
    PutLock(mutation.key, Lock{
        kind:    mutation.kind,    // PUT / DELETE
        primary: primaryKey,
        ts:      startTS,
        ttl:     ttl,
    })

// 批量提交到存储层(原子性写入)
storage.Write(txn.Writes())

4.5.2 写冲突检测细节

条件:存在 commitTS >= startTS 的写记录

语义:从 startTS 开始,已经有另一个事务在 startTS 之后提交了(commitTS >= startTS),这意味着如果当前事务也提交,两个事务的修改顺序违反了一致性。

例:
T1 startTS=100, T2 startTS=90
T2 先提交,commitTS=95
T1 尝试 Prewrite:MostRecentWrite 返回 commitTS=95 >= 100? 不,95 < 100
T1 没有冲突,继续

另一种情况:
T1 startTS=100, T2 startTS=90
T2 先提交,commitTS=105(发生在 T1 开始后!)
T1 尝试 Prewrite:MostRecentWrite 返回 commitTS=105 >= 100?是的
T1 返回 WriteConflict,客户端重试

4.5.3 锁冲突检测细节

条件:key 上存在任意锁(不论 lockTS 大小)

语义:有另一个事务正在修改这个 key,若同时写入可能导致冲突。

客户端收到锁冲突后的处理策略:

  1. 等待:短暂等待后重试(TTL 未超时时)
  2. 强制回滚:通过 CheckTxnStatus 检查锁的状态,若 TTL 超时则协助回滚
  3. 推进提交:若锁的 Primary 已提交,协助完成 Secondary 的提交

4.6 Commit 阶段详解

Commit 是事务的第二阶段,将 Prewrite 的"意图"变为"事实”:

客户端发起 Commit(keys, startTS, commitTS):

FOR each key in keys:
    // 步骤1: 验证锁还在(防止 Prewrite 被回滚)
    lock = GetLock(key)
    IF lock == nil OR lock.ts != startTS:
        RETURN retryable error  // 锁已被清理,事务已回滚

    // 步骤2: 写提交记录
    PutWrite(key, commitTS, Write{
        kind:     lock.kind,   // PUT / DELETE
        start_ts: startTS,
    })

    // 步骤3: 删除锁(释放)
    DeleteLock(key)

// 批量提交
storage.Write(txn.Writes())

关键点:Primary Key 的 Commit 是整个事务的"决策点",Primary Key 的 Write CF 写入成功即代表事务提交,Secondary Keys 的提交可以异步进行。

幂等性:如果同一 Commit 请求被重试,通过 CurrentWrite 检查发现已有匹配的 Write 记录,直接返回成功。

4.7 Read 流程

客户端发起 Get(key, version=startTS):

// 步骤1: 检查锁冲突
lock = GetLock(key)
IF lock != nil AND lock.ts <= startTS:
    // 有事务(startTS <= version)正在修改这个 key
    // 需要等待或协助清理
    RETURN KeyError{locked: lock}

// 步骤2: 在 Write CF 中找最新可见提交
Write = GetValue(key)  // 内部实现:Seek CfWrite[key, startTS]

IF write == nil:
    RETURN nil  // key 不存在(在此快照下)

RETURN value  // 通过 write.startTS 从 CfDefault 读取实际值

注意:只有 lock.ts <= startTS 时才阻塞读。如果 lock.ts > startTS,说明锁是在当前读快照之后的事务加的,对当前快照不可见,可以忽略。

4.8 Rollback 流程

当事务需要回滚时(TTL 超时、手动回滚、崩溃恢复):

Rollback(key, startTS):

// 写 Rollback 标记(重要:即使 CfDefault 无数据也要写)
PutWrite(key, startTS, Write{kind: ROLLBACK, start_ts: startTS})

// 删除 CfDefault 中的临时数据
DeleteValue(key)

// 删除锁(如果锁存在)
lock = GetLock(key)
IF lock != nil AND lock.ts == startTS:
    DeleteLock(key)

为什么必须写 Rollback 标记

  • 如果不写,后续可能有另一个并发事务也尝试 Rollback,然后提交一个新事务
  • 如果这时候原事务的 Commit 消息到来(因为网络延迟),会发现 lock 已不存在,导致不确定状态
  • 有了 Rollback 标记:后续 Commit 发现 CurrentWrite 已有 Rollback 记录,直接返回错误,防止"已回滚的事务被错误提交"

4.9 锁冲突与 Stale Lock 处理

4.9.1 TTL 机制

每个锁都有 TTL(Time To Live),用于检测事务是否"卡死"(崩溃、长期无响应):

TTL = base_ttl + size_factor * txn_size
  • 大事务(写入数据多)的 TTL 更长,防止误伤
  • 小事务 TTL 较短,崩溃后快速清理

4.9.2 CheckTxnStatus 流程

当读操作遇到 Stale Lock(TTL 超时)时,调用 CheckTxnStatus 检查事务状态:

CheckTxnStatus(primaryKey, lockTS, currentTS):

// 检查事务是否已提交
write, commitTS = CurrentWrite(primaryKey)  // 找匹配 startTS=lockTS 的 write
IF write != nil AND write.kind != ROLLBACK:
    RETURN {action: NoAction, commitTs: commitTS}  // 已提交

// 检查是否已回滚
IF write != nil AND write.kind == ROLLBACK:
    RETURN {action: NoAction, commitTs: 0}  // 已回滚

// 检查锁是否存在
lock = GetLock(primaryKey)
IF lock == nil:
    // 锁不存在且无 Write 记录 → 写 Rollback 标记
    PutWrite(primaryKey, lockTS, Rollback)
    RETURN {action: LockNotExistRollback, commitTs: 0}

// 检查 TTL
IF physicalTime(currentTS) >= physicalTime(lockTS) + lock.ttl:
    // TTL 超时,强制回滚
    Rollback(primaryKey, lockTS)
    RETURN {action: TTLExpireRollback, commitTs: 0}

// TTL 未超时,等待
RETURN {action: NoAction, commitTs: 0, lockTtl: lock.ttl}

4.9.3 Resolve Lock 流程

当确定事务结果后(已提交或需要回滚),通过 ResolveLock 批量处理该事务的所有锁:

ResolveLock(startTS, commitTS):
  // 扫描所有 lockTS == startTS 的锁
  locks = AllLocksForTxn(startTS)

  FOR each lock in locks:
    IF commitTS == 0:
        // 回滚
        DeleteValue(lock.key)
        DeleteLock(lock.key)
        PutWrite(lock.key, startTS, Rollback)
    ELSE:
        // 提交
        DeleteLock(lock.key)
        PutWrite(lock.key, commitTS, Write{PUT, startTS})

5. Project4 代码实现详解

5.1 存储层结构与键编码

5.1.1 EncodeKey 实现

// kv/transaction/mvcc/transaction.go
func EncodeKey(key []byte, ts uint64) []byte {
    encodedKey := codec.EncodeBytes(key)
    newKey := append(encodedKey, make([]byte, 8)...)
    binary.BigEndian.PutUint64(newKey[len(encodedKey):], ^ts)  // XOR 取反
    return newKey
}

关键点:时间戳倒序存储(^ts = XOR 取反)

为什么时间戳要倒序?

正常存储(升序): TS=100 < TS=200 < TS=300
倒序存储(降序): ~TS=100 > ~TS=200 > ~TS=300
               (对应字节值: 大 > 中 > 小)

好处

  1. Seek 到最新版本更高效:在 CfWrite 中 Seek EncodeKey(key, startTS) 后,第一个扫到的就是 commitTS <= startTS 中最大的(因为倒序,大 commitTS 对应小字节值,排在前面)
CfWrite(逻辑上按 key ASC, ts DESC 排序):
  Alice/TS=~300 (commitTS=300)  ← 排最前
  Alice/TS=~200 (commitTS=200)
  Alice/TS=~100 (commitTS=100)

Seek(Alice, startTS=250):
  → 找到第一个 key=Alice 且 ~TS <= ~250 (即 TS >= 250) 的记录
  → 因为倒序,"第一个"就是 commitTS 最大且 <= 250 的记录
  → 找到 Alice/TS=~200 (commitTS=200) ✓
  1. 与 MVCC 语义自然契合:读操作总是想要最新的可见版本,倒序存储使 Seek 后第一条就是目标。

5.1.2 DecodeUserKey 和 decodeTimestamp

func DecodeUserKey(key []byte) []byte {
    userKey, _ := codec.DecodeBytes(key)
    return userKey
}

func decodeTimestamp(key []byte) uint64 {
    left, _ := codec.DecodeBytes(key)  // 跳过 user key 部分
    return ^binary.BigEndian.Uint64(key[len(left):])  // XOR 还原
}

5.1.3 三个 CF 在 badger 中的存储布局

CfDefault:
  [EncodeKey("Alice", 100)] → "Bob"
  [EncodeKey("Alice", 80)]  → "Alice"

CfLock:
  ["Alice"] → Lock{ts=100, primary="Alice", ...}

CfWrite:
  [EncodeKey("Alice", ~105)] → Write{kind=PUT, startTS=100}
  [EncodeKey("Alice", ~85)]  → Write{kind=PUT, startTS=80}

5.2 MvccTxn 结构体

// kv/transaction/mvcc/transaction.go
type MvccTxn struct {
    StartTS uint64              // 事务的快照版本(startTS)
    Reader  storage.StorageReader  // 存储读接口(不可变)
    writes  []storage.Modify    // 写操作缓冲(Commit 时批量提交)
}

设计要点

  1. 写缓冲(writes slice):所有的 PutLock、PutValue、PutWrite、DeleteLock、DeleteValue 都只是追加到 writes 缓冲中,不立即写存储。只有调用 storage.Write(txn.Writes()) 时才原子地提交所有修改。

    这保证了:

    • 单个 RPC(如 KvPrewrite)的多个写操作原子性
    • 写失败时不会留下部分状态
  2. Reader 不可变:Reader 是只读的存储快照,通过 Reader.GetCF(cf, key)Reader.IterCF(cf) 读取数据。

5.3 Part A:核心 MVCC 方法

5.3.1 GetLock 和 PutLock

func (txn *MvccTxn) GetLock(key []byte) (*Lock, error) {
    // 直接读 CfLock,无需时间戳编码
    value, err := txn.Reader.GetCF(engine_util.CfLock, key)
    if err != nil || value == nil {
        return nil, err
    }
    lock, err := ParseLock(value)  // 反序列化 Lock 结构体
    return lock, err
}

func (txn *MvccTxn) PutLock(key []byte, lock *Lock) {
    txn.writes = append(txn.writes, storage.Modify{
        Data: storage.Put{
            Key:   key,
            Value: lock.ToBytes(),  // 序列化
            Cf:    engine_util.CfLock,
        },
    })
}

注意:CfLock 的键是原始 userKey,不含时间戳。每个 key 同时只能有一个锁。

5.3.2 GetValue 实现(关键)

func (txn *MvccTxn) GetValue(key []byte) ([]byte, error) {
    // 在 CfWrite 中找 commitTS <= startTS 的最新 Write
    iter := txn.Reader.IterCF(engine_util.CfWrite)
    defer iter.Close()

    // Seek 到 EncodeKey(key, startTS) 处
    // 因为时间戳倒序,第一条 >= startTS 的就是我们想要的
    iter.Seek(EncodeKey(key, txn.StartTS))

    if !iter.Valid() {
        return nil, nil
    }

    item := iter.Item()
    userKey := DecodeUserKey(item.Key())

    // 验证是同一个 user key(防止跨 key)
    if !bytes.Equal(userKey, key) {
        return nil, nil
    }

    // 反序列化 Write 记录
    value, err := item.Value()
    write, err := ParseWrite(value)

    // 如果是 DELETE 或 ROLLBACK,说明此版本不是有效值
    if write.Kind != WriteKindPut {
        return nil, nil
    }

    // 通过 write.StartTS 从 CfDefault 取实际值
    return txn.Reader.GetCF(engine_util.CfDefault, EncodeKey(key, write.StartTs))
}

关键实现细节

  • Seek 后需要验证 userKey == key,防止 Seek 越过了目标 key(扫到了下一个 key)
  • 必须检查 write.Kind != WriteKindPut:若是 DELETE 或 ROLLBACK,说明该版本数据已被删除,应返回 nil

5.3.3 CurrentWrite 实现(关键)

func (txn *MvccTxn) CurrentWrite(key []byte) (*Write, uint64, error) {
    // 从最大时间戳向小扫描,找匹配 startTS 的 Write
    iter := txn.Reader.IterCF(engine_util.CfWrite)
    defer iter.Close()

    // 从 TsMax 开始扫描(相当于最大 commitTS)
    for iter.Seek(EncodeKey(key, TsMax)); iter.Valid(); iter.Next() {
        item := iter.Item()
        userKey := DecodeUserKey(item.Key())

        if !bytes.Equal(userKey, key) {
            break  // 已扫过此 key 的所有版本
        }

        value, _ := item.Value()
        write, _ := ParseWrite(value)

        if write.StartTs == txn.StartTS {
            commitTS := decodeTimestamp(item.Key())
            return write, commitTS, nil
        }
    }
    return nil, 0, nil
}

作用

  • 在 KvCommit 中检查幂等性(已提交则直接成功)
  • 在 KvBatchRollback 中检查是否已处理(已 Rollback 则跳过)
  • 在 KvCheckTxnStatus 中确认事务状态

实现细节:从 TsMax 向小扫描,这样能遍历所有 commitTS,找到 startTS 匹配的记录。

5.3.4 MostRecentWrite 实现

func (txn *MvccTxn) MostRecentWrite(key []byte) (*Write, uint64, error) {
    iter := txn.Reader.IterCF(engine_util.CfWrite)
    defer iter.Close()

    iter.Seek(EncodeKey(key, TsMax))

    if !iter.Valid() {
        return nil, 0, nil
    }

    item := iter.Item()
    userKey := DecodeUserKey(item.Key())
    if !bytes.Equal(userKey, key) {
        return nil, 0, nil
    }

    value, _ := item.Value()
    write, _ := ParseWrite(value)
    commitTS := decodeTimestamp(item.Key())
    return write, commitTS, nil
}

作用:在 KvPrewrite 中检测写冲突(若 commitTS >= startTS 则冲突)。

5.4 Scanner 实现

// kv/transaction/mvcc/scanner.go
type Scanner struct {
    iter engine_util.DBIterator  // CfWrite 的迭代器
    txn  *MvccTxn
    key  []byte  // 上一次返回的 user key(用于跳过同 key 的旧版本)
}

func NewScanner(startKey []byte, txn *MvccTxn) *Scanner {
    scanner := &Scanner{
        iter: txn.Reader.IterCF(engine_util.CfWrite),
        txn:  txn,
    }
    // Seek 到 startKey 的最新版本
    scanner.iter.Seek(EncodeKey(startKey, txn.StartTS))
    return scanner
}

func (scan *Scanner) Next() ([]byte, []byte, error) {
    for {
        if !scan.iter.Valid() {
            return nil, nil, nil  // 已遍历完
        }

        item := scan.iter.Item()
        currentKey := DecodeUserKey(item.Key())

        // 跳过与上次相同的 user key(同一 key 的旧版本)
        if bytes.Equal(currentKey, scan.key) {
            scan.iter.Next()
            continue
        }

        // 检查此版本是否在快照范围内(commitTS <= startTS)
        commitTS := decodeTimestamp(item.Key())
        if commitTS > scan.txn.StartTS {
            scan.iter.Next()
            continue
        }

        scan.key = currentKey

        // 获取值(内部处理 DELETE/ROLLBACK 返回 nil)
        value, err := scan.txn.GetValue(currentKey)
        scan.iter.Next()

        if value == nil {
            continue  // 此 key 已被删除或不可见,继续扫下一个
        }

        return currentKey, value, err
    }
}

设计要点

  • Scanner 维护 key 字段:记录上一次返回的 user key,用于跳过同一 key 的多个版本(只取最新可见版本)
  • 每次 Next() 后移动迭代器,确保不会重复返回同一个 key
  • GetValue 内部已处理 DELETE/ROLLBACK 情况,返回 nil 时 Scanner 继续跳过

5.5 Part B:核心 RPC 处理器

5.5.1 KvGet 实现

// kv/server/server.go
func (server *Server) KvGet(_ context.Context, req *kvrpcpb.GetRequest) (*kvrpcpb.GetResponse, error) {
    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.Version)

    // 检查锁冲突
    lock, _ := txn.GetLock(req.Key)
    if lock != nil && lock.Ts <= req.Version {
        // 有事务(startTS <= version)持有此 key 的锁,需要等待
        return &kvrpcpb.GetResponse{
            Error: &kvrpcpb.KeyError{
                Locked: lock.Info(req.Key),
            },
        }, nil
    }

    // 读取值
    value, _ := txn.GetValue(req.Key)
    if value == nil {
        return &kvrpcpb.GetResponse{NotFound: true}, nil
    }
    return &kvrpcpb.GetResponse{Value: value}, nil
}

注意:只有 lock.Ts <= req.Version 时才阻塞读。如果锁的 startTS > 版本,说明锁是在快照之后加的,读操作可以忽略。

5.5.2 KvPrewrite 实现

func (server *Server) KvPrewrite(_ context.Context, req *kvrpcpb.PrewriteRequest) (*kvrpcpb.PrewriteResponse, error) {
    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.StartVersion)
    var keyErrors []*kvrpcpb.KeyError

    for _, mutation := range req.Mutations {
        // 1. 写冲突检测
        write, commitTS, _ := txn.MostRecentWrite(mutation.Key)
        if write != nil && commitTS >= req.StartVersion {
            keyErrors = append(keyErrors, &kvrpcpb.KeyError{
                Conflict: &kvrpcpb.WriteConflict{
                    StartTs:    req.StartVersion,
                    ConflictTs: commitTS,
                    Key:        mutation.Key,
                    Primary:    req.PrimaryLock,
                },
            })
            continue
        }

        // 2. 锁冲突检测
        lock, _ := txn.GetLock(mutation.Key)
        if lock != nil {
            keyErrors = append(keyErrors, &kvrpcpb.KeyError{
                Locked: lock.Info(mutation.Key),
            })
            continue
        }

        // 3. 写入数据和锁
        switch mutation.Op {
        case kvrpcpb.Op_Put:
            txn.PutValue(mutation.Key, mutation.Value)
            txn.PutLock(mutation.Key, &mvcc.Lock{
                Primary: req.PrimaryLock,
                Ts:      req.StartVersion,
                Ttl:     req.LockTtl,
                Kind:    mvcc.WriteKindPut,
            })
        case kvrpcpb.Op_Del:
            txn.DeleteValue(mutation.Key)
            txn.PutLock(mutation.Key, &mvcc.Lock{
                Primary: req.PrimaryLock,
                Ts:      req.StartVersion,
                Ttl:     req.LockTtl,
                Kind:    mvcc.WriteKindDelete,
            })
        }
    }

    if len(keyErrors) > 0 {
        return &kvrpcpb.PrewriteResponse{Errors: keyErrors}, nil
    }

    // 批量原子提交
    server.storage.Write(req.Context, txn.Writes())
    return &kvrpcpb.PrewriteResponse{}, nil
}

5.5.3 KvCommit 实现

func (server *Server) KvCommit(_ context.Context, req *kvrpcpb.CommitRequest) (*kvrpcpb.CommitResponse, error) {
    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.StartVersion)

    // Latch 防止并发修改同一 key
    server.Latches.WaitForLatches(req.Keys)
    defer server.Latches.ReleaseLatches(req.Keys)

    for _, key := range req.Keys {
        // 检查幂等性:是否已提交
        write, _, _ := txn.CurrentWrite(key)
        if write != nil {
            if write.Kind == mvcc.WriteKindRollback {
                // 已回滚,不能再提交
                return &kvrpcpb.CommitResponse{
                    Error: &kvrpcpb.KeyError{Retryable: "already rolled back"},
                }, nil
            }
            // 已提交(幂等),继续下一个 key
            continue
        }

        // 验证锁
        lock, _ := txn.GetLock(key)
        if lock == nil || lock.Ts != req.StartVersion {
            // 锁不存在或 ts 不匹配(可能被其他事务抢走了)
            return &kvrpcpb.CommitResponse{
                Error: &kvrpcpb.KeyError{Retryable: "lock not found"},
            }, nil
        }

        // 写提交记录
        txn.PutWrite(key, req.CommitVersion, &mvcc.Write{
            StartTs: req.StartVersion,
            Kind:    lock.Kind,
        })

        // 删除锁
        txn.DeleteLock(key)
    }

    server.storage.Write(req.Context, txn.Writes())
    return &kvrpcpb.CommitResponse{}, nil
}

5.6 Part C:辅助 RPC 处理器

5.6.1 KvScan 实现

func (server *Server) KvScan(_ context.Context, req *kvrpcpb.ScanRequest) (*kvrpcpb.ScanResponse, error) {
    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.Version)
    scanner := mvcc.NewScanner(req.StartKey, txn)
    defer scanner.Close()

    var pairs []*kvrpcpb.KvPair

    for i := uint32(0); i < req.Limit; i++ {
        key, value, err := scanner.Next()
        if key == nil {
            break
        }

        if err != nil {
            // 处理 lock 错误:记录到 pairs,而非终止
            if keyErr, ok := err.(*mvcc.KeyError); ok {
                pairs = append(pairs, &kvrpcpb.KvPair{
                    Error: keyErr.Err,
                })
                continue
            }
            return nil, err
        }

        pairs = append(pairs, &kvrpcpb.KvPair{
            Key:   key,
            Value: value,
        })
    }

    return &kvrpcpb.ScanResponse{Pairs: pairs}, nil
}

注意:KvScan 遇到 lock 错误时,应将错误记录到 pairs 中继续扫描,而不是直接返回错误中止整个扫描。这让客户端知道哪些 key 有锁冲突,可以选择性地处理。

5.6.2 KvCheckTxnStatus 实现

func (server *Server) KvCheckTxnStatus(_ context.Context, req *kvrpcpb.CheckTxnStatusRequest) (
    *kvrpcpb.CheckTxnStatusResponse, error) {

    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.LockTs)

    // 1. 检查是否已提交
    write, commitTS, _ := txn.CurrentWrite(req.PrimaryKey)
    if write != nil && write.Kind != mvcc.WriteKindRollback {
        return &kvrpcpb.CheckTxnStatusResponse{
            CommitVersion: commitTS,
            Action:        kvrpcpb.Action_NoAction,
        }, nil
    }

    // 2. 检查是否已回滚
    if write != nil && write.Kind == mvcc.WriteKindRollback {
        return &kvrpcpb.CheckTxnStatusResponse{
            Action: kvrpcpb.Action_NoAction,
        }, nil
    }

    // 3. 检查锁是否存在
    lock, _ := txn.GetLock(req.PrimaryKey)
    if lock == nil {
        // 锁不存在,写 Rollback 标记
        txn.DeleteValue(req.PrimaryKey)
        txn.PutWrite(req.PrimaryKey, req.LockTs, &mvcc.Write{
            StartTs: req.LockTs,
            Kind:    mvcc.WriteKindRollback,
        })
        server.storage.Write(req.Context, txn.Writes())
        return &kvrpcpb.CheckTxnStatusResponse{
            Action: kvrpcpb.Action_LockNotExistRollback,
        }, nil
    }

    // 4. 检查 TTL
    currentPhysical := mvcc.PhysicalTime(req.CurrentTs)
    lockPhysical := mvcc.PhysicalTime(req.LockTs)
    if currentPhysical >= lockPhysical+lock.Ttl {
        // TTL 超时,强制回滚
        txn.DeleteValue(req.PrimaryKey)
        txn.DeleteLock(req.PrimaryKey)
        txn.PutWrite(req.PrimaryKey, req.LockTs, &mvcc.Write{
            StartTs: req.LockTs,
            Kind:    mvcc.WriteKindRollback,
        })
        server.storage.Write(req.Context, txn.Writes())
        return &kvrpcpb.CheckTxnStatusResponse{
            Action: kvrpcpb.Action_TTLExpireRollback,
        }, nil
    }

    // 5. TTL 未超时,返回锁信息
    return &kvrpcpb.CheckTxnStatusResponse{
        LockTtl: lock.Ttl,
        Action:  kvrpcpb.Action_NoAction,
    }, nil
}

5.6.3 KvBatchRollback 实现

func (server *Server) KvBatchRollback(_ context.Context, req *kvrpcpb.BatchRollbackRequest) (
    *kvrpcpb.BatchRollbackResponse, error) {

    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.StartVersion)

    for _, key := range req.Keys {
        // 检查是否已处理
        write, _, _ := txn.CurrentWrite(key)
        if write != nil {
            if write.Kind == mvcc.WriteKindRollback {
                continue  // 已回滚,幂等跳过
            }
            // 已提交,回滚失败
            return &kvrpcpb.BatchRollbackResponse{
                Error: &kvrpcpb.KeyError{Abort: "already committed"},
            }, nil
        }

        // 删除锁(如果存在且属于此事务)
        lock, _ := txn.GetLock(key)
        if lock != nil && lock.Ts == req.StartVersion {
            txn.DeleteLock(key)
        }

        // 删除数据,写 Rollback 标记
        txn.DeleteValue(key)
        txn.PutWrite(key, req.StartVersion, &mvcc.Write{
            StartTs: req.StartVersion,
            Kind:    mvcc.WriteKindRollback,
        })
    }

    server.storage.Write(req.Context, txn.Writes())
    return &kvrpcpb.BatchRollbackResponse{}, nil
}

5.6.4 KvResolveLock 实现

func (server *Server) KvResolveLock(_ context.Context, req *kvrpcpb.ResolveLockRequest) (
    *kvrpcpb.ResolveLockResponse, error) {

    reader, _ := server.storage.Reader(req.Context)
    defer reader.Close()

    txn := mvcc.NewMvccTxn(reader, req.StartVersion)

    // 获取所有属于此事务的锁
    locks, _ := mvcc.AllLocksForTxn(txn)

    for _, lockInfo := range locks {
        txn2 := mvcc.NewMvccTxn(reader, req.StartVersion)

        if req.CommitVersion == 0 {
            // 回滚
            txn2.DeleteValue(lockInfo.Key)
            txn2.DeleteLock(lockInfo.Key)
            txn2.PutWrite(lockInfo.Key, req.StartVersion, &mvcc.Write{
                StartTs: req.StartVersion,
                Kind:    mvcc.WriteKindRollback,
            })
        } else {
            // 提交
            txn2.DeleteLock(lockInfo.Key)
            txn2.PutWrite(lockInfo.Key, req.CommitVersion, &mvcc.Write{
                StartTs: req.StartVersion,
                Kind:    lockInfo.Lock.Kind,
            })
        }

        server.storage.Write(req.Context, txn2.Writes())
    }

    return &kvrpcpb.ResolveLockResponse{}, nil
}

5.7 Latches 并发控制

// 在 KvCommit 中使用 Latches
server.Latches.WaitForLatches(req.Keys)
defer server.Latches.ReleaseLatches(req.Keys)

Latches 的作用

  • Latches 是一个内存级别的行锁(不是分布式锁)
  • 作用范围:同一个 TinyKV 节点上的并发请求
  • 防止问题:同一节点上两个并发 Commit 请求操作同一个 key 时,可能产生竞争条件

与 MVCC 的关系

  • MVCC 解决的是多版本读写并发问题(通过时间戳实现快照隔离)
  • Latches 解决的是同一节点上的写-写并发问题(防止内存状态竞争)
  • 两者互补:MVCC 是逻辑层面的并发控制,Latches 是物理层面的并发保护

5.8 实现注意事项与坑

5.8.1 GetValue 的 WriteKind 检查

// 错误做法:不检查 WriteKind
if write != nil {
    return txn.Reader.GetCF(CfDefault, EncodeKey(key, write.StartTs))
}

// 正确做法:必须检查 WriteKind
if write.Kind != WriteKindPut {
    return nil, nil  // DELETE 或 ROLLBACK 不返回值
}

原因:DELETE 操作在 Write CF 中写的是 WriteKindDelete,如果不检查,会尝试从 Default CF 读值,但 Default CF 中可能没有(或读到旧版本的值),导致数据语义错误。

5.8.2 KvCommit 的 Retryable 错误

锁不存在(lock == nil):
  可能原因:锁已被 Rollback 清理(TTL 超时被其他事务清理)
  正确行为:返回 Retryable Error,客户端应该重新执行整个事务

锁 ts 不匹配(lock.ts != startVersion):
  可能原因:有另一个事务在同一 key 上加了新锁(说明当前事务的锁已被清理)
  正确行为:返回 Retryable Error

Retryable vs Abort

  • Retryable:客户端可以重试(如重新获取 startTS,重新执行事务)
  • Abort:事务无法继续(如尝试提交已回滚的事务)

5.8.3 KvScan 中 lock 错误的处理

// 错误做法:遇到 lock 错误直接返回
if err != nil {
    return nil, err
}

// 正确做法:将 lock 错误加入 pairs 继续扫描
pairs = append(pairs, &kvrpcpb.KvPair{Error: keyErr.Err})
continue

原因:客户端期望获取尽可能多的结果,对于有锁冲突的 key,记录错误信息让客户端决定如何处理,而不是直接终止整个扫描。

5.8.4 KvCheckTxnStatus 无锁时的处理

// 无锁时,必须写 Rollback 标记,即使 Default CF 中没有数据
txn.DeleteValue(req.PrimaryKey)   // 即使不存在也无害
txn.PutWrite(req.PrimaryKey, req.LockTs, Rollback)

原因:不写 Rollback 标记的风险 ——

  • 时序:T1 的锁因网络问题消失 → CheckTxnStatus 认为已回滚,但未写标记
  • 后来 T1 的 Prewrite 消息迟到 → 锁又被加上
  • 或 T1 的 Commit 消息迟到 → 没有 Rollback 标记,Commit 成功!
  • 数据不一致

写了 Rollback 标记:Commit 时 CurrentWrite 发现 Rollback 记录,拒绝提交 ✓

5.8.5 Scanner 跳过旧版本

// 维护 scan.key,跳过相同 user key 的多个版本
if bytes.Equal(currentKey, scan.key) {
    scan.iter.Next()
    continue
}
scan.key = currentKey

如果不跳过:同一个 key 的多个历史版本都会被返回,客户端会看到重复 key,违反 Scan 语义。


6. 工业界分布式事务方案对比

6.1 TiDB/TiKV 事务演进

TiKV 是 TinyKV 的工业原型,其事务方案也基于 Percolator,但在工程实践中进行了多项优化。

6.1.1 基础方案:Percolator + TSO

TiKV 的基础事务方案与 TinyKV 实现基本一致:

  • TSO 由 PD(Placement Driver)提供全局单调递增时间戳
  • 两阶段提交:Prewrite 阶段写锁和数据,Commit 阶段写提交记录并删锁
  • Primary Key 作为事务的状态决策点
  • 快照隔离(SI):通过 startTS 实现一致性快照读

每次事务需要两次 TSO 请求(获取 startTS 和 commitTS)以及两次存储写入(Prewrite 和 Commit),这在高并发场景下成为性能瓶颈。

6.1.2 Async Commit(异步提交)

动机:标准 2PC 的 Commit 阶段需要客户端等待所有 key 的写操作完成,延迟较高。

核心思想:Prewrite 完成后立即返回给客户端(不等待 Commit),Commit 阶段在后台异步完成。

实现关键

  • Prewrite 时记录所有 Secondary Key 的位置(secondaries 列表)
  • 计算 minCommitTS:所有 Secondary Key 所在 Region 的 maxTS 中取最大值,确保 commitTS > 所有可能的读事务的 startTS
  • Primary Key 的 lock 中记录 secondariesminCommitTS
  • 后台异步提交所有 key

好处:客户端在 Prewrite 完成后即可继续,减少了一个网络往返延迟。

代价

  • Prewrite 阶段需要额外收集 minCommitTS(一次额外的读操作)
  • 崩溃恢复更复杂:需要检查所有 Secondary Key 状态来计算 commitTS
  • 不适合大事务(Secondary Key 列表很大)

6.1.3 1PC(One-Phase Commit)

适用条件:事务的所有写操作都在同一个 Region 内(单分片事务)。

原理:单 Region 内的多个写操作可以通过 Raft 一次提交(Raft 本身保证原子性),无需两阶段提交。

实现

  • 检测到单 Region 事务后,跳过 Prewrite 阶段
  • 直接写 Write CF 和 Data CF,不写 Lock CF
  • 一次 Raft 提交完成所有操作

好处:消除 2PC 开销,延迟降低约 50%,吞吐量显著提升。

局限:仅适用于单 Region 事务,跨 Region 仍需 2PC。

6.1.4 Pipelined Locking(流水线加锁)

动机:大事务(写入数千个 key)的 Prewrite 阶段需要串行等待所有 key 的锁写入确认,延迟随事务规模线性增长。

原理:不等待前一个 key 的锁写入确认,立即开始下一个 key 的锁写入(流水线化)。

好处:减少大事务的端到端延迟。

代价:更复杂的错误处理(部分锁写入失败时回滚更复杂)。

6.2 Google Spanner

6.2.1 TrueTime API

Spanner 的核心创新是 TrueTime,一套基于 GPS 天线和原子钟的时钟服务:

TrueTime 返回的不是单一时间点,而是一个区间:
TT.now() = [earliest, latest]
其中 |latest - earliest| ≤ 2ε(ε 通常为 1-7 毫秒)

时间戳保证:

  • 如果事件 A 在事件 B 开始之前结束,则 TT.after(B.start) > TT.before(A.end)
  • 即 B 的时间戳严格大于 A 的时间戳

6.2.2 Commit-Wait 机制

Spanner 的提交等待机制确保外部一致性:

提交流程:
1. 获取提交时间戳 s = TT.now().latest
2. 执行 Prepare(等待所有参与者就绪)
3. 等待(commit-wait):等到 TT.now().earliest > s(即确保 s 已过去)
4. 执行 Commit

意义:等待 时间后,整个地球上所有节点的时钟一定都已经超过了 s,保证了外部一致性(后续任何事务的 startTS > s)。

6.2.3 Spanner vs Percolator

对比项SpannerPercolator/TiKV
时钟方案TrueTime(GPS+原子钟)TSO(中心化时间戳服务)
一致性级别外部一致性快照隔离(SI)
协调者有(基于 Paxos Group)无(Primary Key)
硬件依赖专用 GPS/原子钟无特殊硬件
跨地域延迟commit-wait 增加延迟TSO 往返延迟
适用场景全球分布式数据库通用分布式数据库

6.3 CockroachDB

6.3.1 混合逻辑时钟(HLC)

问题:CockroachDB 希望无需 GPS/原子钟,也不依赖中心化 TSO,但仍要保证全局时序。

HLC(Hybrid Logical Clock):将物理时钟(NTP 同步)与逻辑时钟结合:

HLC 时钟 = (physical, logical)
规则:
  发送消息时:HLC = max(本地HLC, 消息HLC) + 1
  本地事件时:HLC = max(本地HLC, NTP时钟) + 1

特性

  • 物理分量接近 NTP 时间(有界误差)
  • 逻辑分量处理同一物理时刻的多个事件
  • 不需要专用硬件,不需要中心化 TSO

6.3.2 事务不确定区间

由于时钟不确定性,CockroachDB 在读操作中使用不确定区间(uncertainty window)

如果读事务的 maxTS = readTS + uncertaintyInterval
遇到 commitTS ∈ (readTS, maxTS) 的写操作 → 不确定,需要重启事务

这保证了即使时钟略有偏差,也不会读到"未来"写的数据。

6.3.3 Transaction Record

CockroachDB 中的 Transaction Record 类似 Percolator 的 Primary Key:

  • 存储事务的最终状态(PENDING / COMMITTED / ABORTED)
  • 崩溃恢复时通过 Transaction Record 确定事务结果
  • 与数据存储在同一节点(减少网络往返)

6.3.4 Contention Resolution(冲突解决)

CockroachDB 遇到锁冲突时,不是简单等待,而是尝试**推进(push)**持锁事务:

如果高优先级事务 T2 遇到低优先级事务 T1 的锁:
  T2 可以强制推进 T1:
  - 如果 T1 的优先级较低 → T1 的 commitTS 被推高(晚于 T2)
  - T2 继续执行,T1 在提交时需要重新校验

这避免了长时间锁等待,提高了高优先级事务的响应性。

6.4 MySQL XA

6.4.1 XA 标准接口

XA 是 The Open Group 制定的分布式事务标准,MySQL InnoDB 实现了 XA 接口:

-- 开始 XA 事务
XA START 'transaction_id';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
XA END 'transaction_id';

-- Prepare 阶段
XA PREPARE 'transaction_id';

-- Commit 或 Rollback
XA COMMIT 'transaction_id';
-- 或
XA ROLLBACK 'transaction_id';

查询未完成的 XA 事务

XA RECOVER;  -- 返回所有 PREPARED 状态的事务

6.4.2 XA 的限制

  • 性能差:每个 XA 事务需要两次磁盘 fsync(prepare + commit),持锁时间长
  • 协调者单点:应用程序作为协调者,崩溃后 PREPARED 的事务需要手动处理
  • 实现不完整:早期 MySQL XA 实现存在 binlog 一致性问题(已在 5.7 修复)
  • 适用场景:跨数据库实例的少量事务,不适合高吞吐场景

6.5 Saga 长事务方案

6.5.1 Saga 的设计动机

在微服务架构中,一个业务流程可能涉及多个服务(订单服务、库存服务、支付服务),且每个服务拥有独立的数据库。传统 2PC 在此场景下的问题:

  • 需要跨服务加锁,锁持有时间可能很长(分钟级)
  • 服务不同技术栈,XA 支持困难
  • 2PC 的阻塞问题在网络较慢的微服务场景下更加严重

Saga 的思路:接受最终一致性,通过补偿事务实现回滚。

6.5.2 Saga 实现示例

电商下单 Saga

正向事务序列:
T1: 创建订单(订单服务)
T2: 扣减库存(库存服务)
T3: 扣款(支付服务)
T4: 更新积分(积分服务)

补偿事务序列(逆序执行):
C4: 撤销积分更新
C3: 退款
C2: 恢复库存
C1: 取消订单

失败场景(T3 失败):
T1 → T2 → T3(失败)→ C2 → C1

6.5.3 Orchestration vs Choreography

Orchestration(编排)

  • 有一个 Saga Orchestrator(如 workflow engine)
  • Orchestrator 按顺序调用各服务,处理失败和补偿
  • 优点:流程集中,易于监控和调试
  • 缺点:Orchestrator 是单点,流程耦合

Choreography(编舞)

  • 每个服务完成本地事务后发布领域事件
  • 其他服务监听事件并响应
  • 优点:服务解耦,高可用
  • 缺点:流程分散,难以追踪和调试

6.5.4 Saga 的局限

  • 无隔离性:中间状态对外可见,可能导致"脏读"(其他事务看到未完成的 Saga)
  • 补偿逻辑复杂:每个操作都需要实现补偿,业务代码量倍增
  • 补偿不总能实现:如果操作不可逆(已发货),补偿只能是近似恢复

6.6 综合对比表

方案一致性隔离性协调者侵入性性能适用场景
XA/2PC强一致可串行化有(单点)少量跨库操作
PercolatorSI(快照隔离)SI无(Primary Key)通用 KV 事务
Spanner外部一致可串行化有(Paxos)中(受 commit-wait 影响)全球数据库
CockroachDB可串行化可串行化无(HLC)多云/无 GPS 环境
TCC强一致最终一致(中间状态存在)极高金融核心交易
Saga最终一致可选微服务长事务
本地消息表最终一致异步通知场景

7. 现有实现的问题与优化空间

7.1 TinyKV 实现的性能局限

7.1.1 两次 TSO 请求的延迟

标准 Percolator 需要两次从 TSO 获取时间戳:

  • Prewrite 前获取 startTS
  • Commit 前获取 commitTS

每次 TSO 请求都是一次网络往返(取决于客户端到 PD 的延迟),在高频小事务场景下,这两次额外的网络延迟显著影响吞吐量。

优化方向

  • Async Commit:减少一次 TSO 请求(commitTS 由 minCommitTS 计算)
  • TSO 批处理:客户端批量预取时间戳,摊薄网络开销

7.1.2 两次写入放大

每个 key 的写入涉及:

  1. Prewrite:写 CfDefault + 写 CfLock(2 次写)
  2. Commit:写 CfWrite + 删 CfLock(1写1删)

总计:每个 key 4 次存储操作(相比单机事务的 1 次写 + 1 次 WAL)。

优化方向

  • 1PC:对单 Region 事务,跳过 CfLock,直接写 CfWrite + CfDefault(减少 2 次操作)
  • 合并小写入:在存储层批量合并写操作

7.1.3 锁等待与重试开销

Percolator 是乐观并发控制,遇到冲突时需要回滚整个事务并重试:

  • 重试包含重新获取 startTS、重新 Prewrite 所有 key
  • 在高冲突场景下,重试次数可能很多(称为"活锁"问题)

优化方向

  • 死锁检测:主动检测死锁并强制中止优先级低的事务
  • 优先级调度:高优先级事务优先获取锁(类似 CockroachDB 的 Contention Resolution)

7.2 TinyKV 功能局限

7.2.1 无 Async Commit 支持

TinyKV 实现中,所有事务必须完整执行两阶段提交:

客户端等待 Prewrite 完成 → 等待 Commit 完成 → 返回结果

工业级 TiKV 中,Async Commit 允许:

客户端等待 Prewrite 完成 → 立即返回(Commit 在后台异步进行)

这减少了客户端感知的延迟,但 TinyKV 为简化实现未包含此功能。

7.2.2 无 1PC 支持

TinyKV 所有写事务,即使只涉及单个 key,也执行完整 2PC:

单 key 写:PrewriteRequest → CommitRequest(2 次 RPC)

1PC 优化将此简化为:

单 key 写:PrewriteRequest(含 commit 信息)(1 次 RPC)

TiKV 在检测到单 Region 事务时自动使用 1PC。

7.2.3 无写入管道化

TinyKV 的 KvPrewrite 串行处理每个 mutation:

  • 检测冲突 → 写 Default CF → 写 Lock CF
  • 等待第 N 个 key 完成后,才处理第 N+1 个 key

工业级实现通常并行处理多个 key 的加锁操作(Pipelined Locking)。

7.2.4 Scanner 不处理锁等待

TinyKV 的 Scanner 在遇到锁冲突时直接将错误记录到结果中返回:

// 现有实现:锁冲突直接返回错误
if err, ok := err.(*mvcc.KeyError); ok {
    pairs = append(pairs, &kvrpcpb.KvPair{Error: err.Err})
}

工业级实现应该等待锁释放或协助清理 Stale Lock,然后继续扫描,对客户端透明。

7.3 隔离级别问题

7.3.1 TinyKV 的隔离级别

TinyKV 实现达到的隔离级别是快照隔离(Snapshot Isolation, SI)

能防止

  • 脏读 ✓(通过 CfWrite 只读已提交数据)
  • 不可重复读 ✓(通过 startTS 固定快照)
  • 大多数幻读 ✓(快照读不受并发插入影响)

不能防止

  • 写偏斜(Write Skew)

7.3.2 写偏斜示例

场景:医院值班系统,约束:至少 1 名医生在岗
当前状态:医生 A(on_call=true),医生 B(on_call=true)

T1(医生 A 申请下班):
  startTS=100
  Read: SELECT count(*) FROM doctors WHERE on_call=true → 2
  决策: 2 > 1,可以下班
  Write: UPDATE doctors SET on_call=false WHERE id='A'

T2(医生 B 申请下班):
  startTS=100(与 T1 相同快照)
  Read: SELECT count(*) FROM doctors WHERE on_call=true → 2
  决策: 2 > 1,可以下班
  Write: UPDATE doctors SET on_call=false WHERE id='B'

两个事务都提交成功(无冲突,因为写的是不同 key)
最终结果:A.on_call=false, B.on_call=false → 0 名医生在岗!违反约束

原因:T1 和 T2 都读了对方的写(on_call 字段),但写的是不同的 key(AB),MVCC 无法检测到这种读-写依赖。

解决方案

  • 悲观锁:T1 和 T2 各自对读到的所有行加 SELECT FOR UPDATE,T2 会阻塞等待 T1
  • SSI(串行化快照隔离):检测读写冲突环,自动中止一个事务
  • 应用层控制:在应用层显式加锁(如 Redis 分布式锁)

7.3.3 MVCC GC 问题

随着时间推移,MVCC 数据不断积累历史版本,会占用大量存储空间并降低读性能(需要扫描更多历史版本)。

TinyKV 现状:没有实现 MVCC GC,历史版本会无限积累。

工业级 TiKV 的 GC 机制

  • 定期从 PD 获取 GC Safe Point(所有活跃事务的最小 startTS)
  • 对 GC Safe Point 之前的所有版本进行清理
  • 只保留每个 key 在 GC Safe Point 时刻可见的最新版本
Safe Point = 100
CfDefault 中 startTS < 100 的旧版本 → 可以删除
CfWrite 中 commitTS < 100 的旧提交记录(保留每个 key 的最新一条)→ 部分删除

8. 关键设计思考与深度分析

8.1 为什么 Percolator 能去掉独立的协调者

传统 2PC 依赖独立协调者的原因:需要一个中立的节点来做全局决策(Commit or Abort),且该决策必须持久化。

Percolator 的洞察:

  1. 单行原子性:底层存储(BigTable/RocksDB)支持对单个 key 的原子读写
  2. Primary Key 作为决策点:对 Primary Key 的 Write CF 写入是原子的,写入成功即代表"事务已提交"这一事实被持久化
  3. 无需集中协调:所有参与者只需查看 Primary Key 的状态,就能确定事务结果

本质上,Percolator 将"协调者的决策"从一个独立进程变成了"Primary Key 行上的一次原子写操作",避免了独立协调者的单点故障。

代价

  • 读操作可能遇到进行中的事务的锁,需要额外逻辑(CheckTxnStatus)来处理
  • 崩溃恢复需要检查 Primary Key 状态,比独立协调者的 WAL 恢复更复杂

8.2 为什么时间戳要倒序存储

核心原因:MVCC 的读操作需要找到"最新的、commitTS <= startTS 的版本"。

如果时间戳正序存储:

CfWrite(正序):
  Alice/TS=85   (commitTS=85)
  Alice/TS=100  (commitTS=100)
  Alice/TS=200  (commitTS=200)

读操作(startTS=150):
  Seek(Alice, TS=150)
  → 找到第一个 >= TS=150 的记录:Alice/TS=200
  → 但 200 > 150,不可见!
  → 需要向后扫,找上一个记录 Alice/TS=100 ✓

正序存储需要 Seek + 向前回退,实现复杂(LSM-Tree 的迭代器通常只支持向后扫描)。

如果时间戳倒序存储:

CfWrite(倒序,大 TS 排前面):
  Alice/~TS=85   (对应 commitTS=85,字节值最大)
  Alice/~TS=100  (对应 commitTS=100)
  Alice/~TS=200  (对应 commitTS=200,字节值最小)

实际存储顺序(按字节值升序):
  Alice/~TS=200  (字节值最小,排最前)
  Alice/~TS=100
  Alice/~TS=85

读操作(startTS=150):
  Seek(EncodeKey("Alice", 150)) = Seek(Alice/~TS=150)
  → 第一条 >= Alice/~150 的记录:Alice/~100(因为 ~100 > ~150)
  → 对应 commitTS=100 ≤ 150,直接可用!✓

倒序存储使 Seek 直接定位到目标版本,向后遍历即可,实现简洁高效。

8.3 MVCC GC 的必要性与安全性

8.3.1 为什么必须 GC

存储膨胀:每次写操作都产生新版本,删除操作也只是标记(WriteKindDelete),历史版本持续积累。

读性能下降:GetValue 中的 Seek 虽然能直接定位,但如果历史版本很多,会增加 LSM-Tree 的文件数量,降低扫描性能。

8.3.2 Safe Point 的确定

Safe Point = 所有活跃长事务的最小 startTS。Safe Point 之前的所有版本,已经没有任何活跃事务会读取。

活跃事务: T1(startTS=90), T2(startTS=95), T3(startTS=110)
Safe Point = min(90, 95, 110) = 90

Safe Point = 90 意味着:
  - commitTS < 90 的历史版本:没有任何活跃事务的 startTS <= 90 能读到这些版本
  - 可以安全删除(保留每个 key 在 TS=90 时刻可见的最新版本)

8.3.3 GC 的安全性保证

GC 需要保证:执行 GC 后,所有活跃事务(startTS >= Safe Point)的读操作结果不变。

证明:

  • 对于 startTS >= Safe Point 的读事务,其快照版本 >= Safe Point
  • GC 只删除 commitTS < Safe Point 的旧版本,保留最新可见版本
  • 最新可见版本 = commitTS 最接近 Safe Point 的版本,保留后仍能被这些读事务读到

8.4 锁的 TTL 设计权衡

8.4.1 TTL 太短的风险

场景:
大事务(写入 10000 个 key,需要 5 秒)
TTL = 3 秒

时序:
t=0: 开始 Prewrite
t=3: TTL 到期,其他事务清理了部分锁
t=5: 事务继续 Commit → 发现锁已被清理 → 事务失败,需要重试

结果:大事务被误杀,需要重试,浪费资源

8.4.2 TTL 太长的风险

场景:
事务崩溃(客户端宕机),遗留了锁
TTL = 10 分钟

影响:
读取该 key 的其他事务等待长达 10 分钟(或需要主动发起 CheckTxnStatus)
系统吞吐量大幅下降

8.4.3 自适应 TTL

TiKV 实现中,TTL 根据事务大小动态计算:

base_ttl = 3000ms(3 秒)
size_factor = 1ms per KB

TTL = base_ttl + size_factor × txn_size_kb
    = 3000 + 1 × 10000  (10MB 事务)
    = 13000ms(13 秒)

这样大事务有更长的 TTL,小事务快速清理,平衡了两种风险。

8.5 Primary Key 的性能影响

8.5.1 热点问题

如果业务逻辑导致大量事务的 Primary Key 集中在同一个 key(如全局计数器、自增序列),该 key 会成为热点:

  • 所有事务的 CheckTxnStatus 都需要读写这个 key
  • 并发 Commit 时会产生锁竞争

缓解方案

  • 避免使用单一热点 key 作为 Primary
  • 使用分布式序列(如 TiDB 的 AUTO_RANDOM)代替自增主键

8.5.2 Primary Key 选择策略

实践中,Primary Key 通常选择事务写入的第一个 key,原因:

  • 简单,不需要特殊处理
  • 对于有序写入,第一个 key 是最早写入的,崩溃恢复时最先被检查
  • TiKV 的实现也采用此策略

8.6 Read-Your-Own-Writes 一致性

问题:在分布式系统中,写入某个节点后立即读取,可能读不到(因为副本同步有延迟)。

Percolator 的保证

  • 同一事务内的写入,通过写缓冲(MvccTxn.writes)在客户端内存中维护
  • 事务内读取自己写的 key 时,先查 write buffer,再查存储
  • 事务提交后,其他客户端可能因副本延迟暂时读不到(但最终一致)

注意:TinyKV 的实现中,读请求创建新的 MvccTxn,不共享写缓冲,同一"会话"的读写实际上是独立的事务。真正的 Read-Your-Own-Writes 需要客户端维护更多状态。


9. 常见问题与注意事项(代码层面)

9.1 GetValue 的 Seek 越界问题

问题:GetValue 中 Seek 后可能扫到另一个 key 的版本。

// 错误写法(未验证 user key)
iter.Seek(EncodeKey(key, txn.StartTS))
if iter.Valid() {
    item := iter.Item()
    value, _ := item.Value()
    write, _ := ParseWrite(value)
    return txn.Reader.GetCF(CfDefault, EncodeKey(key, write.StartTs))
    // ↑ 如果 Seek 到了下一个 key 的版本,write.StartTs 是另一个 key 的 TS!
}
// 正确写法(必须验证 user key)
iter.Seek(EncodeKey(key, txn.StartTS))
if iter.Valid() {
    item := iter.Item()
    userKey := DecodeUserKey(item.Key())
    if !bytes.Equal(userKey, key) {
        return nil, nil  // 已超出此 key 的范围
    }
    // ... 继续处理
}

原因:CfWrite 按 (key ASC, ts DESC) 排序,当某个 key 没有 commitTS <= startTS 的版本时,Seek 可能跳到下一个 key 的版本。

9.2 CurrentWrite 的扫描终止条件

问题:CurrentWrite 从 TsMax 向小扫描,需要正确判断何时停止。

for iter.Seek(EncodeKey(key, TsMax)); iter.Valid(); iter.Next() {
    item := iter.Item()
    userKey := DecodeUserKey(item.Key())

    if !bytes.Equal(userKey, key) {
        break  // 已扫完此 key 的所有版本,进入下一个 key
    }

    // 处理...
}

如果不检查 !bytes.Equal(userKey, key),扫描会继续到其他 key 的版本,导致错误结果或无限循环。

9.3 KvCommit 的 Retryable 与 Abort 区分

情况1:lock.ts != startVersion(锁属于另一个事务)
  → Retryable: 当前事务的锁已被清理,客户端应重新开始事务

情况2:write.kind == WriteKindRollback(已回滚)
  → Abort: 不应该再提交(返回 Retryable: "already rolled back")
  → 注意:有些实现在这里返回非 Retryable 错误,需根据测试要求调整

情况3:lock == nil(锁不存在)
  → Retryable: 同情况1,事务可能已超时被回滚

关键:区分 Retryable 和非 Retryable 对客户端行为影响很大:

  • Retryable:客户端重试整个事务(重新获取 startTS)
  • 非 Retryable(Abort):客户端报错给用户,不重试

9.4 KvBatchRollback 的幂等性

// 已 Rollback 的 key 直接跳过(幂等)
if write != nil && write.Kind == WriteKindRollback {
    continue  // 不重复写 Rollback
}

// 已提交的 key 报错
if write != nil && write.Kind != WriteKindRollback {
    return AbortError("already committed")
}

为什么需要幂等:客户端可能因为网络重试多次发送 BatchRollback 请求,对已处理的 key 再次 Rollback 应该静默成功,而不是报错。

9.5 KvCheckTxnStatus 必须在无锁时写 Rollback

错误做法

lock = GetLock(primaryKey)
if lock == nil {
    return {Action: LockNotExistRollback}  // 只返回,不写 Rollback 标记
}

问题时序

t=1: T1 Prewrite(获得 startTS=100)
t=2: T1 的 Prewrite 网络延迟,锁还未写入
t=3: T2 读取 primaryKey,发现无锁 → CheckTxnStatus 返回 LockNotExistRollback(但未写标记)
t=4: T1 的 Prewrite 到达 → 锁成功写入!
t=5: T1 尝试 Commit → 发现锁存在 → 成功提交!
t=6: 数据不一致(T2 已经认为 T1 不存在,但 T1 实际提交了)

正确做法:写 Rollback 标记,使后续 Commit 失败:

if lock == nil {
    txn.PutWrite(primaryKey, lockTS, WriteKindRollback)
    server.storage.Write(...)
    return {Action: LockNotExistRollback}
}

这样 T1 的 Commit 在 CurrentWrite 时发现 Rollback 记录,拒绝提交 ✓

9.6 AllLocksForTxn 的性能考量

// 扫描所有 CfLock,找属于 startTS 的锁
func AllLocksForTxn(txn *MvccTxn) ([]KlPair, error) {
    iter := txn.Reader.IterCF(engine_util.CfLock)
    defer iter.Close()

    var locks []KlPair
    for iter.Seek(nil); iter.Valid(); iter.Next() {
        item := iter.Item()
        value, _ := item.Value()
        lock, _ := ParseLock(value)
        if lock.Ts == txn.StartTS {
            locks = append(locks, KlPair{Key: item.Key(), Lock: lock})
        }
    }
    return locks, nil
}

性能问题:需要扫描所有 CfLock,如果系统中有大量锁(大量并发事务),这个操作的时间复杂度为 O(n),n 是总锁数量。

工业级优化

  • 记录事务涉及的所有 key(在 Primary Key 的 lock 中存储 secondary keys 列表)
  • ResolveLock 时直接读取 secondary keys 列表,不需要全表扫描
  • 这也是 Async Commit 中 secondaries 字段的设计动机

9.7 PutWrite 的参数顺序

// TinyKV 中 PutWrite 参数:key, ts(commitTS), write
txn.PutWrite(key, req.CommitVersion, &mvcc.Write{StartTs: req.StartVersion, Kind: lock.Kind})

注意

  • ts 参数是 commitTS(用作 CfWrite 的键的时间戳部分)
  • write.StartTsstartTS(存储在值中,用于从 CfDefault 读取实际数据)
  • 两者含义不同,混淆会导致读操作找不到正确版本

10. 代码索引与附录

10.1 核心代码文件

文件内容关键函数
kv/transaction/mvcc/transaction.goMvccTxn 核心实现GetLock, PutLock, GetValue, PutValue, CurrentWrite, MostRecentWrite, EncodeKey
kv/transaction/mvcc/scanner.go范围扫描实现NewScanner, Next, Close
kv/server/server.goRPC 处理器KvGet, KvPrewrite, KvCommit, KvScan, KvCheckTxnStatus, KvBatchRollback, KvResolveLock
kv/transaction/mvcc/lock.goLock 结构体ParseLock, Lock.ToBytes, Lock.Info
kv/transaction/mvcc/write.goWrite 结构体ParseWrite, Write.ToBytes

10.2 关键常量与类型

// Column Families
engine_util.CfDefault  = "default"  // 存储实际值
engine_util.CfLock     = "lock"     // 存储锁
engine_util.CfWrite    = "write"    // 存储提交记录

// Write Kinds
mvcc.WriteKindPut      // 写入操作
mvcc.WriteKindDelete   // 删除操作
mvcc.WriteKindRollback // 回滚标记

// Lock Kinds(与 Write 相同类型)
mvcc.LockKindPut
mvcc.LockKindDelete

// 特殊时间戳
mvcc.TsMax = math.MaxUint64  // 最大时间戳,用于 Seek 最新版本

10.3 Percolator 事务流程速查

事务生命周期:

1. 客户端获取 startTS(TSO)

2. Prewrite 阶段(FOR 每个 mutation):
   a. MostRecentWrite(key) → commitTS >= startTS? → WriteConflict
   b. GetLock(key) → lock != nil? → LockConflict
   c. PutValue(key, value) + PutLock(key, Lock{primary, startTS, TTL})
   d. storage.Write(批量原子提交)

3. 客户端获取 commitTS(TSO)

4. Commit 阶段(FOR 每个 key):
   a. CurrentWrite(key) → 已有记录? → 幂等成功/已回滚报错
   b. GetLock(key) → 锁不存在或 ts 不匹配? → Retryable Error
   c. PutWrite(key, commitTS, Write{startTS, kind}) + DeleteLock(key)
   d. storage.Write(批量原子提交)

5. 崩溃恢复(遇到 Stale Lock):
   a. CheckTxnStatus(primaryKey, lockTS) → 确认事务状态
   b. ResolveLock(startTS, commitTS) → 批量解决所有锁

10.4 隔离级别与 Percolator 对应关系

隔离级别单机 InnoDB 实现Percolator 对应
READ UNCOMMITTED无 Read View不适用(Percolator 始终读已提交)
READ COMMITTED每次读创建 Read View理论上可通过每次读获取新 startTS 实现
REPEATABLE READ事务开始时创建 Read ViewTinyKV 实现(startTS 固定快照)
SERIALIZABLE2PL + Next-Key Lock需要 SSI 或显式锁(Percolator 本身不支持)

TinyKV 的实现达到快照隔离(SI),近似于可重复读(RR),但不等同于 SQL 标准的 SERIALIZABLE。

10.5 参考资料

  1. Percolator 论文:Google, “Large-scale Incremental Processing Using Distributed Transactions and Notifications”, OSDI 2010
  2. Spanner 论文:Google, “Spanner: Google’s Globally Distributed Database”, TOCS 2013
  3. CockroachDB HLC:Cockroach Labs, “Living Without Atomic Clocks”, 2016
  4. TiKV 事务文档:https://tikv.org/deep-dive/distributed-transaction/introduction/
  5. Async Commit 设计:TiKV, “Async Commit, the Accelerator for Transaction Commit in TiKV 5.0”, 2021