事务的进化:从MySQL单机事务到TiDB分布式事务的探究

1 事务为何重要?

1.1 从银行转账看事务的ACID

想象一个简单的银行转账场景:小明从自己账户向小红账户转账100元,数据库需要两个操作:

sql 复制代码
-- 操作1:从小明账户扣除100元
UPDATE accounts SET balance = balance - 100 WHERE user_id = '小明';

-- 操作2:向小红账户增加100元  
UPDATE accounts SET balance = balance + 100 WHERE user_id = '小红';

如果这两个操作不能保证原子性,会发生什么?加入操作1成功操作2失败,那么小明将无缘无故失去100元。这就是事务要解决的核心问题

事务必须满足ACID特性:

  • A:原子性。转账两个操作要么全部成功,要么全部失败,不能只执行一半。
  • C:一致性。转账前后,系统总金额保持不变(小明+小红的余额总和不变)。
  • I:隔离性。转账过程中,其他用户查询这两个账户时,不应该看到"小明已扣款但小红未到账"的中间状态。
  • D:持久性。一旦转账成功,即使数据库服务器立即断电,重启后转账结果也必须保持。

事务是关系型数据库的基石,它保证了即使在复杂并发和异常情况下,数据依然可靠、准确。

1.2 单机时代:MySQL的辉煌与局限

MySQL自1995年诞生以来,凭借其开源、易用、高性能的特点,成为互联网时代最流行的关系型数据库之一。其InnoDB存储引擎提供了完整的事务支持,满足了绝大多数应用的需求。

在单机架构下,MySQL通过精巧的设计实现了高效的事务:

  • 通过缓冲池减少磁盘IO
  • 通过行级锁实现高并发
  • 通过Redolog/Undolog日志保证数据安全
  • 通过MVCC机制提供非阻塞读

然而随着互联网的高速发展,单机MySQL逐渐也面临挑战:

  1. 容量瓶颈:单机存储有限,无法存储海量数据
  2. 性能瓶颈:单CPU/内存/磁盘IO存在上限
  3. 可用性低:单点故障导致服务完全中断
  4. 扩展困难:垂直扩展成本高且有限

面对这些问题,业界最初采用分库分表的方案,但这又带来了新的复杂性:跨库事务难以保证、跨表查询复杂、数据迁移困难等。

1.3 云原生时代:分布式数据库崛起

随着云计算和微服务架构的普及,催生了新一代的分布式数据库。这类数据库从设计之初就面向分布式环境,具有水平扩展、高可用、强一致性的特点。TiDB是云原生分布式数据库的代表之一,它采用计算与存储分离的架构(详细可以看这篇博客):纸上得来终觉浅?从 0 到 1 实现分布式 KV 后,我才读懂了 TiDB 的设计-CSDN博客

TiDB的核心理念就是"让分布式数据库用起来像单机数据库一样简单",它兼容MySQL协议,使得迁移成本大大降低。但是更重要的是,它解决了分布式核心下的难题:如何保证数据一致性、如何实现跨节点事务、如何自动扩容缩容等。

1.4 本文导读:我们探究什么?

本文将深入探讨事务在单机与分布式环境下的实现:

  1. 深入理解InnoDB,理解单机事务如何通过精巧设计实现ACID
  2. 剖析TiDB的Percolator模型,掌握分布式事务如何协调多个节点
  3. 对比两种实现,理解各自的使用场景和取舍
  4. 探究技术演进,从单机到分布式的设计哲学变换

无论你是数据库开发者、系统架构师,还是对数据库原理感兴趣的工程师,相信这篇文章都能帮助你建立完整的事务知识体系。

接下来,让我们从最熟悉的MySQL开始,看看单机事务是如何实现的。

2 单机事务的经典实现------以Mysql InnoDB为例

2.1 核心架构

在单机数据库中,所有组件都在同一台服务器的掌控之下,这种"**集中式"**架构带来了极大的简化。让我们看看MySQL InnoDB的核心组件如何协同工作:

  • 缓冲池:缓存磁盘的数据页,避免每次读取都访问磁盘。当事务修改数据时,实际上修改的是缓冲池中的页,Innodb会通过检查点机制定期将脏页刷回磁盘
  • 锁机制:管理所有的行级锁。在内存中,Innodb为每个被锁定的行维护一个锁结构,包含事务ID、锁类型等信息。当多个事务竞争同一行时,锁系统负责协调
  • 日志系统:是崩溃恢复的保障。Redo Log记录了对数据的物理修改,用于重做;Undo Log记录了修改前的数据镜像,用于回滚和MVCC。

这种集中式架构的优势显而易见,所有组件共享内存,通讯成本低;锁管理和事务协调都在本地完成,没有网络开销。但是这也注定了它扩展边界------单机硬件性能上限。

2.2 MVCC:多版本并发控制魔法

2.2.1 版本号从何而来?------事务ID的生成

在InnoDB中,每个事务都有一个唯一的事务ID(TRX_ID),这是一个自增的64位整数。当事务首次执行写操作时,InnoDB会为它分配一个TRX_ID(隐藏字段)。

cpp 复制代码
// 简化的TRX_ID分配
uint64_t assign_trx_id() {
    static std::atomic<uint64_t> next_trx_id(1);
    return next_trx_id.fetch_add(1, std::memory_order_relaxed);
}

这个事务ID有两个重要作用:标识事务的开始顺序,以及作为数据版本的标签。

2.2.2 Undo Log:构建数据的时光机

Undo Log是MVCC的基石。当一行数据被修改时,InnoDB会保存修改前的值到Undo Log中,形成一个版本链。让我们看一个具体例子:

假设有一行用户余额数据,初始值为100元,经过三个事务修改:

sql 复制代码
-- 事务1 (TRX_ID=100): 余额增加50元
UPDATE accounts SET balance = 150 WHERE user_id = 1;

-- 事务2 (TRX_ID=200): 余额减少30元  
UPDATE accounts SET balance = 120 WHERE user_id = 1;

-- 事务3 (TRX_ID=300): 余额增加80元
UPDATE accounts SET balance = 200 WHERE user_id = 1;

那么对应生成的Undolog版本链如下所示:

sql 复制代码
当前版本: 200 (TRX_ID=300)
           ↑
Undo指针指向: 120 (TRX_ID=200)
                ↑
      Undo指针指向: 150 (TRX_ID=100)
                       ↑
            Undo指针指向: 100 (初始值)

2.2.3 ReadView:定义"我能看到什么"

  • 当前读:读到的是最新的数据记录,读取时还要保证其他并发事务不能修改当前记录,会对读取记录加锁。如:select ... lock in share mode(共享锁),select ... for update、update、delete(排他锁)
  • 快照读:简单的select(不加锁)就是快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读
cpp 复制代码
struct ReadView {
    // 活跃事务列表:当前未提交的事务ID集合
    std::set<trx_id_t> active_trx_ids;
    
    // 最小活跃事务ID
    trx_id_t min_trx_id;
    
    // 下一个将要分配的事务ID
    trx_id_t next_trx_id;
};

可见性判断很简单:

  1. 如果版本TRX_ID < min_trx_id,说明在事务开始前已经提交,可见
  2. 如果版本TRX_ID > (next_trx_id)max_trx_id,说明在事务开始后才开始,不可见
  3. 如果TRX_ID在活跃事务列表中,说明还未提交,不可见
  4. 否则,可见

2.3 锁机制:并发的守护者

MVCC机制主要解决读冲突问题,但是写冲突还需要锁来解决

2.3.1 行锁、间隙锁、Next-Key Lock

假设有一个用户表:

sql 复制代码
CREATE TABLE users (
    id INT PRIMARY KEY,
    age INT,
    INDEX idx_age (age)
);

INSERT INTO users VALUES (1, 20), (2, 25), (3, 30), (5, 35), (7, 40);
  • 记录锁 :锁住具体的行,如SELECT * FROM users WHERE id = 3 FOR UPDATE
  • 间隙锁 :锁住一个区间但不包括记录,如SELECT * FROM users WHERE age = 28 FOR UPDATE会锁住(25, 30)这个区间
  • Next-Key Lock :记录锁+间隙锁,如SELECT * FROM users WHERE age = 30 FOR UPDATE会锁住(25, 30]这个区间

2.3.2 两阶段锁协议(2PL)

Innodb严格遵守两阶段锁协议:

  1. 增长阶段:事务可以获取锁,但不能释放锁
  2. 缩减阶段:事务可以释放锁,但不能获取新锁

这意味着,即使一个行锁在事务后期不再被需要,也必须等到事务结束时才释放。这是为了保证可串行化调度

2.3.3 死锁检测与处理

考虑两个事务互相等待的场景:

sql 复制代码
-- 事务1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 获取id=1的锁
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 等待id=2的锁

-- 事务2
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 2; -- 获取id=2的锁
UPDATE accounts SET balance = balance + 50 WHERE user_id = 1; -- 等待id=1的锁

此时发送死锁。InnoDB的死锁检测机制会定期扫描锁等待图,发现环后选择回滚代价较小的事务(通常是修改行数较少的事务)

2.4 崩溃恢复:永不丢失

2.4.1 Redo Log:重做的艺术

Redo Log记录的是物理日志,格式如下:

sql 复制代码
[LSN: 1001] [PageID: 0x0012] [Offset: 64] [Old: 0x00] [New: 0xFF]

这表示"在日志序列号1001,将第0x0012页的第64字节从0x00改为0xFF"。

为什么使用物理日志?

因为物理日志恢复速度快,只需重放即可。而逻辑日志(如"UPDATE accounts SET balance=200")需要重新解析执行,且可能因索引变化而失败

2.4.2 Binlog与两阶段提交

为了保证Redo Log和Binlog的一致性,MySQL使用两阶段提交

  1. 准备阶段:Innodb将事务写入RedoLog,标记为PREPARE状态
  2. 提交阶段:Server将事务写入Binlog,然后Innodb将RedoLog标记为COMMIT

如果崩溃发生在阶段1,事务会回滚;如果发生在阶段2,重启后会检查Binlog,已记录的事务会重做。

2.4.3 崩溃后的自动恢复流程

cpp 复制代码
void crash_recovery() {
    // 阶段1:前滚(Redo)
    redo_from_checkpoint();
    
    // 阶段2:回滚(Undo) 
    rollback_uncommitted_trxs();
    
    // 清理
    purge_old_undo_logs();
}

2.5 单机事务的局限

尽管InnoDB的设计十分精巧,但单机架构的本质局限无法回避:

局限 表现 影响
扩展性 只能垂直扩展(更强CPU/更大内存/更快磁盘) 成本指数增长,存在物理上限
可用性 主从复制有延迟,故障切换丢数据 RPO>0,不满足金融级要求
容灾 同城灾备复杂,异地多活几乎不可能 灾难恢复能力有限
架构 计算存储耦合,资源无法独立扩展 存储或计算单方面瓶颈影响整体

这些局限在数据量不大、并发不高的场景下不明显,但随着互联网应用的发展,单机数据库越来越力不从心。这直接推动了分布式数据库的发展。

在下一章,我们将看到TiDB如何通过全新的架构设计,解决这些挑战。

3 迈向分布式 ------ TiDB的架构革新

3.1 TiDB整体架构

当我们从单机数据库走向分布式数据库,最大的变化是从"集中控制"变为"协同工作"。TiDB采用典型的分布式架构,将计算、存储、调度三大功能分离,每个部分都可以独立扩展。

复制代码
// TiDB的简化架构示意
type TiDBCluster struct {
    // 计算层 - 无状态,负责SQL解析、优化、执行
    TiDBServers []*TiDBServer  // 可水平扩展
    
    // 调度层 - 轻量级,负责元信息管理和时间戳分配
    PD *PlacementDriver       // 通常3-5个节点实现高可用
    
    // 存储层 - 有状态,负责数据存储和分布式事务
    TiKVStores []*TiKVStore  // 可水平扩展,数据自动分片
}

计算层:TiDB Server

  • 无状态设计:每个TiDB节点不存储数据,可以任意增加和减少
  • SQL网关:接收MySQL协议连接,解析SQL,生成执行
  • 分布式执行器:将查询计划拆分成多个任务,下发到存储层并行执行
  • 事务协调者:协调分布式事务的两阶段提交

存储层:TiKV

  • 分布式Key-Value存储:数据以Region为单位分片存储
  • Raft共识协议:每个Region在3-5个副本间通过Raft保持一致性
  • MVCC+Percolator:基于Google Perocolator模型实现分布式事务
  • 协处理器:在存储层进行计算,减少网络传输

调度层:PD(Placement Driver)

  • 集群大脑:管理元数据,监控节点状态
  • 时间戳分配器:提供全局单调递增的时间戳(TSO)
  • 负载均衡调度:自动迁移Region,平衡集群负载
  • 故障检测与恢复:自动处理节点故障,保证高可用

3.2 数据如何分片:Region与Raft Group

TiDB将数据划分为连续的key区间,每个区间称为一个Region,默认大小约96MB-144MB

复制代码
整个Key空间:
┌─────────────────────────────────────────────────────────────┐
│  [a, b)  │  [b, d)  │  [d, g)  │  [g, m)  │  [m, z)  │ ...  │
│ Region1  │ Region2  │ Region3  │ Region4  │ Region5  │      │
└─────────────────────────────────────────────────────────────┘
    ↑           ↑           ↑           ↑           ↑
  副本组      副本组      副本组      副本组      副本组
  (Raft)     (Raft)     (Raft)     (Raft)     (Raft)

Region特点:

  1. 每个Region在物理上是一个Raft Group,有3-5个副本
  2. Region是数据迁移和负载均衡的最小单位
  3. 随着数据增加,Region会自动分裂
  4. 副本可以分布在不同的机架、机房,实现容灾

3.3 分布式事务四大挑战

在单机数据库中,事务的ACID特性可以通过集中控制来实现。但在分布式环境中,数据分散在不同的节点中上,这带来了前所未有的挑战:

挑战1:时钟同步

在单机中,我们可以用单调递增的计数器作为版本号。但是在分布式系统中,各个节点的本地时钟都存在误差,无法保证全局有序。

TiDB解决方案:通过PD提供全局单调递增的时间戳(TSO),所有节点的时间都向"中央时间"对齐

挑战2:原子提交

单机中,原子提交可以通过本地日志(undolog,redolog)实现。但分布式事务设计多个节点,如何确保所有节点要么全部提交,要么全部回滚

**TiDB的解决方案:**采用Percolator模型的两阶段提交协议,通过Primary Key决定事务最终状态

挑战3:故障处理

在单机中,故障恢复相对简单。但在分布式系统中,任何节点都可能在任何时刻故障,且故障检测需要时间。

TiDB的解决方案:

  • 通过Raft保证数据多副本一致
  • 通过Lease机制检测节点故障
  • 通过Primary Key状态决定未完成事务的最终结果

挑战4:性能开销

分布式事务涉及多次网络通信,延迟远高于本地内存访问。

TiDB的解决方案:

  • 异步提交:减少客户端等待时间
  • 1PC优化:单Region事务跳过2PC
  • 批量处理:合并小请求,减少RPC数量

3.4 MySQL集群是否是一种分布式架构

**MySQL原生的集群方案通常不被认为是真正的分布式架构,而是一种高可用或读写分离架构。**​ 但通过中间件实现的"分库分表"可以构建分布式系统。

3.4.1 MySQL主从复制(Replication)------ 非分布式

这是最常见的MySQL集群方式

  • 工作原理:主节点(Master)处理所有写请求,并将数据变更以binlog形式同步给其他从节点
  • 为什么不是分布式 :所有节点存储完全相同的数据全集 ,没有数据分片。写能力无法扩展(单点写入),存储容量受单节点限制。它的主要目标是读写分离高可用,而非分布式扩展。

3.4.2 MySQL NDB Cluster ------ 是分布式,但有局限

这是MySQL官方提供的真正分布式内存数据库集群

  • 工作原理:数据自动分片(Partition)存储在多个数据节点。应用通过SQL节点(mysqld进程)访问,SQL节点是无状态的。管理节点负载配置
  • 分布式特性:支持数据节点的ACID事务,写能力可水平扩展
  • 局限:传统上基于内存,对磁盘支持不如InnoDB成熟;对复杂SQL(如多表JOIN)的支持和优化不如传统的InnoDB引擎。它更像一个专用的分布式键值存储附加了SQL接口。

3.4.3 MySQL Group Replication / InnoDB Cluster ------ 高可用集群,非分布式

这是MySQL 5.7/8.0后推出的现代高可用方案。

  • 工作原理:基于Paxos协议实现多主或单主同步复制。所有节点存储全量数据,通过组通信保证数据强一致。
  • 为什么不是分布式 :核心目标是高可用和自动故障切换。虽然写请求可以在多个主节点上发起(多主模式),但本质上它们都在竞争修改同一份完整的数据,容易冲突,且存储容量和写性能仍受单节点限制。

3.4.4 分库分表 + 中间件 ------ 应用层实现的分布式

这是业界应对海量数据的常见方案,在应用层构建了分布式系统

  • 工作原理 :使用MyCAT、ShardingSphere等中间件,将一张大表的数据按规则(如用户ID取模)拆分到后端多个独立的MySQL实例中。
  • 分布式特性:具备了数据分片、写扩展、存储扩展的能力。
  • 巨大缺点对应用不透明 ,跨分片查询(JOIN、排序、聚合)极其复杂且性能低下;跨分片事务难以实现,通常无法保证强一致性,或者性能代价极高。运维复杂度(数据迁移、扩容)也很大。

4 分布式事务的经典方案------TiDB的Percolator模型详解

4.1 Percolator模型设计哲学

Percolator的核心思想可以用一句话来总结:将事务状态编码到数据本身,而非集中存储

在传统的两阶段提交(2PC)中,需要一个中心化的协调者(Coordinator)来记录事务状态。这个协调者成为单点瓶颈和故障点。Percolator通过巧妙的设计,将事务状态分散存储在参与事务的数据行中。

关键洞察:

  1. 事务状态分散化:不再需要中心化的事务管理器
  2. Primary Key决定事务的命运:选择其中一个key作为事务状态"代言人"
  3. 客户端作为协调者:事务的发起者协调整个提交过程
  4. 时间戳作为全局时钟:保证整个集群的事件有序

4.2 全局时钟:分布式世界的时间法则

在分布式系统中,如何确定事件先后顺序?Percolator的答案是:引入一个全局单调递增的时间戳

TSO (Timestamp Oracle) 的工作原理

cpp 复制代码
// 简化的TSO服务实现
type TSO struct {
    mu        sync.Mutex
    lastTS    int64
    leaseTime time.Time
}

// 获取时间戳
func (t *TSO) GetTimestamp() (int64, error) {
    t.mu.Lock()
    defer t.mu.Unlock()
    
    now := time.Now()
    if now.After(t.leaseTime) {
        // 续租逻辑
        t.leaseTime = now.Add(10 * time.Second)
    }
    
    t.lastTS++
    return t.lastTS, nil
}

TSO特点:

  • 全局唯一且单调递增
  • 高可用部署
  • 批量分配时间戳,减少网络请求
  • 物理时钟+逻辑时钟结合,保证性能

4.3 两阶段提交的分布式实现

让我们深入Percolator的两阶段提交流程。我们将通过一个具体的转账例子来说明。

场景设定

假设有两个银行账户:

  • 账户A:余额100元,存储在Region1
  • 账户B:余额50元,存储在Region2

转账事务:从A向B转账20元,使A余额变为80,B余额变为70。

第一阶段:客户端准备

Go 复制代码
// 客户端开始事务
tx := &Transaction{
    startTS:  TSO.GetTimestamp(),  // 假设得到 start_ts = 100
    writes: map[string][]byte{
        "account_A": []byte("80"),
        "account_B": []byte("70"),
    },
}
// 随机选择Primary Key
tx.primary = "account_A"

第二阶段:Prewrite(预写)

Prewrite阶段的目标是:锁定所有要修改的key,并写入新数据。

  • 对Primary Key (account_A)的操作:
sql 复制代码
-- 在Region1上执行
BEGIN TRANSACTION WITH start_ts = 100

-- 1. 检查冲突:account_A是否被其他事务锁定?
-- 2. 写入锁到Lock CF
INSERT INTO LockCF (key, value) 
VALUES ('account_A', 'lock_info: {primary: account_A, start_ts: 100}')

-- 3. 写入数据到Data CF
INSERT INTO DataCF (key, value)
VALUES ('account_A_100', '80')  -- 格式: key_start_ts
  • 对Secondary Key (account_B)的操作:
sql 复制代码
-- 在Region2上执行
BEGIN TRANSACTION WITH start_ts = 100

-- 1. 检查冲突:account_B是否被其他事务锁定?
-- 2. 写入锁到Lock CF
INSERT INTO LockCF (key, value)
VALUES ('account_B', 'lock_info: {primary: account_A, start_ts: 100}')

-- 3. 写入数据到Data CF
INSERT INTO DataCF (key, value)
VALUES ('account_B_100', '70')

注意:Secondary key的锁中记录了primary key的信息,这是故障恢复的关键

Prewrite成功条件:

  • 所有key都没有被其他事务锁定
  • 所有key的已提交版本都早于start_ts(防止写倾斜)

第三阶段:Commite(提交)

如果Prewrite全部成功,客户端获取commit_ts(假设为101),然后提交。

  • 首先提交Primary key:
sql 复制代码
-- 在Region1上执行
-- 1. 检查Primary Key的锁是否仍然存在(防止过期)
-- 2. 写入提交记录到Write CF
INSERT INTO WriteCF (key, value)
VALUES ('account_A_101', '100')  -- 格式: key_commit_ts, 指向start_ts=100的数据

-- 3. 删除Lock CF中的锁
DELETE FROM LockCF WHERE key = 'account_A'

关键点:一旦Primary key提交记录写入成功,事务在外部看来就已经提交了。即使此时客户端崩溃,事务的结果也确定了

  • 然后提交Secondary keys:
sql 复制代码
-- 在Region2上执行
-- 1. 检查Secondary Key的锁是否仍然存在
-- 2. 写入提交记录到Write CF
INSERT INTO WriteCF (key, value)
VALUES ('account_B_101', '100')

-- 3. 删除Lock CF中的锁
DELETE FROM LockCF WHERE key = 'account_B'

Secondary keys的提交可以异步进行,即使客户端在提交完Primary后崩溃,其他事务或后台线程会帮助完成Secondary keys的提交。

4.4 读操作与快照隔离

读操作如何看到一致性快照?这依赖于MVCC和时间戳机制。

Go 复制代码
// 读操作的简化流程
func Read(key []byte, startTS int64) ([]byte, error) {
    // 1. 检查key是否被锁住
    lock := GetLock(key)
    if lock != nil && lock.startTS < startTS {
        // 被较早的事务锁住,等待或abort
        return WaitOrAbort(lock)
    }
    
    // 2. 查找Write CF,找到commit_ts <= startTS的最大commit_ts
    commitTS := FindLatestWriteCF(key, startTS)
    if commitTS == 0 {
        return nil, errors.New("key not found")
    }
    
    // 3. 从Write CF获取start_ts
    startTSOfData := GetStartTSFromWriteCF(key, commitTS)
    
    // 4. 从Data CF读取数据
    return GetDataCF(key, startTSOfData)
}

示例,假设在时间戳120时读取account_A:

  1. 查找WriteCF中key为account_A,commit_ts < 120的最大commit_ts
  2. 找到account_A_101 -> 100(表示在101提交的数据,其start_ts是100)
  3. 从Data CF读取account_A_100,得到数据为80

这就是快照隔离:每个事务看到的是事务开始时的数据快照,无论其他事务如何并发修改

4.5 故障恢复:系统自愈能力

Percolator的精妙之处在于其优雅的故障处理机制。由于没有中心化的事务管理器,故障恢复是通过数据本身完成的。

场景一:客户端在Prewrite阶段崩溃

Go 复制代码
事务T1: start_ts=100
Prewrite: 
  - account_A: 已写入锁和数据 ✓
  - account_B: 已写入锁和数据 ✓
Commit: 客户端在提交前崩溃 ❌

恢复过程:

  1. 事务T2尝试修改account_A,发现锁
  2. 事务T2检测锁的start_ts = 100,判断事务是否过期(通过比较当前时间与start_ts)
  3. 如果过期,T2执行回滚:清理锁和对应数据版本
  4. 如果未过期,T2等待锁释放(避免活锁)

场景二:客户端在Commit Primary后崩溃

Go 复制代码
事务T1: start_ts=100, commit_ts=101
Prewrite: 全部成功 ✓
Commit:
  - account_A: 提交成功 ✓
  - account_B: 客户端在提交前崩溃 ❌

恢复过程:

  1. 事务T2尝试读取account_B,发现锁
  2. T2查看锁信息:{primary: account_A, start_ts: 100}
  3. T3查询account_A的状态:
    1. 检查Write CF中account_A在100之后是否有提交记录
    2. 找到account_A_101 -> 100,说明事务已提交
  4. T2帮助提交account_B:写入Write记录,清理锁
  5. T2继续自己的操作

场景三:客户端在Commit Secondary时崩溃

Go 复制代码
事务T1: start_ts=100, commit_ts=101
Prewrite: 全部成功 ✓
Commit:
  - account_A: 提交成功 ✓
  - account_B: 写入Write记录后,清理锁前崩溃 ❌

恢复过程:

  1. 事务T2尝试读取account_B,发送锁
  2. 检测Write CF,发现account_B_101已存在
  3. 因此事务已提交,直接清理锁
  4. 读取account_B_100的数据

这种设计使得Percolator模型具有自我修复的能力:未完成的事务最终会被其他事务清理,系统总能达到一致性状态

4.6 TiDB的优化实现

在实际的TiDB中,Percolator模型有一些重要优化:

优化一:异步提交

Go 复制代码
// 传统Percolator
func Commit() error {
    commitTS := TSO.GetTimestamp()
    CommitPrimary()     // 同步
    CommitSecondaries() // 同步
    return nil
}

// 异步提交优化
func AsyncCommit() error {
    commitTS := TSO.GetTimestamp()
    CommitPrimary()     // 同步
    go CommitSecondaries() // 异步,不阻塞客户端
    return nil
}

优点:客户端无需等待所有Secondary keys提交完成,降低延迟。

优化二:1PC(One-Phase Commit)

如果事务只涉及单个Region,可以跳过复杂的2PC流程:

Go 复制代码
func OnePCCommit() error {
    // 单Region事务,直接写入数据
    // 无需Prewrite阶段
    WriteDataAndCommitInOnePhase()
    return nil
}

优点:减少一次RPC往返,提升性能。

优化三:批量提交

将多个key的Prewrite或Commit请求批量发送:

Go 复制代码
func BatchPrewrite(keys []string) error {
    // 将keys按Region分组
    groups := GroupByRegion(keys)
    
    // 并行发送到不同Region
    for _, group := range groups {
        go SendBatchPrewrite(group)
    }
    
    return WaitAll()
}

优点:减少网络请求数量,提升吞吐量。

4.7 与单机事务的对比总结

特性 MySQL InnoDB (单机) TiDB Percolator (分布式)
原子性 Redo/Undo Log 两阶段提交 + Primary Key
一致性 约束检查 + 日志 全局时间戳 + Raft
隔离性 MVCC + 锁 全局快照隔离
持久性 WAL Raft复制 + WAL
锁管理 内存锁表 分散在数据中
时钟 本地计数器 全局TSO
扩展性 垂直扩展 水平扩展
故障恢复 Redo/Undo回放 基于Primary Key的状态检查
相关推荐
ServBay1 小时前
不要再盲选了,PostgreSQL、MySQL与SQLite真实性能对比
数据库·mysql·sqlite
無限進步D1 小时前
MySQL 创建和管理表
数据库·mysql
Rick19933 小时前
mysql联合索引经典实例
java·数据库·mysql
独隅3 小时前
MySQL 接入不同 AI 大模型进行数据管理的全面指南(MySQL + AI)
数据库·人工智能·mysql
我是一颗柠檬5 小时前
【MySQL全面教学】MySQL备份与恢复Day14(2026年)
数据库·后端·mysql
渣渣盟5 小时前
MySQL DDL操作全解析:从入门到精通,包含索引视图分区表等全操作解析
大数据·数据库·mysql
mN9B2uk175 小时前
为mysql数据库建立索引
数据库·mysql·oracle
流星白龙5 小时前
【MySQL高阶】7.MySQL日志
数据库·mysql·adb
Irene19915 小时前
SQL示例:正确理解题意(隐藏分组键)严格SQL模式下,ORDER BY中的列必须出现在GROUP BY中或作为聚合函数
mysql