很多人刚开始做 KV 项目时,命令格式都会写成这种最直观的单行形式:
SET Teacher Charon
GET Teacher
HSET user1 Alice
这种方式一开始很好写,但一旦项目继续往下做,就会遇到几个很现实的问题:
第一,参数里如果带空格、中文、特殊字符,单纯用空格分割会很难处理;第二,网络传输里会有半包、粘包问题,单行协议不太适合做"收到一半先等着,收到完整再执行"的流式解析;第三,后面主从复制、AOF 回放、测试构造请求时,如果协议格式不够稳定,很多逻辑都会变复杂。
这篇文章我就结合项目代码,讲清楚这 5 个问题:
- 为什么单行协议不够用
- 多行协议长什么样,为什么设计成这样
- 客户端是怎么把命令构造成多行协议的
- 服务端是怎么解析多行协议并处理半包的
- 我是怎么从单行平滑过渡到多行的
1. 为什么单行协议不够用
我个人项目最开始也是单行思路,旧逻辑里直接把整条消息拷贝到 raw,然后通过 kvs_split_token() 按空格拆分 token,再继续做命令判断和执行。这个逻辑现在在 kvs_protocol_exec() 里还保留着,属于"兼容旧单行协议"的部分。
cpp
// 旧单行协议:保留原来的逻辑
char raw[1024] = {0};
memcpy(raw, msg, length);
raw[length] = '\0';
char *tokens[KVS_MAX_TOKENS] = {0};
int count = kvs_split_token(raw, tokens);
这套写法的问题不在"能不能跑",而在"后面扩展会越来越别扭"。
比如这几个场景:
- value 里有空格:
SET name hello world - value 里有中文和特殊符号
- 网络一次只收到半条命令
- 两条命令连在一起发过来
- 主从复制和 AOF 需要把命令稳定地重新写出来
单行协议最大的问题就是:它太依赖空格分割了,而网络传输和复杂 value 并不会老老实实配合你。 项目里也正是因为这个原因,kvs_protocol_try_exec() 对旧单行协议直接返回 -1,明确写了"旧单行协议不适合做流式半包解析"。
cpp
if (msg[0] == '*') {
int pret = kvs_parse_multiline_try(msg, length, tokens, &count, consumed);
if (pret == 0) return 0; // 半包,继续等
if (pret < 0) return -1; // 协议错误
need_free = 1;
} else {
// 旧单行协议不适合做流式半包解析,这里先只让它走"完整包模式"
return -1;
}
我后来换成多行协议,本质上不是为了"看起来高级",而是为了让协议层更适合网络传输和工程扩展。
2. 多行协议为什么设计成 * + $长度 这种结构
我项目里的多行协议格式是这种风格:
cpp
*3\r\n
$3\r\n
SET\r\n
$7\r\n
Teacher\r\n
$6\r\n
Charon\r\n
它的意思是:
*3:这一条命令一共有 3 个参数$3:第 1 个参数长度是 3,也就是SET$7:第 2 个参数长度是 7,也就是Teacher$6:第 3 个参数长度是 6,也就是Charon
为什么要这样设计?因为这套结构有两个很大的好处:
第一,参数边界特别清楚
不是靠空格猜,而是靠"长度"精确切。
所以即使 value 里带空格、中文、括号、符号,也不会影响解析。测试里专门写了特殊文本用例,而且就是走多行协议请求去发的。
第二,特别适合半包处理
因为协议本身就把每一段都写清楚了"后面应该还有多少字节",所以服务端可以判断:
- 是不是还没收完整
- 当前能不能执行
- 不完整就先缓冲,等下一次
recv
这也是为什么它比单行协议更适合网络层。
在代码里,构造这套协议的函数就是 kvs_build_multiline_cmd() 和测试端的 build_multiline_req()。它们做的事情非常一致:先写参数个数,再逐个写 $长度 + 内容 + \r\n。
cpp
static int kvs_build_multiline_cmd(char *tokens[], int count, char *out, int out_size) {
int pos = 0;
// 先写参数个数,例如 *3\r\n
int n = snprintf(out + pos, out_size - pos, "*%d\r\n", count);
pos += n;
for (int i = 0; i < count; ++i) {
int len = (int)strlen(tokens[i]);
// 再写每个参数长度,例如 $7\r\n
n = snprintf(out + pos, out_size - pos, "$%d\r\n", len);
pos += n;
// 最后写参数内容和结尾 \r\n
memcpy(out + pos, tokens[i], len);
pos += len;
out[pos++] = '\r';
out[pos++] = '\n';
}
return pos;
}
这套结构的核心思想就是一句话:
不要靠分隔符"猜"参数,而要靠长度"精确切"参数。
3. 客户端是怎么从"命令数组"构造成多行协议的
我项目里客户端测试程序 testcase.c 已经完全改成了"先组织参数数组,再统一构造多行请求"的方式。也就是说,测试代码不再自己手写 "SET Teacher Charon" 这种字符串,而是先写:
cpp
const char *argv[] = {"SET", "Teacher", "Charon"};
然后交给 build_multiline_req() 或 build_multiline_req_alloc() 去生成真正发到网络里的报文。
cpp
static int build_multiline_req(char *out, int out_size, int argc, const char *argv[]) {
int pos = 0;
int n = snprintf(out + pos, out_size - pos, "*%d\r\n", argc);
pos += n;
for (int i = 0; i < argc; ++i) {
int len = (int)strlen(argv[i]);
n = snprintf(out + pos, out_size - pos, "$%d\r\n", len);
pos += n;
memcpy(out + pos, argv[i], len);
pos += len;
out[pos++] = '\r';
out[pos++] = '\n';
}
return pos;
}
这样做有两个直接收益:
第一,测试代码更统一
无论是 SET/GET、RSET/RGET 还是 HSET/HGET,本质上都只是"参数个数不同"的命令数组。
所以 testcase2()、testcase3() 最后都能统一走 testcase_args(),再统一走 build_multiline_req()。
第二,主从复制和 AOF 也能复用同样的协议风格
服务端在做 AOF 追加时,会把解析出来的 tokens 再重新拼回标准多行协议,然后写进 appendonly.aof。这样 AOF 文件里的命令格式和网络请求格式是统一的,后面回放时也能继续复用同一套解析器。
也就是说,我这里不是只把"客户端发请求"改成多行,而是把测试、网络、AOF、回放都尽量统一到了同一套协议格式上。
4. 服务端是怎么解析多行协议并处理半包的
这是整个改造里最核心的部分。
我这里没有简单写一个"按 \r\n split"的解析器,而是把多行协议拆成了三层小函数:
kvs_parse_number_line_try():先解析*3或$7这种数字行kvs_parse_multiline_try():基于数字行继续解析完整命令kvs_protocol_try_exec():在网络层被循环调用,决定"执行 / 等待 / 报错"
这一层层拆开之后,逻辑会非常清楚。
第一步:先解析数字行
kvs_parse_number_line_try() 的作用是读出数字,而且很关键的一点是:
如果当前数据还不完整,它返回 0,而不是直接当成错误。
这正是半包处理最需要的行为。
cpp
static int kvs_parse_number_line_try(const char *msg, int length, int *pos, int *value) {
int num = 0;
while (*pos < length && msg[*pos] != '\r') {
if (msg[*pos] < '0' || msg[*pos] > '9') return -1;
num = num * 10 + (msg[*pos] - '0');
(*pos)++;
}
if (*pos >= length) return 0; // 还没收到 '\r'
if (*pos + 1 >= length) return 0; // 收到 '\r' 但还没收到 '\n'
if (msg[*pos] != '\r' || msg[*pos + 1] != '\n') return -1;
*pos += 2;
*value = num;
return 1;
}
第二步:再解析完整多行命令
kvs_parse_multiline_try() 会先读 *argc,再按 $长度 把每个 token 拆出来。
如果当前 buffer 里还没收完整,比如某个 value 只收到一半,它会直接返回 0,告诉上层"不是错误,只是还得继续收"。
第三步:在协议入口里用 consumed 做流式执行
kvs_protocol_try_exec() 做的事情是:
- 尝试解析一条多行命令
- 解析成功就执行
- 返回这次消费了多少字节
consumed - 如果返回 0,说明半包,还不能执行
- 如果返回负数,说明协议错误
这一点特别关键,因为网络层拿到 consumed 之后,就知道该不该把 buffer 前面的请求挪走、后面的数据保留继续收。
cpp
int kvs_protocol_try_exec(char *msg, int length, int *consumed,
char *response, int resp_cap, int enable_aof) {
*consumed = 0;
if (msg[0] == '*') {
int pret = kvs_parse_multiline_try(msg, length, tokens, &count, consumed);
if (pret == 0) return 0; // 半包,继续等
if (pret < 0) return -1; // 协议错误
} else {
return -1;
}
// 解析成功后再继续命令分发与执行
...
}
而且我还在 testcase.c 里还专门写了 half_packet_test() 和 sticky_packet_test()。前者把同一条多行请求故意拆成两半发送,后者把两条请求拼在一起发送,这两个测试其实就是在验证这套"多行 + consumed + buffer"的方案能不能真正扛住网络层的半包和粘包。
5. 怎么从单行平滑迁移到多行的
这里不是"一刀切把旧单行全删掉",而是做了一个比较稳妥的过渡:
第一层:保留旧单行兼容入口
在 kvs_protocol_exec() 里,如果消息不是以 * 开头,就仍然走旧单行逻辑:
拷贝到 raw,调用 kvs_split_token(),然后继续执行命令。这样以前已有的简单调用方式不会一下子全坏掉。
第二层:正式流式处理只支持多行
在 kvs_protocol_try_exec() 里,只有消息以 * 开头才会继续解析,否则直接返回 -1。
也就是说,从"能支持半包、能支持流式执行"的角度看,项目已经正式切到多行协议了。
第三层:测试先统一改成多行
testcase.c 这边已经不再直接拼单行字符串,而是统一用 build_multiline_req() 和 build_multiline_req_alloc() 构造请求。
这一步非常关键,因为测试一旦统一,多行协议就真正成了项目里的主路径。
第四层:AOF 也统一改成多行格式
执行成功的写命令,在追加到 AOF 时不是写原始单行字符串,而是重新通过 kvs_build_multiline_cmd() 拼成标准多行格式,再写入文件。
这样启动恢复和日志回放也能直接复用 kvs_protocol_try_exec()。这就形成了一个很漂亮的闭环:
网络请求是什么格式,AOF 就是什么格式,回放就按同一套解析器来。
所以这个改造不是简单的"改了一种命令格式",而是把:
- 客户端请求构造
- 服务端解析
- 半包处理
- AOF 追加
- AOF 回放
都统一到了一套多行协议上。
总结:为什么我最后选择了多行协议
用一句最直白的话总结:
单行协议适合入门,能快速把功能跑起来;多行协议适合工程化,能更稳地处理复杂 value、半包粘包、主从复制和 AOF 回放。
这个项目最终不是完全抛弃单行,而是做成了:
- 旧单行保留兼容
- 新多行成为主路径
这样既方便平滑迁移,也把项目的网络协议基础打得更扎实。