TiFlash 分布式存储 & 事务体系架构
本文原链接 The architecture of TiFlash’s distributed storage engine and transaction layer 主要站在开发者的视角阐释 TiFlash 分布式存储 & 事务体系的基本架构和设计思想。
这套体系最初是 2018 年底本人加入 PingCAP 后开始从 0 到 1 设计构建的,于 2020-04-16 随 TiDB v3.1.0 版本正式 GA。
背景与设计目标
TiFlash 是 TiDB 集群中的分析型存储引擎,面向 HTAP 场景提供实时分析能力。它不是一个只追求最终一致性的下游副本。为了让 TiDB、TiSpark 等上层组件能够用同一套事务语义访问 TiKV 和 TiFlash,TiFlash 必须复用 TiKV 的 Multi-Raft 复制框架、Percolator 事务模型 和基于 timestamp 的 MVCC 读协议,对外提供强一致的快照读。
这个目标决定了 TiFlash 的核心设计问题:如何把 TiKV 的 Raft 顺序和事务语义,安全地转换成 TiFlash 内部的列存可见性。TiFlash 既要像一个 Raft store 一样被 PD 调度、接收 Raft log、维护 Region epoch,又要把已提交的行数据转换成适合分析查询的列式格式。它不能简单复制 TiKV 的 RocksDB 数据,也不能绕开 TiKV 的事务协议另做异步索引。
本文围绕这个转换边界展开:表副本如何落到 Region learner,Raft apply 如何变成列存写入,learner read 如何证明一致性,以及 split、merge、snapshot、GC、schema 变化如何守住恢复边界。
系统整体架构
TiDB 负责 SQL、DDL、事务时间戳和查询计划;PD 负责 store 注册、placement rules 和 Region 调度;TiKV 承载 Raft quorum、事务写入和 Region leader;TiFlash 只承载 TiFlash learner peer,并把已提交数据转成列存格式服务分析查询;TiSpark 则通过相同的事务读语义访问 TiFlash。
表级 TIFLASH REPLICA 是一个逻辑声明。它不会生成一个物理上的整表副本,而是被转换成 placement rules,由 PD 把与该表 key range 相交的 Region learner peer 调度到 TiFlash。TiFlash 表副本是否可用,也不是单个开关,而是每个 Region learner 独立 catch up、通过读协议检查后组合出来的结果。
这个设计的主要取舍是:TiFlash 不进入写 quorum,因此不会放大 OLTP 写入多数派的成本;但它也不能依赖副本身份天然提供强一致读。每次读都必须证明本地 Region 状态足够新、事务锁不影响本次 timestamp、Region range 与 epoch 仍然匹配,并且读 timestamp 没有越过 GC 边界。
表副本、Region 与 Learner
一张表在存储层首先表现为一段 table keyspace,再被 split 成多个 Region;每个 Region 是一个独立的 Raft group。TiKV store 可以承载完整 Raft peer 角色,包括 leader、follower 以及调度过程中的 learner;TiFlash store 上只能有 learner peer。TiFlash 不进入 Raft quorum,也不会成为 leader。
因此,TiFlash 的扩展单位不是整表,而是 Region peer。一个表的多个 TiFlash replica 会因为 Region split、merge、调度和节点变化分布在不同 TiFlash store 上。查询从 TiDB 或 TiSpark 下发后,会被拆成 Region 级别的读;每个 Region 都要独立完成 learner read 的一致性证明,最后再由上层组合结果。
这个模型把调度、复制和查询都压到 Region 粒度。好处是 TiFlash 能复用 TiKV 已有的 Multi-Raft 调度和容错模型;代价是表副本的可用性、读延迟和恢复状态都可能是局部的,查询层必须把 Region error、lock error 和 retry 看作协议的一部分。
TiFlash 节点内部分层
落到单个 TiFlash 节点内部,分布式存储层可以理解为三层状态。
第一层是 raftstore-proxy。它复用 TiKV 的 Raft store runtime,负责 Raft message、Raft log、Region peer 状态、ReadIndex、snapshot 分发和 store 级服务。proxy 拥有 Raft 顺序,也知道一个 command 何时可以 apply。
第二层是 TiFlash 侧的 Region 状态。TiFlash 维护本地 Region 对象、Region range index、Region 到 table 的映射,以及 Percolator 的 DEFAULT、LOCK、WRITE 三类 CF 数据。这里保存的是理解事务提交所必需的中间状态,而不是面向查询的最终表数据。
第三层是 DeltaMerge 列存。TiFlash 只把已经提交的行写入 DeltaMerge,并用 table schema 将 TiKV row format 解码成列式数据。DeltaMerge 负责 MVCC 版本、列式扫描、数据 compact、range deletion 和 table GC。
这三层没有一个共享的原子 write batch。Raft apply 的顺序来自 proxy,但 command 对查询是否可见由 TiFlash 决定;事务 CF 数据保存在 Region 状态里,但已提交数据会迁移到 DeltaMerge;Region metadata 的恢复依赖持久化 snapshot,而查询路由又依赖 RegionTable 中的 range 映射。TiFlash 的正确性正是建立在这些边界被严格维护之上。
Raft Apply 与写入可见性
TiDB 的写事务遵循 Percolator 模型。Prewrite 阶段通常写入 LOCK 和 DEFAULT,commit 阶段写入 WRITE;其中 DEFAULT 保存原始 value,LOCK 表示未提交事务的锁,WRITE 保存提交记录并指向对应的 start_ts。TiFlash 不参与 prewrite、commit 或 lock resolution 的决策,它只是通过 Raft apply 消费这些已经被 TiKV 复制过来的事务状态。
对 TiFlash 来说,WRITE CF 中的有效提交记录才是把行写入 DeltaMerge 的信号。Raft apply 的关键不是“收到什么就写什么”,而是定义读路径可以观察到的顺序。TiFlash 会先应用非 WRITE CF 的 mutation,把尚未提交的 value 和 lock 保留在本地 Region 状态中;随后应用 WRITE CF mutation,根据 start_ts 找到 value,用稳定的 table schema 解码并写入 DeltaMerge;只有当提交版本已经对列存可见后,才清理对应事务数据和本地 lock 状态,并推进 applied index。
这个顺序比普通 KV engine 的 write batch 更复杂,但它解决了一个直接的可见性问题:如果 TiFlash 在列存写入完成前就清理本地 lock,并发读可能看到“没有锁”,却又读不到刚提交的版本。延迟清理本地 lock 让读路径在列存写入完成前仍能发现该事务尚未对 TiFlash 安全可见。
这里的设计原则可以压缩成一句话:数据先达到可读条件,再暴露路由或推进可见状态。applied index 也不能只表示“Raft log 已处理”,它还必须和 Region snapshot、Raft log GC 一起定义可恢复的边界。否则 proxy 已经回收旧 log,而 TiFlash 还没有持久化到对应状态时,重启后就可能失去恢复依据。
Learner Read 与读一致性
TiFlash 的强一致读不是由 learner 身份天然保证的,而是在每次 Region 读请求中建立的。这里的强一致表示:当 TiFlash 接受一个带有 start_ts 的读请求时,返回的数据必须等价于在该 timestamp 上从 TiDB 事务模型读取到的结果;如果本地状态不足以证明这一点,就必须返回可重试错误,而不是返回近似结果。
一个 Region 读请求通常会经历这条链路:
- 选择目标 Region,并构建读所需的 Region snapshot。
- 判断该 Region 的 SafeTS 是否足以覆盖本次读的
start_ts。 - 如果 SafeTS 不足,则向 leader 发起 ReadIndex 请求。
- 等待本地 Region 至少 apply 到 ReadIndex 返回的 index。
- 检查影响本次
start_ts可见性的锁状态。 - 重新验证 Region identity、epoch、range、peer state 和 GC safepoint。
- 获取并读取 DeltaMerge snapshot。
SafeTS 是 Region 级新鲜度证明的缓存化路径。若某个 Region 的 self safe timestamp 已经不小于查询 start_ts,TiFlash 可以跳过 ReadIndex 的 leader 往返,但不能跳过后续的 lock、epoch、range 和 GC 检查。这个优化只降低协调成本,不改变读语义。
Async Commit 和 1PC 引入后,ReadIndex 请求需要携带事务读的 start_ts,这样 leader 才能正确处理仍可能影响本次读的锁。TiFlash 在本地 apply 到足够新的 index 后再检查锁,才能保证读到的不是缺少事务上下文的中间状态。
读路径还必须防止 Region ABA 问题。读请求开始时看到的 Region,可能在 ReadIndex、wait apply 或构造列存 snapshot 之前经历 split、merge、remove、snapshot apply,再出现一个看起来相似的 Region 状态。ReadIndex 或 SafeTS 只证明时间新鲜度,真正防止 ABA 的是之后对 Region identity、epoch、range、peer state 和 GC safepoint 的重新校验。只要这些信息与请求上下文不匹配,读路径必须返回可重试的 Region error。
TiFlash 不重新实现完整的 lock resolution 协议。它的职责是发现“当前 Region 不能安全服务这次读”的事实,并把冲突交给上层重试或解析。这个边界避免了在分析引擎里复制复杂事务客户端逻辑,也让 TiFlash 和 TiDB/TiKV 的重试模型保持一致。
Region 生命周期与恢复边界
Region 生命周期变化包括 normal write、admin command、split、merge、change peer、snapshot apply 和 peer destroy。它们共同的问题不是“如何执行动作”,而是如何保证过渡期间查询不会看到缺失、重复或错误覆盖的 range。
Split 会把一个 Region range 切成多个子 range。TiFlash 必须保证新 Region 和派生 Region 的持久化顺序能支持 crash 后恢复,并且 RegionTable 在过渡期间不会把请求路由到空洞。Merge 的问题相反:source Region 从 RegionTable 移除后,它对应的列存数据不能简单删除,因为这些数据在逻辑上已经并入 target Region 的 range。TiFlash 必须先保证 target Region 状态可恢复,再更新映射关系,并保留仍被 merged range 覆盖的数据。
Raft snapshot 的数据格式面向 TiKV 的 KV engine,不能直接作为 TiFlash 的列存数据使用。因此 TiFlash 将 snapshot apply 拆成 prehandle 和 apply 两个阶段。Prehandle 负责读取 SST、获取 schema 和 GC safepoint,把已提交数据转换成 DeltaMerge 可 ingest 的文件,并把未提交事务数据保留在新的 Region 状态中。Apply 阶段才真正进入可见性切换:旧 Region 先被标记为不可读,再完成 range 清理、文件 ingest、RegionTable 替换和新 Region 持久化。
这个分阶段设计的好处是把昂贵的解码工作移出可见性切换临界区;代价是 abort、release、中间文件清理和重启恢复协议必须足够严格。特别是 snapshot apply 只能清理新 Region 的 range,不能按旧 Region range 粗暴删除。否则 split-derived snapshot 并发到达时,后 apply 的子 Region 可能误删先 apply 子 Region 已经接管的数据。
失败恢复遵循一个保守原则:无法证明完整性的中间状态必须恢复为不可读,而不是部分可读。Normal apply 可以依赖 applied index 和 Raft log replay 幂等恢复;snapshot prehandle 失败不能改变可见状态;snapshot apply 一旦进入 destructive 阶段,就必须先让旧 Region 不再服务读;CompactLog 和 eager log GC 则必须受 TiFlash 持久化进度约束,不能只按 proxy 局部状态推进。
Schema、GC 与数据导入
TiFlash 写入 DeltaMerge 前必须用 TiDB table schema 解码 TiKV row value。问题在于 DDL schema 变化和 Raft log apply 不共享一个严格的全局顺序。一个 Region 的事务提交可能已经到达 TiFlash,但本地 schema 还没有同步到足以解码该行。
因此,schema snapshot 是一致性边界的一部分,不只是普通缓存。普通 apply 路径会在写入列存前获取稳定 schema,用它解码已提交数据;如果发现 schema 过旧或结构不匹配,就同步 schema 后重试。Snapshot prehandle 也遵循同样原则:一旦解码过程中发现 schema 变化,就丢弃中间结果、刷新 schema、重新生成可 ingest 文件。
GC safepoint 同时影响读路径、snapshot 解码、DeltaMerge compact 和 schema GC。读路径会拒绝早于本地缓存 safepoint 的 start_ts;snapshot prehandle 用 safepoint 过滤不需要保留的历史版本;DeltaMerge 根据 safepoint 回收 MVCC 数据;schema GC 则用 safepoint 判断 stale database 和 stale table 是否可以被物理删除。Table GC 还需要参考 RegionTable:只要某个 table 仍有 Region 映射存在,物理删除就不是安全动作。
IngestSst、BR、Lightning 和 fast add peer 都可以看作“外部或加速路径如何进入列存可见性”的问题。它们可以复用 snapshot prehandle 的能力,也可以通过 checkpoint 降低传输和转换成本;但只要最终要服务查询,就必须遵守同一组规则:schema 可重试、GC 边界明确、ingest 结果完成可见性切换前不能被读路径当作完整数据。
Raftstore v2 进一步放宽了 snapshot 数据和 snapshot index 的对应关系,snapshot 中可能出现更新的 orphan keys。TiFlash 需要记录并在后续 normal 或 admin log apply 中推进这些状态。这个设计降低了对 snapshot 单点完整性的依赖,但也把恢复正确性更多地压到 metadata、checkpoint 和后续 apply 的协调上。
资源治理、观测与测试
TiFlash 的资源治理不能只追求吞吐,还必须服务一致性边界。Region cache 中的未提交事务数据、snapshot prehandle 的临时文件、DeltaMerge ingest、后台 compact、BR/Lightning 导入和 fast add peer checkpoint 都会竞争内存、CPU、IO 和磁盘空间。系统可以退化得更慢,但不能因为压力而提前暴露不完整数据。
CompactLog 和 flush 策略是典型例子。如果 TiFlash 迟迟不持久化 Region 状态,proxy 就不能安全 GC 对应 Raft log;如果过于频繁持久化,又会损害列存批量写入和 IO 效率。因此合理策略必须综合 log 增长、Region cache 大小、snapshot 成本、写入吞吐和恢复时间目标,而不是只用单一阈值。
观测面也应围绕边界设计。一次读变慢可能卡在 Raft apply lag、ReadIndex 往返、SafeTS 未推进、lock check 命中、Region epoch mismatch、schema sync retry、DeltaMerge ingest 或 compact 压力中的任意一层。指标和日志至少要能回答:本地 apply 是否落后,读请求走 SafeTS 还是 ReadIndex,snapshot 卡在 prehandle 还是 apply,schema decode 是否重试,列存 ingest 和 compact 是否成为瓶颈。
测试重点不是普通 happy path,而是边界被打断后是否仍能恢复。关键场景包括:normal apply 中途失败后的 replay 幂等性;本地 lock 清理与 DeltaMerge 写入顺序被并发读观察时不能漏读提交版本;split、merge 与查询并发时 RegionTable 不出现路由空洞;snapshot prehandle、apply、abort、release 在失败和重启后能清理中间状态;schema change 与事务提交、snapshot 解码并发时不写入部分结果;GC safepoint 推进后旧 timestamp 读被拒绝;fast add peer 和 checkpoint cleanup 在 fallback、destroy、重启场景下不丢失恢复依据。
正确性不变量
TiFlash 分布式存储层依赖三类不变量。
Raft 与恢复不变量:
- applied index 不新于本地 Region 的 command 必须可以被幂等忽略。
- 改变 Region metadata 或生命周期的 command 必须持久化 Region 状态。
- Raft log 被 GC 前,TiFlash 必须已经持久化到可恢复的 applied index。
- Snapshot apply 在 destructive storage change 前必须把旧 Region 标记为不可读状态。
事务与读一致性不变量:
- Region 只有在通过 ReadIndex 或 SafeTS,并等待本地 apply 到足够 index 后,才能服务对应读。
- Learner read 必须在 ReadIndex 或 SafeTS 之后重新校验 Region identity、epoch、range、peer state 和 GC safepoint。
- 本地 lock 清理必须发生在提交数据已经写入 DeltaMerge 之后。
- 早于 GC safepoint 的读必须被拒绝。
列存与路由不变量:
- Snapshot apply 只能清理新 Region range,不能按旧 Region range 粗暴删除。
- Split 和 merge 过程中,RegionTable 的 range 映射必须避免查询路由空洞。
- Schema 解码必须基于稳定 schema snapshot,并在 schema update 后可重试。
- Table 只有在 schema GC 和 RegionTable 都确认安全后,才能被物理删除。
这些不变量的共同目标是:TiFlash 可以在任意中间点崩溃并恢复,但不能在中间状态下对外返回违反快照隔离的数据。
设计权衡
TiFlash 当前架构的核心取舍是复用 TiKV 的分布式协议,并在 TiFlash 内部维护列存可见性。这显著降低了重新实现 Multi-Raft 和事务协议的风险,也让 TiFlash 能以 learner 形式自然加入 TiDB 集群;代价是 TiFlash 必须长期维护 proxy、Region 状态和 DeltaMerge 之间的可见性边界。
第一,Region cache 中的未提交数据仍可能带来内存压力。完整 spill、增量中间存储或更细粒度的持久化策略可以缓解问题,但它们会增加恢复和 apply 幂等性的复杂度。
第二,FFI surface 会随着 SafeTS、fast add peer、disaggregated mode 和 status API 持续扩大。每新增一个 callback,都需要明确 ownership、顺序保证和失败语义,否则跨语言边界会成为一致性风险来源。
第三,ReadIndex、SafeTS、lock check、Region epoch validation 和 GC safepoint 共同构成读一致性协议。它们的组合路径较多,必须持续通过测试、metrics 和故障注入验证,而不能只依赖单点逻辑正确。
第四,schema evolution 横跨 Raft apply、snapshot prehandle、query planning、schema GC 和 table GC。TiFlash 需要持续保持“schema 是一致性边界的一部分”这个原则,避免把 schema cache 当作普通性能缓存处理。