事务的进化:从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的状态检查
相关推荐
JavaPub-rodert1 天前
Go 后台如何同时兼容 MySQL、PostgreSQL、SQLite 和 SQL Server?从 ShiyuAdmin 看 GORM 多数据库适配
数据库·mysql·postgresql·golang·javapub·王仕宇
爱吃火鸡面呀1 天前
MySQL 查询与函数详解:从基础条件筛选到高级字符处理
数据库·mysql
知行产研1 天前
中国矿山无人驾驶出海的三重门
数据库·mysql·php
爱和冰阔落1 天前
【MySQL 慢查询排查实战】列表接口逐渐变慢时,怎样从请求链路定位原因
android·数据库·mysql
Dxy12393102161 天前
MySQL有哪些锁?InnoDB锁体系完整梳理博客
数据库·mysql
wear工程师1 天前
MySQL 索引条件下推是什么?Using index condition 并不是覆盖索引
sql·mysql
天涯明月19931 天前
ray深度研究报告
大数据·人工智能·分布式·ray
方便面不加香菜1 天前
MySQL 复合查询
数据库·mysql
花花鱼1 天前
MySQL Illegal mix of collations 全解|字符集与排序规则冲突原理、分层排错、通用解决方案(适配5.7/8.0迁移)
数据库·mysql
Lyyaoo.2 天前
Spring Boot Validation 声明式参数校验
spring boot·后端·mysql