ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零构建基于Raft的高性能分布式KV存储:架构、读写优化与WAL设计

从零构建基于Raft的高性能分布式KV存储:架构、读写优化与WAL设计 “自研基于Raft的高性能分布式KV存储系统一”——这个标题我在立项时想了很久。市面上已经有 etcd、Consul、ZooKeeper 这些强一致组件为什么还要自己造轮子原因很简单我需要一个能支撑高并发读写、可定制存储引擎、并且能自由扩展成多分片方案的 KV 系统而现有组件要么存储模型固化要么在性能调优时到处都是黑盒。这篇文章是这个系列的第一篇重点拆解自研过程中最核心的决策为什么选 Raft、整体架构怎么分层、读写路径如何优化、WAL 和存储引擎怎么设计。如果你正打算从零写一个分布式系统或者想深入理解 Raft 在真实存储系统中的落地方案这篇内容会很对你胃口。1. 项目背景与整体设计思路1.1 为什么放着现成组件不用非要自研先说结论不是不信任现成组件而是业务场景和现有组件的设计目标不匹配。我手头的场景是一个内部平台需要管理海量小文件的元数据以及在多个服务之间做分布式锁和配置下发。数据总量不大单机内存完全装得下但 QPS 要求很高p99 延迟需要控制在 5 毫秒以内而且非常强调强一致。当时先试了 etcdraft 逻辑很成熟但它的存储层是 boltdb写路径上有比较明显的锁竞争和 mmap 刷盘开销。在纯内存操作占大多数的情况下仍然有 30% 以上的 CPU 时间花在了存储层和 RPC 框架上。Consul 依赖 gossip 做服务发现强一致读需要经过 leader 转发延迟也不理想。说到底这些组件首先是“平台级基础设施”功能完整、可运维性强但不一定适合当高性能 KV 引擎用。自研不是赌气而是把系统做成“薄一层强一致协议 可插拔存储后端”。我可以针对自己的读写模型去优化 Raft 的日志批量提交把 WAL 和状态机直接做成零拷贝通道也可以在业务需要时快速引入多 Raft group 来做分片。这些都是基于开源组件二次开发很难绕开成本的事。1.2 基于Raft的系统定位与设计目标既然决定了自研第一件事就是把系统定位写清楚。我设计的这个系统是一个“强一致、单写多读、以内存为主”的分布式 KV 存储。它不是一个通用数据库更不是大数据平台它的核心职责是对外提供Put/Get/Delete和TryLock语义所有节点的数据保持一致读到的结果与历史顺序没有冲突在少数节点故障时依然可以正常提供读写单分片 QPS 目标不低于五万次混合读写p99 延迟低于 5 毫秒。协议选择上我押注 Raft。这背后有一个工程化判断Raft 的一个明显优点是“可理解性”。它把分布式一致性拆成了 leader 选举、日志复制、安全性三个相对独立的部分任何人都能对着论文逐个模块实现。相比之下 Paxos 虽然理论更优雅但工程化时容易在 multi-paxos 的细节里绕晕。另一个原因是 Raft 有大量现成参考实现比如 etcd 的 etcd/raft 库即使不直接依赖也能在协议细节上做交叉验证极大降低了“实现了一个看起来对其实有 bug 的协议”的风险。系统目标也非常朴素先做到单 leader 模式下的极致性能后续再通过拆分成多个 raft group 实现水平扩展。因此第一代版本不考虑跨数据中心容灾只保证在同一机房或同一可用区内的强一致性和高可用。2. 系统架构与Raft模块拆解2.1 整体架构分层与服务端模型整个系统的代码分层我控制得非常严格避免业务逻辑和一致性协议互相纠缠。大致分为四层接入层负责网络协议解析、并发控制、客户端会话管理。对外提供基于自研二进制协议的 SDK同时也支持一个简单的 ASCII 版协议用于调试。Raft 一致性层只处理日志复制、选举、快照、配置变更。这一层不关心业务数据本身它看到的只是一个带 index 和 term 的 Entry 序列。状态机层执行是纯函数输入一组日志条目输出新的存储状态。每个节点上的状态机必须完全相同这是 Raft 安全性的前提。存储层负责 WAL 管理、快照存储、内存索引。第一版我直接做成了一个append-only file memory hash index的结构避免引入不必要的 LSM 复杂度。在服务端进程模型上我没有采用业界常见的“每请求一 goroutine Raft 独立事件循环”的模式而是把 Raft 的 tick、请求处理和状态机应用都放到了同一个多线程模型里。原因是 Raft 的消息处理对顺序要求很高尤其是AppendEntries和RequestVote如果多个 goroutine 同时修改节点状态锁竞争会非常严重p99 会被拉上去。实际运行时的线程划分是一个网络 IO 线程池负责收包解析后投递到 Raft 核心的无锁 MPMC 队列Raft 核心线程按 batch 消费队列一次性处理一批消息状态机应用线程只从applyCh读取已提交的日志。这样既保证了核心逻辑的串行性又能通过批处理摊薄网络和日志落盘开销。2.2 Raft核心模块日志、选举与配置变更Raft 模块拆完以后核心数据结构其实就两个LogEntry和RaftState。前者是日志的最小单元后者是节点在当前 term 下的状态快照。我的定义大致如下type Entry struct { Term uint64 Index uint64 Type EntryType Data []byte } type RaftState struct { currentTerm uint64 votedFor uint64 commitIndex uint64 lastApplied uint64 nextIndex map[NodeID]uint64 matchIndex map[NodeID]uint64 role Role leaderID NodeID }选举逻辑上我严格按照论文实现了一些关键细节这些细节后来帮我们避免了好几个线上问题每个 Follower 的选举超时必须是随机化的。否则网络分区恢复后多个 follower 同时变成 candidate很容易反复冲突。我采用的是base rand(0, 150ms)base 设成 500ms节点启动时重新随机。Candidate 在发起选举时要先本地持久化currentTerm 1再发RequestVoteRPC。如果先发 RPC 再持久化 term一旦节点在收到多数投票前宕机重启后旧 term 会重新进入 Follower 状态可能造成多主。日志复制时leader 不会直接硬覆盖每个 follower 的日志。我保留了CheckProbe和OnRejected两个回调当 follower 的nextIndex回退时leader 需要通过 checkpoint 或安装快照来重新同步而不是简单地把所有旧日志重新传一遍。这些点看似都是论文里的原话真正实现的时候很容易省略或顺序搞反。尤其是commitIndex的推进条件leader 严格维护一个matchIndex数组只有当某个日志条目的 term 匹配当前 term并且被大多数节点复制成功后才允许推进 commitIndex。为了降低实现风险第一版我只支持完整的配置变更不做单节点增减后续版本再考虑 joint consensus。3. 高性能读写路径的关键设计3.1 写路径批量提交与日志合并Raft 的写路径听上去很简单leader 收到请求追加日志并行复制到 follower过半后提交并返回。但在高性能场景下这个路径上的每一步都可能放大延迟。首先是日志落盘。如果每个写请求都立即fsync一次 WAL那么即使是 NVMe 磁盘单次 fsync 也要 40-100 微秒。当 QPS 只有一万时光落盘就占掉了 CPU 和 IO 的时间。我的做法是 group commit客户端请求先进入一个writeBatchCh通道Raft 核心线程每 200 微秒或者积攒到 64 条请求时才批量取走一次写 WAL、一次发 RPC。这样单次 fsync 的平均成本被几十个请求平摊吞吐量提升非常明显。批量提交还解决了另一个问题Raft 的日志条目粒度。第一版我的每条 Raft log entry 只包含一条用户请求后来发现这会导致 log index 增长过快snapshot 频率也会被拉高。优化后一个 Entry.Data 可以包含多条操作在状态机应用时才逐条解析执行。这样 log index 的消耗速率被压缩到原来的十分之一快照生成周期也被拉长了。写路径上的另一个优化是流式复制。传统做法是 leader 发出一个 AppendEntries RPC必须在超时时间内收到响应才继续发送下一条。这在高带宽、高延时网络下非常浪费。我改成了 window-based pipelineleader 同时最多允许 N 个未确认的批次发出。N 的初始值是 32然后根据 RTT 动态调整。只要 follower 的响应按顺序到达且没有拒绝leader 就可以持续把新日志推过去几乎能做到“客户端写多少leader 就复制多少”。3.2 读路径ReadIndex与LeaseReadRaft 本身的最强一致读最朴素的做法是走一遍日志复制也就是把读请求也当作一条 Raft log 写入并提交。这样读的本质和写没区别性能自然上不去。所以我在实现中抛弃了这条路径直接实现了 ReadIndex 机制和 LeaseRead 优化。ReadIndex 的核心思路是leader 在收到读请求时先记录当前的 commitIndex然后向大多数 follower 发一个轻量级心跳确认自己仍然是 leader只有确认过后才能把本地状态机在 commitIndex 之后的状态读出来返回给客户端。这个方案比写日志复制多了至少一个 RTT但相比普通的“直接读内存”还是多了一次网络握手。后来我在集群内没有分区、网络稳定的前提下启用了 LeaseRead。LeaseRead 基于一个假设如果 leader 在这轮选举中拿到了多数节点的投票并且它持续向 follower 发送心跳这些 follower 在 election timeout 内不会发起新选举。因此 leader 可以认为在 lease 有效期内自己一定还是合法 leader不需要每次读请求都做一次 ReadIndex 确认。这个依赖时间同步和网络延迟上限只在单机房部署并且网卡延迟稳定时开启。配置上我用lease_check_period 100ms如果发现最近一次心跳广播已经超过 80ms则暂时退回到 ReadIndex 模式。必须强调的是LeaseRead 不是给弱一致性找借口而是把“lease 提前过期”的可能完全排除。如果系统里有任何慢节点或网络抖动我会立刻关闭该优化。线上压测数据显示ReadIndex 模式下的读 p99 大概 1.2msLeaseRead 开启后能降到 300 微秒左右差距还是很大的。4. 存储引擎与WAL实现要点4.1 存储引擎选型与取舍很多人以为 KV 存储就一定得上 LSM Tree。实际上对于元数据、配置类数据数据总量通常很小写多读少但读要求低延迟。LSM 的写放大和 compaction 抖动在这种场景下反而是负担。第一版我选择的是类似 Bitcask 的存储模型一个 append-only 的数据文件配合一个全内存的 hash 索引。每次Put操作直接把 key、value、过期时间序列化追加到当前 data file 尾部同时更新内存 hash 表指向最新 offset。Get操作直接根据内存索引读取 offset然后做一次 file read。这个模型有两个鲜明优势顺序写吞吐高写入路径上不需要维护 B 树或 SSTable读操作只涉及一次内存查找和一次文件读取没有多层合并p99 非常稳定。缺点是定期需要做 compaction否则老数据会无限膨胀。由于数据量可控我在空闲周期用后台线程做 full compaction读取当前 data file 中的所有有效 key生成新文件后原子切换指纹。这里有个我自己踩过的坑compact 期间不能简单用“文件替换”避免读写冲突必须让读请求先在内存索引中确认版本如果 key 对应的 offset 还在旧文件中就延迟到 compact 结束后重查否则会出现“读到旧文件已删除的数据快照”。4.2 WAL设计与落盘策略WAL 是 Raft 的命根子也是所有一致性的基础。我设计的 WAL 文件按段切分每段默认 64MB超过后轮转到新文件。每条日志记录的格式是[crc32][length][term][index][data]。CRC 校验不可省因为磁盘静默损坏可能会让日志数据在复制时错得更离谱。落盘策略直接在配置里做成了三档方便测试时对比syncalways每条日志写入后立即 fsync保证任何情况下不丢已提交日志syncbatch用一个 group buffer每 5ms 或 32 条日志做一次 fsyncsyncrelaxed只调用File.Write不主动刷盘依赖内核自动回写。上线前我们做了故障注入测试在syncrelaxed模式下突然断电果然出现了已返回客户端成功的写丢失。因为 Raft 提交要求多数节点持久化日志但“持久化”如果只是写到了页缓存重启后仍然可能没真正落到磁盘。最终线上我选择了syncbatch并且把 group buffer 的刷盘间隔压到 1ms 以内。性能比 always 模式高了大约 4 倍可靠性和客户端语义也说得过去。快照方面每累积到 10000 条日志或 256MB 数据量就生成一次 snapshot。快照格式和 data file 完全一致区别是快照是某个时间点的全量状态。生成过程中我用了一个逻辑复制而非文件拷贝的方式先 fork 一个独立状态机把已提交日志按序应用到临时状态机再序列化到临时文件。直接拷贝当前文件虽然快但很可能拷贝到一半文件还在写入产生不一致快照。这是一个常识性问题但很多新人在第一步就写错。5. 从零搭建Raft的实操步骤5.1 定义Raft节点与日志结构如果你也想照着这个思路做我建议先做一个最小可运行版本再逐步加优化。第一步是定义节点状态机代码里不需要炫技清晰比什么都重要。type Node struct { id uint64 peers []uint64 state *RaftState log *Wal applyCh chan []Entry voteCh chan *RequestVote appendCh chan *AppendEntries }启动时节点从 WAL 中恢复currentTerm、votedFor并回放最后一段日志到内存状态机。这一步很关键如果重启后内存索引不一致后续客户端就会读到错乱数据。回放结束后节点以 Follower 角色进入事件循环等待两个东西选举超时和 leader 的消息。日志结构我用最精简的版本不提前引入复杂编码格式。Entry中的 Data 字段先直接用 JSON 序列化等跑通后再改成二进制。第一版跑 JSON 在性能上有损失但排查问题方便得多。等确认 Raft 正确性后再切换到 protobuf 或自研编码。5.2 选举与心跳的代码骨架选举逻辑必须按论文时序来。Follower 在超时之后本地任期currentTerm加一给自己投票然后给所有 peers 并发发RequestVote。收到最多票的节点就是 leader。这里有一个很容易犯的错发送投票请求之前必须先持久化新的 term 和投票记录。否则节点收到投票请求后宕机重启后 term 还是旧的会参与一轮错误的选举。心跳我用了空AppendEntries不带日志数据。leader 每隔heartbeatInterval100ms向所有 follower 广播一次follower 收到后重置选举超时。这样做的另一个作用是让新 leader 能快速把nextIndex收敛到正确位置因为空心跳也会携带leaderCommit信息follower 可以据此推进自己的 commitIndex。选举超时和心跳间隔的关系我建议至少留 5 倍的余量。比如心跳 100ms选举超时范围在 500-650ms。这个比例可以避免网络抖动导致的频繁主从切换。之前我把心跳调到 50ms、选举超时 300ms结果一个 GC 暂停就把 leader 强制切换了线上直接抖动。5.3 客户端请求协议与状态机应用客户端协议不需要复杂。请求类型我用一个字节标识0x01 Putdata 内是 key/value0x02 Getdata 内是 key0x03 Delete同 Get。客户端发送Put时接入层把请求包装成一条指令投递到 Raft 日志等日志提交后再由状态机应用到内存 index。返回给客户端的响应里必须带上本次提交的 log index这样客户端可以用于后续一致性校验。状态机应用线程是唯一允许修改内存 index 的地方。它从applyCh拿到一批已提交的 Entry逐个解析并更新 hash index。为了让读写并行我用了 double buffer写线程先把新状态写到pending缓冲再原子切给读线程。这里的原子切换比加锁性能高很多。实际压测下来锁版本有大约 1.2 微秒的临界区开销而 double buffer 几乎不增加延迟只是内存占用多了一倍。6. 常见问题与排查技巧实录6.1 选举抖动配置与日志不一致上线初期最常遇到的问题就是集群频繁选主。现象是监控里 leader 每几十秒就切换一次读请求经常因为 NotLeader 报错。排查下来根因有几个第一选举超时设置太短和网络/磁盘抖动同步共振。心跳 100ms 时某个节点 GC 停顿 200ms其他节点以为它挂了就会发起新选举。我最后的解决办法是给心跳间隔加一个小随机抖动同时把选举超时下限提高到 500ms。这样单个节点的短时抖动不会波及全局。第二follower 的 WAL fsync 耗时不稳定导致它无法及时响应AppendEntries。这里要重点关注磁盘本身。如果用的云硬盘P50 和 P99 延迟可能差 10 倍以上这时要把batch fsync的窗口调大一点等于用吞吐换稳定。第三日志里有空洞。当 follower 的日志落后很多时leader 发来的AppendEntries会带prevLogIndex如果对不上follower 会立即拒绝。这个拒绝被误判为节点不可达从而触发选主。正确的做法是区分“日志冲突拒绝”和“网络超时”拒绝时只需要回退 nextIndex不需要触发选举。6.2 脑裂与旧leader过时问题Raft 在设计上能避免传统脑裂但实现时容易出错。比如旧 leader 在网络分区恢复后作为 Follower 收到了新 term 的消息但自己的状态机里还残留了一些未提交的日志。如果状态机应用线程不检查lastApplied和 commitIndex就会把旧 leader 的过期数据应用进去。我的排查经验是为每个节点加一个AppliedIndexGauge指标同时记录每个节点最后一次更新的currentTerm。当发现某个节点 appliedIndex 居然比 leader 的 commitIndex 还大时基本可以断定状态机应用逻辑有 bug没有检查 term。旧 leader 的另一个问题是读请求。分区前 leader 可能长时间无法感知自己被隔离此时如果直接读本地状态机会返回已经过期的数据。我在接入层强制所有读请求都必须经过 leader如果本地节点角色不是 leader就直接返回 redirect 响应。在开启 LeaseRead 后我还会额外判断 lease 是否仍然有效以防这个 leader 是旧的。6.3 性能瓶颈CPU、磁盘与网络把功能跑通后性能优化是一场持久战。我的第一版在 20 万 QPS 压测下直接打满 CPU但磁盘和网络都还有余量。用pprof一抓热点全在协议解析的 JSON 序列化上。换用二进制协议后CPU 下降了一半。所以别一上来就怀疑 Raft 协议本身很多时候瓶颈在业务协议和接入层。磁盘方面WAL 刷盘是主要瓶颈。使用iostat -d 1能看到%util很高这时要么降低 fsync 频率要么换更高性能的企业盘。在syncbatch模式下如果 batch 窗口设置过大会让端到端延迟显著上升。我最后是用一个自适应算法正常情况下每 200 微秒刷一次盘但队列积压超过阈值时立即触发一次紧急刷盘。这样既保证了吞吐又不会让某一批请求等太久。网络方面小包问题很突出。一个AppendEntries消息里只有几条日志时TCP 包的有效载荷可能不到 20%。为了避免这种浪费我把多个 follower 的消息合并到同一个 TCP 连接上发送并启用了TCP_NODELAY让每个批次写入后立即 flush。实测下来网络吞吐提升了将近一倍CPU 中断压力也下降了。最后说一个我在实际调试中总结的小技巧所有 Raft 相关指标比如term、commitIndex、matchIndex、role一定要以日志形式定期打点。不要只在出错时打印。因为分布式问题很多时候是“某个节点在某个时间点看到了什么状态”才能定位。我吃过不少亏最后把指标导到监控平台每次故障都能按时间线重放排查效率提升非常明显。这些内容构成这个系列的第一篇。后续我会继续写多做一步的扩展multi-raft group 分片、集群成员变更、节点故障恢复和跨机房容灾。第一个版本已经能稳定跑在一台三节点的普通服务器集群上单分片混合读写 QPS 稳定压到 6 万以上。如果你也在做类似的分布式存储欢迎沿着这些设计思路继续深挖。
返回列表