例论 TiFlash 的 HTAP 架构对排查不一致问题的影响

以几年前的一个 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 给出的抽象复现可以概括成:

  1. 线程 A 通过 resolveLocksAndReadRegionData 写入一批行 batch_1,随后获取 segment snapshot snap_a
  2. 线程 C 又写入一批和 batch_1 具有完全相同 row_id 和 version 的行 batch_2
  3. 线程 B 获取更晚的 segment snapshot snap_b
  4. 线程 B 先于线程 A 执行读流程,并更新 segment 的共享 DeltaIndex
  5. 线程 B 更新 DeltaIndex 时,因为 row_id/version 重复,batch_1 在 delta tree 中被 delete 掉。
  6. 线程 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_index RPC 已经不再使用,而 TiFlash 看起来发送的是 RaftCmdRequest{cmd_type=ReadIndex}
    后来通过继续跟 TiFlash proxy 到 TiKV leader 的交互确认,TiFlash 虽然内部使用 RaftCmdRequest,但真正和 TiKV 交互仍走 MsgReadIndex raft 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 后续恢复”的现象。

这个阶段最迷惑的是“矛盾日志”:

  1. TiKV 已经 advance max_ts 并返回 read index,例如 region 459 返回到 313679。
  2. 但 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,那么:

  1. 要么能看到 lock
  2. 要么能看到 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 的匹配关系,而不是事务层。

为什么这么难查

  1. 症状像事务一致性问题,但根因在存储读索引复用。TiFlash 少读的行对应的 commit_ts 小于读 TSO,且现场经常出现 ReadIndex、Async Commit、memory lock、bypass lock、Remote Read、Region Split,因此很自然会先怀疑 max_ts 推进、lock resolve 或 ReadIndex 复用。
  2. 问题会自动恢复,容易误导排查。相同 TSO 之后再查是正确的,说明数据既没有永久丢也没有损坏;这排除了很多常规方向,但也让现场窗口很短,必须在错误发生时保留 segment snapshot、DeltaIndex、KVStore、日志和 region 信息。
  3. 复现条件苛刻。它需要高并发读、Remote Read/resolve lock 触发的写入、Raft Apply 写入、相同 row_id/version 的重复 tuple、旧 snapshot 和新共享 DeltaIndex 交错复用。早期本地集群和 IDC 集群很难稳定复现,后来通过调小 region 大小、提高 split 频率、打开 failpoint 增加 remote read 才提高概率。
  4. 中间出现了多个合理但非最终的假设。PR #3971 引入的 batch/async read-index 复用逻辑一度被怀疑;#8873 增强了 ReadIndexWorker 日志;#8874 增加写入 version 和 segment snapshot rows 日志;#8928 用来检查 async task 测试/日志路径。这些帮助排除方向,但不是最终修复。
  5. TiKV/TiFlash 日志存在时钟漂移和跨组件链路。文档里曾用 TiKV 加日志确认 TiFlash Proxy 确实发了 MsgReadIndex,TiKV 也 advance max_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 隔离和列存性能,但一致性排查天然更复杂。

  1. 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 存储视图、查询执行任意一层。相比单一存储引擎,怀疑面更宽。
  2. 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 共同决定。任何一层日志单独看都可能“合理”,但组合起来才暴露矛盾。
  3. 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 读任务全部串起来。
  4. TiFlash 本地列存不是 Raft KV 的直接镜像,而是 DeltaMerge 的多层读视图。一次读会拿 segment snapshot,再决定是否复用 shared DeltaIndex、是否需要 ensurePlace、是否 fully_indexed。这类内部优化通常不改变持久化数据,只影响某个瞬间的读视图,所以 bug 表现为“瞬时少读,后续恢复”。这种问题不会像数据损坏那样留下稳定现场,也不会像事务协议错误那样一定能从 TiKV 日志直接解释。
  5. 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,并补充单测 DupHandleVersionAndDeltaIndexAdvancedThanSnapshotReadWithMoreAdvacedDeltaIndex2 等。
  • #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 如果只根据表象下结论,也很可能走同样的弯路。真正能加速的是把大量日志、代码路径、假设和反证组织起来,让调查更快收敛到“哪个假设还站得住”。

  1. 日志归并和时间线重建
    这个 bug 的证据分散在 endless 报错、TiFlash MPPTask、LearnerReadWorker、RemoteRequest、DeltaMergeStore、Segment、Proxy、TiKV raftstore 日志里。
    AI 可以围绕同一个 start_tsregion_idapplied_indexrow_id/version 自动抽取事件,重建“split -> remote read -> read index -> resolve lock -> raft apply -> segment read”的时间线。
  2. 维护假设和排除矩阵
    排查过程中出现过 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。
  3. 生成更精准的 instrumentation
    排查中实际合入了 #8873 和 #8874 来增强日志。AI 可以基于当前假设建议更有针对性的打点,这能减少“先广泛加日志再人工筛”的迭代成本。
  4. 从现场日志反推出确定性单测
    最终 #9000 的测试本质上是把线上交错压缩成一个可控序列:线程 A 先拿旧 snapshot,线程 B 先读并推进 shared delta index,然后线程 A 再用旧 snapshot 读。AI 很适合根据日志里的 happens-before 关系生成这种 test skeleton,帮助把偶发线上问题变成稳定单测。
  5. 跨组件语义校验
    ReadIndexObserverMsgReadIndexRaftCmdRequest、Async Commit、max_tsmin_commit_ts、read through lock、bypass lock 这些概念很容易混在一起。AI 可以把设计文档、代码和日志放在一起核对:当前路径到底是否推进了 max_ts,哪个 message 类型生效,bypass_locks=[] 能排除什么,commit_ts < read_ts 的日志是否一定表示事务层错误。这个能力能帮助更快识别“看似矛盾但其实不是根因”的证据。
    这能够减少查问题的时候,因为涉及到不同组件,不同人之间的协调产生的损耗。