一、分布式事务的挑战
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减少内存占用