前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、事务模式:

  • 如果你的应用不能很好地处理提交失败,建议使用悲观事务

  • 如果你追求高并发性能且冲突较少,可以考虑乐观事务
  • 对只读分析查询,可考虑使用 因果一致性事务 降低延迟;
特性 乐观事务 悲观事务
默认启用 ❌ 否 ✅ 是
是否锁行 ❌ 否 ✅ 是
冲突处理 提交时检测冲突,可能失败 执行时锁定资源,避免冲突
支持惰性检查 ✅ 是 ❌ 否
支持因果一致性 ❌ 否 ❌ 否
适用场景 并发写入冲突少 高并发写入或复杂业务逻辑
锁机制 执行时不加锁,提交时加锁 执行时立即加锁

1、乐观事物

特点:

  • 在执行DML操作时不会立即检查唯一性约束或获取锁。
  • 这些检查和锁的获取是在事务提交时进行的。
  • 如果在提交时检测到冲突,则事务将失败并需要重试。
  • 提供了惰性检查优化,通过减少网络通信来提升性能。

适用场景:

  • 当预期并发写入冲突较少时,使用乐观事务可以获得更好的性能。
  • 对于读多写少的应用程序特别有效。

示例:

BEGIN OPTIMISTIC;
INSERT INTO t1 VALUES (1);
COMMIT; -- 此时才会进行唯一性检查等

2、悲观事物

特点:

  • 在执行 DML 操作时立即尝试获取必要的锁,并且会在操作过程中立即检查唯一性约束。
  • 这种方式可以防止其他事务在同一数据上进行修改,直到当前事务完成。
  • 减少了由于冲突导致的事务失败和重试的可能性。

适用场景:

  • 对于高并发写入或者业务逻辑复杂的情况,悲观事务更适合。
  • 应用程序能够更好地处理锁等待情况。

示例:

BEGIN; -- 默认为悲观事务
INSERT INTO t1 VALUES (1); -- 立即获取锁并检查约束
COMMIT;

二、事物操作

1、开启事务

  • TiDB 中,BEGINSTART TRANSACTION 在执行时就获取快照。
  • MySQL 中,BEGINSTART 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,直到 COMMITROLLBACK
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、概念

非事务 DMLNon-transactional DML 是指将一个普通的 DML 操作(如 UPDATEDELETE 等)自动拆分为多个小批量(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 语句时,如果遇到错误,是否忽略这些错误并继续执行后续批次。当设置为 ON1 时,即使某个批次失败,整个操作也不会中断,而是跳过错误继续执行。
    • 应用场景:适用于那些可以容忍部分失败、并且希望尽可能多地完成任务的场景。
  • tidb_redact_log
    • 说明:该参数用于控制是否在日志中隐藏(脱敏)敏感信息。当设置为 ON1 时,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、报错处理流程:

  1. 从日志中获取失败的 batch 范围
  2. 手动构造对应的 SQL 语句
  3. 在普通事务中执行修复
  4. 必要时结合业务逻辑做幂等处理

b、注意事项:

  • 如果开启了 tidb_redact_logtidb_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_logtidb_nontransactional_ignore_error 同时开启时需谨慎 错误细节不会提现出来,评估是否要重试

五、Percolator 事物模型

PercolatorGoogle 的上一代分布式事务解决方案,构建在 BigTable 之上,利用其提供的底层存储能力,实现了跨行、跨表的 ACID 级别事务支持。 在 Google 内部 用于网页索引更新的业务,总体来说就是一个经过优化的二阶段提交的实现,进行了一个二级锁的优化。

虽然 Percolator 本身不是一个通用数据库,但它提出的事务模型深刻影响了后续许多分布式数据库的设计,如 **TiDB **

  • Percolator 的本质是一个优化的两阶段提交协议(2PC,但它通过引入“主锁(Primary Lock)机制”解决了传统 2PC 中协调者单点故障和阻塞问题,实现了高可用性和异步提交。
  • Primary Key:每个事务都有一个主键,这是事务锁定的主要目标。
  • Secondary Keys:除了主键之外的其他行锁也被称为 Secondary Keys
  • Timestamp Oracle (TSO):用于生成全局唯一的时间戳,确保事务之间的顺序。TiDB 中的 PDPlacement Driver)组件负责分配时间戳。
  • Two-phase Commit Protocol (2PC):两阶段提交协议,用来确保事务的原子性和一致性。

1、写事务流程(Prewrite + Commit

1)场景:银行转账假设我们有一个分布式数据库(基于 Percolator 模型),用于管理用户账户余额。现在要执行一个事务:

用户 A 向用户 B 转账 100 元

  • A 账户当前余额:500 元 → 转账后应为 400 元
  • B 账户当前余额:300 元 → 转账后应为 400 元

这个事务涉及两行数据(AB 的账户记录),需要原子性地完成:要么都成功,要么都失败。

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、 选择 PrimarySecondary

  • 从所有要修改的行中 选出一行作为 primary row(通常按行键排序选第一个)。

  • 其余行称为 secondary rows

  • Primary 锁是整个事务的“协调者”

案例:

  • 按行键排序:假设 A < B
    • Primary Row: A
    • Secondary 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

  • Aprewrite 成功。

    • 注意: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 之前系统崩溃,恢复后也能通过 AW 列发现事务已提交,继续补交 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 行:同理
    • 尚未进入 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 自己):
  • 查看 PrimaryW 列状态
    • 读取 A 的 W: 列,查找时间戳 ≤ read_start_ts = 12 的最新版本:
    • 结果发现:W:@8 = 8(上次提交)
  • 结论:事务 start_ts = 10 还没有提交

第四步:判断事务是否超时?

  • 预期超时时间判断:Percolator 中每个事务都有一个预期超时时间(比如 10 秒)。如果当前时间 - start_ts=10 已经超过阈值,说明它可能已经“卡住”了。
    • 假设现在系统时间对应的时间戳是 now_ts = 20,而事务 start_ts = 10 已经运行了太久(比如超过 10 秒),判定为超时

第五步:执行“清理锁”(Clean Up):

  • 既然事务已超时,读事务可以充当“好人”,帮助清理这个“僵尸事务”:
  • 回滚该事务:
    • 删除 A 的 L:@10
    • 删除 data:balance@10(即那个未提交的 400
  • 同样去清理 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、跨节点的强一致性事务。
  • 利用 PDPlacement Driver)提供全局时间戳(TSO),满足 start_tscommit_ts 需求。
  • 实现了类似 Google 的高可用、可扩展的 OLTP 事务处理能力。

3)Percolator 的核心创新

创新点 说明
分布式事务 Bigtable 上实现跨行 ACID 事务
主锁机制 primary 锁替代集中式协调者,提升可用性
异步提交 secondary 提交可异步,降低延迟
故障恢复 通过锁状态自动检测并回滚失败事务
时间戳排序 使用全局时间戳实现 MVCC 和冲突检测

ContactAuthor