TiDB事务机制深入学习理解

一、分布式事务的挑战

1.1 单机事务 vs 分布式事务

单机数据库的事务实现相对直接:

单机事务流程:
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  ← 本地写
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  ← 本地写
COMMIT;  ← 本地提交,原子性由本地存储引擎保证

分布式数据库的事务则复杂得多:

分布式事务流程:
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  ← 可能在 TiKV Node A
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  ← 可能在 TiKV Node B
COMMIT;  ← 如何保证 A 和 B 要么都提交,要么都回滚?

核心问题:多个节点上的操作要么全部成功,要么全部失败,不能出现一部分提交一部分没提交的情况

1.2 分布式事务的关键概念

概念 说明
全局时间戳 保证事务的先后顺序在分布式环境中一致
两阶段提交 保证跨节点操作的原子性
隔离级别 控制并发事务之间的可见性
写冲突 两个事务同时修改同一行时的处理方式

二、TiDB 的事务隔离级别

2.1 支持的隔离级别

TiDB 支持以下隔离级别:

隔离级别 脏读 不可重复读 幻读 支持情况
读未提交 (Read Uncommitted) 可能 可能 可能 不支持
读已提交 (Read Committed) 不会 可能 可能 支持
可重复读 (Repeatable Read) 不会 不会 不会 默认
串行化 (Serializable) 不会 不会 不会 支持

2.2 验证隔离级别

-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 输出: REPEATABLE-READ

-- 设置隔离级别
SET SESSION transaction_isolation = 'READ-COMMITTED';

2.3 可重复读的工作原理

TiDB 使用 快照隔离(Snapshot Isolation) 实现可重复读:

时间线:

T1 开始 (start_ts=100)           T2 开始 (start_ts=200)
  │                                 │
  │ 读取: balance = 1000             │
  │  (看到 TS=100 时的快照)           │ 读取: balance = 1000
  │                                 │  (看到 TS=200 时的快照)
  │                                 │
  │ 修改: balance = 900              │
  │ 提交 (commit_ts=300)             │ 修改: balance = 1100
  │                                 │ 提交时会检测到冲突!
  v                                 v

T2 提交时,会发现它修改的数据已经被 T1 修改过(版本已更新),此时会触发冲突处理。


三、乐观事务

3.1 原理

乐观事务假设冲突很少发生,因此在执行阶段不加锁,只在提交时检查冲突:

乐观事务流程:

BEGIN;  ← 获取 start_ts
  │
  v
读取数据  ← 不加锁,直接读快照
  │
  v
修改数据  ← 在内存中标记修改
  │
  v
COMMIT;  ← 关键步骤:
  │       1. 检查是否有其他事务修改了相同数据
  │       2. 如果有冲突 → 回滚,返回错误
  │       3. 如果无冲突 → 两阶段提交
  v
成功 or 失败 (Write Conflict)

3.2 写冲突错误

-- 设置事务模式为乐观(默认)
SET SESSION tidb_txn_mode = 'optimistic';

-- 模拟冲突
-- 事务 A:
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- 成功

-- 事务 B(几乎同时执行):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 也读到 1000
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
COMMIT;
-- ERROR 9007 (HY000): Write conflict, ...

应用需要处理这个冲突:

# Python 示例: 乐观事务重试
import pymysql
import time

def transfer_with_retry(conn, from_id, to_id, amount, max_retries=5):
    for retry in range(max_retries):
        cursor = conn.cursor()
        try:
            cursor.execute("BEGIN")
            cursor.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (from_id,))
            balance = cursor.fetchone()[0]
            if balance < amount:
                cursor.execute("ROLLBACK")
                return False, "余额不足"

            cursor.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, from_id))
            cursor.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, to_id))
            cursor.execute("COMMIT")
            return True, "转账成功"
        except pymysql.err.OperationalError as e:
            cursor.execute("ROLLBACK")
            if "Write conflict" in str(e):
                time.sleep(0.01 * (2 ** retry))  # 指数退避
                continue
            raise
    return False, "事务冲突过多,请稍后重试"

3.3 乐观事务的适用场景

适合 不适合
读多写少 写冲突频繁的场景
冲突概率低 热点行的频繁更新
批量数据处理 高并发扣库存

四、悲观事务

4.1 原理

悲观事务假设冲突经常发生,因此在读取时就加锁,阻止其他事务修改:

悲观事务流程:

BEGIN;  ← 获取 start_ts
  │
  v
SELECT ... FOR UPDATE  ← 加锁!阻止其他事务修改
  │
  v
修改数据
  │
  v
COMMIT;  ← 释放锁,提交修改
  │
  v
成功

4.2 使用方式

-- 设置事务模式为悲观
SET SESSION tidb_txn_mode = 'pessimistic';

-- 或使用语句级指定
BEGIN OPTIMISTIC;  -- 当前事务用乐观模式
BEGIN PESSIMISTIC;  -- 当前事务用悲观模式

4.3 悲观锁演示

-- 事务 A:
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 加锁
-- 输出: balance = 1000

-- 事务 B(同时执行):
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 等待...  ← 被事务 A 的锁阻塞

-- 事务 A:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- 事务 B(此时才能继续):
-- 现在读到了: balance = 900
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
COMMIT;
-- 成功,balance = 700

关键区别:悲观事务不会产生 Write Conflict 错误,因为锁阻止了并发修改。

4.4 死锁处理

悲观锁可能产生死锁:

事务 A: 锁了行 1,等待行 2
事务 B: 锁了行 2,等待行 1
→ 死锁!

TiDB 会自动检测死锁并回滚其中一个事务:

-- 查看死锁信息
SELECT * FROM information_schema.data_lock_waits;

4.5 悲观事务的适用场景

适合 不适合
写冲突频繁 读多写少且冲突少
热点行更新 大规模批量处理
业务逻辑要求强顺序 需要极高吞吐的场景

五、两种事务模式的对比

                  乐观事务                      悲观事务
                ┌──────────┐                  ┌──────────┐
                │ 不加锁    │                  │ 读时加锁  │
  执行阶段       │ 直接读快照 │                  │ FOR UPDATE│
                └──────────┘                  └──────────┘

                ┌──────────┐                  ┌──────────┐
                │ 检查冲突  │                  │ 已通过锁  │
  提交阶段       │ 冲突则回滚 │                  │ 保证无冲突 │
                └──────────┘                  └──────────┘

                ┌──────────┐                  ┌──────────┐
                │ 无锁开销  │                  │ 锁开销    │
  性能特点       │ 冲突多时重试│                  │ 可能死锁  │
                │ 吞吐更高   │                  │ 更安全    │
                └──────────┘                  └──────────┘
维度 乐观事务 悲观事务
锁机制 不加锁 SELECT ... FOR UPDATE 加锁
冲突处理 提交时检测,冲突则回滚 读时加锁,天然避免冲突
性能 冲突少时更高 锁有开销,但避免了重试
适用场景 冲突概率低 冲突概率高
死锁 不会产生 可能产生(自动检测处理)
兼容性 与 MySQL 乐观行为不完全一致 与 MySQL InnoDB 一致

如何选择

是否需要 FOR UPDATE 语义?
├── 是 → 使用悲观事务
└── 否 → 写冲突是否频繁?
    ├── 是 → 使用悲观事务
    └── 否 → 使用乐观事务(默认,性能更好)

TiDB 默认使用乐观事务,可以通过以下配置切换默认模式:

-- 全局修改默认事务模式
SET GLOBAL tidb_txn_mode = 'pessimistic';

六、大事务处理

6.1 什么是大事务

大事务是指涉及大量数据修改的事务,会带来以下问题:

  • 占用内存多(MVCC 需要保存多个版本)
  • 持锁时间长(悲观事务中阻塞其他事务)
  • 回滚代价大

6.2 TiDB 的事务限制

限制
单事务最大写入行数 默认 30 万行
单事务最大写入大小 约 100MB
事务 TTL 默认 1 小时

6.3 大事务的处理方法

方法一:分批提交

-- 不适合:一次性更新大量数据
UPDATE orders SET status = 1 WHERE status = 0 AND created_at < '2024-01-01';

-- 适合:分批处理
-- 第 1 批
UPDATE orders SET status = 1
    WHERE status = 0 AND created_at < '2024-01-01'
    LIMIT 5000;

-- 第 2 批
-- ...(循环执行直到影响行数为 0)

方法二:使用后台任务

-- TiDB 支持后台任务处理大批量操作
-- 通过设置 tidb_enable_batch_dml 启用(GLOBAL 变量)
SET GLOBAL tidb_enable_batch_dml = ON;

七、事务隔离的实践

7.1 转账业务示例

-- 创建账户表
CREATE TABLE accounts (
    id BIGINT PRIMARY KEY,
    balance DECIMAL(12, 2) NOT NULL DEFAULT 0,
    UNIQUE KEY uk_id (id)
);

INSERT INTO accounts VALUES (1, 10000.00), (2, 5000.00);

-- 转账存储过程(使用悲观事务)
-- 注意:DELIMITER 是 mysql 客户端命令,不是 SQL 语句。
-- 在 mysql CLI 中执行时需要先用 DELIMITER 改分隔符,再创建存储过程。
DELIMITER //
CREATE PROCEDURE transfer(
    IN from_id BIGINT,
    IN to_id BIGINT,
    IN amount DECIMAL(12,2),
    OUT result VARCHAR(50)
)
BEGIN
    DECLARE from_balance DECIMAL(12,2);

    -- 使用悲观事务
    SET SESSION tidb_txn_mode = 'pessimistic';

    START TRANSACTION;

    -- 锁定并检查余额
    SELECT balance INTO from_balance
    FROM accounts WHERE id = from_id FOR UPDATE;

    IF from_balance < amount THEN
        ROLLBACK;
        SET result = 'INSUFFICIENT_BALANCE';
    ELSE
        UPDATE accounts SET balance = balance - amount WHERE id = from_id;
        UPDATE accounts SET balance = balance + amount WHERE id = to_id;
        COMMIT;
        SET result = 'SUCCESS';
    END IF;
END //
DELIMITER ;

-- 调用转账
CALL transfer(1, 2, 500, @result);
SELECT @result;  -- 'SUCCESS'

-- 验证余额
SELECT * FROM accounts;

7.2 库存扣减示例(高并发场景)

CREATE TABLE inventory (
    product_id BIGINT PRIMARY KEY,
    stock INT NOT NULL DEFAULT 0,
    version INT NOT NULL DEFAULT 0  -- 乐观锁版本号
);

INSERT INTO inventory VALUES (100, 1000, 0);

-- 方式一:乐观锁(CAS)
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 100 AND stock > 0;

-- 如果影响行数 = 0,说明库存不足或被其他事务修改了

-- 方式二:悲观锁
BEGIN;
SELECT stock FROM inventory WHERE product_id = 100 FOR UPDATE;
-- 检查库存...
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
COMMIT;

7.3 查看事务信息

-- 查看当前活跃事务
SELECT * FROM information_schema.tidb_trx;

-- 查看正在执行的事务
SELECT * FROM information_schema.processlist WHERE COMMAND != 'Sleep';

-- 查看锁等待信息
SELECT * FROM information_schema.data_lock_waits;

八、避坑指南

坑点 1:乐观事务的 Write Conflict

现象:应用偶尔报 Write Conflict 错误。

原因:两个事务同时修改了同一行。

解决

  • 应用层增加重试逻辑
  • 如果冲突频繁,改用悲观事务

坑点 2:悲观事务的锁超时

现象SELECT ... FOR UPDATE 长时间阻塞。

原因:持有锁的事务未提交或异常中断。

解决

-- 设置锁等待超时时间(秒)
SET SESSION innodb_lock_wait_timeout = 50;  -- 50 秒

注意:TiDB 兼容 MySQL 的 innodb_lock_wait_timeout 变量,单位为秒。TiDB 没有独立的 tidb_lock_wait_timeout 变量。

坑点 3:DDL 与事务冲突

现象:DDL 执行时报错或阻塞。

原因:DDL 需要获取元数据锁,与运行中的事务冲突。

解决

  • 在低峰期执行 DDL
  • 使用 ADMIN CANCEL DDL 取消长时间阻塞的 DDL

坑点 4:大事务内存溢出

现象:TiDB Server OOM。

原因:单事务写入数据量过大。

解决

  • 分批提交
  • 调整 tidb_mem_quota_query 限制单查询内存
  • 启用 tidb_enable_chunk_rpc 减少内存占用

感谢分享

谢谢分享

这个总结的到位,可以作为理论提高用

总结到位,谢谢分享,已收藏

写得好详细

学习学习

写的很好 赞

赞一个。写的很详细

楼主厉害,尤其是悲观锁,乐观锁这块,收藏了

总结到位,谢谢分享,已收藏

学到了

悲观锁和乐观锁一直的理解就是事务操作结果可以被其他事务看到的时机