客户端发出一次 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 级):
- Propose:客户端请求发给 Region 的 Raft Leader,Leader 把这次操作编码成一条 Raft 日志条目,这个动作叫 propose。
- Append:Leader 把这条日志条目写入自己本地的 Raft 日志(这一步本身也叫 append),同时通过 Raft 协议把日志条目复制给 Follower。Follower 收到后也执行本地的 append,并告知 Leader append 成功。
- Commit:Leader 发现这条日志条目已经被多数副本 append 成功,就认为这条日志已提交(committed)。这是 Raft 协议本身定义的安全性边界——已提交的日志不会在后续任何情况下被覆盖或丢失(该不变量的证明见 Raft 解读,本文直接引用结论)。
- 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 级)。这两组线程池通过消息队列解耦:
- raftstore 线程池:驱动 Raft 状态机的 tick、处理 propose、append、判断是否达到多数 commit,并把已提交的日志条目打包成 apply 任务,扔进队列。
- apply 线程池:从队列取出已提交的日志条目,把里面编码的
Key-Value 操作攒成 RocksDB 的
WriteBatch,批量写入本地 RocksDB。
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 协议推进速度。拆成两个线程池、用队列解耦之后:
- raftstore 线程池可以持续推进 Raft 协议(处理心跳、接收新的 append 请求、判断新的 commit),不必等前一批 apply 完成。
- apply 线程池可以按自己的节奏批量处理已提交的日志,批量写入 RocksDB 摊薄单次写入开销。
这个「协议推进」与「状态机执行」分离的思路,与 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(PeerFsm 与 ApplyFsm)的两个
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”这个问题的完整答案。
常见误解
- 「客户端收到写成功,说明数据已经能被立刻读到」:这个说法在单个 Region 内部通常成立(因为 apply 完成之后才回调客户端),但涉及其他 Region 或者 Follower 副本时未必——Follower 的 apply 进度可能落后于 Leader(各自独立执行 apply),Follower Read 场景下需要额外机制保证读到的数据不早于要求的时间点,这不是本篇要展开的话题,但读者需要知道「写成功」的语义边界止于发起写入的那个 Region 的 Leader。
- 「commit 就是数据落盘」:commit 只保证这条 Raft 日志条目已经被多数副本持久化到Raft 日志里(不会丢失),不代表它编码的操作已经写进 RocksDB 的用户数据里。日志持久化和状态机执行是两件事,第三节会讲这也是 log GC 必须等 apply 完成才能截断日志的原因。
- 「apply 线程池慢,就是 RocksDB 参数没调好」:apply 慢的原因可能是 RocksDB 写入本身变慢(compaction 落后、write stall,参见 RocksDB 内核系列),也可能是 apply 线程池本身的调度、批量攒批策略,或者是这个 Region 单位时间内提交的日志条目过多——需要先分层定位,不能一概而论地调 RocksDB 参数。
三、与读路径的边界(简述)
写路径必须走完整的 propose-append-commit-apply 才能返回,是因为写操作要改变状态机内容,必须经过 Raft 的安全性保证。读操作则不一定需要走这么重的路径——如果只是简单地要求「读到 Leader 当前已知的最新数据」,TiKV 有更轻量的手段:
- Lease Read:Leader 在确认自己持有租约(没有其他节点能在租约有效期内成为新 Leader)的前提下,可以直接读本地状态机,不需要发起新的 Raft propose。
- ReadIndex:Leader 向自己发起一次不追加日志、只确认「当前已提交索引」的检查,确保没有更新的 Leader 已经产生(用于租约机制不可靠的场景,如时钟漂移较大时),确认后再读本地状态机。
这两种机制都是为了避免每次读请求都要走一次完整的 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-batch、apply-max-batch-size)把这个权衡暴露成可调参数,而不是给出一个理论上的最优拆分点。公开资料里没有给出”多少层解耦、多大批量是通用最优”的结论,本文也不编造一个数字。
七、小结
三句话小结
- 一次写请求依次经过 propose(编码为日志)、append(本地持久化+复制)、commit(多数确认)、apply(真正执行到 RocksDB)四个阶段,客户端收到成功回复的时机是本 Region 内 apply 完成之后。
- raftstore 线程池负责推进 Raft 协议直到 commit,apply
线程池独立负责把已提交日志执行进
RocksDB,两者通过队列解耦,这个异步边界是 TiKV
能持续推进协议而不被存储写入拖慢的关键设计;
store-io-pool-size等配置项还能把日志持久化的 I/O 再拆出第三层。 - commit 只保证日志不丢,不代表数据已经落进用户可读的存储状态;排查写延迟要先判断问题落在哪一段,不能笼统归因为「RocksDB 慢」或「Raft 慢」。这套「协议达成一致」与「状态机执行」分离的思路,源自 Schneider 1990 复制状态机教程给出的经典模型,TiKV 用线程池和批量参数把权衡暴露成可调项,而非给出唯一最优解。
下一篇讲 apply 完成之后,Raft 日志如何被安全截断,以及落后的副本如何用 Snapshot 追赶:Snapshot、log GC 与副本迁移。
参考资料
规范 / 文档
- TiKV Blog, How TiKV reads and writes(B 级,propose/append/commit/apply 四阶段定义)。
- TiDB Development Guide, Transaction on TiKV(B 级,写请求进入 raft peer 队列到 apply 回调的描述)。
tikv/tikv源码,components/raftstore/src/store/fsm/apply.rs(TiKV release 分支,A 级,ApplyContext的prepare_for/commit/finish_for/write_to_db流程;ApplyDelegate.apply_state与 KV 数据同批写入的崩溃一致性注释)。tikv/tikv源码,components/batch-system(A 级,FSM/Mailbox/Router/Poller 模型)。- 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 分离的经典表述)。
- TiDB Docs, Tune TiKV Thread Pool
Performance、TiKV Configuration
File(
store-pool-size/apply-pool-size/store-io-pool-size/raft-max-inflight-msgs/cmd-batch默认值,A 级)。
站内
- Raft 解读(已提交日志不会丢失的安全性证明,本文直接引用结论)。
- RocksDB
内核系列(apply 阶段落盘涉及的 write stall、compaction
机制;WAL group commit 与本文
cmd-batch的类比)。 - 第 4 篇 Multi-Raft、本系列 index。
上一篇:Multi-Raft:一 Region 一 Raft 组
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】Multi-Raft:一 Region 一 Raft 组
拆解 TiKV 的 Multi-Raft 架构:为什么每个 Region 是独立的 Raft 组,与 etcd 单 Raft 组对照差在哪;跨 Region 事务代价作为开放问题收束,不重写 Raft 协议本身。
【TiKV / HTAP 内核】Snapshot、log GC 与副本迁移
拆解 TiKV 落后副本如何靠 Raft Snapshot 追赶进度、Raft 日志如何在 apply 完成后安全截断(log GC),并简述 witness/learner 副本形态;TiFlash Learner 的列存链路留给第 14 篇。
【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 的负载分裂叙事。
【TiKV / HTAP 内核】生产排障:Region 过多、热点、TSO 抖动、锁冲突、apply 积压
用官方 Grafana 指标与系统表钉住五类高频生产故障的根因链:Region 过多、写/读热点、PD TSO 抖动、悲观锁冲突、raftstore apply 积压;给出症状到机制的决策树,不重复调参手册。