1. 为什么一个 Raft 实现要自己造存储引擎
第一次翻到 Dragonboat 的代码结构时,最让我意外的不是它那套多组 Raft 的调度逻辑,而是它底下单独拎出来的一个存储层——Tan。很多人做 Raft 的第一反应是"找个现成的 KV 引擎塞进去就完事了",LevelDB、RocksDB、Pebble、Badger,随便挑一个都能跑。我早期做类似系统时也是这么干的,结果在压测阶段被日志写入的尾延迟狠狠教育了一顿。Tan 这个存储引擎的存在,本质上就是对"通用 KV 引擎在 Raft 场景下水土不服"这件事的一次正面回应。
先把话说清楚:Tan 是 Dragonboat 这个多组 Raft 一致性系统里专门服务于 Raft 日志(Log)和元数据(Metadata)的存储引擎。它不是一个通用数据库,你没法拿它去存业务数据、做范围查询、跑复杂事务。它的目标非常窄——把 Raft 协议运行过程中产生的那几类特定数据,用最贴合其访问模式的方式落盘,并且保证崩溃恢复后的正确性。适合谁来读这篇内容?如果你正在实现或维护一套 Raft 库、正在为日志存储选型发愁、或者单纯好奇"专用存储引擎"和"通用 KV 引擎"的边界到底在哪,那这篇东西对你有用。如果你只是想找个嵌入式数据库存点配置,那 Tan 不适合你,直接跳过。
我先把结论性的判断摆出来:Raft 的日志写入有一个非常鲜明的特征——顺序追加为主、随机读取为辅、按索引区间批量截断、对 fsync 的延迟极其敏感。通用 LSM 引擎为了兼顾各种负载,在写放大、compaction 调度、内存表刷盘上做了大量折中,这些折中在 Raft 场景下往往变成纯粹的负担。Tan 的设计思路就是把这些折中全部砍掉,只保留 Raft 真正需要的那条路径。下面我按"设计思路—核心结构—实操落地—问题排查"的顺序,把这个引擎拆开讲。
2. 整体设计与思路拆解
2.1 Raft 数据的访问模式决定了引擎形态
要理解 Tan 为什么长这样,得先把 Raft 到底往磁盘上写什么、怎么读、怎么删搞清楚。我把一个 Raft 节点生命周期内的存储访问归纳成四类,这四类几乎覆盖了 Tan 的全部职责:
- 日志追加(Append):Leader 收到客户端请求后生成 Entry,按单调递增的索引顺序写入。这是最高频的操作,且天然顺序。
- 日志读取(Read):Follower 复制时按区间批量读,节点重启恢复时从头或从快照点顺序读,偶尔有按单条索引的随机读(比如查某条 entry 是否已提交)。
- 日志截断(Truncate):发生冲突回滚或应用快照后,把某个索引之后的日志整段丢弃。这是区间删除,不是随机删。
- 元数据读写(Metadata):包括当前任期(Term)、投票记录(Vote)、提交索引(Commit Index)、成员配置(Membership)、快照元信息等。这类数据量极小但要求强一致、必须原子更新。
把这四类摊开看,你会发现一个很关键的规律:日志是"只追加 + 区间删除"的,元数据是"小体积 + 原子覆盖写"的。这两类数据的形态差异巨大,硬塞进同一个通用引擎里,要么日志被元数据的随机写拖累,要么元数据被日志的写放大波及。Tan 的第一个设计决策就是把日志存储和元数据存储分开对待,各自用最合适的结构。
提示:这里说的"分开"是逻辑和物理上的双重分离。日志走自己的追加式文件段,元数据走自己的小结构存储,两者不共享同一个写路径,避免互相干扰。
2.2 为什么不用现成的 LSM 引擎
我拿 LevelDB 和 RocksDB 都做过 Raft 日志的底座,踩过的坑很具体。LSM 的核心是 MemTable 攒够一批再刷成 SSTable,后台再跑 compaction 合并。这套机制对写吞吐友好,但对 Raft 有两个致命的不匹配点。
第一是写放大与 compaction 抖动。Raft 日志写入是持续不断的,LSM 后台 compaction 一旦和前台写入抢 IO,尾延迟会突然飙高。Raft 对日志落盘的延迟很敏感——Leader 要等多数派 fsync 完才能提交,任何一次 compaction 抖动都可能让一次提案的延迟从几百微秒跳到几十毫秒。我实测过,在持续写入压力下,LSM 引擎的 P99 延迟能比 P50 高出两个数量级,这在 Raft 里是不可接受的。
第二是区间删除的低效。Raft 截断日志是"删掉索引 N 之后的所有 entry",在 LSM 里这要么变成逐条 tombstone 写入(写放大爆炸),要么依赖 range tombstone(实现复杂且回收不及时)。而 Tan 用的是追加式文件段,截断直接就是"把超出范围的段文件删掉"或者"截断最后一个段文件",成本极低。
第三是元数据的原子性。Term、Vote 这类元数据要求"要么全写成功要么全不写",LSM 的单条 Put 虽然原子,但跨多条元数据的批量更新需要额外的事务机制。Tan 对元数据用了更直接的结构,配合校验和与双写策略保证崩溃一致性。
2.3 追加式段文件的核心取舍
Tan 对日志采用的方案是分段追加文件(segmented append-only files)。这个思路不新鲜,Kafka 的日志存储、etcd 的 WAL 都是类似套路,但 Tan 在细节上做了针对 Raft 的裁剪。核心取舍有三点:
- 段大小固定且偏小:每个段文件容纳固定数量的 entry(或固定字节数),写满就滚动到新段。段小意味着截断时回收的粒度细,不会因为一个巨大的段文件导致空间迟迟不释放。
- 索引与数据分离:段文件里只存 entry 的原始字节,另有一份轻量的索引记录"索引号 → 段文件 + 偏移"。读取时先查索引再定位,避免扫描整个段。
- 不做原地更新:日志一旦写入就不再修改,这消除了并发读写同一位置的复杂性,也让崩溃恢复的逻辑变得简单——只需校验每个段的完整性,坏掉的尾部直接丢弃。
这套设计的直接好处是写路径极短:一次追加就是"定位当前活跃段 → 追加字节 → 更新内存索引 → 按需 fsync",没有内存表、没有后台合并、没有写放大。代价是空间回收依赖段粒度,以及随机读需要索引辅助。但对 Raft 来说这两个代价都可以接受,因为随机读占比很低,空间回收本来就是按区间进行的。
2.4 元数据存储的独立设计
元数据这块,Tan 没有复用日志的段文件结构,而是单独设计了一套小体积、强一致的存储。原因很直白:元数据总量可能就几百字节到几 KB,但每次更新都要求原子且持久。如果塞进日志段里,每次改个 Term 都要追加一条记录再等 fsync,既浪费空间又拉长恢复时的重放链路。
Tan 对元数据的处理更接近"带校验的定长/变长记录 + 原子替换"。常见做法是维护一份当前元数据快照,更新时先写新版本到临时位置并 fsync,再原子地切换指针(或写一个提交标记),最后清理旧版本。这样即使在中途崩溃,恢复时也能根据提交标记判断哪份是有效的。这个思路和很多嵌入式系统里的"双缓冲 + 提交标记"是一致的,我在自己的项目里也用过类似手法,可靠性经得起考验。
注意:元数据的原子更新不能依赖文件系统的"重命名原子性"就万事大吉,重命名本身原子,但重命名之前的数据是否真正落盘需要 fsync 保证。顺序必须是"写临时文件 → fsync 临时文件 → 重命名 → fsync 目录",少一步都可能在掉电时丢数据。
3. 核心细节解析与实操要点
3.1 段文件的内部布局
一个 Tan 日志段文件,我按从前往后的顺序拆成三块:段头、entry 区、段尾校验。段头记录这个段的起始索引号、格式版本、创建时间戳等元信息,长度固定,方便快速定位。entry 区是连续的 entry 记录,每条记录带自己的长度前缀和校验和,这样即使中间某条损坏,也能通过长度前缀跳过继续解析后面的(虽然实际恢复时通常遇到损坏就截断)。段尾放一个汇总校验,用于快速判断整个段是否完整写入。
每条 entry 记录的布局大致是这样:[长度][索引号][任期][类型][数据][CRC]。长度前缀让解析器知道这条记录占多少字节;索引号和任期用于恢复时校验连续性;类型区分普通日志、配置变更、空操作等;CRC 覆盖整条记录,防止位翻转。这里有个细节值得说:长度前缀放在最前面,是因为恢复时需要先知道长度才能安全地读取整条记录,如果长度本身损坏,就只能靠段尾校验兜底并截断。
我在实现类似结构时踩过一个坑:早期把 CRC 只覆盖数据部分,结果索引号被写坏后恢复逻辑把 entry 挂到了错误的索引上,引发了一连串诡异的状态不一致。后来改成 CRC 覆盖除长度前缀外的全部字段,问题才消失。Tan 这类引擎通常也是全字段校验,这是血泪教训换来的。
3.2 索引结构怎么选
日志的索引要回答的问题是"索引号 N 的 entry 在哪个段的哪个偏移"。因为日志是顺序追加的,索引本身也是单调增长的,所以最自然的方案是内存中的有序映射,比如一个按索引号排序的数组或跳表,每个元素记录段 ID 和偏移。内存索引在启动时通过扫描段文件重建,重建成本与日志总量成正比,但因为只读段头和数据记录的定长部分,速度可以接受。
这里有个权衡:索引全放内存会不会爆。假设每条索引占 16 字节,一百万条日志就是 16MB,对现代服务器不算什么。但如果日志量到亿级,内存索引就有点重了。Tan 的应对通常是只对活跃段和最近若干段保留精细索引,老段用稀疏索引——每个段只记起始索引和段内偏移规律,读取时在段内做二分或线性扫描。因为老段基本只读不写,扫描成本可以接受。
提示:稀疏索引的粒度选择很关键。段内 entry 如果定长,可以直接算偏移;如果变长,稀疏索引要记录"每 K 条一个锚点",K 太大扫描慢,K 太小索引膨胀。我一般取 K 使得每个段的锚点数量在几十到几百之间,实测比较平衡。
3.3 截断操作的实现细节
截断是 Tan 相对通用引擎最有优势的地方,但实现上也有讲究。截断分两种情形:整段截断和段内截断。
整段截断很直接:要删的索引范围覆盖了某几个完整段,直接关闭文件句柄、删除段文件、从索引里移除对应条目。这里要注意删除顺序——先更新内存索引标记这些段为"待删",再删文件,最后清理索引。如果先删文件再更新索引,中间崩溃会导致索引指向不存在的文件,恢复时要额外处理。
段内截断稍复杂:截断点落在某个段的中间,需要把这个段从截断点之后的部分丢弃。做法通常是重写这个段——读取截断点之前的有效数据,写到临时文件,fsync 后替换原段文件。或者更轻量的做法是记录一个逻辑截断点,段文件物理上不变,但索引和读取逻辑忽略截断点之后的数据,等后续 compaction 再真正回收。前者实现简单但有一次重写开销,后者省 IO 但需要维护逻辑截断状态。Tan 这类引擎往往根据截断频率选择,高频截断场景下逻辑截断更划算。
3.4 元数据的崩溃一致性保障
元数据虽然小,但它是 Raft 正确性的命门。Term 写错、Vote 丢失,轻则重新选举,重则出现双主。Tan 对元数据的保障我归纳为三层:校验、原子提交、恢复校验。
校验层是每条元数据记录带 CRC,读取时验证,损坏则拒绝使用并触发恢复流程。原子提交层是前面说的"写临时 + fsync + 切换 + fsync 目录"。恢复校验层是启动时读取元数据,验证其内部一致性(比如 commit index 不能大于最后一条日志索引,term 不能为负等),不一致则根据日志重建。
这里有个容易忽略的点:元数据和日志的恢复顺序。正确顺序是先恢复日志(确定最后一条有效 entry 的索引和任期),再恢复元数据,然后用日志校验元数据里的 commit index 是否合法。如果反过来先信元数据再读日志,一旦元数据里的 commit index 指向一条实际不存在的日志,就会出问题。这个顺序在 Tan 的恢复流程里是硬编码的,不能随意调整。
4. 实操过程与核心环节实现
4.1 从零搭建一个最小可用的段文件写入器
光讲结构不够,我按可复现的粒度把关键环节写出来。先看段文件的写入流程,这是整个引擎最热的路径,任何多余的操作都会被放大。
写入一条日志的步骤:
- 检查当前活跃段是否有足够空间容纳这条记录(记录长度 + 头部开销)。不够则滚动:关闭当前段、fsync、创建新段、写入段头、更新内存中的活跃段指针。
- 在活跃段末尾追加记录字节:写长度前缀、索引号、任期、类型、数据、CRC。
- 更新内存索引,记录"索引号 → 段 ID + 偏移"。
- 根据配置决定是否立即 fsync。Raft 场景下通常由上层批量提交时统一 fsync,而不是每条都 fsync。
第 4 步是性能关键。每条 fsync 的代价在机械盘上是毫秒级,在 SSD 上也有几十到几百微秒。如果每条日志都 fsync,吞吐会被 fsync 次数直接卡死。正确做法是攒批:上层把一批 entry 写完后调用一次 fsync,一次系统调用覆盖多条记录。Tan 的接口设计通常暴露一个显式的 flush/sync 方法,让 Raft 层控制提交时机。
// 伪代码示意:批量追加 + 单次 fsync func (w *SegmentWriter) AppendBatch(entries []Entry) error { for _, e := range entries { if !w.active.HasRoom(e.Size()) { if err := w.rollSegment(); err != nil { return err } } off, err := w.active.WriteRecord(e) if err != nil { return err } w.index.Put(e.Index, w.active.ID, off) } return w.active.Sync() // 一批只 sync 一次 }这段伪代码里rollSegment的顺序很重要:先确保旧段 fsync 完成,再创建新段。如果旧段还有未落盘的数据就切走,崩溃后旧段尾部可能残缺,虽然恢复能截断处理,但会白白丢失已经返回成功的日志。
4.2 恢复流程的完整走查
崩溃恢复是检验存储引擎成色的试金石。Tan 的恢复流程我按顺序拆成五步,每一步都有明确的失败处理:
- 扫描段目录,按段起始索引排序,得到段列表。如果发现段文件命名或段头损坏,标记该段及之后所有段为可疑。
- 逐段校验,从段头开始解析每条记录,验证长度前缀合理、CRC 匹配、索引号连续。遇到第一条损坏记录,记录其位置,该位置之后的数据全部丢弃。
- 重建内存索引,把校验通过的记录逐条加入索引。如果采用稀疏索引,这里同时构建锚点。
- 确定最后有效索引和任期,作为日志恢复的终点。
- 恢复元数据并交叉校验,读取元数据,验证 commit index 不超过最后有效索引,term 与最后一条日志的任期关系合理。
第 2 步的"遇到损坏就截断"是核心策略。这里有个细节:损坏可能发生在段中间而非末尾(比如磁盘坏块),此时不能简单截断到损坏点之前就完事,因为损坏点之后可能还有有效数据。但 Raft 的语义允许我们丢弃损坏点之后的所有日志——只要这些日志还没被提交,丢弃后重新从 Leader 复制即可。如果损坏点之前的日志已经提交,那说明数据已经应用到状态机,日志本身可以安全丢弃。所以"损坏点之后全丢"在 Raft 语义下是安全的,这也是 Tan 敢用简单截断策略的底气。
注意:如果损坏发生在已提交日志区间内,且状态机还没持久化对应状态,那就真的丢数据了。所以 Raft 实现必须保证"日志提交 → 状态机应用 → 状态机持久化"这条链的持久化顺序,存储引擎只能保证日志本身的完整性,不能替上层背锅。
4.3 段滚动的参数计算
段大小怎么定,是个需要算一算的问题。假设单条日志平均 256 字节,目标段大小 64MB,那么每段约容纳 26 万条日志。这个数量级下,稀疏索引每 1024 条一个锚点,每段约 256 个锚点,内存开销可忽略。
段大小的影响因素:
| 段大小 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 较小(如 8MB) | 截断回收粒度细,空间释放快 | 段数量多,文件句柄和元数据开销大 | 日志量小、截断频繁 |
| 中等(如 64MB) | 平衡回收粒度与开销 | 无明显短板 | 大多数通用场景 |
| 较大(如 256MB) | 段数量少,顺序读效率高 | 截断后空间迟迟不释放 | 日志量大、截断少 |
我的经验是从 64MB 起步,根据实际截断频率调整。如果监控发现磁盘占用长期高于预期,说明段太大导致回收不及时,往小调;如果发现打开文件数吃紧或段管理开销明显,往大调。这个参数没有银弹,得看负载。
4.4 元数据双缓冲的具体落地
元数据的双缓冲实现,我按文件级别写清楚。假设元数据目录下有两个槽位文件meta_a和meta_b,外加一个meta_commit记录当前有效槽位。
更新流程:
- 读取
meta_commit确定当前有效槽位(假设是 A)。 - 把新元数据写入非有效槽位 B,带版本号和 CRC。
- fsync B。
- 更新
meta_commit指向 B,fsyncmeta_commit。 - 下次更新时有效槽位变成 B,写 A,如此交替。
恢复流程:读meta_commit,读对应槽位,校验 CRC 和版本号。如果meta_commit本身损坏,则读两个槽位,选版本号更大且 CRC 通过的那个。这个设计能容忍任意单点崩溃——写 B 时崩溃,A 仍有效;更新 commit 时崩溃,要么 commit 还是旧值(A 有效),要么 commit 已更新(B 有效),不会出现两个都无效的情况。
# 目录结构示意 data/ meta_commit # 记录当前有效槽位,如 "B:42" meta_a # 槽位 A,含版本号 41 meta_b # 槽位 B,含版本号 42 logs/ seg_000001.log seg_000002.log版本号单调递增,恢复时选版本号大的有效槽位,这个规则简单且不会错。
5. 常见问题与排查技巧实录
5.1 尾延迟突然飙高的排查路径
这是我在实际系统里遇到最多的问题。现象是 Raft 提案延迟的 P99 突然从几百微秒跳到几十毫秒,持续几秒后恢复。排查顺序我总结成一张表:
| 排查项 | 观察指标 | 可能原因 | 处理方式 |
|---|---|---|---|
| 段滚动频率 | 单位时间新建段数 | 段太小或写入突增 | 调大段大小或限流 |
| fsync 次数 | 每秒 fsync 调用数 | 每条都 sync 未攒批 | 改为批量 sync |
| 磁盘 IO 队列 | iostat 的 await/util | 磁盘饱和或坏盘 | 换盘或分流 |
| 索引锁竞争 | 锁等待时间 | 索引结构并发差 | 分段锁或读写分离 |
| 元数据更新频率 | 元数据写次数 | 频繁改 term/vote | 合并更新或缓存 |
我遇到过一次典型案例:尾延迟周期性飙高,最后定位到是段滚动时的 fsync 和正常写入的 fsync 撞在一起。旧段 fsync 是同步阻塞的,滚动瞬间把写入路径卡住了。解决办法是把旧段 fsync 放到后台线程,滚动时只切换指针,旧段的持久化异步完成——但前提是旧段的数据在切换前已经通过之前的批量 sync 落盘了,否则会丢。这个改动需要仔细验证崩溃一致性,不能想当然。
5.2 恢复时日志"凭空消失"
有同学反馈重启后最后几条日志不见了,但客户端明明收到了成功响应。这几乎总是fsync 时机的问题。Raft 的语义是"多数派持久化后才返回成功",如果存储层在返回成功前没真正 fsync,掉电后日志就丢了。排查要点:
- 确认批量 sync 的调用点在"返回成功"之前,而不是之后。
- 确认 sync 覆盖了所有刚写入的段,包括滚动产生的新段。
- 确认文件系统没有把 fsync 当建议(某些配置下 fsync 语义会弱化,需要确认挂载参数)。
提示:测试这类问题不能只靠正常重启,必须做掉电模拟(比如直接断电或强制 kill -9 后立即重启),正常 shutdown 会触发额外的 flush,掩盖问题。
5.3 元数据损坏导致节点起不来
元数据损坏的表现是节点启动时校验失败,拒绝服务。排查时先看 CRC 是否通过,再看版本号是否合理。如果两个槽位都损坏(概率极低,通常是磁盘问题),只能从日志重建元数据——用最后一条日志的索引和任期作为 commit index 和 term 的下界,vote 重置。重建后的节点可能触发一次选举,但数据不会丢。
我踩过的坑是元数据版本号回绕。早期用 32 位版本号,理论上写到 40 亿次才回绕,但测试时用了个疯狂压测脚本,居然真把它跑回绕了,导致恢复时选了旧槽位。后来改成 64 位,问题消失。这种边界情况平时想不到,压测时才会暴露。
5.4 段文件句柄泄漏
长时间运行后打开文件数持续增长,最终触发系统限制。原因通常是段滚动后旧段句柄没关,或者截断删除段后句柄没释放。排查方法很简单,定期 dump 打开的文件列表,对比段目录。修复要点是确保每个段在"不再需要写入"时关闭写句柄,在"被截断删除"时关闭所有句柄。读句柄可以用池化管理,避免频繁开关。
5.5 索引重建太慢
日志量大的节点重启时,索引重建可能耗时几十秒甚至几分钟,期间节点无法服务。优化方向有三个:并行扫描各段(段之间独立,可多线程)、稀疏索引减少内存操作、持久化索引快照(定期把索引 dump 到磁盘,重启时加载快照再增量扫描)。第三个方案最有效但最复杂,需要保证快照与段文件的一致性,我一般建议先做前两个,规模真的很大再上快照。
6. 我在实际使用中的几点体会
Tan 这类专用存储引擎的价值,不在于它用了多高深的技术,而在于它敢于为特定负载做减法。通用引擎的每一个"通用"特性,在 Raft 场景下都可能是负担:LSM 的 compaction 是负担,B+ 树的页分裂是负担,事务的 MVCC 是负担。Tan 把这些全砍了,只留下追加、区间删、原子元数据三条路径,换来的是可预测的低延迟和简单的恢复逻辑。
但减法也有代价。Tan 不适合存业务数据,不能做范围查询,扩展性受限于单机磁盘。如果你的系统里 Raft 只是众多组件之一,业务数据另有存储,那 Tan 的定位就很清晰;如果你指望一个引擎通吃,那还是老老实实用通用 KV。我见过有人想把 Tan 改造成通用存储,最后改得四不像,性能还不如直接用 RocksDB,这就是没想清楚边界。
最后一个实操建议:把段大小、批量 sync 阈值、稀疏索引粒度这三个参数做成可配置,并且在压测阶段重点调。这三个参数几乎决定了引擎的全部性能特征,而且最优值随负载变化。我一般会跑一组不同参数组合的压测,画出延迟-吞吐曲线,选拐点附近的配置。别信任何"推荐值",包括我上面给的 64MB,那只是个起点。