当前位置: 首页 > news >正文

【kv存储】基于 C 的 KV 存储项目:主从单向同步是怎么实现的

在一个 KV 存储系统里,单机把SET/GET跑起来并不难,真正往工程方向走时,通常还会继续补两个能力:
一个是持久化,解决进程重启后数据恢复的问题;另一个是主从同步,解决多节点之间数据复制的问题。

本文聚焦一个更具体的点:主从单向同步
这里的“单向”指的是:数据只从主库流向从库,主库负责接收写请求并广播变更,从库负责同步和查询,不反向把写操作同步回主库。项目启动入口在kvstore.c,主库和从库角色由命令行参数决定;从库模式会调用repl_slave_start()主动连接主库并开始同步流程。

1. 什么是主从单向同步,为什么要做这个功能

主从单向同步可以先理解成一种“主写从读”的结构:

  • 主库:接收写请求,保存最新数据
  • 从库:把主库的数据复制过来,主要用于查询、备份、验证同步结果

这里之所以叫“单向”,是因为同步方向只有一个:主库 → 从库
从库不会把自己的写操作再回传给主库,而且项目代码里还显式限制了这一点:当当前节点是从库时,如果收到的是普通客户端写命令,而不是来自主库复制链路的命令,就会直接返回READONLY。这一逻辑在 Reactor 路径里写得很清楚,Proactor 和 NtyCo 也用了同样思路。

/* 从库拒绝普通客户端写请求 */ if (g_kvs_role == KVS_ROLE_SLAVE && c->is_replica != CONN_ROLE_REPLICA && is_write_multiline_cmd(c->rbuffer, c->rlength)) { c->wlength = snprintf(c->wbuffer, c->wcap, "READONLY\r\n"); c->rlength = 0; return 1; }

做这个功能的好处主要有三点:

  1. 提高数据可靠性:主库数据还能在从库保留一份副本
  2. 便于读写分离:主库写,从库查
  3. 便于后续扩展:有了同步链路,后面继续做故障恢复、更多副本时,基础已经有了

从工程角度看,主从单向同步比双向同步简单很多,因为不需要处理双向冲突,也不用解决“谁覆盖谁”的问题,所以特别适合作为存储系统中的第一版复制机制。项目中的从库线程会持续连接主库,如果连接失败或断开,还会间隔 2 秒重新连接,这说明复制链路被设计成了持续运行的后台流程。

2. 这套主从同步在项目里是怎么分工的

要把这个功能讲清楚,最重要的是先把职责分开。

主库负责什么

主库负责三件事:

  • 识别一个新连接是不是来做同步的从库
  • 给新从库发送一份完整快照(全量同步)
  • 在后续每次写命令成功后,把原始写命令广播给所有从库(增量同步)

从库负责什么

从库也负责三件事:

  • 主动连接主库
  • 发送SYNC请求,表明自己要做同步
  • 接收主库发来的快照和后续增量写命令,并在本地执行

这个分工在代码里非常明确:

  • kvstore.cmain()里,如果是从库模式,会调用repl_slave_start(g_master_ip, g_master_port);如果是主库模式,就正常启动网络层等待连接。
  • replication.c里的repl_slave_loop()负责从库主动连接主库、发送SYNC、等待OK、接收快照和增量流。
  • reactor.c/proactor.c/ntyco.c里都维护了一个副本连接表,主库一旦识别到SYNC,就把这个连接登记成副本连接,之后写命令广播就走这张表。

从接口设计上看,这个项目还专门抽了一个公共工具头文件net_repl_util.h,里面直接把同步链路的几个关键动作抽成了公共函数,比如:

  • try_match_sync_req():识别是不是SYNC
  • is_write_multiline_cmd():识别是不是要广播的写命令
  • full_sync_to_replica():给副本发一整份快照

这说明三种网络模型虽然调度方式不同,但复制语义是统一的。

3. 主从单向同步的核心流程:先全量,再增量

这套实现最关键的地方,其实就是一句话:

先做全量同步,再做增量同步。

第一步:从库启动并发送SYNC

从库线程repl_slave_loop()会先连接主库,然后发送一条固定请求:

#define REPL_SYNC_REQ "*1\r\n$4\r\nSYNC\r\n"

发送逻辑如下:

static int repl_send_sync(int fd) { const char *req = REPL_SYNC_REQ; int left = (int)strlen(req); const char *p = req; while (left > 0) { int n = send(fd, p, left, 0); if (n <= 0) return -1; p += n; left -= n; } return 0; }

也就是说,从库不是被动等主库找上门,而是主动发起同步请求

第二步:主库识别SYNC并登记副本连接

以 Reactor 路径为例,kvs_request()里会先判断当前连接身份是不是未知,如果未知,就尝试匹配SYNC:

if (c->is_replica == CONN_ROLE_UNKNOWN) { int sret = try_match_sync_req(c->rbuffer, c->rlength); if (sret == 1) { c->is_replica = CONN_ROLE_REPLICA; replica_add(c); ... if (full_sync_to_replica(c) < 0) { return -1; } return 0; } }

这段逻辑做了两件事:

  1. 把这个连接标记为副本连接
  2. 立刻触发一次全量同步

如果当前只收到半个SYNCtry_match_sync_req()会返回 0,说明这不是错误,而是“还没收完整,继续等”。这也说明复制链路是和多行协议、半包处理结合起来设计的。

第三步:主库发完整快照

Reactor 中的full_sync_to_replica()逻辑非常直白:

  1. 先给从库回OK\r\n
  2. 调用kvs_save_snapshot()生成dump.kvs
  3. 打开dump.kvs,把文件内容发给从库
  4. 最后发一个EOF\r\n,表示快照发送结束
/* 主库给新连入的副本做一次全量同步: * 1. 先回 OK * 2. 生成 dump.kvs * 3. 发送 dump.kvs * 4. 发送 EOF\r\n */ static int full_sync_to_replica(struct conn *c) { if (!c) return -1; if (send_all(c->fd, "OK\r\n", 4) < 0) { return -1; } kvs_save_snapshot(); FILE *fp = fopen("dump.kvs", "r"); ... }

所以全量同步的本质就是:
把当前主库完整状态做成一份快照,再整份发给从库。

第四步:从库接收快照并加载

从库收到OK后,并不会立刻认为同步结束,而是继续进入repl_apply_stream()
这个函数先把主库发来的快照内容写入本地dump.kvs,一直写到遇到EOF\r\n为止;一旦检测到EOF,就说明全量快照接收完成,接着调用kvs_load_snapshot()把本地快照重新加载进内存。

if (!full_sync_done) { char *eof = find_fixed_token(*rbuf, *rlen, REPL_SNAPSHOT_EOF, ...); if (!eof) { ... fwrite(*rbuf, 1, flush_len, fp); } else { int data_len = (int)(eof - *rbuf); fwrite(*rbuf, 1, data_len, fp); fclose(fp); pthread_mutex_lock(&g_kvs_lock); kvs_load_snapshot(); pthread_mutex_unlock(&g_kvs_lock); full_sync_done = 1; } }

这里有两个很值得注意的小细节:

  • 为了防止EOF被拆成跨recv边界的半包,代码不会盲目把整个缓冲都写盘,而是故意保留最后几个字节
  • 加载快照时用了g_kvs_lock加锁,避免与其他线程/协程路径并发冲突

这说明这套实现不是简单“读到啥就写啥”,而是有意识地考虑了网络流式接收和并发安全。

第五步:主库广播后续增量写命令

从全量同步完成之后,同步并没有结束。
后面只要主库有成功写命令,就会把原始写命令报文广播给所有副本连接。以 Reactor 为例,逻辑在kvs_request()后半段:

if (c->is_replica != CONN_ROLE_REPLICA && is_write && raw_cmd != NULL && strncmp(c->wbuffer, "OK", 2) == 0) { replica_broadcast_raw(raw_cmd, consumed); }

这段逻辑的判断非常严谨:

  • 当前请求不能是副本链路自己发来的
  • 必须是写命令
  • 必须执行成功,返回OK
  • 然后才广播

也就是说,主库不会把所有请求都同步给从库,只会同步真正成功落地的写命令。这就是“增量同步”的核心。

而从库在repl_apply_stream()里,进入full_sync_done = 1之后,就会把收到的后续命令流继续交给kvs_protocol_try_exec()去执行,从而把主库新增的修改持续复制到本地。

4. 为什么这套结构要设计成“全量 + 增量”,而不是只选一种

这一步很容易被忽略,但它其实是主从同步能否稳定工作的关键。

只做增量,不够

如果一个从库刚启动,自己本地是空的,只靠后续增量命令是不够的。
因为从库根本不知道主库当前已经有哪些历史数据。

只做全量,也不够

如果每次主库有新写入,都重新把整份dump.kvs发一遍,那代价太高,而且非常浪费网络和磁盘。

所以更合理的做法就是:

  • 第一次接入时:给从库发一整份快照,快速对齐当前状态
  • 对齐完成后:只同步后续新增写命令,降低复制成本

这正是项目里“全量同步 + 增量同步”的原因。
从代码角度看,这套结构也非常清楚:

  • full_sync_to_replica()负责“第一次完整对齐”
  • replica_broadcast_raw()负责“后续变更传播”
  • repl_apply_stream()负责从库侧“先吃快照,再吃增量命令”

另外,“单向同步”还有一个实现上的好处:结构简单。
因为写入口始终只有主库一个,复制链路永远只从主库流向从库,所以不需要额外解决双向冲突和循环同步问题。

5. 这套功能是怎么测试的:两个虚拟机,两个客户端 shell

这部分非常适合放在文章结尾,因为它能把“代码实现”落到“实际验证”上。

测试环境采用的是两个虚拟机节点

  • 一台作为主库
  • 一台作为从库

同时,在两台虚拟机上分别打开 shell 窗口,用作客户端测试入口。
项目里的testcase.c本身就提供了连接目标服务器、构造多行协议请求、发送命令并校验返回结果的能力,例如:

  • connect_tcpserver():连接指定 IP 和端口
  • build_multiline_req()/build_multiline_req_alloc():构造多行协议
  • send_msg()/recv_msg():负责请求发送和响应接收
int connect_tcpserver(const char *ip, unsigned short port){ int connfd = socket(AF_INET, SOCK_STREAM, 0); ... if(0 != connect(connfd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr_in))){ perror("connect"); return -1; } return connfd; }

测试流程可以概括为下面几步

1)主虚拟机启动主库

例如:

./kvstore 2000

这时main()会把当前节点识别成主库,并启动对应网络层。

2)主虚拟机客户端窗口向主库写入数据

可以直接通过testcasenc往主库插入多组数据,验证主库本地写功能正常。

3)从虚拟机启动从库

例如:

./kvstore 2001 slave <master_ip> 2000

从库启动后会自动调用repl_slave_start(),建立后台复制线程,向主库发送SYNC请求并接收快照。

4)从虚拟机客户端窗口查询从库

等到同步完成后,从库客户端窗口执行查询命令,例如:

  • GET
  • RGET
  • HGET

如果主库之前写入的数据能在从库查到,说明全量同步成功;
接着继续在主库写入新数据,再去从库查询,如果能查到新结果,则说明增量同步也成功。

5)继续验证只读限制

如果在从库客户端窗口直接发写命令,系统应返回READONLY,这可以证明“单向同步”的角色限制已经生效。

总结

主从单向同步的核心并不复杂,但要真正稳定跑起来,通常要把下面几个点同时做好:

  • 主库与从库角色分离
  • 从库主动发起SYNC
  • 主库识别副本连接并登记
  • 先发快照做全量同步
  • 再广播写命令做增量同步
  • 从库禁止普通客户端直接写入

这个项目里,这套流程已经被拆成了比较清晰的几个模块:

  • kvstore.c:负责主从启动入口
  • replication.c:负责从库主动连接、接收快照和增量流
  • reactor.c / proactor.c / ntyco.c:负责主库侧识别SYNC、全量同步和增量广播
  • net_repl_util.h:负责把复制链路里的公共动作抽出来

从实现效果看,这套“主写从读、先全量后增量、只单向同步”的结构,既能把主从复制这个功能讲清楚,也足够适合作为一个 KV 存储项目中的核心亮点。

0voice · GitHub

http://www.jsqmd.com/news/644112/

相关文章:

  • 终极OBS多平台直播解决方案:obs-multi-rtmp插件深度指南
  • IMX415传感器与RV1126 SoC实战:如何从零搭建一个低延迟视频监控系统(附避坑指南)
  • 2026比较好的雅思线上培训机构推荐,一对一辅导的提升课程全攻略 - 品牌2025
  • 思源宋体TTF终极指南:免费商用字体快速上手与专业应用
  • 魔兽争霸3兼容性问题终极解决方案:WarcraftHelper完全指南
  • AI时代,还有必要学C语言吗?
  • BMS开发避坑指南:从电压采样RC滤波到菊花链通信,那些硬件设计中的细节与“坑点”
  • 视频理解Agent从Demo到商用仅差1步?2026奇点大会披露的4层推理加速架构,已获3家头部车企紧急采购
  • 2026年昆明GEO优化服务机构实力分析:市场主流3家机构适配指南 - 商业小白条
  • Sunshine游戏串流完整指南:3步搭建你的个人云游戏服务器
  • Windows IPsec策略实战:从本地安全策略到组策略的深度配置指南
  • 别再手动抄数据了!用STM32CubeMonitor实时监控全局变量并自动导出CSV(附Matlab处理脚本)
  • ARM 架构NVIDIA GB10 Grace Blackwell 芯片环境下安装conda - yi
  • 智慧树自动学习助手:3分钟实现高效课程自动化管理
  • 基于机器学习的智能预热算法
  • 动手学深度学习——BERT微调
  • 2026年靠谱的BIPV/BIPV光伏大棚/BIPV解决方案/BIPV支架厂家推荐及选购指南 - 行业平台推荐
  • Windows下InfluxDB 2.0.7全家桶下载安装指南(附直接下载链接)
  • 2026雅思线上课程全攻略:避坑指南与高效提分策略 - 品牌2025
  • 别再为Zotero的300M空间发愁了!手把手教你用坚果云WebDAV实现文献库无限同步
  • 从PPT到Production:多模态大模型工程化落地的12个致命断点(附SITS2026官方Checklist v2.3)
  • 突破性网盘直链解析工具:革新你的文件下载体验
  • Git核心概念与版本控制思想启蒙
  • 2026年热门的光伏防水支架/光伏防水/光伏防水屋面改造/光伏防水方案高评分品牌推荐(畅销) - 品牌宣传支持者
  • G-Helper:华硕笔记本性能调校的轻量级神器,释放硬件潜能
  • 2026完整版沃尔玛卡回收价格表 正规平台首选京尔回收 - 购物卡回收找京尔回收
  • 给科研小白的DPARSF保姆级教程:从安装Matlab到一键处理fMRI数据
  • Sunshine游戏串流终极指南:打造你的私有云游戏服务器
  • LeetCode:42. 接雨水
  • 【反爬虫】极验4 W参数逆向分析