【kv存储】如何将单行命令改成多行协议

很多人刚开始做 KV 项目时,命令格式都会写成这种最直观的单行形式:

复制代码
SET Teacher Charon
GET Teacher
HSET user1 Alice

这种方式一开始很好写,但一旦项目继续往下做,就会遇到几个很现实的问题:

第一,参数里如果带空格、中文、特殊字符,单纯用空格分割会很难处理;第二,网络传输里会有半包、粘包问题,单行协议不太适合做"收到一半先等着,收到完整再执行"的流式解析;第三,后面主从复制、AOF 回放、测试构造请求时,如果协议格式不够稳定,很多逻辑都会变复杂。

这篇文章我就结合项目代码,讲清楚这 5 个问题:

  1. 为什么单行协议不够用
  2. 多行协议长什么样,为什么设计成这样
  3. 客户端是怎么把命令构造成多行协议的
  4. 服务端是怎么解析多行协议并处理半包的
  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/GETRSET/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 回放。

这个项目最终不是完全抛弃单行,而是做成了:

  • 旧单行保留兼容
  • 新多行成为主路径

这样既方便平滑迁移,也把项目的网络协议基础打得更扎实。

0voice · GitHub

相关推荐
Alonse_沃虎电子2 小时前
沃虎电子:SPE连接器在工业物联网与车载网络中的应用解析
网络·物联网·产品·方案·电子元器件
wearegogog1232 小时前
NEC红外线协议编码与解码(STM32实现)
网络·stm32·嵌入式硬件
十三画者2 小时前
【文献分享】TREE通过基于 Transformer 的图表示技术,在生物网络中对癌症基因进行可解释的识别学习
网络·学习·transformer
拓朋2 小时前
拓朋AR60P转发台,构建洞穴探险安全通讯网
网络
kongba0073 小时前
学习COZE编程 / LangGraph 通用工作流项目 提示词模板
java·网络·学习
Java成神之路-3 小时前
深度解析TCP连接管理:三次握手、四次挥手与保活机制
网络·网络协议·tcp/ip
卤炖阑尾炎3 小时前
LVS+Keepalived 高可用集群实战精讲从原理到上线全流程
网络·lvs
刘佬GEO3 小时前
本地门店做 GEO 的起步顺序:第一步先做什么?
大数据·网络·人工智能·搜索引擎·ai
Z_Wonderful3 小时前
文件上传,pc端上传成功,手机上传失败,有线网络与移动 网络的限制
网络·智能手机