【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; }做这个功能的好处主要有三点:
- 提高数据可靠性:主库数据还能在从库保留一份副本
- 便于读写分离:主库写,从库查
- 便于后续扩展:有了同步链路,后面继续做故障恢复、更多副本时,基础已经有了
从工程角度看,主从单向同步比双向同步简单很多,因为不需要处理双向冲突,也不用解决“谁覆盖谁”的问题,所以特别适合作为存储系统中的第一版复制机制。项目中的从库线程会持续连接主库,如果连接失败或断开,还会间隔 2 秒重新连接,这说明复制链路被设计成了持续运行的后台流程。
2. 这套主从同步在项目里是怎么分工的
要把这个功能讲清楚,最重要的是先把职责分开。
主库负责什么
主库负责三件事:
- 识别一个新连接是不是来做同步的从库
- 给新从库发送一份完整快照(全量同步)
- 在后续每次写命令成功后,把原始写命令广播给所有从库(增量同步)
从库负责什么
从库也负责三件事:
- 主动连接主库
- 发送
SYNC请求,表明自己要做同步 - 接收主库发来的快照和后续增量写命令,并在本地执行
这个分工在代码里非常明确:
kvstore.c的main()里,如果是从库模式,会调用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():识别是不是SYNCis_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; } }这段逻辑做了两件事:
- 把这个连接标记为副本连接
- 立刻触发一次全量同步
如果当前只收到半个SYNC,try_match_sync_req()会返回 0,说明这不是错误,而是“还没收完整,继续等”。这也说明复制链路是和多行协议、半包处理结合起来设计的。
第三步:主库发完整快照
Reactor 中的full_sync_to_replica()逻辑非常直白:
- 先给从库回
OK\r\n - 调用
kvs_save_snapshot()生成dump.kvs - 打开
dump.kvs,把文件内容发给从库 - 最后发一个
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)主虚拟机客户端窗口向主库写入数据
可以直接通过testcase或nc往主库插入多组数据,验证主库本地写功能正常。
3)从虚拟机启动从库
例如:
./kvstore 2001 slave <master_ip> 2000从库启动后会自动调用repl_slave_start(),建立后台复制线程,向主库发送SYNC请求并接收快照。
4)从虚拟机客户端窗口查询从库
等到同步完成后,从库客户端窗口执行查询命令,例如:
GETRGETHGET
如果主库之前写入的数据能在从库查到,说明全量同步成功;
接着继续在主库写入新数据,再去从库查询,如果能查到新结果,则说明增量同步也成功。
5)继续验证只读限制
如果在从库客户端窗口直接发写命令,系统应返回READONLY,这可以证明“单向同步”的角色限制已经生效。
总结
主从单向同步的核心并不复杂,但要真正稳定跑起来,通常要把下面几个点同时做好:
- 主库与从库角色分离
- 从库主动发起
SYNC - 主库识别副本连接并登记
- 先发快照做全量同步
- 再广播写命令做增量同步
- 从库禁止普通客户端直接写入
这个项目里,这套流程已经被拆成了比较清晰的几个模块:
kvstore.c:负责主从启动入口replication.c:负责从库主动连接、接收快照和增量流reactor.c / proactor.c / ntyco.c:负责主库侧识别SYNC、全量同步和增量广播net_repl_util.h:负责把复制链路里的公共动作抽出来
从实现效果看,这套“主写从读、先全量后增量、只单向同步”的结构,既能把主从复制这个功能讲清楚,也足够适合作为一个 KV 存储项目中的核心亮点。
0voice · GitHub
