在一个 KV 存储系统里,单机把
SET/GET跑起来并不难,真正往工程方向走时,通常还会继续补两个能力:一个是持久化 ,解决进程重启后数据恢复的问题;另一个是主从同步,解决多节点之间数据复制的问题。
本文聚焦一个更具体的点:主从单向同步 。
这里的"单向"指的是:数据只从主库流向从库 ,主库负责接收写请求并广播变更,从库负责同步和查询,不反向把写操作同步回主库。项目启动入口在
kvstore.c,主库和从库角色由命令行参数决定;从库模式会调用repl_slave_start()主动连接主库并开始同步流程。
1. 什么是主从单向同步,为什么要做这个功能
主从单向同步可以先理解成一种"主写从读"的结构:
- 主库:接收写请求,保存最新数据
- 从库:把主库的数据复制过来,主要用于查询、备份、验证同步结果
这里之所以叫"单向",是因为同步方向只有一个:主库 → 从库 。
从库不会把自己的写操作再回传给主库,而且项目代码里还显式限制了这一点:当当前节点是从库时,如果收到的是普通客户端写命令,而不是来自主库复制链路的命令,就会直接返回 READONLY。这一逻辑在 Reactor 路径里写得很清楚,Proactor 和 NtyCo 也用了同样思路。
cpp
/* 从库拒绝普通客户端写请求 */
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() 会先连接主库,然后发送一条固定请求:
cpp
#define REPL_SYNC_REQ "*1\r\n$4\r\nSYNC\r\n"
发送逻辑如下:
cpp
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:
cpp
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,表示快照发送结束
cpp
/* 主库给新连入的副本做一次全量同步:
* 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() 把本地快照重新加载进内存。
cpp
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() 后半段:
cpp
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():负责请求发送和响应接收
cpp
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)主虚拟机启动主库
例如:
bash
./kvstore 2000
这时 main() 会把当前节点识别成主库,并启动对应网络层。
2)主虚拟机客户端窗口向主库写入数据
可以直接通过 testcase 或 nc 往主库插入多组数据,验证主库本地写功能正常。
3)从虚拟机启动从库
例如:
bash
./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 存储项目中的核心亮点。