大数据-TiDB_2_事物
前言
Github:https://github.com/HealerJean
一、事务模式:
-
如果你的应用不能很好地处理提交失败,建议使用悲观事务。
- 如果你追求高并发性能且冲突较少,可以考虑乐观事务。
- 对只读分析查询,可考虑使用 因果一致性事务 降低延迟;
| 特性 | 乐观事务 | 悲观事务 |
|---|---|---|
| 默认启用 | ❌ 否 | ✅ 是 |
| 是否锁行 | ❌ 否 | ✅ 是 |
| 冲突处理 | 提交时检测冲突,可能失败 | 执行时锁定资源,避免冲突 |
| 支持惰性检查 | ✅ 是 | ❌ 否 |
| 支持因果一致性 | ❌ 否 | ❌ 否 |
| 适用场景 | 并发写入冲突少 | 高并发写入或复杂业务逻辑 |
| 锁机制 | 执行时不加锁,提交时加锁 | 执行时立即加锁 |
1、乐观事物
特点:
- 在执行DML操作时不会立即检查唯一性约束或获取锁。
- 这些检查和锁的获取是在事务提交时进行的。
- 如果在提交时检测到冲突,则事务将失败并需要重试。
- 提供了惰性检查优化,通过减少网络通信来提升性能。
适用场景:
- 当预期并发写入冲突较少时,使用乐观事务可以获得更好的性能。
- 对于读多写少的应用程序特别有效。
示例:
BEGIN OPTIMISTIC;
INSERT INTO t1 VALUES (1);
COMMIT; -- 此时才会进行唯一性检查等
2、悲观事物
特点:
- 在执行
DML操作时立即尝试获取必要的锁,并且会在操作过程中立即检查唯一性约束。 - 这种方式可以防止其他事务在同一数据上进行修改,直到当前事务完成。
- 减少了由于冲突导致的事务失败和重试的可能性。
适用场景:
- 对于高并发写入或者业务逻辑复杂的情况,悲观事务更适合。
- 应用程序能够更好地处理锁等待情况。
示例:
BEGIN; -- 默认为悲观事务
INSERT INTO t1 VALUES (1); -- 立即获取锁并检查约束
COMMIT;
二、事物操作
1、开启事务
- 在
TiDB中,BEGIN和START TRANSACTION在执行时就获取快照。 - 在
MySQL中,BEGIN和START TRANSACTION是在第一个读操作时才获取快照。
BEGIN;
START TRANSACTION;
START TRANSACTION WITH CONSISTENT SNAPSHOT; -- 等效于 MySQL 中的标准一致性快照
START TRANSACTION WITH CAUSAL CONSISTENCY ONLY; -- 因果一致性事务(需启用 Async Commit)
2、提交事务
- 成功提交后,所有修改永久生效。
COMMIT;
3、回滚事务
- 所有未提交的修改被撤销。
ROLLBACK;
4、自动提交
- 当
autocommit=1时,单条语句自动提交; - 当显式开启事务后,会禁用
autocommit,直到COMMIT或ROLLBACK。
SELECT @@autocommit; -- 查看当前 autocommit 设置
SET autocommit = 0; -- 关闭自动提交
三、隔离级别
| 隔离级别 | 脏写 (Dirty Write) | 脏读 (Dirty Read) | 不可重复读 (Fuzzy Read) | 幻读 (Phantom) |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 不可能 | ✅ 可能 | ✅ 可能 | ✅ 可能 |
| READ COMMITTED | ❌ 不可能 | ❌ 不可能 | ✅ 可能 | ✅ 可能 |
| REPEATABLE READ | ❌ 不可能 | ❌ 不可能 | ❌ 不可能 | ✅ 可能 |
| SERIALIZABLE | ❌ 不可能 | ❌ 不可能 | ❌ 不可能 | ❌ 不可能 |
1、REPEATABLE READ对比
1)与 MySQL 对比
| 特性 | MySQL RR | TiDB RR |
|---|---|---|
| 是否允许更新其他事务修改过的行 | ✅ 是(不会检查可见性) | ❌ 否(乐观事务会冲突回滚) |
| 是否加锁防止并发更新 | ❌ 否 | ✅ 是(悲观事务) |
是否使用 MVCC |
✅ 是 | ✅ 是 |
a、是否允许更新其他事务修改过的行
乐观事务模式下,
TiDB RR会拒绝更新“自己看不见的数据版本”,避免数据覆盖问题;悲观事务模式下,
TiDB表现更接近MySQL,通过加锁保证顺序执行和提交成功。
MySQL RR-
即使某一行被其他事务修改过,只要没有被加锁(如
SELECT ... FOR UPDATE),当前事务仍然可以更新这一行。 -
这意味着两个事务可以并发地更新同一行,后提交的事务会覆盖前一个事务的修改。
-
TiDB RR中的行为(乐观事务):-
如果某个事务尝试更新一行数据,而该行已经被另一个已提交的事务修改过,则事务在提交时会失败并回滚。
-
这是因为
TiDB使用了快照隔离(Snapshot Isolation, SI),每个事务都基于一个快照版本进行操作。 - 当提交时发现该行已被其他事务修改(即“版本冲突”),就会触发冲突检测机制,导致事务回滚。
b、是否加锁防止并发更新 ?
| 数据库 | 是否加锁 | 并发更新行为 |
|---|---|---|
MySQL RR |
❌ 不自动加锁 | 多个事务可同时更新同一行,最后提交者胜出 |
TiDB RR(悲观事务) |
✅ 自动加锁 | 后发起的事务必须等待前一个事务释放锁 |
c、是否使用 MVCC?
- ·
MySQL RR下的写操作不检查MVCC版本; TiDB RR下的写操作(尤其是乐观事务)会严格检查MVCC版本,防止冲突。
| 数据库 | MVCC 支持情况 |
|---|---|
| MySQL RR | ✅ 支持,MVCC 用于实现一致性读 |
| TiDB RR | ✅ 支持,MVCC 是其核心机制,用于实现快照隔离 |
四、非事务 DML 语句
1、概念
非事务 DML(Non-transactional DML) 是指将一个普通的 DML 操作(如 UPDATE、DELETE 等)自动拆分为多个小批量(batch)执行的语句。
缺点:
- 原子性:整个操作不会作为一个整体成功或失败;
- 隔离性:其他事务可能在执行过程中看到部分结果。
优势:
- 避免大事务超出内存或性能瓶颈;
- 提高大规模数据处理效率;
- 内置于数据库内核中,无需手动拆分语句;
- 支持
DRY RUN功能预览拆分后的 SQL。
2、支持的语句类型
| 类型 | 示例 |
|---|---|
INSERT INTO ... SELECT |
批量插入 |
REPLACE INTO ... SELECT |
替换插入 |
UPDATE |
批量更新 |
DELETE |
批量删除 |
3、语法示例
-- 批量更新,按 id 列每批处理 10000 条
BATCH ON test.t.id LIMIT 10000 UPDATE t SET v = v + 1;
-- 批量删除过期数据
BATCH ON test.t.create_time LIMIT 10000 DELETE FROM t WHERE create_time < '2023-01-01';
-- 批量插入
BATCH ON test.t.id LIMIT 5000 INSERT INTO t SELECT * FROM t_old;
5、实践
1)参数说明与推荐设置
| 参数 | 说明 | 默认值 | 是否必填 | 推荐值 |
|---|---|---|---|---|
| 划分列 | 用于划分 batch 的列,必须有索引 |
自动选择 handle 第一列(不推荐) |
否 | 可高效匹配 WHERE 条件的列,如主键、时间列等 |
Batch size |
每个 batch 的大小(影响 SQL 数量) | N/A | 是 | 5000 ~ 500000(根据负载测试调整) |
tidb_nontransactional_ignore_error:- 说明:决定在执行非事务
DML语句时,如果遇到错误,是否忽略这些错误并继续执行后续批次。当设置为ON或1时,即使某个批次失败,整个操作也不会中断,而是跳过错误继续执行。 - 应用场景:适用于那些可以容忍部分失败、并且希望尽可能多地完成任务的场景。
- 说明:决定在执行非事务
tidb_redact_log- 说明:该参数用于控制是否在日志中隐藏(脱敏)敏感信息。当设置为
ON或1时,TiDB会在日志输出中隐藏 SQL 查询中的具体值,仅保留查询结构。 - 应用场景:主要用于提高安全性,防止敏感数据泄露到日志文件中。
- 说明:该参数用于控制是否在日志中隐藏(脱敏)敏感信息。当设置为
2)DRY RUN 功能使用建议
a、使用方法:
EXPLAIN BATCH ON orders.id LIMIT 50000 DELETE FROM orders WHERE create_time < '2024-01-01';
b、输出示例:
DELETE FROM orders WHERE id >= 1 AND id < 50001;
DELETE FROM orders WHERE id >= 50001 AND id < 100001;
...
c、建议检查点:
| 检查项 | 说明 |
|---|---|
| 是否每条语句都只处理一个明确区间 | 避免跨区间的误操作 |
| 是否存在重复处理的风险 | 划分列是否被更新? |
| 是否利用了索引 | 检查 EXPLAIN 输出中的 key 使用情况 |
3)执行失败后的处理建议
a、报错处理流程:
- 从日志中获取失败的 batch 范围;
- 手动构造对应的 SQL 语句;
- 在普通事务中执行修复;
- 必要时结合业务逻辑做幂等处理;
b、注意事项:
- 如果开启了
tidb_redact_log和tidb_nontransactional_ignore_error,日志中可能无法看到具体出错的数据范围; - 必须确保你的 DML 是 幂等的,否则多次执行可能导致数据异常;
- 可以考虑配合外部监控工具记录每个
batch的执行状态。
4)使用建议
a、场景
| 场景 | 说明 |
|---|---|
| 大规模数据清理 | 删除旧日志、历史记录等 |
| 数据迁移/归档 | 将数据从一张表迁移到另一张表 |
| 批量更新 | 如调整价格、状态、字段值等 |
| 幂等性任务 | 可以重复执行而不影响最终一致性 |
b、条件
| 要求 | 说明 |
|---|---|
| 不需要原子性 | 允许部分执行成功、部分失败 |
| 具有幂等性 | 或者可以基于错误信息重试失败的部分 |
| 无并发写入 | 确保没有其他事务同时修改目标数据 |
| 不修改自身读取列 | 否则后续 batch 可能重复处理同一行 |
| 不建议并发 DDL | 避免边执行 DML 边改表结构 |
d、限制
| 限制项 | 说明 |
|---|---|
❌ 不支持 ON DUPLICATE KEY UPDATE |
在 INSERT INTO ... SELECT 中慎用 |
❌ 不支持 ORDER BY / LIMIT 子句 |
不能控制排序和数量 |
| ❌ 不支持子查询或集合操作 | 如 IN (SELECT ...) |
| ✅ 划分列必须有索引 | 可是单列索引或联合索引第一列 |
✅ 必须在 autocommit = ON 下运行 |
避免事务冲突 |
❌ 不能开启 batch-dml 模式 |
与非事务 DML 冲突 |
❌ 不能在 tidb_snapshot 模式下使用 |
影响一致性读 |
❌ 不能与 PREPARE 语句一起使用 |
语法不兼容 |
| ❌ 划分列类型不能是 ENUM/BIT/SET/JSON | 不支持 |
| ❌ 不支持临时表 | 没有持久化索引 |
| ❌ 不支持 CTE(公共表表达式) | 语法受限 |
tidb_redact_log 和 tidb_nontransactional_ignore_error 同时开启时需谨慎 |
错误细节不会提现出来,评估是否要重试 |
五、Percolator 事物模型
Percolator是BigTable之上,利用其提供的底层存储能力,实现了跨行、跨表的ACID级别事务支持。 在虽然
Percolator本身不是一个通用数据库,但它提出的事务模型深刻影响了后续许多分布式数据库的设计,如 **TiDB **
Percolator的本质是一个优化的两阶段提交协议(2PC),但它通过引入“主锁(Primary Lock)机制”解决了传统2PC中协调者单点故障和阻塞问题,实现了高可用性和异步提交。
Primary Key:每个事务都有一个主键,这是事务锁定的主要目标。Secondary Keys:除了主键之外的其他行锁也被称为Secondary Keys。Timestamp Oracle (TSO):用于生成全局唯一的时间戳,确保事务之间的顺序。TiDB中的PD(Placement Driver)组件负责分配时间戳。Two-phase Commit Protocol (2PC):两阶段提交协议,用来确保事务的原子性和一致性。
1、写事务流程(Prewrite + Commit)
1)场景:银行转账假设我们有一个分布式数据库(基于 Percolator 模型),用于管理用户账户余额。现在要执行一个事务:
用户 A 向用户 B 转账 100 元
- A 账户当前余额:500 元 → 转账后应为 400 元
- B 账户当前余额:300 元 → 转账后应为 400 元
这个事务涉及两行数据(A 和 B 的账户记录),需要原子性地完成:要么都成功,要么都失败。
2)时间线与关键角色:
-
全局时间戳服务(
TSO)分配:-
事务开始时间戳:
start_ts = 10 -
提交时间戳(稍后分配):
commit_ts = 15(必须 >10)
-
-
存储结构(基于
Bigtable风格):-
每行代表一个用户账户,行键为用户
ID。 -
列族设计:
data:balance:存储余额数据L::锁列(Lock)W::写记录列(Write)
-
1)客户端缓存操作
- 在事务提交前,客户端会 缓存所有更新操作(
put/delete),不立即写入。 - 所有操作都关联一个全局唯一的开始时间戳(
start_ts),由全局时间分配器(TSO)提供。
案例:客户端先不写入,而是缓存两个操作:然后进入 Prewrite 阶段。
PUT A:balance = 400 (start_ts = 10)
PUT B:balance = 400 (start_ts = 10)
3)Prewrite 阶段(第一阶段:准备阶段)
这是事务的“预写”或“加锁”阶段,目标是为所有涉及的行加上锁,并写入数据。 只有所有行(包括 primary 和所有 secondary)都 prewrite 成功,才算第一阶段完成。
a、 选择 Primary 和 Secondary
-
从所有要修改的行中 选出一行作为
primary row(通常按行键排序选第一个)。 -
其余行称为
secondary rows。 -
Primary锁是整个事务的“协调者”。
案例:
- 按行键排序:假设
A<BPrimaryRow: ASecondary Row: B
b、Prewrite Primary Row:
对
primary行执行以下操作:
- 尝试写入一个特殊的
Lock列(L列),内容为:{start_ts, transaction_meta}。 - 写入前检查:
- 是否已有锁存在(其他事务正在操作)→ 冲突。
W列(Write列)中是否存在时间戳 >start_ts的写记录 → 表示该数据已被更新,发生版本冲突。
- 如果检查通过,则:
- 成功写入
L列(加锁成功)。 - 将实际数据写入该行,时间戳为
start_ts。
- 成功写入
案例:客户端尝试对 A 加锁并写入数据。
-
检查项:
- 是否已有锁? → 查询
L:列,时间戳 ≤ 10 的锁是否存在?- 假设没有 → 通过
- 是否有更新覆盖了本次事务开始时间? → 查
W:列是否有版本 >10的提交?- 假设最新是版本
8→ 通过
- 假设最新是版本
- 是否已有锁? → 查询
-
执行操作:
-
写入锁:
L:列,时间戳 =10,值={start_ts=10, primary = A } -
写入数据:
data:balance,时间戳 =10,值 =400
-
-
A行prewrite成功。-
注意:
W列还没更新,说明这笔修改还未“生效”。 -
列 时间戳 值 data:balance 10 400 L: 10 {start_ts = 10, primary = A} W: 8 500(旧提交)
-
c、Prewrite Secondary Rows:
对每个
secondary行执行类似操作:
- 同样尝试加锁(写
L列),内容为:{start_ts, primary_key}(指向primary行的位置)。 - 检查逻辑同
primary。 - 加锁成功后,写入数据,时间戳也为
start_ts。
案例:同样流程:
-
检查项:
-
B 是否有锁? → 无
-
B 是否已被更新? → 最新 W 版本是 7 < 10 → 通过
-
-
执行操作:
-
写入锁:
L:列,时间戳=10,值={start_ts=10, primary=A}(指向 A!) -
写入数据:
data:balance,时间戳=10,值=400
-
-
B 行
prewrite成功:B 行状态:-
所有
prewrite完成!进入Commit阶段 -
列 时间戳 值 data:balance 10 400 L: 10 {start_ts=10, primary=A} W: 7 300(旧提交)
-
(4)若任一 prewrite 失败
- 触发回滚机制:
- 删除所有已加的
L锁。 - 删除时间戳为
start_ts的数据版本(防止脏数据残留)。
- 删除所有已加的
4)Commit 阶段(第二阶段:提交阶段)
此时事务已准备好提交,使用一个新的全局时间戳 commit_ts(必须大于 start_ts)。
a、 Commit Primary
如果
commit primary失败(如节点宕机),其他节点可以通过检查primary的锁状态判断事务是否超时,并主动回滚整个事务。
- 写入
W列(Write列),内容为start_ts(表示最新版本是哪个时间戳的数据)。 - 删除
L列上的锁(释放primary锁)。 - 这一步标志着事务正式提交成功。
案例:操作:
- 写入
W:列,时间戳 =15,值=10(表示:最新有效版本是10) - 删除
L:列(释放锁)
A 行更新后: 此时外界已可认为“事务已提交成功”!
| 列 | 时间戳 | 值 |
|---|---|---|
| data:balance | 10 | 400 |
| W: | 15 | 10 |
b、异步 Commit Secondary
primary锁释放即代表事务完成,secondary可异步提交,避免了传统2PC的同步阻塞。
primary提交成功后,secondary的提交可以异步进行。- 每个
secondary行:- 写
W列,值为start_ts。 - 删除 L 列。
- 写
- 即使某些
secondary提交失败,也可以由后续读操作或后台清理线程补交。
案例:一旦 primary 提交成功,secondary 可异步提交。
-
操作(可能由后台线程完成):
-
写入
W:列,时间戳=15,值=10 -
删除
L:列
-
-
B行最终状态:-
即使在
commit B之前系统崩溃,恢复后也能通过A的W列发现事务已提交,继续补交B。 -
列 时间戳 值 data:balance 10 400 W: 15 10
-
2、读事务流程(Read Operation)
-
场景设定:查询用户 A 的余额,假设在转账事务(A→B 转 100 元)正在进行中,另一个客户端想要查询 用户 A 的当前余额。
-
转账事务“正在进行中”,但尚未提交。
start_ts = 10- 已完成
Prewrite阶段- A 行:已加锁(
L列),数据写入balance=400@10,但W列未更新 - B 行:同理
- A 行:已加锁(
- 尚未进入
Commit阶段(A 的 W 列还没写)
1)检查锁(Lock Detection)
关键点:不能直接读旧版本数据,否则可能看到未提交事务的部分结果,导致不一致。
- 查询该行是否有
L列,且时间戳 ≤ 当前事务的start_ts。 - 如果有锁:
- 检查锁是否超时(根据
start_ts判断)。- 若超时 → 可尝试 清理锁 并回滚事务(称为“垃圾回收”或“事务中断”)。
- 若未超时 → 当前读操作需等待或重试(避免幻读)。
- 检查锁是否超时(根据
第一步:读事务获取自己的 start_ts
-
读操作也是一个事务(即使是只读事务),它需要一个时间戳来确定“我应该看到哪个时刻的数据”。
-
向
TSO(全局时间分配器)请求时间戳 → 得到read_start_ts = 12- 这个
12表示:“我要看截止到时间12为止,已经提交的数据。”
- 这个
第二步:检查是否有锁(Lock Detection)
-
读操作:首先查询
A行的L:列:读取: Row=A, Column=L:, Timestamp ≤ 12 -
结果发现:
-
存在一个锁:
L:@10 = {start_ts=10, primary=A} -
说明:有一个事务(start_ts = 10)正在修改 A,且这个事务的开始时间 10 < 12(早于我)
- 此时,读事务不能直接返回
data:balance@10(即400),因为那是未提交的脏数据!
- 此时,读事务不能直接返回
-
第三步:判断锁的状态 —— 事务是否已提交或超时?
- 读事务需要决定:是等待、清理锁,还是回滚自己?
- 它会去检查
Primary行的状态(因为锁里写着primary = A,所以就是查A自己): - 查看
Primary的W列状态- 读取 A 的
W:列,查找时间戳 ≤read_start_ts = 12的最新版本: - 结果发现:
W:@8 = 8(上次提交)
- 读取 A 的
- 结论:事务
start_ts = 10还没有提交
第四步:判断事务是否超时?
- 预期超时时间判断:
Percolator中每个事务都有一个预期超时时间(比如 10 秒)。如果当前时间 -start_ts=10已经超过阈值,说明它可能已经“卡住”了。- 假设现在系统时间对应的时间戳是
now_ts = 20,而事务start_ts = 10已经运行了太久(比如超过10秒),判定为超时
- 假设现在系统时间对应的时间戳是
第五步:执行“清理锁”(Clean Up):
- 既然事务已超时,读事务可以充当“好人”,帮助清理这个“僵尸事务”:
- 回滚该事务:
- 删除 A 的
L:@10 - 删除
data:balance@10(即那个未提交的400)
- 删除 A 的
- 同样去清理
B行(通过B的锁找到primary = A,确认状态后删除其锁和数据)
2)读取最新有效版本
示例:
W列存的是“哪个版本是有效的”,数据列存的是具体值。
- 读取
W列,找到时间戳 ≤start_ts的最大版本(即最近一次提交)。 - 得到该版本对应的数据时间戳
write_ts。 - 再去读取该行在
write_ts时间戳下的实际数据内容。
案例1:继续读取最新已提交数据
-
再次读取
W:列 ≤12的最新版本:W:@8 = 8 -
再去读
data:balance@8→ 得到 500
3、常见问题
1)二级锁机制的意义
| 特性 | 说明 |
|---|---|
Primary 锁作为事务协调者 |
它的存在决定了事务的状态(进行中、已提交、已回滚)。 |
Secondary 锁指向 Primary |
使得任何读取者都能通过 secondary 锁找到 primary,进而判断事务状态。 |
异步提交 secondary |
primary 提交后即可释放锁,secondary 可后台提交,提升性能。 |
| 故障恢复能力强 | 即使 coordinator 宕机,其他节点也能通过 primary 锁状态判断事务命运。 |
2)为什么 TiDB 使用 Percolator?
TiDB 采用 Percolator 模型实现其分布式事务(默认开启 悲观事务 或 乐观事务 模式),原因包括:
- 基于
Key-Value存储(如TiKV)构建,天然适合Percolator的行级锁模型。 - 支持跨
Region、跨节点的强一致性事务。 - 利用
PD(Placement Driver)提供全局时间戳(TSO),满足start_ts和commit_ts需求。 - 实现了类似
Google的高可用、可扩展的OLTP事务处理能力。
3)Percolator 的核心创新
| 创新点 | 说明 |
|---|---|
| 分布式事务 | 在 Bigtable 上实现跨行 ACID 事务 |
| 主锁机制 | 用 primary 锁替代集中式协调者,提升可用性 |
| 异步提交 | secondary 提交可异步,降低延迟 |
| 故障恢复 | 通过锁状态自动检测并回滚失败事务 |
| 时间戳排序 | 使用全局时间戳实现 MVCC 和冲突检测 |


