很多人刚开始做 KV 项目时命令格式都会写成这种最直观的单行形式SET Teacher Charon GET Teacher HSET user1 Alice这种方式一开始很好写但一旦项目继续往下做就会遇到几个很现实的问题第一参数里如果带空格、中文、特殊字符单纯用空格分割会很难处理第二网络传输里会有半包、粘包问题单行协议不太适合做“收到一半先等着收到完整再执行”的流式解析第三后面主从复制、AOF 回放、测试构造请求时如果协议格式不够稳定很多逻辑都会变复杂。这篇文章我就结合项目代码讲清楚这 5 个问题为什么单行协议不够用多行协议长什么样为什么设计成这样客户端是怎么把命令构造成多行协议的服务端是怎么解析多行协议并处理半包的我是怎么从单行平滑过渡到多行的1. 为什么单行协议不够用我个人项目最开始也是单行思路旧逻辑里直接把整条消息拷贝到raw然后通过kvs_split_token()按空格拆分 token再继续做命令判断和执行。这个逻辑现在在kvs_protocol_exec()里还保留着属于“兼容旧单行协议”的部分。// 旧单行协议保留原来的逻辑 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 worldvalue 里有中文和特殊符号网络一次只收到半条命令两条命令连在一起发过来主从复制和 AOF 需要把命令稳定地重新写出来单行协议最大的问题就是它太依赖空格分割了而网络传输和复杂 value 并不会老老实实配合你。项目里也正是因为这个原因kvs_protocol_try_exec()对旧单行协议直接返回-1明确写了“旧单行协议不适合做流式半包解析”。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. 多行协议为什么设计成*$长度这种结构我项目里的多行协议格式是这种风格*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。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这种字符串而是先写const char *argv[] {SET, Teacher, Charon};然后交给build_multiline_req()或build_multiline_req_alloc()去生成真正发到网络里的报文。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\nsplit”的解析器而是把多行协议拆成了三层小函数kvs_parse_number_line_try()先解析*3或$7这种数字行kvs_parse_multiline_try()基于数字行继续解析完整命令kvs_protocol_try_exec()在网络层被循环调用决定“执行 / 等待 / 报错”这一层层拆开之后逻辑会非常清楚。第一步先解析数字行kvs_parse_number_line_try()的作用是读出数字而且很关键的一点是如果当前数据还不完整它返回 0而不是直接当成错误。这正是半包处理最需要的行为。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 前面的请求挪走、后面的数据保留继续收。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 回放。这个项目最终不是完全抛弃单行而是做成了旧单行保留兼容新多行成为主路径这样既方便平滑迁移也把项目的网络协议基础打得更扎实。0voice · GitHub