1. 核心基石:全局唯一的时间戳 (TSO)
分布式事务最难的是确定"谁先谁后"。TiDB 不依赖物理时钟(因为服务器时间可能不同步),而是通过 PD (Placement Driver) 组件提供一个逻辑上的、单调递增的全局时间戳 (TSO)。
- 作用 :每个事务在开始时,都会从 PD 获取一个
start_ts(开始时间戳);在提交时,会获取一个commit_ts(提交时间戳)。 - 意义:这保证了整个集群所有事务的串行化顺序是全局一致的(类似 Percolator 模型)。
2. 写流程:两阶段提交 (2PC) + 乐观锁
当你执行一个 UPDATE 或 INSERT 涉及多个节点的数据时,流程如下:
第一阶段:预写入 (Prewrite) - 乐观锁检查
- 获取时间戳 :TiDB Server 向 PD 申请
start_ts。 - 本地缓冲:TiDB Server 将需要修改的数据缓存在本地内存中。
- 分发请求 :TiDB Server 根据数据所在的 Region,将写请求并行发送给对应的 TiKV 节点。
- 冲突检测 (关键点) :
- TiKV 收到请求后,利用 MVCC 机制检查该数据键上是否有其他未提交的事务,或者是否有比当前
start_ts更新的已提交版本。 - 如果没有冲突 :TiKV 将数据以"锁定"状态写入(此时数据对其他事务不可见,标记为
Lock),并返回成功。 - 如果有冲突:TiKV 直接返回错误,TiDB Server 收到后会回滚整个事务(重试或报错给用户)。
- 注意:这里就是 MVCC 发挥作用的地方,它在存储层直接拦截了冲突,不需要计算节点去"汇总"判断。
- TiKV 收到请求后,利用 MVCC 机制检查该数据键上是否有其他未提交的事务,或者是否有比当前
第二阶段:提交 (Commit)
- 二次获取时间戳 :如果所有涉及的 TiKV 节点都 Prewrite 成功,TiDB Server 再次向 PD 申请
commit_ts。 - 下发提交指令 :TiDB Server 向所有参与交易的 TiKV 节点发送
Commit指令,带上commit_ts。 - 落盘生效 :
- TiKV 收到指令后,将之前的
Lock状态移除,并将commit_ts写入数据版本。 - 此时,其他事务如果
start_ts大于这个commit_ts,就能读到新数据了。
- TiKV 收到指令后,将之前的
- 异步清理:提交成功后,后台会有异步任务清理旧的版本数据(GC)。
3. 读流程:快照读 (Snapshot Read)
这是 MVCC 最典型的体现:
- 当事务 A 读取数据时,它带着自己的
start_ts去 TiKV 读。 - TiKV 会根据 MVCC 规则,只返回
commit_ts<=start_ts且 未被锁定 的最新版本数据。 - 结果 :事务 A 永远看不到在它启动之后才提交的事务 B 的修改,从而实现了可重复读 (Repeatable Read) 隔离级别,且读不阻塞写,写不阻塞读。
4.锁原理
在写入数据时候会写入lock数据,内部有锁处理并发,如果新事务进入会检查lock.s_ts<new_lock.s_ts直接异常出去。
这个过程就像是一个**"三步验证"** 机制,确保你读到的数据既是最新的 ,又是已提交的 ,且没有被锁住。
让我们把这个流程拆解成标准的伪代码逻辑,看看底层到底发生了什么:
假设你要读取 Key = t100_r1,当前事务的读版本号是 read_ts = 500。
第一步:检查锁 (Check Lock)
目标:确认这行数据有没有被"正在运行"的事务锁住。
- 动作 :去
Lock CF查找Key = t100_r1。 - 判断 :
- 情况 A:找到了锁
- 检查锁的
start_ts。 - 如果锁的
start_ts<read_ts(500):说明有个老事务还没提交,挡路了!- 结果 :读取阻塞 (等待锁释放)或者 报错(取决于隔离级别),无法继续。
- 如果锁的
start_ts>read_ts(500):说明是个未来的事务(不可能发生,除非时钟乱跳),忽略。
- 检查锁的
- 情况 B:没找到锁 (这是正常路径)
- 结论:这行数据现在是"干净"的,没有并发冲突。
- 下一步:进入第二步。
- 情况 A:找到了锁
关键点 :这一步极快,因为
Lock CF是独立的,而且通常数据量很小(只有活跃事务才有锁)。
第二步:查找版本指针 (Find Version in Write CF)
目标 :在已提交的历史版本中,找到对于当前读版本号来说,最新且有效的那个版本。
- 动作 :去
Write CF查找Key = t100_r1。- 注意:
Write CF里的 Key 格式通常是t100_r1_w{commit_ts}。 - 我们要找的是:
commit_ts<=read_ts(500) 的最大那个版本。
- 注意:
- 扫描逻辑 (从大到小扫描):
- 先看
commit_ts = 600? -> 大于 500,跳过(那是未来的数据,不可见)。 - 再看
commit_ts = 400? -> 小于 500,命中!
- 先看
- 读取内容 :
- 读取这条记录的 Value。
- Value 里存着最重要的信息:
start_ts(比如是300) 和 操作类型 (Put/Delete)。 - 含义:"在时间 400 提交了一个事务,这个事务是在时间 300 开始的,它修改了这行数据。"
- 特殊情况 :
- 如果找到的记录类型是
Delete(删除标记):说明这行数据在这个时间点被删了。 - 结果 :直接返回"数据不存在",不需要去第三步查 Default CF 了。
- 如果找到的记录类型是
关键点 :
Write CF充当了索引的角色。它不存真实数据,只存"版本地图"。
第三步:获取真实数据 (Fetch Data from Default CF)
目标 :拿着第二步找到的"钥匙"(start_ts),去仓库里取货。
- 动作 :去
Default CF查找Key = t100_r1。- 注意:
Default CF里的 Key 格式通常是t100_r1_{start_ts}。 - 我们要精确匹配第二步拿到的
start_ts(即300)。
- 注意:
- 查找 :
- 查找
Key = t100_r1_300。
- 查找
- 结果 :
- 找到 :读取 Value,这就是你要的真实数据(比如
{status: 1, name: "Alice"})。 - 没找到:这就出大问题了!说明数据不一致(写了锁和提交记录,但没写数据),通常意味着底层存储损坏或严重的 Bug。
- 找到 :读取 Value,这就是你要的真实数据(比如
流程图总结
用户发起读请求 (read_ts = 500)
↓
[1. 查 Lock CF]
Key: t100_r1
├── 有锁? ──→ 阻塞/报错 (等待事务结束)
└── 无锁? ──→ 继续 ↓
↓
[2. 查 Write CF]
寻找:commit_ts <= 500 的最大版本
扫描:... w600(跳过), w400(命中!)
读取 Value: { start_ts: 300, type: Put }
├── type 是 Delete? ──→ 返回 NULL (数据已删)
└── type 是 Put? ──→ 拿着 start_ts=300 继续 ↓
↓
[3. 查 Default CF]
查找:Key = t100_r1_300
读取 Value: { status: 1, ... }
↓
[4. 返回结果给用户]
"status: 1"
为什么要这么麻烦分三步?
你可能会问:"为什么不直接把数据存在 Write CF 里,省得再查一次 Default CF?"
原因有二:
-
性能分离 (Read Amplification):
Write CF只需要存很小的元数据(几个字节的时间戳和类型)。Default CF存的是巨大的业务数据(可能几 KB 甚至几 MB)。- 如果把大数据都塞进
Write CF,那么第二步扫描版本历史时会变得巨慢无比(因为要读很多大字段)。 - 分开存,第二步扫描只需读几十字节,速度极快。只有确定要哪个版本了,才去读一次大数据。
-
存储优化 (Compaction):
- 历史版本(旧数据)在
Default CF中可以更容易地被 GC 清理。 Write CF保留更长的历史记录用于一致性读,而Default CF可以更早地清理掉没人用的大对象,节省空间。
- 历史版本(旧数据)在
总结图示
[客户端]
|
v
[TiDB Server (计算节点)] <--- 1. 申请 start_ts (从 PD)
| <--- 2. 解析 SQL, 规划路由
| (并行发送)
+-----> [TiKV Node A] --- 3. 检查冲突 (MVCC), 写 Lock (Prewrite)
+-----> [TiKV Node B] --- 3. 检查冲突 (MVCC), 写 Lock (Prewrite)
| <--- 4. 返回结果给 TiDB
| (若全部成功)
| <--- 5. 申请 commit_ts (从 PD)
+-----> [TiKV Node A] --- 6. 提交事务, 移除 Lock, 写入 commit_ts
+-----> [TiKV Node B] --- 6. 提交事务, 移除 Lock, 写入 commit_ts