土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】悲观事务与 ResolveLock:TTL、死锁检测边界,纠正「TiKV 无锁」

文章导航

分类入口
databasestorage
标签入口
#tikv#pessimistic-lock#deadlock-detection#resolve-lock#ttl#wait-for-graph#in-memory-lock#gc-worker

目录

一个流传很广的说法是”TiKV 是乐观并发控制,所以没有锁”。这个说法在两个层面都不成立:第 10 篇 已经说明,即便是纯乐观的 Percolator 模型,Prewrite 阶段本身就要往 CF_LOCK 写一把锁——只是这把锁的持有时间通常很短(从 Prewrite 到 Commit);而 TiDB 默认的悲观事务模式(自 v3.0.8 起为默认模式)会在语句执行时就显式加锁,和传统关系库的行锁在语义上几乎没有区别。真正的问题不是”有没有锁”,而是锁存在多久、谁来判断锁的持有者已经死亡、死锁怎么发现

本篇钉住这三件事:悲观锁的写入路径与内存优化,TTL/心跳如何界定”锁已经过期”,死锁检测器的选举与它检测不到什么;最后梳理 ResolveLock 两条触发路径(GC 批量清理 vs 客户端遇锁即时清理)如何收尾这些锁。

本文是「TiKV / HTAP 内核」系列第 11 篇(共 18 篇)。上一篇Percolator 乐观事务落地下一篇Coprocessor→ 系列目录

版本锚定:TiKV 7.x–8.x。in-memory 悲观锁自 v6.0.0 起默认开启;in-memory-peer-size-limit/in-memory-instance-size-limit 两个细粒度阈值自 v8.4.0 起可配置,本文按当前默认行为描述,不复述更早版本的历史演进。


一、悲观锁的写入路径:AcquirePessimisticLock 与内存优化

TiDB 执行 UPDATEDELETESELECT ... FOR UPDATE 时,会在语句执行时(不是等到事务提交)向对应 key 发出 AcquirePessimisticLock 命令。这把锁和 Prewrite 阶段写的锁类型不同LockType::Pessimistic,不携带任何 data 值——它只是一个占位,表示”这个 key 现在被某个事务持有中”。TiKV 官方 Locking 文档把这条路径和乐观锁的核心差异说得很直接:

We use the Percolator Transaction model in TiKV, which uses an optimistic locking strategy. […] Pessimistic locking is mainly used in environments where write contention is heavy.

到了 Prewrite 阶段,如果该 key 上已经持有本事务的悲观锁,Prewrite 直接把它升级成正常的 Prewrite 锁并写入数据,不需要重新做冲突检测——冲突检测已经在加悲观锁的时候做过了(TiDB Development Guide, Lock Resolver)。

in-memory 悲观锁(v6.0.0 起默认开启)

正常情况下,悲观锁要写 CF_LOCK 并经 Raft 复制到多数副本才能返回成功,这一步的延迟等于一次 Raft 提交。TiKV v6.0.0 引入 in-memory pessimistic lock:悲观锁默认只存在 Region Leader 的内存表PeerPessimisticLocks)里,不落盘、不经 Raft 复制,因此加锁延迟从”一次 Raft 提交”降到”一次本地内存写入”。

这个优化天然有一个问题:Region Leader 切换或合并时,内存里的锁会不会丢?TiKV 的处理是:Leader 在主动转移领导权前,把当前内存锁表序列化并通过一次 Raft 提议同步给候选 Leader,之后才真正转移(tikv/rfcs0077-in-memory-pessimistic-locks)。如果转移过程超时或被拒绝,Leader 会撤销”停止接收新内存锁”的标记,继续走内存路径。但这套保护只覆盖计划内的 Leader 转移——网络分区或节点宕机导致的非计划切主,内存里的锁确实会丢失,官方文档也直接写明了这一点:

However, when network isolation occurs in TiKV or a TiKV node is down, the acquired pessimistic lock might be lost.

—— TiDB Docs, Pessimistic Transaction Mode

丢锁的后果不是数据错误——事务后续的 Prewrite 会重新发现冲突并报错,客户端重试即可;代价是已经执行过的语句可能需要重跑。如果业务强依赖”加锁即代表已经持久化的排他状态”(例如把加锁失败率当作强一致性信号),需要显式关闭这个特性(pessimistic-txn.in-memory = false)。8.4.0 起还能用 in-memory-peer-size-limit(默认 512KiB)和 in-memory-instance-size-limit(默认 100MiB)限制内存锁表的大小,超限自动退化到”pipelined”路径(锁仍然落盘复制,但不等 Raft 提交完成就提前返回)。

flowchart TD
  A["AcquirePessimisticLock"] --> B{"in-memory enabled<br/>and under size limit?"}
  B -- "yes" --> C["write to leader's<br/>PeerPessimisticLocks (memory only)"]
  B -- "no / over limit" --> D["pipelined: propose via Raft,<br/>return before commit ack"]
  C --> E{"voluntary leader transfer?"}
  E -- "yes" --> F["serialize lock table,<br/>replicate via one Raft proposal<br/>before actually transferring"]
  E -- "no (crash / partition)" --> G["locks in memory are lost;<br/>later prewrite will re-detect conflict"]

二、TTL 与心跳:谁来判断锁已经过期

无论乐观锁还是悲观锁,锁本身都带一个 TTL(Time-To-Live,毫秒)。默认值 3000msclient-go 历史实现里的 defaultLockTTL),实际写入的 TTL 会按事务写入量放大:

\[ \text{ttl} = \text{ttlFactor} \times \sqrt{\text{writeSizeInMiB}} \]

其中 ttlFactor 默认 6000——写入量越大的事务,Prewrite/Commit 需要的时间越长,给它更长的 TTL 避免被误杀。这条公式来自 client-go 早期实现,思路延续至今:TTL 不是固定常量,是按事务规模估算的动态值

长事务,光靠一次性设置的 TTL 不够——一个跑了几十秒的事务,它的锁不该在 3 秒后就被判定为”孤儿锁”。解决办法是心跳续期:primary lock 的持有者(TiDB)会每 20 秒给 primary key 发一次心跳,更新锁的 min_commit_ts/TTL;只要事务还活着、还在按时续期,其他事务遇到这把锁就应该等待而不是清理。但只有 primary lock 会被续期,secondary lock 不会——这是 §四 会展开的”为什么必须靠 primary 的状态来判断整个事务命运”的直接后果。

遇到锁时,等待方不是无脑等到 TTL 过期才清理:wait-for-lock-timeout(默认 1s)控制的是”本次请求最多愿意等多久”,超时后返回错误给客户端,客户端决定要不要重试——这个超时和锁的 TTL 是两个独立的概念,前者是请求侧的耐心,后者是判断锁是否孤儿的证据


三、死锁检测:中心化 leader、wait-for-graph、检测边界

纯乐观事务不需要死锁检测——所有写操作先在客户端缓冲,冲突只在 Prewrite 阶段才暴露,不存在”A 等 B、B 等 A”的运行时阻塞。悲观锁改变了这一点:语句执行时就可能阻塞在别的事务持有的锁上,如果两个事务交叉持锁又交叉等待,就会死锁。TiKV 的解决方式是一个中心化的死锁检测服务,由 LockManagerDetector 两部分组成。

Leader 选举:借用”第一个 Region”

死锁检测器的 leader 不是单独选举出来的角色,而是复用了一个巧妙的规则:key 范围覆盖空字符串 "" 的那个 Region(即整个集群按 key 排序的第一个 Region)的 Leader 所在 Store,天然就是死锁检测的 leader。任何 TiKV 节点要发起死锁检测,先向 PD 查这个”第一 Region”的 Leader 地址,建立一条 gRPC 长连接把 Detect 请求转发过去。

sequenceDiagram
    participant Txn as Waiting txn (any TiKV node)
    participant PD
    participant Leader as Deadlock detector leader<br/>(leader of the region covering empty key)

    Txn->>PD: GetRegion(key = "")
    PD-->>Txn: leader store address of the first region
    Txn->>Leader: open gRPC stream, send Detect(txn_ts, wait_for_txn_ts)
    Leader->>Leader: try add edge txn_ts -> wait_for_txn_ts<br/>to wait-for-graph (DAG)
    alt adding edge creates a cycle
        Leader-->>Txn: deadlock detected
    else no cycle
        Leader-->>Txn: ok, continue waiting
    end

这个设计的好处是不用另起一套选举协议——第一 Region 的 Leader 本来就受 Raft 选举保证唯一,死锁检测的”leader 唯一性”直接蹭现成的保证。代价是这个 Region 的 Leader 一旦切换(可能是故障,也可能只是 PD 出于均衡目的做的一次 transfer-leader),死锁检测器的内部状态——wait_for_map(记录 txn_ts → wait_for_txn_ts 依赖关系的 DAG)——直接清空重建,不做任何跨切换的状态迁移。

检测边界

三个值得明确写下来的边界,都不是缺陷,而是设计取舍:

  1. 只在”非第一把锁”时才检测:如果一个事务当前在等待的是它遇到的第一把锁(is_first_lock == true),TiKV 不会把这条等待关系加入检测图——单独一把锁不可能构成环,没必要为此发一次 RPC。这意味着检测只对”已经在等第二把及以后的锁”的事务生效,逻辑上是完备的(第一把锁永远不是环的一部分),但也说明检测请求本身有选择性,不是每次等锁都会触发一次 RPC
  2. Leader 切换清空历史依赖:wait-for-graph 在检测器 leader 变更时被清空,这段时间内已经形成、尚未被检测到的死锁环可能被漏检——只有当事故双方后续再次发起 Detect 请求时,环才会在新 leader 上被重建并发现。这不是理论假设,是实现层面能观察到的行为(TiKV 死锁检测源码的中文解读明确提出过这个疑问:leader 变更时 wait_for_map 被直接清空,“这样不会有问题吗”)。
  3. 检测请求本身要走一次 RPC:如果这次 RPC 因为网络问题失败或超时,wait-for-lock-timeout(默认 1s)会先让请求方超时返回,而不是无限期等待死锁检测的结果——死锁检测是一个尽力而为的加速手段,不是锁等待超时机制的替代品,就算检测器完全失效,锁等待超时仍然是兜底。

TiKV 社区在设计死锁检测器归属时也曾讨论过”放在 TiDB 侧还是 TiKV 侧”:把死锁管理放进 TiKV、复用第一 Region 的 Leader 选举,被认为”和实现另一套 GC/DDL leader 机制一样简单可靠”;反对意见指出 TiKV 的 Leader 会因为副本迁移、Region 分裂合并而随时变化,这为死锁机制引入了本可以避免的复杂度(tikv/tikv PR #6415 讨论)。这场讨论最终定型为当前”复用第一 Region”的方案,本身就是一次公开可查的工程权衡记录。


四、ResolveLock:两条触发路径

ResolveLock 不是一个孤立的命令,而是”发现一把锁、判断这把锁背后的事务状态、据此清理或前滚”这整套逻辑的统称,触发路径分两条:

4.1 客户端遇锁即时清理

任何请求(读、写)在处理时遇到别的事务留下的锁,都会先尝试解锁,而不是无脑等待。TiDB Development Guide Lock Resolver 把解锁分成三种情况:

锁类型 判断方式 处理函数
Async Commit 锁 从锁里的 secondaries 列表反查所有 key 的状态 resolveLockAsync
悲观锁(LockType::Pessimistic 直接发 PessimisticRollbackRequest 清理,不涉及数据可见性判断 resolvePessimisticLock
普通乐观锁 查 primary 的 CF_WRITE:有提交记录就前滚,没有就回滚 resolveLock

第三种情况正是 distributed/30 第四节 详细推导过的”前滚 vs 回滚”逻辑:判断权威永远在 primary,任何 secondary 上残留的锁都只能通过读 primary 的状态来确定。悲观锁不需要这套判断,因为它不携带 data,回滚只是简单删除,不存在”是否已提交”的歧义。

4.2 GC 触发的批量清理

TiDB 的 GC Worker 按周期计算一个 safe point(保证所有活跃事务的 start_ts 都大于它),然后分三步清理(TiDB Docs GC Overview):

  1. Resolve Locks:扫描 safe point 之前的所有锁,逐一清理——这一步保证 GC 不会误删一个”看起来过期但事务仍在等待判断”的版本。
  2. Delete RangesDROP TABLE/DROP INDEX 产生的整段 key range,直接物理删除,不逐 key 扫描。
  3. Do GC:清理每个 key 在 safe point 之前的旧版本(默认走 RocksDB compaction filter,5.0 之后的默认路径——GC 逻辑嵌进 compaction 过程本身,不需要额外扫描和写墓碑)。

这一步的 ResolveLock 命令签名(TiKV 源码 src/storage/txn/commands/resolve_lock.rs)直接体现了”批量做前滚/回滚”的语义:

// tikv/tikv, src/storage/txn/commands/resolve_lock.rs(节选)
ResolveLock:
    content => {
        /// Maps lock_ts to commit_ts. If a transaction was rolled back, it is mapped to 0.
        txn_status: HashMap<TimeStamp, TimeStamp>,
        scan_key: Option<Key>,
        key_locks: Vec<(Key, Lock)>,
    }

txn_status 把一批 start_ts 映射到它们各自的结局(提交到哪个 commit_ts,或者 0 表示回滚),一次命令就能把一批 key 上的锁批量收尾——这是 GC 路径特有的批量优化,客户端遇锁时的即时清理通常只处理单个事务。


五、纠正「TiKV 无锁」

回到开头的问题。把”TiKV 是无锁架构”这个说法拆开看,错在两个层次:

  1. 乐观事务不是无锁,只是锁的持有窗口短(Prewrite 到 Commit),且判定方式是数据结构CF_LOCK 里的一条记录),不是内存里的互斥量——这是实现方式的差异,不是”没有锁”。
  2. TiDB 默认的悲观事务模式使用的锁和传统 RDBMS 的行锁在语义上几乎等价SELECT ... FOR UPDATE 立即加锁、其他事务立即阻塞、遇到环会被检测为死锁——这套行为和 MySQL InnoDB 的行锁模型几乎是对照着设计的(TiDB Docs 明确写”lock wait timeout 由 innodb_lock_wait_timeout 设置”,直接借用了 MySQL 的配置项语义)。

“无锁”这个误解大概来自两个真实但被过度推广的事实:Percolator 论文本身用的是乐观并发控制(容易被简化成”没有锁”),以及 TTL/心跳这种去中心化的锁生存判定方式(不像传统数据库那样靠一个中心锁管理器维护会话状态,容易被误认为”没有锁管理器”)。但去中心化的锁生存判定,和”不存在锁”是两件完全不同的事。


六、学术谱系、争论与开放问题

谱系:死锁检测算法的经典分类——集中式(centralized)、层级式(hierarchical)、分布式(distributed)——来自 Knapp, Edgar, Deadlock Detection in Distributed Database Systems, ACM Computing Surveys, 1987,这篇综述系统整理了早期分布式数据库对 wait-for-graph 的不同维护策略。TiKV 的方案落在集中式这一类:全局唯一的检测 leader、全局 wait-for-graph、检测请求都转发给它——这类方案的经典权衡就是”单点简单可靠,但 leader 变更时的状态一致性需要额外处理”,TiKV 的”leader 切换直接清空重建”正是这条权衡的具体体现,不是 TiKV 独有的疏漏。

工程间隙:Percolator 论文假设的是低冲突批处理场景,完全没有讨论死锁——增量索引更新任务里几乎不存在”A 等 B、B 等 A”的运行时阻塞。TiDB 引入悲观锁模式服务在线 OLTP 之后,死锁检测成为一个论文完全没有涉及、必须从零设计的新问题。这是继 第 10 篇 提到的”高冲突场景逼出悲观锁模式”之后,同一条工程间隙继续延伸出的下一个新问题。

开放问题:死锁检测器 leader 变更时清空 wait_for_map,理论上会造成一个短暂窗口内的漏检——已经形成的死锁环要等到相关事务重新发起检测请求才会被重新发现。这个窗口有多长、在生产负载下漏检的实际概率有多大,官方文档没有给出量化分析,社区讨论(tikv/tikv PR #6415)也只停留在”是否应该换成更贴近 TiDB 生命周期的选举机制”的方向性争论,没有定论。这仍是一个有明确源码依据、但缺乏量化研究的开放问题,本文不编造具体数字。


七、常见误解

误解 事实
「TiKV 用乐观事务,所以没有锁」 Prewrite 阶段本身就在写锁;悲观模式的锁在语义上接近传统行锁,只是判定方式和持有时长不同
「TTL 是固定的 3 秒」 默认基准是 3000ms,但会按 ttl = ttlFactor × sqrt(写入量) 动态放大,长事务还靠每 20 秒的心跳续期
「悲观锁一定会经过 Raft 才算加锁成功」 v6.0 起默认走 in-memory 路径,只在内存里加锁,Leader 计划内切换才会同步给新 Leader
「死锁检测器是单独选举出来的角色」 复用了”覆盖空字符串 key 的第一个 Region”的 Leader,不额外跑一套选举协议
「只要发生死锁,检测器一定能发现」 检测器 leader 切换会清空历史依赖图,存在漏检窗口;且只在非第一把锁时才触发检测

八、小结

三句话小结

  1. TiKV 的悲观锁默认走 in-memory 路径(v6.0 起),加锁延迟从”一次 Raft 提交”降到”一次内存写入”,代价是非计划的 Leader 切换可能丢锁——这是”用一致性窗口换吞吐”的显式权衡,不是缺陷。
  2. TTL 不是固定常量,按写入量动态放大,长事务靠 primary lock 每 20 秒的心跳续期;死锁检测复用”第一个 Region”的 Leader 选举做集中式 wait-for-graph 检测,Leader 变更会清空历史依赖,存在有限的漏检窗口。
  3. 「TiKV 无锁」是一个需要纠正的误解——乐观事务的锁窗口短、悲观模式的锁语义接近传统行锁,ResolveLock 靠客户端遇锁即时清理和 GC 周期性批量清理两条路径共同收尾,锁的存在贯穿整个事务模型,只是形态和传统中心化锁管理器不同。

下一篇进入 Coprocessor:计算下推的 DAG 模型与它的边界。


上一篇Percolator 乐观事务落地

下一篇Coprocessor

返回 系列目录


参考资料

规范 / 文档(A 级)

  1. TiKV Deep Dive, Locking(乐观 vs 悲观锁定策略对照)。
  2. TiDB Development Guide, Lock ResolverresolveLockAsync/resolvePessimisticLock/resolveLock 三种路径)。
  3. TiDB Docs, Pessimistic Transaction Mode(in-memory 悲观锁行为、size limit 参数、丢锁场景说明)。
  4. TiDB Docs, TiKV Configuration Filewait-for-lock-timeout 默认 1s)。
  5. TiDB Docs, GC Overview(Resolve Locks / Delete Ranges / Do GC 三步)。

源码 / 工程讨论(A 级)

  1. tikv/rfcs0077-in-memory-pessimistic-locks.md(内存锁表设计、Leader 转移前的迁移流程)。
  2. tikv/tikvcomponents/raftstore/src/store/txn_ext.rsPeerPessimisticLocks 结构与迁移标记语义)。
  3. tikv/tikvsrc/storage/txn/commands/resolve_lock.rsResolveLock 命令的 txn_status 语义)。
  4. tikv/tikvstore/tikv/lock_resolver.go(历史实现中的 defaultLockTTLttlFactor 公式)。
  5. tikv/tikv PR #6415,deadlock: more solid role change observer(死锁检测器归属与 Leader 稳定性的工程讨论)。

论文(A 级)

  1. Knapp, Edgar. Deadlock Detection in Distributed Database Systems. ACM Computing Surveys, 1987(集中式/层级式/分布式死锁检测算法分类,TiKV 方案的谱系锚点)。

站内对读

  1. 《Percolator 乐观事务落地》(Prewrite 阶段锁的形态、高冲突场景催生悲观模式)。
  2. 《Percolator 模型:Google 的乐观事务方案》(前滚/回滚判断逻辑的完整推导)。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .