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

【TiKV / HTAP 内核】raftstore 写路径:propose 到 Apply

文章导航

分类入口
databasestorage
标签入口
#tikv#raftstore#write-path#propose#apply#batch-system#raft-log

目录

客户端发出一次 Put,到它能在 Leader 和多数 Follower 上被安全地认为「已经发生」,中间经过四个明确的阶段:propose、append、commit、apply。这四个词在 TiKV 官方博客和源码注释里反复出现,但如果没搞清楚哪个阶段是「数据已经安全」、哪个阶段是「数据才真正写进 RocksDB 可以被读到」,很容易在排查写延迟问题时找错层。

本文是「TiKV / HTAP 内核」系列第 5 篇,只讲一次写请求在 raftstore 里走的这条路径,以及一个经常被忽略的关键设计——commit 和 apply 是两个独立阶段,之间存在异步边界。第 4 篇讲过的 Multi-Raft 并行度、Raft 协议本身的选举与安全性证明,本篇不再重复。

本文是「TiKV / HTAP 内核」系列第 5 篇(共 18 篇)。→ 系列目录

篇目 核心内容
第 4 篇 · Multi-Raft 一 Region 一 Raft 组
第 5 篇 · raftstore 写路径 propose → append → commit → apply
第 6 篇 · Snapshot、log GC 快照与副本迁移

版本锚定:TiKV 7.x/8.x raftstore(TiKV Blog How TiKV reads and writes,B 级;tikv/tikv 源码 components/raftstore/src/store/fsm/apply.rs,A 级;TiDB Development Guide Transaction on TiKV,B 级)。本文不粘贴未实测的 QPS/延迟数字,只描述阶段划分与依赖关系。


一、四个阶段:propose、append、commit、apply

TiKV 官方博客给出的定义(How TiKV reads and writes,B 级):

  1. Propose:客户端请求发给 Region 的 Raft Leader,Leader 把这次操作编码成一条 Raft 日志条目,这个动作叫 propose。
  2. Append:Leader 把这条日志条目写入自己本地的 Raft 日志(这一步本身也叫 append),同时通过 Raft 协议把日志条目复制给 Follower。Follower 收到后也执行本地的 append,并告知 Leader append 成功。
  3. Commit:Leader 发现这条日志条目已经被多数副本 append 成功,就认为这条日志已提交(committed)。这是 Raft 协议本身定义的安全性边界——已提交的日志不会在后续任何情况下被覆盖或丢失(该不变量的证明见 Raft 解读,本文直接引用结论)。
  4. Apply:日志提交之后,Leader(以及每个 Follower 各自独立)才会把日志里编码的操作真正执行,写入本地的 RocksDB 状态机。这一步叫 apply。
sequenceDiagram
  participant C as Client
  participant L as Leader (Region)
  participant F as Follower (Region)
  participant DB as RocksDB (local)
  C->>L: write request
  L->>L: propose: encode as raft log entry
  L->>L: append to local raft log
  L->>F: replicate entry
  F->>F: append to local raft log
  F-->>L: append ack
  L->>L: majority acked -> commit
  L->>DB: apply: execute + write to RocksDB
  F->>F: apply (independently, on its own pace)
  L-->>C: write success

关键点在图里已经能看出来:commit 只依赖多数副本完成 append,不依赖任何副本完成 apply。Leader 一旦确认多数 append 成功,就可以告知客户端写入成功——不需要等自己或者任何 Follower 把这条日志真正应用到 RocksDB。这正是下一节要讲的异步边界。


二、commit 与 apply 之间的异步边界

TiKV 把 propose/append/commit 这条链路交给 raftstore 线程处理,把 apply 阶段交给独立的 apply 线程池处理(tikv/tikv 源码 raftstore/src/store/fsm/apply.rs 的注释明确把 apply 过程描述为 prepare_for -> commit [-> commit ...] -> finish_for 的独立流程,A 级)。这两组线程池通过消息队列解耦:

flowchart LR
  subgraph raftstore_pool ["raftstore thread pool"]
    propose["propose / append"]
    committed["detect majority commit"]
    propose --> committed
  end
  subgraph apply_pool ["apply thread pool"]
    prep["prepare_for"]
    wb["accumulate WriteBatch"]
    write["write_to_db"]
    prep --> wb --> write
  end
  committed -->|"apply task"| prep
  write -->|"callback"| client_cb["reply to client / TiDB"]

这个划分带来的直接后果:Leader 判断「写成功」的时机,是多数副本完成 append(commit),而不是本地完成 apply。TiDB 开发指南对这条链路的描述与此一致(Transaction on TiKV,B 级):「写入会先发到 raft peer 消息队列,由 raft 线程池轮询处理;所有 raft 日志在多数成员上持久化后视为 commit,随后对应的 apply 任务被派发到 apply worker pool 执行实际写入,写入完成后才回调通知客户端」——注意这里的回调时机是apply 完成之后,而不是 commit 那一刻,这是因为客户端需要的是「数据已经落到本地存储引擎、后续读能读到」的保证,单纯的日志 commit 还不满足这一点。

2.1 为什么要拆成两个线程池,而不是 commit 后立刻同步 apply

如果 commit 和 apply 挤在同一个线程里同步执行,Raft 状态机的 tick、心跳、下一条日志的 propose 都会被这次 apply 的 RocksDB 写入延迟卡住——一次稍慢的磁盘 I/O 就会拖慢整个 Region 的 Raft 协议推进速度。拆成两个线程池、用队列解耦之后:

这个「协议推进」与「状态机执行」分离的思路,与 Raft 论文本身描述的复制状态机模型是一致的——论文只要求「已提交的日志最终会以相同顺序应用到状态机」,并不要求提交和应用必须同步发生。TiKV 在工程实现上利用了这条余地,换来更高的流水线并行度。

2.2 FSM 与 Mailbox:一个 Region 对应一个状态机

raftstore 和 apply 两个线程池底层都基于同一套 batch-system 框架(tikv/tikv 源码 components/batch-system,A 级):每个 Region 的 Peer 被建模成一个普通 FSM(Finite State Machine),管理调度或指标汇总一类跨 Region 的逻辑则用控制 FSM。每个 FSM 有一个专属的 Mailbox,其他组件要给某个 Region 发消息(比如把已提交的日志条目送去 apply),就是通过 Router 把消息投递到对应 Region 的 Mailbox,再由线程池的 Poller 轮询取出批量处理。

这个模型解释了第 4 篇提到的「少量线程驱动海量 Region」具体怎么落地:不是每个 Region 占一个线程,而是每个 Region 占一个 FSM + Mailbox,线程池按批从多个 Mailbox 里取消息处理——线程数量与 Region 数量解耦,只与配置的线程池大小相关。这也是为什么 apply 线程池和 raftstore 线程池可以分别独立扩容:两者只是运行着不同种类 FSM(PeerFsmApplyFsm)的两个 batch-system 实例。

2.3 崩溃恢复:apply_index 为什么要和数据写进同一个 WriteBatch

apply 线程池把日志执行到 RocksDB 时,不是只写用户数据——它还要更新一个叫 RaftApplyState 的结构,其中记录着「这个 Region 当前已经 apply 到哪一条日志索引」(applied_index)。tikv/tikv 源码注释(components/raftstore/src/store/fsm/apply.rs,A 级)专门解释了一个容易被忽略的细节:这个 apply_state 被写进 KV RocksDB,而不是 Raft 日志引擎,并且和用户数据的 Put/Delete 在同一个 WriteBatch 里原子提交。源码注释原话(大意):如果把 apply_state 写进 Raft 引擎,它和 KV 数据的 WAL 就是两个独立文件——一旦发生断电,可能出现「applied_index 已经同步到磁盘,但对应的 KV 数据没有同步」的情况,重启后状态机会以为自己已经处理过这条日志,实际上数据从未落盘,造成静默丢数据。

flowchart LR
  subgraph wrong ["若 apply_state 写入 Raft 引擎(假设的错误设计)"]
    A1["Raft engine WAL:<br/>applied_index = N"]
    A2["KV engine WAL:<br/>Put/Delete for log N"]
    A1 -.->|"独立文件,同步时机不保证一致"| A2
  end
  subgraph correct ["TiKV 实际设计"]
    B1["Single WriteBatch:<br/>apply_state(applied_index=N) + Put/Delete"]
    B1 -->|"原子提交到 KV engine"| DONE["Crash-consistent:<br/>要么都在,要么都不在"]
  end

这个设计把「记录进度」和「记录数据」绑成一次原子写入,重启时只需要读 KV RocksDB 里的 apply_state 就能确定「上次真正 apply 到了哪里」,不需要额外的一致性校验逻辑去猜测两份状态是否同步——这是”状态机执行结果必须和执行进度标记一起原子落盘”这条崩溃恢复基本原则在 TiKV 里的具体实现,也是回答”重启后 TiKV 怎么知道该从哪条日志继续 apply”这个问题的完整答案。

常见误解

  1. 「客户端收到写成功,说明数据已经能被立刻读到」:这个说法在单个 Region 内部通常成立(因为 apply 完成之后才回调客户端),但涉及其他 Region 或者 Follower 副本时未必——Follower 的 apply 进度可能落后于 Leader(各自独立执行 apply),Follower Read 场景下需要额外机制保证读到的数据不早于要求的时间点,这不是本篇要展开的话题,但读者需要知道「写成功」的语义边界止于发起写入的那个 Region 的 Leader。
  2. 「commit 就是数据落盘」:commit 只保证这条 Raft 日志条目已经被多数副本持久化到Raft 日志里(不会丢失),不代表它编码的操作已经写进 RocksDB 的用户数据里。日志持久化和状态机执行是两件事,第三节会讲这也是 log GC 必须等 apply 完成才能截断日志的原因。
  3. 「apply 线程池慢,就是 RocksDB 参数没调好」:apply 慢的原因可能是 RocksDB 写入本身变慢(compaction 落后、write stall,参见 RocksDB 内核系列),也可能是 apply 线程池本身的调度、批量攒批策略,或者是这个 Region 单位时间内提交的日志条目过多——需要先分层定位,不能一概而论地调 RocksDB 参数。

三、与读路径的边界(简述)

写路径必须走完整的 propose-append-commit-apply 才能返回,是因为写操作要改变状态机内容,必须经过 Raft 的安全性保证。读操作则不一定需要走这么重的路径——如果只是简单地要求「读到 Leader 当前已知的最新数据」,TiKV 有更轻量的手段:

这两种机制都是为了避免每次读请求都要走一次完整的 Raft propose 往返(Follower Read 场景下机制会更复杂,涉及副本与 Leader 的进度对齐,超出本篇范围)。这里只需要记住一个边界:写路径的四阶段是不可省略的(改变状态必须经过共识),读路径存在专门的优化空间(读不改变状态,只需要确认「我看到的数据是安全的最新数据」)。混淆两者,会导致「点查为什么比想象中快/慢」这类问题查错方向——如果点查也表现出完整 Raft propose 的延迟特征,通常意味着走的是强一致读路径下某个环节(例如 Follower Read 未生效,退化成打到 Leader 且租约失效需要走 ReadIndex)出了问题,而不是 apply 阶段变慢。


四、写路径与后续篇目的接口

这条 propose → append → commit → apply 路径,是理解后面几篇的共同基础:

后续篇目 依赖本篇的哪个环节
第 6 篇 Snapshot、log GC apply 进度决定 Raft 日志哪些条目可以安全截断(未 apply 的日志不能删)
第 10–11 篇 Percolator 事务 prewrite/commit 请求本质上都是走这条 propose-apply 路径,只是 apply 阶段写入的是三 CF 而不是单个 Key
第 17 篇 生产排障 「apply 积压」是一类独立于 Raft 协议本身的故障信号,需要看 apply 队列长度而不是 Raft 选举日志

一次写请求的完整代价,可以粗略拆成 \(T_{propose} + T_{append\_replicate} + T_{apply}\) 三段:第一段是本地编码开销,通常很小;第二段是网络往返加 Follower 本地持久化,通常是这条路径的延迟主体(尤其跨可用区部署时);第三段是 RocksDB 实际写入开销。三段各自的瓶颈需要不同的排查手段,这也是本篇强调阶段划分的实际意义——不是为了炫术语,而是排障时要知道该看哪一段的指标。


五、批量与流水线:写路径的并行度配置

前四节讲的是单次写请求的阶段划分,但生产环境几乎不会只有一个 Region 在写。raftstore 和 apply 各自的线程池大小、单个 Peer 允许的流水线深度,都是可以配置的(TiKV Configuration File,A 级),直接决定了「多少写请求可以同时在飞行」:

配置项 默认值 作用
raftstore.store-pool-size 2 raftstore 线程池大小,处理 propose/append/tick
raftstore.apply-pool-size 2 apply 线程池大小,负责把日志写进 RocksDB
raftstore.store-io-pool-size 1 独立的 StoreWriter 线程池,专门做 Raft 日志的 fsync;为 0 时该 I/O 直接由 raftstore 线程完成
raftstore.raft-max-inflight-msgs 256 单个 Peer 允许同时存在的未确认 append 消息数,超过则减速发送,本质是流水线深度上限
raftstore.cmd-batch true 是否把同一个 Peer 在同一轮里连续到达的多条写命令合并成一次 Raft propose

store-io-pool-size 值得单独说明:它把「驱动 Raft 状态机的 tick/收发消息」和「把 Raft 日志真正 fsync 到磁盘」拆成两个独立线程池——这是第二节讲的 raftstore/apply 两级解耦之外的第三层解耦:raftstore 线程池不再因为等磁盘 fsync 完成而被阻塞,只负责状态机逻辑;I/O 本身交给专门的 StoreWriter 线程(TiDB Docs, Tune TiKV Thread Pool Performance,A 级)。三层解耦(raftstore 状态机 / StoreWriter I/O / apply 执行)分别对应「协议推进」「持久化」「状态机执行」三件本可以并行的事。

cmd-batch 的效果类似传统数据库常见的组提交(group commit):如果不合并,Peer 收到 10 个几乎同时到达的写请求就要发起 10 次独立的 propose,每次都有固定的编码和调度开销;合并成一次 propose 之后,这些开销被摊薄到一条更大的日志条目上。这与 RocksDB 内核系列 讲过的 WAL group commit 是同一个工程思路在不同层的复用——只是 TiKV 合并的是即将进入 Raft 日志的命令,RocksDB 合并的是即将写 WAL 的批次,两者互不依赖,分别在各自的层面减少单位写入的固定开销。

flowchart TB
  subgraph old ["Without cmd-batch"]
    r1["req 1"] --> p1["propose 1"]
    r2["req 2"] --> p2["propose 2"]
    r3["req 3"] --> p3["propose 3"]
  end
  subgraph new ["With cmd-batch"]
    r4["req 1,2,3<br/>arrive in same tick"] --> merge["merge into<br/>one raft command"]
    merge --> p4["single propose"]
  end

需要注意这条优化的边界:cmd-batch 合并的是同一个 Peer同一轮处理里收到的命令,不会跨 Region 合并,也不会无限等待攒批——cmd-batch-concurrent-ready-max-count 等配置项限制了等待攒批与已有未持久化 Ready 数量的关系,避免为了攒更大的批次而无限拖延单个请求的延迟。这是吞吐与延迟之间一个显式暴露给运维的调节旋钮,不是默认放之四海皆优的设置——高并发小写入的场景收益明显,低并发大写入场景收益有限。


六、学术谱系:从复制状态机模型到 apply 流水线解耦

第二节说过「协议推进」与「状态机执行」分离的思路与 Raft 论文本身的复制状态机模型一致。往前追一层,这个分离原则的经典表述来自 Schneider 的教学综述(Schneider, F. B., Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial, ACM Computing Surveys, 1990,A 级):一个容错服务可以建模成「一组副本各自维护同一状态机,靠一致的输入顺序保证状态收敛」,只要满足协议不变量(每个副本按相同顺序执行相同的输入)。Schneider 的表述里,「达成关于顺序的一致(agreement)」和「按此顺序执行命令(execution)」在概念上是分离的两个步骤——具体系统可以自由选择让它们同步或异步发生,只要不破坏「相同顺序」这条不变量。

TiKV 的 raftstore/apply 拆分正是这条经典原则的一次具体工程落地:commit 阶段完成的是 Schneider 所说的 agreement(多数副本对日志顺序达成一致),apply 阶段完成的是 execution(按这个顺序把操作真正执行到状态机,这里是 RocksDB)。这个模型也解释了为什么”批量 apply”是安全的优化,而不是走捷径——只要 apply 线程池严格按照 Raft 日志的索引顺序处理任务(prepare_for -> commit -> finish_for 这套流程本身按顺序驱动),批量执行多条日志条目和逐条执行在最终状态上是等价的,批量只改变了「多快执行完」,不改变「以什么顺序执行」。

一个开放的工程张力:批量 apply 提高吞吐(第五节的 cmd-batch、apply 阶段的 WriteBatch 攒批),但攒批越大,单个 apply 任务的延迟越高、恢复时需要重放的未确认状态窗口也可能越大。Schneider 的模型本身没有回答”agreement 和 execution 之间应该拆多细、拆多少级”这个工程问题——这是留给具体系统根据延迟/吞吐目标自行权衡的空间,TiKV 用三层线程池(raftstore/StoreWriter/apply)加多个批量攒批参数(cmd-batchapply-max-batch-size)把这个权衡暴露成可调参数,而不是给出一个理论上的最优拆分点。公开资料里没有给出”多少层解耦、多大批量是通用最优”的结论,本文也不编造一个数字。


七、小结

三句话小结

  1. 一次写请求依次经过 propose(编码为日志)、append(本地持久化+复制)、commit(多数确认)、apply(真正执行到 RocksDB)四个阶段,客户端收到成功回复的时机是本 Region 内 apply 完成之后。
  2. raftstore 线程池负责推进 Raft 协议直到 commit,apply 线程池独立负责把已提交日志执行进 RocksDB,两者通过队列解耦,这个异步边界是 TiKV 能持续推进协议而不被存储写入拖慢的关键设计;store-io-pool-size 等配置项还能把日志持久化的 I/O 再拆出第三层。
  3. commit 只保证日志不丢,不代表数据已经落进用户可读的存储状态;排查写延迟要先判断问题落在哪一段,不能笼统归因为「RocksDB 慢」或「Raft 慢」。这套「协议达成一致」与「状态机执行」分离的思路,源自 Schneider 1990 复制状态机教程给出的经典模型,TiKV 用线程池和批量参数把权衡暴露成可调项,而非给出唯一最优解。

下一篇讲 apply 完成之后,Raft 日志如何被安全截断,以及落后的副本如何用 Snapshot 追赶:Snapshot、log GC 与副本迁移


参考资料

规范 / 文档

  1. TiKV Blog, How TiKV reads and writes(B 级,propose/append/commit/apply 四阶段定义)。
  2. TiDB Development Guide, Transaction on TiKV(B 级,写请求进入 raft peer 队列到 apply 回调的描述)。
  3. tikv/tikv 源码,components/raftstore/src/store/fsm/apply.rs(TiKV release 分支,A 级,ApplyContextprepare_for/commit/finish_for/write_to_db 流程;ApplyDelegate.apply_state 与 KV 数据同批写入的崩溃一致性注释)。
  4. tikv/tikv 源码,components/batch-system(A 级,FSM/Mailbox/Router/Poller 模型)。
  5. Schneider, F. B., Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial, ACM Computing Surveys, Vol. 22, No. 4, 1990(A 级,复制状态机模型,agreement/execution 分离的经典表述)。
  6. TiDB Docs, Tune TiKV Thread Pool PerformanceTiKV Configuration Filestore-pool-size/apply-pool-size/store-io-pool-size/raft-max-inflight-msgs/cmd-batch 默认值,A 级)。

站内

  1. Raft 解读(已提交日志不会丢失的安全性证明,本文直接引用结论)。
  2. RocksDB 内核系列(apply 阶段落盘涉及的 write stall、compaction 机制;WAL group commit 与本文 cmd-batch 的类比)。
  3. 第 4 篇 Multi-Raft本系列 index

上一篇Multi-Raft:一 Region 一 Raft 组

下一篇Snapshot、log GC 与副本迁移

返回 系列目录 · 数据库索引

同主题继续阅读

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

2026-07-16 · database / storage

【TiKV / HTAP 内核】Snapshot、log GC 与副本迁移

拆解 TiKV 落后副本如何靠 Raft Snapshot 追赶进度、Raft 日志如何在 apply 完成后安全截断(log GC),并简述 witness/learner 副本形态;TiFlash Learner 的列存链路留给第 14 篇。

2026-07-16 · database / storage

【TiKV / HTAP 内核】Split / Merge / 热点:Region 何时该切、何时该并

拆解 TiKV Region 何时触发 size/key-count 分裂、如何用 BatchSplit 在不拷贝数据的前提下切分 RocksDB key range;PrepareMerge/CommitMerge 两阶段合并;Load Base Split 与 PD hot-region-scheduler 两层热点机制的分工,并对齐 distributed/27 的负载分裂叙事。


By .