自研Raft+LSM分布式KV存储:架构设计与性能优化实践 做后端时间长了总会跟一致性较上劲。之前维护过一个 Redis 主从集群主节点一宕机从节点顶上来的那几秒里缓存里的数据是能丢的后来换过 ETCD一致性倒是没问题了但存业务数据又太重动不动占用好几个 G 的内存。于是当时脑子里就一直盘旋一个想法要是能有一个小型的、基于 Raft 的分布式 KV 存储既有强一致保证又能扛住比较高的写入吞吐所有行为都自己可控那该多好。这个想法最终变成了一个持续了两个多月的自研项目——一个从零实现 Raft 共识协议、再用 LSM 引擎落地存储的高性能分布式 KV 存储。这篇文章是这个系列的第一篇主要讲清为什么做、怎么设计、关键模块怎么落地给同样打算碰分布式存储的同行一个参考。这个系统适合谁如果你正在学习 Raft 协议但看得懂论文、写不出代码或者你所在团队需要一个贴合特定 workload 的 KV 存储不想被通用系统的各种约束绑架又或者你想知道写入 QPS 翻车时到底是网络问题还是 fsync 太慢——那这套笔记大概率对你有用。系列第一篇不会堆代码而是把架构骨架、选型逻辑、核心链路讲透。1. 为什么还要自研一个 KV 存储先把需求想清楚1.1 现有方案覆盖不到的角落分布式 KV 存储已经有足够成熟的选项Redis、ETCD、TiKV、CockroachDB 都在各自的领域里做得很好。那为什么还要自研不是因为它们不好而是因为它们各自的适用半径太清楚了。Redis 主从切换会丢写入数据异步复制决定了它无法提供真正的强一致ETCD 强一致、稳但整个数据都挂在内存里数据量一上来成本就很难看而且它的 API 和存储模型更适合配置类、元数据类场景TiKV 功能强大但部署和运维成本都不低单机性能也不是它的第一优先级。如果业务想要的是一个小集群、强一致、持久化、几万 QPS 写入不抖的中间地带自研一个轻量级系统就变得合理了。它不是替代品而是一个更贴身的选项。自研的核心动机有三层第一是可控性Raft 的选举超时、心跳间隔、批量大小、刷盘频率在通用系统里不一定暴露给你而在自研系统里每一个都是可调参数出问题时可以直接定位到协议层第二是学习价值把一个分布式系统从 0 推到可用比调参任何开源产品都能加深对一致性协议的理解第三是体积与服务模式自研可以做成一个小型持久化引擎加可嵌入 SDK不强制引入复杂的编排系统。1.2 需求边界哪些做哪些明确不做在写第一行代码之前把边界画清楚后面才不会跑偏。目标 API 很克制KV 系统不需要 SQL也不需要跨 Key 的复杂分布式事务Set、Get、Delete、Batch 这四个接口已经能覆盖绝大多数场景后续再加原子 CAS 和 Watch对很多业务来说就足够了。性能目标我以三节点、普通 SSD、千兆内网为基准写入 QPS 目标 5 万以上读路径如果走 Lease Read 再叠加本地缓存P99 能压到 1ms 以内。一致性语义默认给线性一致读同时兼容一个可容忍最终一致的低延迟读接口。明确不做的东西也要写进文档跨区域多活不做、跨 Key 事务不做、SQL 解析不做、自动数据分片也放到第二期第一版先做单 Raft Group 的集群。画出边界不是限制发展而是让每一阶段的验收标准都清晰可见。2. 整体架构与模块划分2.1 五个模块一条核心链路系统从上到下分五层接入层client SDK、RPC 通信层、Raft 共识层、状态机层、存储引擎层。接入层负责封装客户端 API、路由、超时重试和请求 IDRPC 层负责节点间消息传输、连接池管理Raft 共识模块负责领导选举、日志复制、快照与成员变更状态机层把 Raft 日志逐条 apply 到存储引擎并记录 applyIndex存储引擎层基于 RocksDB 封装负责最终落盘、压缩、崩溃恢复。用一条具体写流程串一下客户端 Set(key, value) 到达接入层接入层找到 Leader 节点Leader 把操作打包成一条日志复制到多数 FollowerLeader 确认提交后异步应用到状态机最后返回成功。读路径如果是强一致读则要先通过 ReadIndex 确认 Leader 身份等 apply 追到对应 index 之后再去存储引擎 Get。这幅链路图在脑子里定下来后面很多细节就不会跑偏。2.2 存储引擎为什么选了 RocksDB写多读少、写盘频繁的 KV 存储最适合 LSM-Tree 结构。RocksDB 的第一个理由是顺序写LSM 的写入是 append 到 WAL 加 memtable磁盘写模型非常友好而 B 树引擎每次修改都可能触发随机页写高并发写入下很容易成为瓶颈。第二个理由是分层能力RocksDB 天然支持 ColumnFamily我可以把 Raft 日志、用户数据、元数据分别放进不同的 CF互不干扰。第三个理由是生态成熟布隆过滤器、Block Cache、可调 Compaction、备份接口全都现成不需要从零造轮子。当然代价也有读放大、压缩带来的 CPU 峰值、参数调优的复杂度。第一版先用默认参数跑通再根据性能测试逐步调整这些问题后面单独开一小节讲。在这里我只强调一点选型不要贪全第一版的目标是用最短时间跑出一个可验证的全链路所以存储引擎直接复用成熟的 RocksDB把精力留给真正需要自研的 Raft 层这个钱花得值。3. 存储引擎层实现Key 怎么设计决定系统上限3.1 Key 编码与数据布局KV 系统里Key 编码不是一个可以随手糊弄的事。一个 Key 一旦写到引擎里很难在不做数据迁移的情况下随意改格式所以我第一版就定了三条编码规范长度前缀、大端字节序、固定分隔符。物理 Key 由三个部分组成namespace1 字节区分元数据、Raft 日志、用户数据、逻辑 key 的长度varint、逻辑 key 的原始字节。长度前缀有两个作用一是避免 key 之间出现a和ab这类解析歧义二是保证按逻辑 key 排序时和按字节序排序保持一致。数值型 key 转成 uint64 后的字节序也要提前想好否则数字排序会反过来这个细节很多人写完了才发现改起来非常痛苦。ColumnFamily 的分配策略是default CF 存用户数据raft-log CF 存未压缩的日志条目meta CF 存当前 Term、VotedFor、最后快照 index 等元信息。对应 RocksDB 的代码层我封装了一个 Storage 接口先往外暴露 Get、Write、Snapshot 三个方法隔离底层引擎。这样后续如果要把 RocksDB 换成自研引擎或者内存表上层 Raft 代码可以完全不动。3.2 写路径与组提交策略Raft 日志复制到多数派之后状态机 apply 时不逐条直写而是用一个写缓冲合并成批量提交。这里有两个关键点。第一RocksDB 的 WriteBatch 本质上是一个有序变更集合批量写入比一次一条 Put 少非常多的内部开销而且只有整批的最后一次才需要触发 fsync。第二刷盘策略直接决定延迟目标。RocksDB 的 WriteOptions 里可以设置 sync 为 true 或 falsesync 为 true 时每次提交都刷盘P99 可控但吞吐下降sync 为 false 时吞吐高但掉电会丢数据。折中方案是组提交多个客户端请求合并到同一批日志一次网络复制、一次 fsync既不牺牲持久性也能把 fsync 次数从单条降到每批一次。实测下来这个配置是整条写路径性能提升最大的点。我把 Raft 层的日志批量大小和 RocksDB 的 WriteBatch 大小做了联动配置pending 队列攒到一定量就触发一次批量写而不是等超时。3.3 读路径与布隆过滤器读路径的映射分两种Point Get 和 Range Scan。第一版只要求 Point Get 性能好所以重点用 RocksDB 的 Bloom Filter。布隆过滤器的作用很直白它告诉你某个 key 大概率不存在把不存在时扫描 LSM 多层 SST 的成本降到接近零。我配置了 10 bit/key换来的是不存在读的极快返回代价是每个 SST 多占一点内存这个性价比非常高。Block Cache 用来缓存热数据块read_options 里打开 readahead 和 verify_checksum都能在保持一致性的前提下让读路径更快。如果后面做 Range Scan就需要引入 prefix seek 和 prefix bloom这属于二期规划我在接口上已经预留了 Scan(start, end) 的空实现。分布式 KV 的读路径还有一个隐藏设计强一致读必须等本地 applyIndex 追上 readIndex否则读到的可能是旧值这个逻辑放在状态机层而不是存储层。4. Raft 共识层把论文变成能跑的状态机4.1 自研 Raft 而不是直接抄 etcd 库写到这里肯定会有同行问Raft 不是有现成库吗etcd 的 raft 库和 braft 都是很成熟的实现为什么不直接用答案分三层。第一自研是为了真正理解协议Raft 论文会告诉你日志匹配特性是这么个概念但只有自己实现日志冲突回滚时才会把日志是唯一权威数据源这句话刻进脑子里。第二通用库的抽象接口为适应各种项目做了很多妥协日志条目格式、快照触发策略、消息序列化都要再套一层转换自研接口可以完全贴合状态机的调用方式。第三排障爽。线上出问题时我可以直接 dump 选举超时、心跳时间、批量大小从日志里看清楚一轮复制用了多少毫秒、卡在哪一步而不是对着一个黑盒猜。当然用现成库一定比从零靠谱所以我的建议是如果你的目标是尽快上线就用成熟库如果你的目标中包含把协议吃透这五个字自研一遍的收益远超想象。反正代码量也就一千行出头正确性测试花的时间远大于编码时间。4.2 选举与心跳term 和随机超时Raft 选举的核心是任期加随机化超时。每个节点有三个角色Follower 被动接收、Candidate 发起选举、Leader 被多数派认可。Follower 在选举超时后变成 Candidate把自己的 term 加一投票给自己并广播 RequestVote。这里有三个典型坑。第一选举超时必须随机化否则多个 Follower 同时超时同时发起选举票数被分散系统可能一直选不出 Leader。我给每个节点的选举超时设置为 150ms 到 300ms 之间的随机值出现多个候选者的概率被大幅降低。第二旧任期的 Leader 回来后会瞎指挥。Raft 的解法是每个 RPC 请求都带 term接收方发现请求 term 比自己大就立即切换成 Follower 并更新 termterm 比自己小的请求直接拒绝。没有这个机制网络分区恢复时可能出现旧 Leader 继续写日志、覆盖新数据的严重问题。第三心跳间隔必须远小于选举超时。我第一版把心跳设成 50ms、选举超时 150ms 到 300ms结果一次 GC 卡顿就能让集群重新选举后来把心跳调到 20ms选举超时下限提到 300ms稳定性好了很多。参数不是拍脑袋出来的得看实际环境的抖动幅度。4.3 日志复制与提交最容易写错的那几行日志复制是 Raft 正确性的核心链路。Leader 收到客户端请求后把操作封装成 Entry{Term, Index, Data}向所有 Follower 并行发送 AppendEntries RPC。Follower 收到后不是无条件接受它要先检查日志匹配特性本地最后一条日志的 (term, index) 必须与 RPC 里的 prevLog(term, index) 匹配才允许追加如果不匹配返回 falseLeader 就把 nextIndex 往前回退继续尝试直到对齐。为什么需要这个匹配特性因为日志必须是前后一致的链表任何一个节点在某个 index 处存储的内容只有一份。如果某个节点在 index 5 存了一条和 Leader 不一样的日志那么从这个位置往后的所有日志都不该被信任只能整体截断重写。提交规则是另一个容易写错的地方。复制到多数派不等于可以提交。Raft 提交的准确条件是Leader 只能提交当前任期内的日志条目并且该条目已经复制到多数派对于上一任期残留的日志必须等它后面出现一条当前任期的日志且被提交后才能间接确定提交。我一开始直接按多数派即提交实现结果用故障注入测出来旧 Leader 带着旧日志活了一段时间恢复后把已经提交的日志回滚了。后来自己画了几遍 Figure 8 的场景才算彻底搞清楚。Leader 的提交循环伪代码大致长这样func (n *Node) advanceCommit() { for idx : n.lastLogIndex(); idx n.commitIndex; idx-- { if n.log[idx].Term ! n.currentTerm { continue } if n.quorumReplicated(idx) { n.commitIndex idx break } } }这个检查 term 等于 currentTerm 而不是简单看多数派的逻辑就是 Figure 8 场景的核心解法。4.4 快照、日志压缩与慢节点追赶日志无限增长迟早会撑爆磁盘。当某个节点落后太多时用 AppendEntries 方式补日志效率太低Leader 直接把自己的最新状态压缩成一个快照通过 InstallSnapshot RPC 一次性传给落后节点。快照里放的是状态机在 applyIndex 处的完整数据而不是日志。Follower 收到快照后清空本地状态机到快照位置再继续从 Leader 那里复制后续日志。这里有一个很容易出问题的地方快照传输不能阻塞正常日志复制。我第一版把快照串行放在复制队列里大快照传输时正常写请求被卡住整个集群的 P99 直接涨到秒级。改成独立队列异步传输后问题立刻消失。快照触发条件也要保守我用日志达到 64MB 或 applyIndex 减 compactIndex 超过 10000 条作为触发条件这样既能防止快照过于频繁也避免日志占满磁盘。慢节点的追赶逻辑还涉及 Leader 侧 nextIndex 的动态回退这里不展开讲后面会单独写一篇细节。5. 高性能路径把每一微秒握在手里5.1 写入链路合并请求加组提交高性能写入的第一原则是减少 fsync 次数。单条写请求直接 fsync一次磁盘刷写大约要 1ms 到 2ms三节点复制一轮怎么也要两次 fsync吞吐天花板直接被锁死。所以我在 Leader 端做了请求合并客户端请求到达后先放进 pending 队列攒够 N 条或者等待一个收集超时比如 0.5ms合并成一条日志一次性复制。复制成功后状态机的 batch 再一次性写入 RocksDBRocksDB 的 WriteBatch 也合并成一次刷盘。这样做的效果非常直观原来 10000 条请求要 10000 次 fsync合并后可能只要 20 次 fsync。P99 非但没有因为多等一个收集周期而变差反而因为排队效应显著下降。批大小和超时是矛盾需要用测试数据来平衡。我给一个参考值写入 QPS 在 1 万以下时batch 设 128 条QPS 到 5 万时batch 设 512 条收集窗口控制在 1ms 以内。如果追求更低延迟可以把收集窗口降到 0.2ms但吞吐会掉一些这个取舍完全取决于业务。5.2 强一致读ReadIndex 与 Lease Read要在分布式系统里提供强一致读读请求必须打到 Leader 的提交状态上。最朴素的方式是Follower 收到读请求后转发给 LeaderLeader 先向多数派确认自己没有被分区这就是 ReadIndex 方案等 apply 追上该 index 后再读引擎。这样会多一轮网络 RTT读延迟和吞吐都受影响。Lease Read 的优化思路是既然 Leader 在选举成功时已经得到了多数派确认它可以在一个租约期内认为自己仍是合法 Leader期间读操作免去每轮确认直接等 apply 到本地 commitIndex 后读引擎即可。租约期通常取选举超时的一半左右原理是多数派已经知道了当前 term不可能在租约期内再投票给别的候选者。但 Lease Read 有个前提系统时钟漂移必须有界。如果某个节点的时钟快了几百毫秒它可能会在租约期内发起新选举导致两个节点同时自认为 Leader一致性被破坏。我的处理方式是做成配置开关默认用 ReadIndex 稳扎稳打时钟约束特别好的内网环境才敢开 Lease Read。5.3 网络、序列化与连接池节点间网络是性能的隐形瓶颈。RPC 层我用的是基于内存池的通信模块后端用 epoll 加多线程 Reactor连接上区分数据连接和复制连接避免慢写请求阻塞日志复制。消息序列化第一版用 Protobuf 保正确性后续发现日志条目高频复制时反射开销偏高就把 Raft 内部消息改成了手写二进制编码固定头加多段 payload不用反射、不用额外分配。这个改动让 Leader 到 Follower 的单条消息体积下降约 30%网络延迟从 0.25ms 降到了 0.18ms。还有一个很容易忽略的点连接复用。Raft 的 AppendEntries 是高频操作每 20ms 一次心跳每次还可能要带一批日志。如果每次发送都新建连接握手开销会直接吃掉几个百分点的性能。我把每个节点之间的连接池提前建好长期保活只在连接断开时重建。这个细节在低 QPS 时无感QPS 上来之后差距非常明显。6. 验证手段与踩坑记录没有故障注入不敢说系统是对的6.1 三节点集群与故障注入方案搭建一个三节点集群做测试很简单三个进程、一个共享网段、不同端口。配置大致长这样[node] id node1 raft.log_dir /data/raft-log data_dir /data/kv-data peers [node1:8100, node2:8101, node3:8102] heartbeat_ms 20 election_timeout_ms 300启动顺序没有严格要求。如果三个节点同时启动它们会各自进入 Candidate 状态第一次选举大概率成功如果其中一个节点没启动另外两个也能先形成多数派正常选举等第三个节点追上来。故障注入工具我最常用的是 kill -STOP 与 iptables 丢包kill -STOP 模拟进程卡死恢复后相当于节点从网络分区里归来iptables DROP 直接模拟节点间通信中断观察读路径请求是否超时、是否依然线性一致。验证强一致读的手段是构造读后写冲突序列写入 key 后立即读要求读到的永远是最后一次写入的值。更严格的验证要上 Jepsen用随机分区加线性一致性检查器校验历史记录这个已经列入二期计划。第一版能用 kill -STOP 加半手动脚本跑两小时不出问题基本就能放心扩大测试范围了。6.2 高频问题速查表把这段时间踩过的坑整理成一个速查表后面的同学遇到类似问题可以优先排查症状可能原因解决方法Leader 频繁切换心跳间隔相对选举超时太接近心跳 20ms选举超时下限 300msGC 卡顿下再放宽写入吞吐上不去fsync 次数过多开启组提交与批量复制Follower 日志堆积永远追不上快照传输串行阻塞复制快照走独立队列大快照并发传输网络抖动导致 P99 飙升RPC 连接未复用保活连接池、超时退避旧 Leader 恢复后写入异常未严格校验 term每个 RPC 都带 termterm 过小时拒绝节点重启后行为异常VotedFor 未持久化meta CF 中保存 voted_for重启后从磁盘恢复6.3 几点实测体会与后续计划最后说点实操体会。第一先跑通再优化这条铁律在分布式项目里也同样适用。我第一版先单机单节点把全链路跑通然后才上三节点集群和故障注入否则问题会被网络和时序变量混在一起排错成本翻倍。第二性能指标从第一行代码就要开始记录我最开始没记录基线后面调优时根本不知道哪个参数真正起了作用白白浪费了两天时间。第三共识协议的测试一定要把各种失败场景写进单元测试而不是只验证 happy pathLeader 宕机、网络分区、日志冲突这些场景的测试用例价值远大于正常的读写用例。接下来计划做四件事多 Raft Group 分片、Watch 与 CAS 接口、Jepsen 线性一致性验证、Compaction 参数精调。每完成一个模块我会在这个系列里继续拆开讲。下篇先写 Raft 日志模块的单元测试设计把我在 4.3 节里讲过的那些容易写错的地方用测试用例证明一遍。