☰
C++分布式KV存储源码拆解:网络库与一致性哈希核心实现
2026/10/20 10:10:22 网站建设 项目流程

简介:一套基于C++的分布式KV存储与网络库实现源码包,整合SRaft分布式键值存储系统与mymuduo网络库两份工程,适合希望深入理解Raft共识算法、gRPC框架及Reactor网络模型的中高级C++开发者。SRaft部分覆盖选举机制、日志复制、持久化与快照等核心流程;mymuduo部分参考Muduo实现事件驱动、epoll与多线程模型,两者互补,可对照学习分布式系统与高并发网络编程的完整落地路径。压缩包共68个文件,含30个h头文件、28个cc源文件、1个proto接口定义、CMake构建脚本、shell自动化脚本与说明文档,包体仅78KB,代码结构清晰,便于本地编译调试与模块拆解。已有113人学习下载,适合作为课程设计、技术调研或源码精读的参考资料。通过阅读工程源码与配套文档,可直观掌握服务端网络库的分层设计、Raft节点通信与KV读写流程,并快速迁移到自己的实验项目中。

1. 分布式存储和网络编程:这套C++源码为什么值得拆开看

分布式存储和网络编程这两块,是计算机后端里最吃底子的方向,也最容易在面试时连环追问:一致性哈希怎么加虚拟节点才不倾斜、写前日志为什么必须先刷盘、集群扩容时数据怎么迁移才不漏。概念翻书都能答,但真要在本机从零把一套分布式KV存储跑起来,从epoll事件循环到hash环定位再到日志恢复,每一环都要有能落的代码,很多人就卡住了。这套基于C++的分布式KV存储与网络库源码,解决的正是这个缺口:网络层、存储层、集群层三个模块边界清晰,既能当教材从头读到尾,也能拆出来改一改接到自己的后端项目里。适合有C++基础、想真正弄懂分布式底层细节,或者正在备战后端面试的开发者。它不是一个生产级系统,但把生产级系统最关键的三层骨架都搭出来了。

2. 网络库内核:从epoll事件到Reactor循环,先读懂这坨代码

2.1 源码结构:先分清网络层和存储层的边界

我拆这种源码包的习惯是,先看目录结构再读代码,因为很多源码不是写不出来,是目录乱了让人劝退。这个包的网络层、存储层、集群层是分开的:

network/ # 网络库模块,不依赖上层KV逻辑 include/event_loop.h # epoll封装、事件循环、pending回调 include/tcp_server.h # 服务端:监听、accept、连接分发 include/tcp_connection.h # 单条连接的读写处理 include/buffer.h # 应用层缓冲,解决粘包半包 src/event_loop.cpp src/tcp_server.cpp storage/ # 存储层:内存表、WAL日志、快照 cluster/ # 集群层:一致性哈希环、节点通信、迁移

网络层只做一件事:把fd上的事件变成回调,不关心事件里跑的是KV逻辑还是聊天消息。存储层只关心自己的内存表和日志文件,不关心数据从哪台机器过来。集群层是胶水,用哈希环决定key去哪台机器,用自定义协议让机器之间互相通信。这个分层顺序,就是你读代码的顺序。

这套代码的目标不是再造一个etcd,它把网络、存储、集群三条线讲清楚就够了。生产环境里的鉴权、限流、监控上报都没做,这些恰恰是留给自己的扩展空间。我给一个阅读顺序:先读event_loop.cpp,再读tcp_server.cpp,接着读一致性哈希,最后看WAL恢复,一路下来不会卡壳。真正的门槛是事件循环,先把它的逻辑钉死了,后面都是顺着回调往下流。

2.2 事件循环:epoll_wait的超时时间,是个被低估的参数

所有网络库的地基都是同一段逻辑:注册fd、等事件、分发回调。拆出来看,事件循环的核心就是这么一段:

// EventLoop.cpp void EventLoop::Loop() { int timeoutMs = 10; // 毫秒,无事件时的最长睡眠 while (!quit_) { int activeNum = ::epoll_wait(epfd_, events_, kMaxEvents, timeoutMs); for (int i = 0; i < activeNum; ++i) { Channel* ch = static_cast<Channel*>(events_[i].data.ptr); ch->HandleEvent(events_[i].events); } RunPendingFuncs(); // 执行跨线程提交的回调 } }

这段代码里有三个参数值得背下来。第一个是kMaxEvents,它决定单次epoll_wait最多返回多少个事件,我一般取1024起步,如果你的连接数上万,只取1024会导致事件积压,需要按峰值连接数调整。第二个是timeoutMs,设成10而不是-1,是为了让事件循环在没有网络事件时也能定期醒来,处理从其他线程通过QueueInLoop提交过来的任务,比如连接建立、定时器回调。第三个是data.ptr这个联合体字段的用法,它应该存指针而不是存fd,存fd的话每次还要重新找Channel对象,多一次查找,压测时能差出几个百分点的CPU。

epoll还有LT和ET两种触发模式。这个包默认走LT,我自己的血泪经验是先用LT把逻辑跑通,再换ET做优化:

// LT模式下,即使一次只read了512字节,没读完的数据下次会继续触发epoll // ET模式下,必须while循环read到EAGAIN,否则剩余数据可能永远不再通知 ssize_t n = ::read(fd_, buf_, kReadBufSize); if (n < 0 && errno == EAGAIN) { // ET模式正确的退出条件;LT模式极少走到这里 DisableReading(); }

LT的容错性好,写起来不容易漏事件;ET的吞吐高,但要求每次read到EAGAIN,处理不干净就会丢事件。很多人把ET空转导致CPU飙高当成玄学,其实就是漏了EAGAIN判断,或者read循环里没处理EPOLLRDHUP。我一般建议新手按LT跑通再研究ET,能省一半的调试时间。

2.3 连接建立:accept之后要做的三件小事

TcpServer收到新连接后,处理流程很模板化,但几个细节值得单独说:

void TcpServer::OnNewConnection(int listenFd) { struct sockaddr_in addr; socklen_t len = sizeof(addr); int connFd = ::accept4(listenFd, (struct sockaddr*)&addr, &len, SOCK_NONBLOCK); int keepAlive = 1; setsockopt(connFd, SOL_SOCKET, SO_KEEPALIVE, &keepAlive, sizeof(keepAlive)); int noDelay = 1; setsockopt(connFd, IPPROTO_TCP, TCP_NODELAY, &noDelay, sizeof(noDelay)); EventLoop* ioLoop = NextLoop(); // 轮询选一个IO线程 ioLoop->QueueInLoop(std::bind(&TcpConnection::Attach, conn, ioLoop, connFd)); }

第一件小事是accept4一步到位设置非阻塞,省掉accept之后再调fcntl的那一步,连接建立的hot path上能省一点是一点。第二件小事是SO_KEEPALIVE,很多人以为开了这个就万事大吉,其实TCP的keepalive默认两个小时才探一次,对分布式节点间检测死连接来说形同虚设,真正的探活还是得靠应用层心跳。第三件小事是TCP_NODELAY,做KV这种小包交互时必须关掉Nagle算法,否则小包会等ACK一起发,延迟多一个RTT。这个包对单写请求的延迟很敏感,所以默认就关掉了。

2.4 线程模型:one loop per thread是最不容易踩坑的布局

网络层在IO线程模型上用的是经典的one loop per thread:主线程只做accept,完成后把连接轮询交给几个子线程,每个子线程独立跑自己的EventLoop。这样设计的最大好处是,每条连接的所有读写只发生在固定的一个线程里,不需要加锁,比多线程共享一个fd然后锁来锁去要干净得多。

// 连接分发:轮询选择IO线程 EventLoop* TcpServer::NextLoop() { if (ioLoops_.empty()) return mainLoop_; return ioLoops_[(nextIndex_++) % ioLoops_.size()]; }

跨线程要提交任务时,通过QueueInLoop把函数丢给目标线程,丢之前先给那个线程的eventfd写一个字节,把sleep在epoll_wait里的线程唤醒。这就是2.2节留的那个坑:如果epoll_wait的timeout设成-1,又没有wakeup fd,跨线程任务就可能在IO线程睡死的时候无限期积压。所以这个包的EventLoop同时依赖“10ms超时+eventfd唤醒”双保险,这个习惯值得抄进自己的工程里。

到这里,网络层的主干就清楚了。很多人读网络库喜欢一头扎进缓冲区里研究粘包半包,但我建议先看事件循环和线程模型,这两块是骨架,理解了再回头看buffer就自然得多。

3. 分布式KV存储的骨架:一致性哈希环与节点协议,别照着博客抄

3.1 哈希环与虚拟节点:MD5之后取模,别把范围算错

大多数博客讲一致性哈希都是画一条环、放几个点,看起来很直观,等自己实现时就会发现好几个细节博客不会告诉你。这个包的核心寻址逻辑拆出来是这样:

const int kHashSpace = 1 << 30; // 环形区间大小 const int kVirtualNodes = 150; // 每台物理节点的虚拟节点数 std::map<int, NodeId> ring_; void BuildRing(const std::vector<NodeId>& nodes) { ring_.clear(); for (const auto& node : nodes) { for (int i = 0; i < kVirtualNodes; ++i) { std::string vnode = node.ip + ":" + std::to_string(node.port) + "#" + std::to_string(i); int pos = HashVnode(vnode); // MD5做异或折叠后取32位 ring_[pos] = node; } } } NodeId FindNode(const std::string& key) { int pos = HashKey(key); auto it = ring_.lower_bound(pos); // 顺时针找第一个节点 if (it == ring_.end()) it = ring_.begin(); // 绕回环头 return it->second; }

第一个坑是哈希空间的长度。MD5的输出是16字节128位,直接放进int做取模会被截断,高位语义全丢,真实的哈希分布会严重偏斜。常见做法是把128位先做一次异或折叠,把前8字节和后8字节异或,再取低32位作为整型。第二个坑是查找用lower_bound,它表达的正是“顺时针遇到的第一个虚拟节点”。但是要注意,map本身是红黑树,虚拟节点多了以后查找开销也不小,这个包的数据量下完全够用,你可以把它当做一个后续性能优化的观察点。

虚拟节点数量是个比例题。默认150意味着每台物理节点在环上有150个分身。太少了会导致数据分布不均衡,一台机器可能扛了半环的数据;太多了内存和计算开销都会上来,迁移时每个虚拟节点都要判断是否受影响。我调这类的习惯是:先用一段真实key集合做一次模拟分布,看每台节点的key数量占比,差距不大于5%就收手,别盲目堆虚拟节点。

3.2 节点间通信:二进制头加payload,别一开始就上protobuf

节点之间要通信,协议设计越简单越好。这个包用的是自定二进制头加负载,避免引入额外序列化依赖,这个取舍在面试里也是加分项。

// 每个消息统一用这个头 struct MsgHeader { uint32_t totalLen; // 整包长度(含头部本身),网络字节序 uint32_t cmd; // 命令字:1=SET,2=GET,3=DEL,4=FORWARD uint32_t bodyLen; // payload长度 uint32_t crc32; // payload校验,防错包 };

消息头固定16字节,所有字段用htons/htonl转网络字节序。粘包半包的问题靠totalLen解决:读满16字节拿到头,再根据bodyLen读全payload,就完事了。命令字和转发行为的对应关系如下:

命令字含义节点处理行为
1SET若key不属于本节点,转发给目标节点并等待结果
2GET同上,转发后返回value或NOT_FOUND
3DEL同上,转发后返回删除结果
4FORWARD收到此命令说明是转发的请求,直接处理并沿原链路回包

这里有个设计取舍:每个节点都保存完整的哈希环,所以任意节点都可以当协调者,对客户端完全透明。代价是协调节点要等真正的数据节点处理完再回包,多一跳延迟也没有办法。如果想把这一跳省掉,做法是让数据节点直接把响应回给客户端,也就是回边路由,这需要在头部里多存一个“原始请求方地址”,属于这个源码天然的扩展点,留着给你自己挑战。

3.3 扩容与缩容:迁移的数据边界该落在哪

新节点N加入集群后,N在环上的插入位置会分走原本属于后继节点S的一段区间。受影响的是N的后继,迁移范围就是“N的插入位置到后继节点S之间”这一段环形区间上的数据。很多人的第一版迁移代码会写成:迁移时只搬数据、不暂停区间的写请求,结果扩容过程中写进旧节点的更新,迁移完成之后在新节点上查不到。

正确流程是三步走:迁移区间先开启双写,新旧节点同时写;后台限速扫描拷贝;拷贝完成后切换路由,读和写都指向新节点,再删除旧节点上的残留数据。

// 迁移线程:批量扫描,限速拷贝 const int kBatchSize = 1000; while (!migrationDone_) { auto batch = storage_.ScanRange(migrateStart_, migrateEnd_, kBatchSize); if (batch.empty()) break; for (auto& kv : batch) { newNode_->Store(kv.key, kv.value); // 通过网络RPC写入新节点 } migrationCursor_.store(batch.back().key); }

迁移参数里最值得调的是kBatchSize和迁移游标的落点。batch太大,单次发送的RPC包太大,网络和远端写放大严重;batch太小,小包往返过多,迁移时间拉长。我一般会先用1000起步,跑到中后段再根据CPU和网卡占用调整。

4. 持久化与故障恢复:WAL刷不刷、快照何时做,直接影响数据安全

4.1 写路径:先刷日志再进内存,顺序不能反

分布式系统有个常识:进程可以挂,数据不能丢。所以KV存储的写入路径几乎都是同一个顺序——先写日志,再更新内存。

void KVStore::Set(const std::string& key, const std::string& value) { LogEntry entry(LogType::SET, key, value); WALAppend(entry.Serialize()); // 1. 追加日志 WALFlush(); // 2. 刷盘(批量策略见下) memTable_[key] = value; // 3. 更新内存表 if (memTable_.size() >= kFlushThreshold) { CreateSnapshot(); // 4. 内存超阈值时打快照 WALTruncate(); // 滚动并清理旧日志 } }

为什么必须先刷日志再更新内存?因为内存里的数据在掉电瞬间就没了,而日志在磁盘上,只要先写成功了,重启后就能重放出来。反过来如果先改了内存再写日志,改到一半宕机,内存数据丢了,日志也没有,这条写就彻底消失。

WALFlush的策略是性能和安全的胜负手。这个包支持三种刷盘模式,我按经验整理了一张对比:

刷盘策略数据丢失窗口写性能适合场景
每条都fsync无最低,QPS可能腰斩交易、账本类
攒够N条或时间窗再刷最多几十毫秒到几秒高缓存、日志类
每N条刷一次最多N条写入中一致性要求中等的业务

批量刷盘不是无脑攒。攒得太多,宕机丢失窗口会变长;攒得太少又回到性能问题。这个包暴露了batchSize和flushTimeoutMs两个参数,前者控制条数,后者控制时间窗,谁先到就先刷。我习惯设在64条/5ms,压测时根据业务可接受的数据丢失量来回调。

4.2 启动恢复:先快照后日志,重放的顺序和幂等性

节点重启后的恢复流程是:加载最近一次快照进内存,然后把快照之后产生的日志按顺序重放一遍。

bool KVStore::Recover() { if (!LoadSnapshot(snapshotFile_)) return false; auto entries = WALReadAll(); for (auto& e : entries) { if (e.version <= snapshotVersion_) continue; // 快照已包含的更旧操作,跳过 if (e.type == LogType::SET) memTable_[e.key] = e.value; else if (e.type == LogType::DEL) memTable_.erase(e.key); } return true; }

“先快照后日志”的顺序不能反,如果先重放日志再加载快照,旧快照会覆盖掉新日志里更新的值,等于白恢复。日志重放是幂等的,因为日志记录的是“操作”而不是“最终状态”,重复执行SET或DEL不会出错。但有一个边角容易翻车:如果快照版本号没记对,重放时把已经进快照的旧日志又跑了一遍,SET倒还好,DEL可能把新数据误删掉。所以snapshotVersion和日志entry.version必须严格递增对齐。

打快照的频率也要权衡。太频繁写放大严重,太稀疏老日志越攒越长,恢复耗时越久。我一般把阈值设在内存1GB左右,或者按“重放耗时不超过5秒”反过来推算,这比拍脑袋定阈值靠谱。

4.3 副本同步:异步还是同步,参数就决定了数据丢失窗口

单机恢复只能扛单点宕机,数据有副本才能扛机器损坏。副本同步有两种策略,这个包用replicaSync参数控制:

bool Primary::Propose(LogEntry& e, int syncReplicas) { int ack = WaitForAck(e, kRpcTimeoutMs); // 等待备节点确认 if (ack >= syncReplicas) { Commit(e); return true; } return false; }

syncReplicas=0是纯异步复制,主节点写本地就返回,备机在后台慢慢同步,吞吐最高,但主节点宕机时刚写的日志可能没到备机,数据直接消失。syncReplicas=1意味着至少一个备节点确认后才提交,不惜多等一趟网络RTT。三节点集群一般配1,不会因为单台宕机在提交后丢数据。最猛的是syncReplicas=2,等两台都确认,安全但性能基本对半砍,绝大多数业务用不起。

这里有个边界要说清楚:这个包的副本同步是主从异步或半同步,没有引入Raft这类共识协议。配置同步复制意味着每次写入最坏等一个RTT,而不是像Raft里需要多数派quorum。如果要用在必须严格一致的场景,得换共识算法,但这个源码的教学定位下,先搞懂网络、存储、集群三层再去扩展Raft,路径会顺畅很多。到这里,三条线在故障恢复时交汇,这正是分布式系统难调试的地方,也是最值得你花时间的地方。

5. 避坑记录:跑这个分布式KV源码最容易翻车的五个地方

跑这套源码,最令新手头大的问题往往不在算法,而在一些看起来与业务无关的边界。我把容易翻车的五个问题按现象、原因、解决方式整理出来,每一条都是实际跑过的痕迹。

5.1 连接被对端半关闭后,事件循环空转CPU飙到100%

现象:压测时客户端发完请求就close,服务端CPU突然到100%,而且一直掉不下来。

原因:对端关闭写端时,服务端fd会同时触发EPOLLIN和EPOLLRDHUP。如果处理逻辑只判断EPOLLIN,读到0字节后又没有把fd从epoll里摘掉,epoll就会反复通知,空转打满CPU。

解决:在事件处理里显式判断EPOLLRDHUP,read返回0时立刻close并移除fd。

if (events & EPOLLRDHUP) { CloseConnection(fd); // 对端关闭,直接回收 return; }

如果业务想保留连接等对端再发数据,至少要把EPOLLIN摘掉,改成等EPOLLRDHUP再回收。这条处理不好,压测过程中服务端会莫名其妙地“挂死”,其实是CPU被空转吃光了。

5.2 虚拟节点太少,三台机器的数据全堆到一台了

现象:三节点集群部署完,看物理内存占用,一台占了百分之六七十的数据,另外两台空转。

原因:虚拟节点设成几十个,随机分布点位太少,某个实际节点在环上的占位正好覆盖了一大段热门key;更常见的是Hash函数实现有偏斜,把MD5的16字节直接转int取模,高位全被截断。

解决:虚拟节点提到150以上,并写脚本统计每台机器在环上的覆盖占比,超过5%继续加。排查Hash问题时,把中间哈希值打印两三千个,看分布是否均匀,这是最直观的手段。

5.3 WAL每条都刷盘,单机QPS直接腰斩

现象:单机压测,代码逻辑没变,把刷盘策略改成“每条都fsync”后,QPS直接掉了一半以上。

原因:fsync一次大概要几毫秒,高频写入时磁盘成了真正的瓶颈,吞吐全耗在等刷盘上。

解决:改成批量刷盘。batchSize设到64、flushTimeoutMs设到5ms,压测时观察延时尖峰和磁盘util都能拉回一个量级。前提是业务能接受那几十毫秒的丢失窗口,先把窗口算给业务方,再谈性能。

5.4 扩容迁移没处理新写入,新节点上缺了一部分更新

现象:加了一台节点,迁移完成后查询,总是发现最近一会儿写入的key不存在。

原因:迁移是“扫描拷贝”式的,扫描结束后如果对同一key又发生了新写入,这个写入晚于拷贝,只写进了旧节点,新节点上自然没有。

解决:迁移区间先开启双写,新旧节点都写;等迁移完成、路由切换后,再关闭旧节点的写权限,最后清掉旧数据。“双写关闭的时机”要在代码里显式打点,否则你根本不知道什么时候该切。从那以后我每次扩容之前,都要先检查双写标记是否打开、旧写路径是否关死,这个流程走熟了,扩容就再也没丢过数据。

5.5 压测时短连接把本地端口打光了

现象:压测脚本用短连接循环发请求,跑几分钟后客户端报Cannot assign requested address。

原因:每建一次连接,主动关闭方的端口要等TIME_WAIT超时,默认60秒,才能复用。短连接一多,压测机本地端口被占满。

解决:压测客户端改成长连接加连接池,这是最治本的。如果只能短连接,就调大压测机的本地临时端口范围,同时打开net.ipv4.tcp_tw_reuse。顺便说一句,测分布式系统时看到“端口耗尽”,先怀疑压测端自己的连接模型,别一上来就查服务端,这是新手最容易跑偏的一条。

6. 从单机到三节点:正确性验证与压测调参的完整路径

6.1 三节点集群的配置与启动顺序

先在每台节点放一份配置文件,以node1.yaml为例:

node.ip: 127.0.0.1 node.port: 7001 cluster.members: 127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003 wal.batch: 64 wal.flush_timeout_ms: 5 replica.sync: 1

启动顺序有讲究:第一台启动时自己作为种子节点,后两台的cluster.members指向同一份成员列表。最需要注意的坑是,先启动的节点必须能被后启动的节点连上,如果顺序反了,后启动的节点会因为连不上集群而报错,查半天都是连接失败类的错误。我一般先把种子节点起好,确认日志稳定了再起另外两台。

6.2 正确性验证:kill -9、断网、扩容

单机验证只是起点。我会照这套清单走一遍:开两个终端,一个循环写,一个循环读并校验;然后依次kill -9一个节点,确认读写不中断,再恢复该节点,确认日志能追平;接着断开集群中一台的网络,数秒后恢复;最后扩容一台节点,确认哈希环分布变化,迁移结束后旧数据都能查到。这套流程走完,这套源码绝大部分正确性问题都会被暴露出来。

6.3 压测指标与调参路径

压测时我看两个核心指标:QPS和P99延迟。单节点网络层QPS取决于IO线程数、事件循环超时和缓冲池大小;加上副本同步后,P99延迟会多一跳RTT,这很正常。调参顺序我一般是先调IO线程数,再看WAL批量窗口,最后动副本同步参数,尽量避免一上来就动虚拟节点数。早几年调第一个网络库的时候,时序写反了,每次都先调虚拟节点数,后来发现前两个参数才是当前瓶颈,白折腾了大半个下午。从那以后我每次都先看CPU和磁盘util,确认瓶颈在哪一层再动手,不然就是在瞎调。希望这个习惯能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询