以几年前的一个 case 为例,讨论了 TiFlash 的 HTAP 架构对排查不一致问题的影响,也讨论了如何用 AI 来减少这一类问题的调查时间。
正文
结论
这个 bug 的表象是:endless consistency 测试中,insert 负载下同一个 start_ts 对拍 TiKV 和 TiFlash,TiFlash 偶发少返回若干行,例如 tikv: [35068980], tiflash: [35068960]。再次用同一个 TSO 查询,结果会自动恢复一致,说明底层数据没有永久丢失,也不是被后续 snapshot 覆盖修好的持久化损坏。
最终根因不是 ReadIndex 没有推进 max_ts,也不是 TiKV memory lock、bypass lock、Region Split 本身。真正的问题在 DeltaMerge 的 DeltaIndex 复用:一个较早的读已经拿到了旧的 segment snapshot,但尚未完成 place index;另一个较晚的读先执行,把共享 DeltaIndex 推进到了更新的状态。这个更新后的 DeltaIndex 中如果包含“重复写入的同一批 row_id + version”,会把较早 snapshot 中仍应该可见的那批重复 tuple 标记为 deleted。较早的读继续执行时复用了这个过新的共享 DeltaIndex,于是漏读了这些行。
PR #9000 的修复就是围绕这个点:
- 在
DeltaTree里记录max_dup_tuple_id,place insert 时记录发生重复 tuple 的最大 tuple id - clone
DeltaIndex时除了检查 delete range 数量不超过 snapshot,还要检查 snapshot 是否覆盖了共享DeltaIndex中所有 duplicated tuples- 如果不覆盖,就不能复用共享
DeltaIndex,必须为当前 snapshot 重新 place。
- 如果不覆盖,就不能复用共享
最小化后的触发序列
issue 评论中 J-H 给出的抽象复现可以概括成:
- 线程 A 通过
resolveLocksAndReadRegionData写入一批行batch_1,随后获取 segment snapshotsnap_a。 - 线程 C 又写入一批和
batch_1具有完全相同 row_id 和 version 的行batch_2。 - 线程 B 获取更晚的 segment snapshot
snap_b。 - 线程 B 先于线程 A 执行读流程,并更新 segment 的共享
DeltaIndex。 - 线程 B 更新
DeltaIndex时,因为 row_id/version 重复,batch_1在 delta tree 中被 delete 掉。 - 线程 A 继续使用
snap_a读,但 clone/reuse 了线程 B 推进后的共享DeltaIndex,导致batch_1对线程 A 不再可见,从而少读。
这个错误是 transient 的:后续读通常会拿到更完整、更靠后的 snapshot,delta 覆盖了重复写入的全部 tuple,DeltaIndex 和 snapshot 再次匹配,所以结果自然恢复。
现场现象和证据链
初始 issue #8845 在 2024-03-14 提交,描述是 consistency test 中 select count(*) 对拍失败,TiFlash 少 20 行。Issue 被标记为 storage critical bug。
关键现象:
- workload 基本只有 insert,没有注入错误。
- 错误窗口很短,同一个事务或同一个 TSO 后续查询会收敛到正确结果。
- 多次现场都伴随 Region Split、Remote Read、Learner Read、lock resolve 或并发读写 DeltaMerge。
- 不是所有 TiFlash 节点都错,通常是某个节点的某次 TableScan 少了固定数量的行,例如 20、50、863。
- 检查同一时间之后的 apply snapshot,排除了“后续 snapshot 覆盖坏数据导致恢复”的解释。
4 月下旬的日志把方向从事务层拉回到了 DeltaMerge:
- 同一 region、同一 applied index 下,来自 Raft Apply 线程的写入和来自 Learner Read/resolve lock 路径的写入发生并发
这其实是一个关键线索,因为它能够极大的缩小范围。实际上如果 C-N 当时没有看到这条线索,那么会在事务层上浪费更多的时间。
C-N 当时提到,不妨假设事务层是有问题的,那么在这种情况下,Delta Index 也有问题。 - 同一批 50 条记录被两条路径写入。第一次读的 snapshot 中
fully_indexed=false,第二次读已经fully_indexed=true
两次 snapshot 行数差和 delta index 行数差相差正好 50,吻合“重复 tuple 被过新的 delta index delete 掉”的模型。
定位过程:难点主要在找到 DeltaIndex
这个问题在定位到 DeltaIndex 之后,修复并不复杂,真正困难和耗时的是前面的定位:现象强烈指向事务/ReadIndex/lock/Remote Read,但最终根因藏在 DeltaMerge 的读索引复用。
早期首先确认了几个事实:
- TiFlash 少的是某一次 TableScan 的行数,不是聚合或 TiDB 层统计错误。把同一查询拆到各 TiFlash 节点看
MPPTaskStatistics,发现只有某个节点某次 scan 少了 20/50 行。 - 用相同
start_ts后续再查会恢复,说明不是数据永久丢失,也不是 PageStorage/DeltaMerge 持久化写坏。 - 错误现场经常伴随 Region Split 后 epoch mismatch 触发 Remote Read,因此最早怀疑 remote read 的 key range、region split 处理、apply snapshot 覆盖等路径。
- 检查错误时间点之后的 apply snapshot,发现没有发生能解释“恢复正确”的 snapshot 覆盖行为,于是排除了 apply snapshot 覆盖坏数据。
这个判断是很重要的,因为在检查 raft 路径导致的不一致问题的时候,需要首先区分是否是 apply snapshot 导致的。对应的后续调查方案是不一样的。
接着排查转向事务可见性。C-N 在 issue 第一条评论里提出过一个很合理的怀疑:
- #3971 引入了同 region 的历史 ReadIndex 结果复用
- 如果两个事务互不可见,
start_ts和 commit record 在 Raft log 中的顺序不一定一致,那么较小 TSO 复用较大 TSO 的 read index 可能读不到后面的 commit record
这个假设解释力很强,因为现象正好是“TiFlash 少读已提交数据”。 - 但后续 J-H 指出,Async Commit 的
max_ts推进理论上应该防止这个问题,所以它不能直接作为根因。
之后又集中排查了 max_ts、memory lock 和 bypass lock:
- TiFlash 的 read index 路径一度被怀疑没有真正通过 TiKV 的
ReadIndexObserver推进max_ts,因为旧的read_indexRPC 已经不再使用,而 TiFlash 看起来发送的是RaftCmdRequest{cmd_type=ReadIndex}。
后来通过继续跟 TiFlash proxy 到 TiKV leader 的交互确认,TiFlash 虽然内部使用RaftCmdRequest,但真正和 TiKV 交互仍走MsgReadIndexraft message,路径是合理的。 - 使用 TiKV 侧临时日志确认:TiFlash Proxy 发出的
MsgReadIndex带上了目标start_ts;TiKV 收到后确实打印了advance max_ts to ...,也返回了对应 read index。这排除了“完全没有推进max_ts”的简单解释。 - memory lock 也被排除
因为:多个复现场景中错误附近没有足够的 memlock 事件;有些焦点 region 的bypass_locks为空;TiFlash 遇到 lock 后触发 Remote Read、resolve lock、重试的行为在日志上能解释,但不能解释最终少行。 - bypass lock 也被排除
因为:相关日志显示读事务并没有依赖异常的 bypass lock 路径,且 lock 的min_commit_ts/commit_ts关系不能完整解释“同一 TSO 后续恢复”的现象。
这个阶段最迷惑的是“矛盾日志”:
- TiKV 已经 advance
max_ts并返回 read index,例如 region 459 返回到 313679。 - 但 TiFlash 随后在 apply 313680 时仍看到
commit_ts < read_tso的记录。
这看起来像 follower read 与 Async Commit 的线性一致性问题。后续还去咨询 TiKV 侧,分析 async commit、read through lock、min_commit_ts 推进规则。这个方向耗时很大,因为它确实和一致性语义相关,而且日志表面上支持。
后续这个问题进入了僵局。最终 C-N 发现了下面的问题:
转⽽考虑,⽆论是否承认 max_ts 有问题,⾄少确认已经 apply 到了 79 这个 raftlog,那么:
- 要么能看到 lock
- 要么能看到 commit
不应该出现既没有看到 lock ⼜没有看到 commit 的情况。因此准备转⽽调查 resolvelock 和 raft command 两个地⽅的⾏转列的过程。
这是整个问题解决的关键,因为它揭示了在经过了那一团浆糊一样的事务问题之后,无论这时候的数据是否已经有问题,那么后续的处理也是有问题的。
C-N 把两次读同一个 segment 的信息做了对比:
- 第一次读拿到的 snapshot:
fully_indexed=false,snapshot 中的 stable/persisted/mem rows 总数较少。 - 第二次读拿到的 snapshot:
fully_indexed=true,snapshot 行数更多,DeltaIndex也更靠后。 - 两次 snapshot 行数差是 4860,两次 delta index placed rows 差是 4810,差值正好 50。
- 同时 Learner Read 线程和 Raft Apply 线程对同一 region、同一 applied index 写入了同一批 50 条记录,版本和 handles 完全吻合。
这组证据把问题从“为什么读不到已经 commit 的 MVCC 数据”改写成“为什么旧 snapshot 会使用包含额外重复 tuple 删除信息的过新 DeltaIndex”。随后 JHL 在 PR #9000 中抽象出单测:
- 线程 A 先拿旧 snapshot,线程 B 先读并推进 shared delta index
- 然后线程 A 复用这个 shared delta index 时少读。
PR 评论里最小测试曾复现 count1 = 118, count2 = 128,这直接证明了问题在 DeltaIndex 和 snapshot 的匹配关系,而不是事务层。
为什么这么难查
- 症状像事务一致性问题,但根因在存储读索引复用。TiFlash 少读的行对应的
commit_ts小于读 TSO,且现场经常出现 ReadIndex、Async Commit、memory lock、bypass lock、Remote Read、Region Split,因此很自然会先怀疑max_ts推进、lock resolve 或 ReadIndex 复用。 - 问题会自动恢复,容易误导排查。相同 TSO 之后再查是正确的,说明数据既没有永久丢也没有损坏;这排除了很多常规方向,但也让现场窗口很短,必须在错误发生时保留 segment snapshot、DeltaIndex、KVStore、日志和 region 信息。
- 复现条件苛刻。它需要高并发读、Remote Read/resolve lock 触发的写入、Raft Apply 写入、相同 row_id/version 的重复 tuple、旧 snapshot 和新共享 DeltaIndex 交错复用。早期本地集群和 IDC 集群很难稳定复现,后来通过调小 region 大小、提高 split 频率、打开 failpoint 增加 remote read 才提高概率。
- 中间出现了多个合理但非最终的假设。PR #3971 引入的 batch/async read-index 复用逻辑一度被怀疑;#8873 增强了 ReadIndexWorker 日志;#8874 增加写入 version 和 segment snapshot rows 日志;#8928 用来检查 async task 测试/日志路径。这些帮助排除方向,但不是最终修复。
- TiKV/TiFlash 日志存在时钟漂移和跨组件链路。文档里曾用 TiKV 加日志确认 TiFlash Proxy 确实发了
MsgReadIndex,TiKV 也 advancemax_ts并返回 read index;但 TiFlash 随后仍看到看似commit_ts < read_tso的写入。这让问题一度看起来像 Async Commit 与 follower read 的线性一致性问题。后续才确认这不是最终矛盾,而是并发写入和 DeltaIndex 复用造成的读视图错配。
从 TiFlash 架构看,为什么一致性问题更难查
TiFlash 的 HTAP 架构选择是“TiKV 行存主副本 + TiFlash 列存 learner 副本”。官方文档中也说明,TiFlash columnar replica 是通过 Raft Learner 异步复制得到的:TiFlash Overview;VLDB 论文 TiDB: A Raft-based HTAP Database 也描述了 TiFlash learner 接收 Raft log,并把 row-format tuple 转成 columnar representation。这个架构带来了很好的 HTAP 隔离和列存性能,但一致性排查天然更复杂。
- TiFlash 不是事务提交路径上的 voting replica,而是 learner columnar replica。写入先在 TiKV/Raft 事务语义中成立,然后 TiFlash 通过 Raft log apply、row-to-column decode、DeltaMerge 写入、segment snapshot、DeltaIndex 等步骤把数据转成可分析查询的列存视图。因此一个读结果不一致,可能发生在事务层、Raft learner apply、proxy 交互、行转列、DeltaMerge 存储视图、查询执行任意一层。相比单一存储引擎,怀疑面更宽。
- TiFlash 的“读到某个 TSO”不是简单读一个本地 MVCC store。它需要 ReadIndex 保证 learner 至少 apply 到足够新的 Raft index,还要处理 Async Commit lock、
max_ts/min_commit_ts、read through lock、bypass lock、resolve lock、Remote Read 重试。也就是说,读可见性由 TiKV 事务协议、Raft read index、TiFlash learner 状态和本地 DeltaMerge snapshot 共同决定。任何一层日志单独看都可能“合理”,但组合起来才暴露矛盾。 - Region Split 和 Remote Read 会把一个逻辑 table scan 拆成跨 region、跨 TiFlash 节点、跨 epoch 的多个子读。Issue #8845 的现场多次出现 split 后 epoch mismatch,随后触发 Remote Read;Remote Read 又可能在另一个 TiFlash 节点上以 Coprocessor 请求形式读同一个 region。这样一来,同一个 SQL 的错误可能只出现在某个 region 的某个 remote cop task 上,而其他 TiFlash 节点和后续查询都正确。定位时必须把 SQL、MPP task、region epoch、remote target、read index、segment 读任务全部串起来。
- TiFlash 本地列存不是 Raft KV 的直接镜像,而是 DeltaMerge 的多层读视图。一次读会拿 segment snapshot,再决定是否复用 shared
DeltaIndex、是否需要ensurePlace、是否fully_indexed。这类内部优化通常不改变持久化数据,只影响某个瞬间的读视图,所以 bug 表现为“瞬时少读,后续恢复”。这种问题不会像数据损坏那样留下稳定现场,也不会像事务协议错误那样一定能从 TiKV 日志直接解释。 - TiFlash 为了性能引入了多处跨请求复用:ReadIndex 结果复用、shared DeltaIndex 复用、batch read index、remote read 重试、snapshot 复用/推进。这些优化单独看都有正确性条件,但一致性 bug 往往出现在“两个正确优化的边界交错”上。#8845 就是旧 snapshot 和过新的 shared
DeltaIndex在 duplicated tuple 场景下组合出了错误读视图。
和其他 HTAP 实现相比,这个复杂度有 TiFlash 架构自身的特点:
- SingleStore 官方文档强调它可以在一个系统中结合 rowstore 和 columnstore,并在单个查询里合并实时和历史数据:High-Performance for OLTP and OLAP Workloads。它也有分布式和存储格式复杂性,但通常不是“TiKV 行存主副本 + TiFlash Raft learner 列存副本”这种跨进程、跨引擎复制后的读视图问题。
- CockroachDB 更偏向单一分布式 KV/MVCC 存储层,上层有向量化/列式执行来加速分析型查询:Architecture Overview。这类架构的难点更多集中在事务、租约、range、closed timestamp 等同一存储系统内的一致性,而不是额外列存 learner 副本与行存主副本之间的视图对齐。
- OceanBase 官方 HTAP 介绍强调在同一个分布式数据库内直接承载交易和分析:OceanBase HTAP introduction。它同样有分布式事务和分析执行复杂性,但 TiFlash 这种“主行存 + 异步列存副本 + Raft learner + 行转列 DeltaMerge”的链路,给排查额外引入了副本同步点和列存视图构建点。
所以 TiFlash 的一致性问题难查,并不是因为某个模块特别脆弱,而是因为它把事务系统、Raft 复制、异步列存副本、MPP/Remote Read、DeltaMerge 本地读优化组合在一起。问题的真实根因可能在任意一层,但表象往往会穿过多层后才被 SQL 对拍发现。#8845 正是这种架构复杂性的典型案例:外观看起来像事务/ReadIndex 问题,实际是列存读视图中 shared DeltaIndex 和 snapshot 的代际错配。
关联 PR 和作用
- #9000:最终修复。核心是阻止旧 snapshot 复用包含其未覆盖 duplicated tuples 的过新共享
DeltaIndex,并补充单测DupHandleVersionAndDeltaIndexAdvancedThanSnapshot、ReadWithMoreAdvacedDeltaIndex2等。 - #8873:排查期间改进 ReadIndexWorker 代码和日志,记录 read index 复用等信息。
- #8874:增加可选的 record version 日志和 segment snapshot rows 日志,用于确认写入版本、行数和 snapshot 关系。
- #8928:排查 remote read / async task 相关现象时用于检查测试路径,不是根因修复。
- #3971:历史上引入高并发下按 start-ts batch/reuse read-index 的机制,一度被怀疑会导致旧 TSO 复用过小 read index;最终由
max_ts逻辑和后续证据排除为主因。
如果用 AI 辅助调查,能加速哪里
这类问题里,AI 最有价值的地方不是直接猜根因。早期现象确实很像 ReadIndex、Async Commit、lock 或 Remote Read 问题,AI 如果只根据表象下结论,也很可能走同样的弯路。真正能加速的是把大量日志、代码路径、假设和反证组织起来,让调查更快收敛到“哪个假设还站得住”。
- 日志归并和时间线重建
这个 bug 的证据分散在 endless 报错、TiFlash MPPTask、LearnerReadWorker、RemoteRequest、DeltaMergeStore、Segment、Proxy、TiKV raftstore 日志里。
AI 可以围绕同一个start_ts、region_id、applied_index、row_id/version自动抽取事件,重建“split -> remote read -> read index -> resolve lock -> raft apply -> segment read”的时间线。 - 维护假设和排除矩阵
排查过程中出现过 remote read key range、Region Split、apply snapshot、ReadIndex 复用、max_ts未推进、memory lock、bypass lock、DeltaIndex 等多个假设。
AI 的作用是能够快速地去探明一个可能的原因是否是真的 root cause,从而去简化上下文。这是因为随着查询链条的深入,也引入了多个分叉,链条之间的因果就不是那么牢靠。从一个角度能解释的通,但是从另一个角度可能这个原因就解释不通。
更重要的是,它可以提醒“fully_indexed=false+ 后续 shared delta index 被推进”本身就是存储读视图风险点,避免在事务层钻牛角尖,从而更早转回 DeltaMerge。 - 生成更精准的 instrumentation
排查中实际合入了 #8873 和 #8874 来增强日志。AI 可以基于当前假设建议更有针对性的打点,这能减少“先广泛加日志再人工筛”的迭代成本。 - 从现场日志反推出确定性单测
最终 #9000 的测试本质上是把线上交错压缩成一个可控序列:线程 A 先拿旧 snapshot,线程 B 先读并推进 shared delta index,然后线程 A 再用旧 snapshot 读。AI 很适合根据日志里的 happens-before 关系生成这种 test skeleton,帮助把偶发线上问题变成稳定单测。 - 跨组件语义校验
ReadIndexObserver、MsgReadIndex、RaftCmdRequest、Async Commit、max_ts、min_commit_ts、read through lock、bypass lock 这些概念很容易混在一起。AI 可以把设计文档、代码和日志放在一起核对:当前路径到底是否推进了max_ts,哪个 message 类型生效,bypass_locks=[]能排除什么,commit_ts < read_ts的日志是否一定表示事务层错误。这个能力能帮助更快识别“看似矛盾但其实不是根因”的证据。
这能够减少查问题的时候,因为涉及到不同组件,不同人之间的协调产生的损耗。