TiDB 内核解剖室SECTION 10 · 病理分析

四个坏掉的瞬间 —— 时间尺度 / 空间死结 / 冲突时机 / 规模效应,每个都回到真机日志与官方口径,逐字可对

1024 TiDB AIGC 黑客松 · 参赛作品 v0.2
这页与 08 号容灾实况、实测笔记页的分工: 08 号把一次真故障的 23 分钟重放给你看 —— 它回答「发生了什么」;实测笔记页摊开原始数据 —— 它回答「量到了什么」; 这一页只做一件事:把四个坏掉的瞬间剖开 —— 为什么会坏、边界在哪里、官方口径怎么讲。 四个病理与云集群兑现记录合计 46 条关键证据值,逐条能在 dev/probe/ 与 dev/cloud-evidence/ 的原始输出里逐字找到,并由 dev/pathology-check.js 机器比对(找不到就报错)。本页为静态快照,零真机依赖,断网可看。
病理 01 · 时间尺度

"恢复"不是一个数 —— 它是五个量级不同的等待

监控红线 20 秒就报警,业务方却问"怎么 9 分钟才恢复满配"。这两个数说的不是同一件事 —— 剖开看。

症状 2026-09-21 的真故障实验(08 号页的素材源)里,五个数字被反复引用:15 秒检出、117 秒判死、8 分 44 秒迁移、20 秒上线、14 秒回填。 它们经常被揉成一句"恢复要几分钟" —— 但它们是五种性质完全不同的等待,治理手段也完全不同。
病灶 · 第一层:官方给的是机制时钟,不是端到端承诺 官方口径里,Raft 的治理动作挂在逻辑时钟上:raft-base-tick-interval = 1s(基准 tick 间隔), 选举超时按 tick 换算 —— raft-election-timeout-ticks = 10,即 10 秒(参数换算,非实测)。 但"从故障发生到新 Leader 就绪"的端到端时长,官方未给出 —— 所以本页也不给"一个恢复时长",只把真机上的五个阶段分开量、分开列:
检出 Disconnected10:40:12 → 10:40:28
15 秒
判死 Down→ 10:42:08
117 秒
失效副本迁移→ 10:50:56
8 分 44 秒
重新上线10:51:02 → 10:51:23
20 秒
空店回填开始→ 10:51:37
14 秒
条宽按对数刻度示意(8 分 44 秒为满格)——否则最短的 14 秒会细成一条线,反而看不出"差 35 倍"这件事。 全部时刻来自 dev/probe/fr1-failover-replica.txt 的关键时刻表,可逐行对。
病灶 · 第二层:最长的那个阶段里,服务其实没断 8 分 44 秒的迁移是分批复制的:store 4 上的失效副本 6 → 5 → 4 → 2 → 1 → 0 一批批搬走, 不是一次搬完。这段时间里写入探针 95/95 次全部成功 —— 时间尺度的"长",不等于服务的中断; 但迁移期间副本数在最低点是 2(欠一份),这段窗口里再坏一台就是数据风险 —— 这才是"8 分 44 秒"真正值得盯的原因。 同一场景的官方机制描述:心跳丢失 → Disconnect → 超过 max-store-down-time → Down → 在存活 store 上补足副本,本实验的 down-time 见病理 02 的账本行。
判定 "恢复慢不慢"是个错问题。正确的问题是 "哪一阶段慢": 15 秒检出动的是参数,117 秒判死动的是 max-store-down-time(本次由 30m 临时调为 2m 才压到 117 秒), 8 分 44 秒迁移动的是数据搬运量本身。把一个多阶段过程压缩成单一数字,等于把所有治理抓手也一起丢掉了。
官方口径 vs 实测 · 一处边界如实并列 官方 store-state 口径写的是"心跳丢失超过 20 秒 → Disconnect";本次真机实测停机后 15 秒检出 Disconnected。 两个数字并不严丝合缝 —— 2 秒级采样本身有盲区,检出时机还与心跳重试节奏相关。 本页不推断差异原因,只把两个口径都摆出来:这种"实测贴着官方、又不完全等于官方"的地方,最不该被抹平。
病理 02 · 空间死结

3 店 3 副本,坏 1 台 —— 为什么"补副本"会补不出来

2026-09-20 的 r1 实验:想把 3 店集群缩容到 2 店,命令被 PD 直接拒绝;同一天另一条线上,停 1 台店后"补副本没有候选"。两条线撞的是同一堵墙。

症状 缩容命令 store delete 4 发出去,PD 在入口就拒绝 —— 不是"进去以后卡住",是根本不让进。 拒绝原文(r1 实验 · 真机):
$ ctl pd -u http://192.168.3.112:2379 store delete 4
Failed to delete store 4: [400] "[PD:core:ErrStoresNotEnough]can not remove store 4 since the number of up stores would be 2 while need 3"
病灶 · 亲手摆一遍:约束是怎么把路走死的 两条硬约束:每 Region 3 副本,且同 Region 的副本不在同一店。 3 店 × 3 副本的布局里,每个 Region 的三个副本恰好占满三个店 —— 下面是简化示意(3 个 Region),亲手停一台试试:
店 ①Up
店 ②Up
店 ③Up
店 ④未加入
R1
①②③
R2
①②③
R3
①②③
稳态:3 店 × 3 副本,每个 Region 的三个副本恰好落在三个店上,任意一台坏掉都还能剩 2 份 —— 这就是"3 副本"买到的容错。
病灶 · 死结的推理链 停掉店 ③ 后,每个 Region 剩两组副本在 ① ② 上。要恢复 3 副本,得再补一份 —— 可是候选店只剩 ① ②, 而这两家都已经持有该 Region 的副本:放进任何一家,都会违反"同 Region 副本不在同一店"。 没有可落的位置,补位就失败 —— 这不是调度器偷懒,是布局本身的数学下限。 FR1 实验的解不在这堵墙里:临时扩容 第 4 个 TiKV 实例(store 3153)承接失效副本, 制造"4 店 3 副本"格局,让迁移有处可落;这就是"补副本没有候选"在真机上的标准解法。
判定 · 官方口径给这个死结留了名字 下线时若不存在满足搬迁条件的目标 Store —— 该 Store 将一直处于 Offline 状态。 这就是"缩容下限保护"的出处:3 店集群的下限是 3 店。r1 的拒绝不是拦你,是替你把"可用性下限"焊住了 —— 主动留 3 店,好过扩容失败再救火。 调度相关动作本身的口径:副本增 / 删 / 转移 Leader、 调度建议不保证一定被执行。
病理 03 · 冲突时机

两个 UPDATE 都成功了 —— 为什么 COMMIT 时才报写写冲突

2026-09-19 的 r2 实验:两个事务改同一行,乐观模式下 UPDATE 双双返回成功,提交时第二个被 9007 拒绝。剖开这个"时机差"。

症状 乐观模式(BEGIN OPTIMISTIC)下,两个事务的 UPDATE 都返回成功 —— 那一刻数据还没写入存储; 真正的碰撞发生在 COMMIT 发起之后,第二个事务撞上写写冲突,错误原文(r2 实验 · 真机采集输出照录):
(9007, 'Write conflict, txnStartTS=469183914540531717, conflictStartTS=469183914540531716, conflictCommitTS=469183914540531719, key={tableID=135, tableName=lab.txn_demo, handle=1}, originalKey=7480000000000000875f728000000000000001, primary={tableID=135, tableName=lab.txn_demo, handle=1}, originalPrimaryKey=7480000000000000875f728000000000000001, reason=Optimistic [try again later]')
界面上看到的是"UPDATE 成功、COMMIT 失败" —— 这到底是数据丢了,还是姿势错了?
病灶 · 冲突不是"发生在提交",是"到提交才有机会被发现" 官方口径:冲突检测发生在 TiKV 的 prewrite 阶段; 而 prewrite 是 两阶段提交的第一步 —— 在客户端发起 COMMIT 之后才执行。 拼起来就是时间线:乐观模式的 UPDATE 只写进 TiDB 的本地缓冲区(不碰存储、不检测冲突), 直到 COMMIT 触发 prewrite,两个事务的修改第一次在 TiKV 相遇 —— 冲突这才显形。 左右对照,两种模式把同一场碰撞暴露在完全不同的时刻:

实验一乐观模式 —— 碰撞在 COMMIT 暴露

A · BEGIN OPTIMISTIC 后读 v(0,)
B · BEGIN OPTIMISTIC 后读 v(0,)
A · UPDATE✓ 本地缓冲
B · UPDATE(两边都还没写进存储)✓
A · COMMIT✓
B · COMMIT✗ 9007 写写冲突
落盘结果:v 只加了一次(1,)

实验二悲观模式(默认)—— 碰撞在 UPDATE 暴露

A · UPDATE id=2✓ 持有行锁
B · UPDATE id=2被阻塞 · 已等 3.0s
A · COMMIT(释放行锁)✓
B · UPDATE 在 A 提交后返回waited=3.006s
落盘结果:v 只加了一次(1,)
病灶 · 第二层:被拒 ≠ 丢更新(实验三) 乐观事务被 9007 拒绝后,库里到底是什么状态?同一实验把这件事也量了: A 先提交(v 从 1 变 2)→ B 的提交被 9007 拒绝 → 被拒后库里的值是 (2,) —— 说明 B 这次的加一没有写进去,也没有把 A 的覆盖掉; 随后 B 重试提交成功,重试后的值是 (3,) —— 两次加一最终都生效,一次都没丢。 两行收尾终值 ((1, 3), (2, 1)) 与逐步读数逐条吻合。
判定 UPDATE 成功 ≠ 数据已落地(乐观模式下);COMMIT 失败 ≠ 更新丢失。 9007 揭示的是"你读的快照已经过期,这次提交被安全拒绝"—— 正确姿势是捕获错误、整事务重试。 两条官方护栏直接决定了应用该怎么写:集群默认就是悲观事务(v3.0.8 起), "等锁"是默认路径上的正常行为;v8.0.0 起不再支持乐观事务的自动重试 —— 冲突重试必须由应用自己实现。
病理 04 · 规模效应

一行等 3 秒不可怕 —— 可怕的是它排队

实验二里 B 的 3.006 秒只是一个标本:两个事务、抢一行。把标本放进"热点行 × 高并发"的培养皿,才是这个病理真正要防的东西。

症状 悲观模式下,第二个写者被阻塞并等待 —— 实验二的读数 waited=3.006s。 单点、单行、两个事务,3 秒不是病:这是悲观模式用顺序化换正确性的正常代价。
病灶 · 真机事实:等锁在默认路径上 同一个实验的开头读出了两条环境事实:@@tidb_txn_mode = pessimistic、@@innodb_lock_wait_timeout = 50(真机读值)。 也就是说:default 配置下,取锁、排队、等待(上限 50 秒)是正常的常态机制,不是异常状态。 而等待是串行队列:同一行的写者一个接一个,先来先得 —— 队列里每个人的等待,都会被前一个人的持锁时长直接决定。
推导 · 未被实测(已显式标注) 把单点标本放进"热点行 × 高并发 × 长持锁"的乘积里,链条是:
① 队尾等待 ≈ 队列长度 × 平均持锁时长(粗略线性近似);
② 持锁时长会被大事务放大 —— 官方单事务大小上限 100 MB(且内存放大可达提交大小的 2–3 倍),事务越大,提交链路越长、锁的持有窗口越久;
③ 等待一旦越过重试护栏,应用侧的重试流量再进入队列,队列继续变长 —— 这就是"锁等待的自我放大"。
推导边界:本次没有做并发压测,以上链条未经实测;也不含锁调度公平性、网络抖动、TiKV 内部排队等现实因素。真机上量到的只有 waited=3.006s 这一个点,其余是机制推演 —— 两者不允许混着读。
判定 单点 3 秒不是病;病灶是"同一热点行的并发度 × 持锁时长"的乘积。 悲观模式保证了正确性,代价是把延迟压力集中到热点行上 —— 监控盯"慢查询"往往盯不到它,因为每条语句单独看都很快, 只有把"等锁时长"单独画出来,那条队尾的尾巴才会显形。
附 · 未覆盖清单(P-C)

从四条到零条 —— 没跑过的断言,现在没有了

这一节原本列出四条「画了但没跑」的断言。09-25 云集群上,B1–B4 四条已逐个转成实测(下方绿色标记的兑现记录);B4(TSO 分配开销)兑现的是机制(批合并与时间戳对账),不是性能。所有条目继续保留「当时为什么没跑」的原记录 —— 边界被补掉的过程,也是证据的一部分。

B1Offline → Tombstone 的完整流转09-25 已转实测
当时阻塞:3 店上 offline 任一店,剩余 2 店无法继续满足 max-replicas = 3(且 同 Region 副本不得同店)—— r1 实验止步于入口拒绝(ErrStoresNotEnough),恰好是「无满足搬迁条件的目标店则一直 Offline」的实证。
官方口径:Store 五态状态机(Up / Disconnect / Offline / Down / Tombstone)—— Region 与 Leader 计数清零后方可进入 Tombstone。
推导路径:第 4 店加入后,副本有处可迁 → 被下线店的 Region / Leader 清零 → Tombstone 可达。此链条从状态机定义直接推出。
兑现记录(09-25 · 云集群 · 5 个 TiKV):完整走通,而且两条路径各走一遍 —— ① TiUP 原生 scale-in:Offline 后 Region 从 71 每 2 秒降一批(71 → 62 → 55 → 45 → 38 → 27 → 22 → 13 → 5 → 2 → 0,共 20 秒),prune 日志出现 RemoveTomestoneNodesInPD / Destroy success;② 重新上线后用 pd-ctl store delete 手动下线:12:44:43.766 采样器首次读到 "tombstones": [1058],终态 display 保留 10.206.16.14:20160 tikv 10.206.16.14 20160/20180 linux/x86_64 Tombstone 一行。
已兑现 · 2026-09-25 · 证据:cloud-evidence/samples.jsonl / _cloud_prune.txt / final-state.txt(一次性快照,服务器已释放)
B2跨 Region 的多行事务09-25 已转实测
当时阻塞:r2 的三个事务实验都在单店、单 Region 规模内完成(表小、不分裂);跨 Region 的 2PC 行为没有量过。
官方口径:两阶段提交官方 7 步里明确:取写入路由并按路由对 Key 分类、并发向所有涉及的 TiKV 发起 prewrite;冲突判定则统一发生在 TiKV 的 prewrite 阶段。
推导路径:跨 Region 只改变"prewrite 触达的 TiKV 组数"与失败恢复面;正确性机制(快照版本校验、两阶段原子提交)不随 Region 数变化。
兑现记录(09-25 · 云集群):先量归属再动手 —— id=1000 / 200000 / 300000 分属 region 290 / region 390 / region 442 三个互不相邻的 Region(SHOW TABLE REGIONS 解析)。正例:一个事务改两行,提交后 CROSS-A / CROSS-B 同时可见;反例:第二行撞锁拿 ERROR 1205 (HY000) at line 1: Lock wait timeout exceeded; try restarting transaction,第一步虽已执行,id=300000 仍回滚为原值 —— 无部分提交。
已兑现 · 2026-09-25 · 证据:cloud-evidence/b2b3-txn.txt(B2-1 / B2-2 全文)
B3SELECT … FOR UPDATE 的锁范围09-25 已转实测
当时阻塞:r2 实验二量的是 UPDATE 写写冲突的等锁时长(waited=3.006s);FOR UPDATE 的加锁范围未单独观测。
官方口径:集群默认悲观事务 —— 悲观事务在读取时对目标行加锁(先取锁后写入);而 冲突检测发生在 prewrite 阶段。
推导路径:悲观模式下 FOR UPDATE 的加锁时机在"读取时"而非"提交时";锁范围随执行计划(点查 / 范围扫描 / 索引选择)变化 —— 具体范围官方文档未展开,本作品不写。
兑现记录(09-25 · 云集群):点查与范围各测一组 —— 点查 id=1000:data_lock_waits 里直接读出锁键 KEY: 7480000000000000725F7280000000000003E8 与 "handle_value":"1000"(锁的就是那一行),左邻 id=999 / 右邻 id=1001 的更新各 real 0m0.009s / real 0m0.008s 返回、不被阻塞;范围 BETWEEN 1000 AND 1005:范围内 id=1003 被挡(1205),范围外 id=2000 real 0m0.009s 通过。
已兑现 · 2026-09-25 · 证据:cloud-evidence/b2b3-txn.txt(B3-1 / B3-2 全文)
B4TSO 分配开销09-25 已转实测
当时阻塞:云集群首轮补采时,没有任何针对 TSO 的观测或压测 —— 这是覆盖边界的如实声明。当时的验证设计:高并发小事务下采样 TSO 等待,与尾延迟曲线对账。
官方口径:两阶段提交官方 7 步里,start_ts 与 commit_ts 两次时间戳都从 PD 获取 —— 事务的每个时间戳都走 TSO;官方对 TSO OPS 的说明:gRPC 请求数(cmd)之外还列出实际的 TSO 请求数(request)—— 每个 gRPC 请求包含一批 TSO 请求。
推导路径(保留原记录):TSO 是全局逻辑时钟,按批次分配以摊还开销 —— 兑现实验只测批合并机制本身;"单次分配耗时为多少"仍不出结论,也不给任何"能撑多少吞吐"的说法。
兑现记录(09-25 · 云集群 · 本底扣除法三段实验):空闲窗 64 秒先标定出集群本底 10.57 批次/s(部署后恒定存在、无法归因到具体组件,如实记为集群本底);串行窗(1 并发)平均批 1.010(≈ 1,逐时间戳请求);并发窗(16 并发)平均批 1.092(> 1,单次 RPC 携带多个时间戳);bucket 直方图里,并发窗 batch≥2 的批次 +2525 个、串行窗 batch≥2 的批次 +45(56 倍):批合并真的发生,动因是并发。时间戳对账:串行窗净时间戳 +1978(理论 2×1135=2270,87%)、并发窗净时间戳 +34974(理论 2×17966=35932,97%)—— 两次时间戳(start_ts / commit_ts)都经 PD 分配的机制成立;精确百分比受本底扣除与 15 秒采样粒度影响,如实标注。PD 服务端处理随负载增长(+1475 → +4146 → +31928 次),处理耗时均值微秒级(5.9/5.7/3.2 µs),与「TSO 处理是轻量内存操作」的官方口径一致。
已兑现 · 2026-09-25 · 证据:cloud-evidence/tso-b4-20260925-144942.txt(脚本 stdout 全文;一次性快照,服务器已释放)
B1–B4 已在 09-25 的云集群上逐个转成实测(绿色「已兑现」记录,证据落在 dev/cloud-evidence/)。已兑现的条目保留「当时为什么没跑」的原记录,不删 —— 边界被补掉的过程,也是证据的一部分: 知道什么没做,和知道什么做了一样重要。
附 · 诚实账本

本文全部结论 × 等级 × 可对账处

三个等级分开标注:实测 来自真机原始输出;实验设置 是人为调过、用后回滚的参数;推导 未经实测;官方未列明 的写法本作品一个字都不写。

结论等级可对账处
停机后 15 秒检出 Disconnected(官方口径为"超过 20 秒",两口径并列见病理 01)实测fr1-failover-replica.txt · 10:40:28 行
判死 Down 距停机 117 秒(max-store-down-time 临时由 30m 调为 2m,实验后已回滚)实测 实验设置fr1-failover-replica.txt · 10:42:08 行
失效副本迁移 8 分 44 秒,分批搬离(6 → 5 → 4 → 2 → 1 → 0)实测fr1-failover-replica.txt · 关键时刻表
迁移与判死全程写入探针 95/95 次成功实测fr1-writes.jsonl · 95 行
副本归位完成:存活店合计 21/21(store4 清零)实测fr1-failover-replica.txt · 10:50:56 行
恢复后 20 秒重新上线 · 14 秒收到第 1 个副本实测fr1-failover-replica.txt · 10:51:23 / 10:51:37 行
迁移机制为整体搬移:mv peer: store [4] to [5](总数恒 21)实测fr1-failover-replica.txt · 机制发现 2
3 店缩容被拒:can not remove store 4 since the number of up stores would be 2 while need 3实测r1-offline.txt · store delete 段
乐观冲突在 COMMIT 暴露:reason=Optimistic [try again later]实测r2-txn.txt · 实验一
悲观等锁时长 waited=3.006s实测r2-txn.txt · 实验二
被拒不丢更新:重试后 (3,)、两行终值 ((1, 3), (2, 1))实测r2-txn.txt · 实验三 / 收尾
选举超时参数 = 10 tick × 1s = 10 秒(换算,非实测)推导spec.js · raft-base-tick-interval / raft-election-timeout-ticks
故障到新 Leader 就绪的端到端时长 —— 官方未给出官方未列明spec.js · leader-fail-e2e
798 扩容:Up 后 4 分 34 秒承接 74 个 Region,其后 179 个采样点稳定在 74实测cloud-evidence/samples.jsonl · 12:07:58–12:13:55
TiUP 原生下线:Offline 后 20 秒搬空,prune 报 RemoveTomestoneNodesInPD / Destroy success(13 → 12 节点)实测cloud-evidence/samples.jsonl · _cloud_prune.txt
pd-ctl 手动下线:12 秒归零、25 秒后进入 Tombstone,终态 display 保留 Tombstone 行实测cloud-evidence/samples.jsonl · final-state.txt
跨 Region 两行事务被 1205 挡下后整体回滚(无部分提交);FOR UPDATE 点查只锁目标行(邻行 9ms / 8ms 通过)实测cloud-evidence/b2b3-txn.txt · B2-2 / B3-1
TSO 批合并:并发窗平均批 1.092 vs 串行窗 1.010(batch≥2 的批次 +2525 vs +45);两次时间戳都经 PD 分配实测cloud-evidence/tso-b4-20260925-144942.txt · 本底扣除法
锁等待的自我放大链条(队列 × 持锁时长 × 重试回流)推导本页病理 04 · 未做压测
上表是本页的叙述级结论;四个病理与账本里出现的全部 46 条关键读数(从第一轮的 15 秒检出,到 09-25 云集群的 real 0m0.008s,再到 B4 的 batch≥2 直方图)由 dev/pathology-check.js 与 dev/probe/ / dev/cloud-evidence/ 原文逐字比对 —— 页面值必须在原始日志里逐字命中,否则构建不绿; 同一份脚本还反向检查本页不得出现任何未经标注的性能数字(本作品未做压测)。
本页证据的五个出处
病理 01 / 02dev/probe/fr1-failover-replica.txt 病理 02dev/probe/r1-offline.txt 病理 03 / 04dev/probe/r2-txn.txt B1–B4 兑现dev/cloud-evidence/(09-25 · 一次性快照) 口径字典spec.js(69 键 · 点开的取证卡)
怎么核验这页
带虚线下划线的词都可以点开 —— 每张取证卡给出官方原文、适用版本与直达链接。 页面上的每个实测值都由 dev/pathology-check.js 与 dev/probe/ / dev/cloud-evidence/ 原文逐字比对,找不到即报错。 三个等级分开标注:实测 实验设置 推导 —— 推导不是实测,不允许混着写。
诚实边界(四条)
① 全部数字来自单次实验(3 节点集群 + 临时第 4 店 + 5 台云服务器)。生产规模的迁移速率与时机不同,请读作机制证据,不是容量基准。 ② 病理 01 的"10 秒选举超时"是官方参数的换算值,不是实测;本页不提供任何"端到端恢复时长"的单一数字。 ③ 病理 04 的雪崩链条是推导 —— 本次没有做并发压测,推导段已单独标注。 ④ B1–B4 的兑现来自 09-25 的云集群,是一次性快照 —— 五台服务器当天已释放,dev/cloud-evidence/ 的原始输出不可复跑;B4 兑现的是机制(批合并与时间戳对账),不出任何性能结论。
上游页面
08 · 容灾实况 —— 23 分钟全过程回放(2 秒级采样 · 586 个采样点)。 实测笔记 —— 全部原始采集数据与工具纠偏记录。 核验实录 —— 69 项官方口径的逐条出处。