9.1 kv存储resp协议模拟面试

一、请介绍一下这个功能的整体架构

面试官:

你的项目是如何支持 key/value 中包含空格、换行、中文、标点,以及较长文章的?

应聘者:

整体采用分层设计:

cpp 复制代码
redis-cli / hiredis
        |
        | RESP 字节流
        v
TCP 网络层:src/ntyco.c
        |
        | 动态接收缓冲区
        v
RESP 边界判断:src/kvs_resp.c
        |
        | 判断一条命令是否收完整
        v
RESP 参数解析:src/kvs_resp.c
        |
        | argv + argv_len
        v
命令分发:src/kvstore.c
        |
        | SET/RSET/HSET/SSET
        v
四种存储引擎
        |
        | key/value + 长度
        v
动态 RESP 响应

当前主要使用 NtyCo 网络模型,因为:

cpp 复制代码
#define NETWORK_SELECT NETWORK_NTYCO

核心思想是:

网络协议用长度确定边界,存储引擎保存真实长度,响应协议也按真实长度返回。

因此标题和正文不再通过空格、换行或者 strlen() 来判断边界。


二、为什么以前的空格协议不能存文章

面试官:

为什么不能继续使用:

cpp 复制代码
RSET 文章标题 第一段,有空格
第二段,有换行

应聘者:

因为老协议通常使用:

cpp 复制代码
strtok(msg, " ");

按空格切割。

例如:

cpp 复制代码
RSET 文章 标题 第一段,有 空格

可能被拆成:

cpp 复制代码
argv[0] = RSET
argv[1] = 文章
argv[2] = 标题
argv[3] = 第一段,有
argv[4] = 空格

这样无法判断:

  • 哪些空格属于标题
  • 哪些空格属于正文
  • 换行是不是正文内容
  • 正文什么时候结束

所以文章内容不适合使用"分隔符协议",而适合使用"长度协议"。


三、RESP 为什么可以解决特殊字符问题

面试官:

RESP 是如何解决空格和换行问题的?

应聘者:

RESP 的 bulk string 格式是:

cpp 复制代码
$长度\r\n
正文\r\n

例如:

cpp 复制代码
$8\r\n
my title\r\n

表示 key 的内容长度是 8 个字节。

一条 RSET 命令大致是:

cpp 复制代码
*3\r\n
$4\r\n
RSET\r\n
$8\r\n
my title\r\n
$20\r\n
hello world
!@#$%^&*()\r\n

RESP 不会扫描正文里的空格或换行来判断结束,而是:

  1. 读取 $20
  2. 精确读取后面的 20 个字节
  3. 再读取结尾的 \r\n

所以正文中出现:

cpp 复制代码
空格
换行
中文
标点
括号

都不会破坏协议边界。

RESP 的解析代码在:


四、RESP 相关的核心结构体是什么

面试官:

请介绍一下你设计的 RESP 命令结构体。

应聘者:

命令结构体定义在:

include/kvstore.h (line 75)(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/include/kvstore.h:75)

cpp 复制代码
typedef struct kvs_command_s {
    int argc;
    char *argv[KVS_MAX_TOKENS];
    int argv_len[KVS_MAX_TOKENS];
    int is_resp;
} kvs_command_t;

各字段作用是:

cpp 复制代码
argc       参数数量
argv       每个参数的地址
argv_len   每个参数的真实字节长度
is_resp    是否使用 RESP 协议

例如:

复制代码
RSET "文章 标题" "第一段,有空格\n第二段"

解析后大致是:

cpp 复制代码
req.argc = 3;

req.argv[0] = "RSET";
req.argv[1] = "文章 标题";
req.argv[2] = "第一段,有空格\n第二段";

req.argv_len[0] = 4;
req.argv_len[1] = 标题的字节长度;
req.argv_len[2] = 正文的字节长度;

req.is_resp = 1;

这里最重要的是 argv_len

argv 只表示数据地址,argv_len 才表示数据边界。


五、TCP 分包是如何处理的

面试官:

如果一篇文章有 200KB,而一次 recv() 只能收到 64KB,怎么办?

应聘者:

TCP 是字节流,一次 recv() 不保证对应一条完整命令。

服务端在:

src/ntyco.c (line 111)(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/src/ntyco.c:111)

cpp 复制代码
void server_reader(void *arg)

里使用两个缓冲区:

cpp 复制代码
char *recv_buf;
kvs_io_buffer_t request_buf;

recv_buf 是一次接收的临时缓冲区,request_buf 是连接级别的动态累积缓冲区。

cpp 复制代码
typedef struct kvs_io_buffer_s {
    char *data;
    int len;
    int cap;
} kvs_io_buffer_t;

每次收到数据后,追加到 request_buf

cpp 复制代码
kvs_buffer_reserve(
    &request_buf,
    request_buf.len + ret + 1
);

memcpy(
    request_buf.data + request_buf.len,
    recv_buf,
    ret
);

request_buf.len += ret;

容量不够时,由:

cpp 复制代码
kvs_buffer_reserve()

进行扩容:

cpp 复制代码
64KB -> 128KB -> 256KB -> 512KB

因此:

cpp 复制代码
单次 recv 的大小

和:

复制代码
一条命令允许的总长度

是两个不同概念。


六、如何判断一条命令已经完整

面试官:

服务端怎么知道当前收到的是半条命令,还是完整命令?

应聘者:

由:

cpp 复制代码
kvs_resp_get_complete_len()

负责判断。

返回值约定是:

cpp 复制代码
1   已经收完整
0   还不完整,需要继续 recv
-1  RESP 格式错误

它会依次检查:

cpp 复制代码
*argc\r\n
$命令长度\r\n
命令内容\r\n
$key长度\r\n
key内容\r\n
$value长度\r\n
value内容\r\n

例如正文长度声明为 100000,但当前只收到了 70000 字节:

cpp 复制代码
check == 0

服务端不会提前执行,而是继续接收。

只有当正文和结尾的 \r\n 都收到以后,才会得到:

cpp 复制代码
complete_len = 当前完整命令的总长度;

这解决的是 TCP 分包问题。


七、实际发送一条 RSET 命令时,具体调用链是什么

面试官:

请你完整描述这条命令的执行过程:

cpp 复制代码
redis-cli -p 8000 RSET "文章 标题 1" "第一段,有空格\n第二段,标点:!@#$"

应聘者:

完整调用链是:

cpp 复制代码
redis-cli
  ↓
RESP 编码
  ↓
TCP send
  ↓
ntyco.c: server_reader()
  ↓
kvs_buffer_reserve()
  ↓
kvs_resp_get_complete_len()
  ↓
kvstore.c: kvs_protocol()
  ↓
kvstore.c: kvs_protocol_inner()
  ↓
kvs_resp_parse_one()
  ↓
kvs_process_resp_cmd()
  ↓
kvs_filter_protocol()
  ↓
kvs_rbtree_set()
  ↓
kvs_malloc() + memcpy()
  ↓
AOF / 主从复制
  ↓
kvs_reply_status()
  ↓
kvs_send_all()

第一步,server_reader() 收到 TCP 数据。

第二步,把数据追加到动态 request_buf

第三步,用:

cpp 复制代码
kvs_resp_get_complete_len()

判断整条 RSET 是否收完整。

第四步,调用:

cpp 复制代码
kvs_protocol()

进入:

cpp 复制代码
kvs_protocol_inner()

第五步,由:

cpp 复制代码
kvs_resp_parse_one()

解析出:

cpp 复制代码
argv[0] = RSET
argv[1] = 文章 标题 1
argv[2] = 第一段,有空格\n第二段,标点:!@#$

以及:

cpp 复制代码
argv_len[1] = key 的字节长度
argv_len[2] = value 的字节长度

第六步,进入:

cpp 复制代码
kvs_filter_protocol()

它提取:

cpp 复制代码
char *key = tokens[1];
char *value = tokens[2];

int key_len = req->argv_len[1];
int val_len = req->argv_len[2];

第七步,根据 RSET 进入:

cpp 复制代码
kvs_rbtree_set(
    &global_rbtree,
    key,
    key_len,
    value,
    val_len
);

八、红黑树是如何保存文章正文的

面试官:

RSET 进入红黑树以后,具体如何保存?

应聘者:

红黑树节点中增加了长度字段:

include/kvstore.h (line 158)(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/include/kvstore.h:158)

cpp 复制代码
typedef struct _rbtree_node {
    char *key;
    int key_len;

    void *value;
    int value_len;
} rbtree_node;

真正的插入函数是:

src/kvs_rbtree.c (line 409)(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/src/kvs_rbtree.c:409)

cpp 复制代码
int kvs_rbtree_set(
    kvs_rbtree_t *inst,
    char *key,
    int key_len,
    char *value,
    int value_len
)

它会重新申请空间:

cpp 复制代码
node->key = kvs_malloc(key_len + 1);
node->value = kvs_malloc(value_len + 1);

再按照真实长度复制:

cpp 复制代码
memcpy(node->key, key, key_len);
memcpy(node->value, value, value_len);

最后保存长度:

cpp 复制代码
node->key_len = key_len;
node->value_len = value_len;

多申请的一个字节用于:

cpp 复制代码
node->key[key_len] = '\0';
node->value[value_len] = '\0';

这个 '\0' 只是为了兼容 C 字符串操作,不代表文章结束位置。真正的长度仍然是:

复制代码
node->value_len

九、为什么不能直接保存 argv 指针

面试官:

为什么不直接写:

cpp 复制代码
node->key = key;
node->value = value;

应聘者:

因为 keyvalue 指向的是网络请求缓冲区。

请求处理完成以后:

cpp 复制代码
kvs_protocol_inner()

会释放请求副本:

cpp 复制代码
kvs_free(request);

网络层还可能执行:

cpp 复制代码
kvs_buffer_consume(
    &request_buf,
    complete_len
);

后续数据会向前移动。

因此 argv 指向的内容只在当前请求期间有效。

存储引擎必须复制数据,形成自己的所有权:

cpp 复制代码
request_buf 中的 key/value
        |
        | 临时使用
        v
存储引擎重新 malloc
        |
        | memcpy
        v
节点自己长期持有

这也是请求缓冲区和存储数据分离的原因。


十、四个引擎分别如何支持长度

面试官:

四个引擎是不是都使用了相同的长度设计?

应聘者:

整体原则相同,但数据结构不同。

cpp 复制代码
SET/GET       array
RSET/RGET     rbtree
HSET/HGET     hash
SSET/SGET     skiplist

接口都从旧的:

cpp 复制代码
set(key, value)

改成了:

cpp 复制代码
set(key, key_len, value, value_len)

例如:

cpp 复制代码
kvs_array_set(
    &global_array,
    key,
    key_len,
    value,
    value_len
);

四个引擎都遵循:

cpp 复制代码
复制 key/value
保存 key_len/value_len
比较 key 时使用长度
读取 value 时返回真实长度

区别是:

cpp 复制代码
array    线性扫描
rbtree   红黑树查找
hash     哈希桶加链表
skiplist 跳表查找

红黑树比较 key 时使用:

cpp 复制代码
memcmp(k1, k2, min_len)

再比较长度。

hash 计算哈希值时循环:

cpp 复制代码
for (int i = 0; i < key_len; i++)

而不是一直循环到 '\0'

跳表也使用:

cpp 复制代码
memcmp()

加长度比较。


十一、GET 如何返回长文章

面试官:

客户端执行 RGET 时,如何避免正文被截断?

应聘者:

RGET 进入:

cpp 复制代码
kvs_rbtree_get()

返回两个东西:

cpp 复制代码
char *result;
int out_vlen;

代码类似:

cpp 复制代码
int out_vlen = 0;

char *result = kvs_rbtree_get(
    &global_rbtree,
    key,
    key_len,
    &out_vlen
);

然后不能使用只依赖 strlen() 的旧函数:

cpp 复制代码
kvs_reply_bulk()

而使用:

cpp 复制代码
kvs_reply_bulk_len()

src/kvstore.c (line 277)(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/src/kvstore.c:277)

cpp 复制代码
kvs_reply_bulk_len(
    out,
    resp_mode,
    result,
    out_vlen
);

RESP 返回格式是:

cpp 复制代码
$正文长度\r\n
正文原始字节
\r\n

因此客户端知道应该读取多少字节,不会因为正文中有换行而提前结束。

响应对象是动态的:

cpp 复制代码
typedef struct kvs_response_s {
    char *data;
    int len;
    int cap;
} kvs_response_t;

响应容量不够时:

cpp 复制代码
kvs_response_reserve()

会扩容。


十二、连续发送多条命令是否支持

面试官:

同一个 TCP 连接连续发送多条 RSET,能否正确处理?

应聘者:

可以,这叫 pipeline。

server_reader() 中有:

cpp 复制代码
while (request_buf.len > 0)

处理完第一条命令以后:

cpp 复制代码
kvs_buffer_consume(
    &request_buf,
    complete_len
);

例如:

cpp 复制代码
处理前:
[命令1][命令2][命令3]

处理命令1以后:
[命令2][命令3]

然后继续处理命令 2 和命令 3。

kvs_protocol_inner() 内部也有:

cpp 复制代码
while (offset < length)

可以在一段 RESP 数据里循环解析多条命令。

但它不是事务。

如果结果是:

cpp 复制代码
命令1成功
命令2失败
命令3成功

不会自动回滚命令 1。


十三、持久化是否也支持长文章

面试官:

写入成功后,文章如何保存到磁盘?

应聘者:

kvs_filter_protocol() 中,写命令成功后会进入:

cpp 复制代码
kvs_append_increment_by_cmd()

根据命令确定引擎:

cpp 复制代码
SET  -> ARRAY
RSET -> RBTREE
HSET -> HASH
SSET -> SKIPLIST

然后调用:

cpp 复制代码
persist_append_increment()

持久化记录逻辑上包含:

cpp 复制代码
操作类型
引擎类型
key 长度
value 长度
key 内容
value 内容

启动时:

cpp 复制代码
persist_load_increment()

读取记录,再根据引擎调用:

cpp 复制代码
kvs_array_set()
kvs_rbtree_set()
kvs_hash_set()
kvs_skiplist_set()

不过面试时要诚实说明一个当前限制:

persist_append_increment() 当前仍然通过:

cpp 复制代码
strlen(key)
strlen(value)

计算长度。

所以:

cpp 复制代码
空格、换行、中文、普通标点:可以
正文中嵌入真正的 '\0':还不能完全支持

如果要支持完整二进制数据,应该把接口改成:

cpp 复制代码
int persist_append_increment(
    const char *path,
    persist_op_t op,
    persist_engine_t engine,
    const char *key,
    uint32_t key_len,
    const char *value,
    uint32_t value_len
);

十四、主从复制有什么问题

面试官:

主库写入带空格和换行的文章后,从库能否完全还原?

应聘者:

当前主从复制仍有历史兼容代码。

src/kvs_replication.c(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/src/kvs_replication.c) 中部分逻辑仍然把命令拼成:

cpp 复制代码
RSET key value\r\n

也就是旧的空格协议。

这种方式无法可靠表示:

cpp 复制代码
key 中的空格
value 中的空格
value 中的换行

所以当前可以这样回答:

复制代码
客户端到主库的 RESP 长文章路径已经支持;
内存引擎的长度处理已经支持;
但主从复制还需要进一步改造成 RESP 或显式长度协议。

更合理的方案是复制时也发送 RESP:

cpp 复制代码
REPL *3\r\n
$4\r\n
RSET\r\n
$key_len\r\n
key\r\n
$value_len\r\n
value\r\n

或者设计独立的二进制复制记录。


十五、当前设计为什么采用长度字段,而不是全部使用字符串

面试官:

为什么要同时保存指针和长度?只保存 char * 不行吗?

应聘者:

只保存 char * 有几个问题:

  1. 无法区分内容中的 '\0' 和真正结束。
  2. 需要依赖 strlen()
  3. key 中的特殊字节无法准确比较。
  4. value 返回时无法知道准确长度。
  5. 网络协议和存储层的边界不一致。

所以使用:

cpp 复制代码
char *data;
int data_len;

是一种长度感知设计。

在当前项目里分别体现为:

cpp 复制代码
char *key;
int key_len;

char *value;
int value_len;

这样:

cpp 复制代码
memcpy()
memcmp()
RESP bulk reply
持久化记录

都可以使用显式长度。


十六、当前功能是否真的"无限长度"

面试官:

你能否说项目已经实现了真正无限长度?

应聘者:

不能直接说无限长度,准确说法是:

已经取消了原来固定 64KB 请求/响应缓冲区带来的主要限制,但实际长度仍受数据类型、内存、引擎容量和其他模块限制。

当前限制包括:

cpp 复制代码
1. 大量长度字段仍然使用 int
2. 机器内存有限
3. array 固定只有 1024 个槽位
4. KVS_MAX_TOKENS 限制参数数量
5. reactor/proactor 仍有部分固定缓冲区代码
6. AOF 增量接口仍使用 strlen()
7. 主从复制仍有旧的空格协议
8. 同步模块有独立的缓冲区大小

所以面试中最好说:

cpp 复制代码
长文本功能已经实现;
固定缓冲区限制已经显著降低;
但完整二进制无限长度还需要继续统一所有模块的长度接口。

这比直接说"完全无限"更加严谨。


十七、性能为什么可能比较慢

面试官:

你之前测试 1000 条 array 数据都很慢,原因是什么?

应聘者:

主要原因不只是内存分配,而是多个因素叠加:

cpp 复制代码
1. 每条命令都要一次请求和一次响应
2. 测试程序是串行等待响应
3. 每次都要构造 RESP
4. 长文章需要复制多次
5. GET 需要构造和发送完整正文
6. array 是线性查找
7. 每次写入还可能追加 AOF
8. 大于 4096 字节的内存分配仍走系统 malloc

当前 mempool 已经接入:

cpp 复制代码
kvs_malloc()
kvs_free()

但内存池只重点管理小对象。大于 4096 字节的申请仍然直接走系统内存分配。

所以 mempool 对:

cpp 复制代码
短 key
短 value
node
链表节点
小型响应

有帮助。

但对 80KB 文章的主要耗时帮助有限,长文章的瓶颈更多在:

cpp 复制代码
网络往返
数据复制
RESP 编解码
持久化写盘

十八、如何测试这个功能

面试官:

你如何证明四个引擎真的支持特殊字符和长文章?

应聘者:

我写了:

test/testcase_article.c(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/test/testcase_article.c)

测试程序直接构造 RESP,而不是使用空格拼接命令。

测试内容包括:

cpp 复制代码
1. key 中包含空格和特殊字符
2. value 中包含中文和标点
3. value 中包含换行
4. 单条 80KB 左右文章
5. SET 后 GET
6. MOD 后 GET
7. EXIST
8. DEL 后 GET
9. array、rbtree、hash、skiplist
10. 批量插入和读取

测试发送函数是:

cpp 复制代码
resp_send_array()
send_cmd1()
send_cmd2()

读取响应时也不是用普通字符串比较,而是解析:

cpp 复制代码
$长度\r\n
正文\r\n

然后通过:

cpp 复制代码
memcmp()

比较正文内容,通过长度确认正文没有被截断。


面试时可以背的总结版

这个功能的核心是把系统从"基于空格和字符串结束符的处理"改成"基于 RESP 长度字段的处理"。网络层使用动态请求缓冲区解决 TCP 分包和粘包问题,RESP 层通过 bulk string 长度确定每个参数的边界,命令层把 argvargv_len 同时传给四个存储引擎。存储引擎复制 key/value,并保存 key_len/value_len,比较时使用 memcmp 和长度,读取时把真实长度返回给响应层。响应层使用动态缓冲区构造 $长度\r\n正文\r\n。因此空格、换行、中文、标点和长文章不会被错误切分。当前仍需要继续改进的地方是 AOF、主从复制、其他网络模型以及真正包含 \0 的二进制数据支持。

1.协议边界和 TCP 边界是两回事

可以说:

TCP 是字节流,不能假设一次 recv() 就是一条完整命令。所以我在网络层加了动态缓冲区,用 RESP 的长度字段判断一条命令是否完整。

2.这是 pipeline,不是事务

可以说:

连续多条 RESP 命令会顺序执行并顺序返回,但不是事务。某条失败不会回滚前面已经成功的命令。

3.为什么要复制 key/value

可以说:

RESP 解析出来的 argv 指向请求缓冲区,请求处理结束后这块内存会被释放或复用,所以存储引擎必须重新申请内存并 memcpy,不能保存外部指针。

4.为什么要保留 '\0'

可以说:

虽然核心逻辑使用长度,但我仍多申请一个字节补 '\0',是为了兼容项目里部分旧的字符串打印、调试和持久化代码。真正边界仍然以 key_len/value_len 为准。

5.当前没有完全二进制安全

这个很重要,面试官可能会追问。

可以说:

目前对普通文本的特殊字符、中文、空格、换行、长文章是支持的。但如果 value 中包含真正的 '\0' 字节,AOF 和部分旧逻辑仍有 strlen(),所以还不是完整 binary-safe。

6.主从复制还需要继续改

可以说:

客户端到主库的 RESP 路径已经是长度感知的,但复制模块部分地方还会把命令重新拼成空格协议。下一步应该让复制也发送 RESP 或二进制日志格式。

7.array 引擎不是大数据量重点

可以说:

array 主要用于教学和对比,它固定容量 1024,查找是 O(n)。真正更适合大数量数据的是 hash、rbtree、skiplist。

8.性能优化方向

可以说:

当前测试慢的原因主要是串行请求响应、长正文多次复制、GET 大响应发送、AOF 写盘,以及 array 线性查找。mempool 对小对象有帮助,但对 80KB 大文章帮助有限。

面试官追问时的好回答

如果问:"你这个设计最大的价值是什么?"

答:

最大价值是统一了数据边界。以前边界来自空格和 '\0',这是不可靠的;现在边界来自 RESP 的显式长度,并且这个长度一直传到存储和响应层。

如果问:"还有什么没做完?"

答:

主要有三点:复制链路还没完全 RESP 化,AOF 增量接口还没完全传长度,长度类型可以从 int 升级到 size_tuint32_t/uint64_t。这些是我下一步会继续完善的地方。

相关推荐
米糕闯编程1 小时前
鱼香ros2(五)用C++编写节点
c++·ros2·cmakelists·鱼香
xiangyun612 小时前
【408数据结构 08】队列:循环队列判空判满,408年年考,一次讲清
c语言·开发语言·数据结构·c++·算法
xiangyun613 小时前
【408数据结构 06】双链表、循环链表、静态链表
c语言·开发语言·数据结构·c++·算法
fpcc4 小时前
计算机原理—构建过程中的分段分析
c++
xxwxx__4 小时前
C++ AVL 树深度剖析:平衡二叉搜索树原理、源码与面试考点
数据结构·c++
Escalating_xu5 小时前
【C语言常见概念】从第一行代码到字符串结束标志:一次打通编译、main、ASCII、转义字符与注释
c语言·c++
bnmoel5 小时前
C++ 基础入门篇(三):内联函数与空指针
c++·内联函数·nullptr·低层
码匠许师傅5 小时前
【C++三方组件】TinyXML2:两个文件的极简 XML 解析器
xml·c++
free-elcmacom5 小时前
C++学习<1>程序分区
开发语言·c++