一、请介绍一下这个功能的整体架构
面试官:
你的项目是如何支持 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 不会扫描正文里的空格或换行来判断结束,而是:
- 读取
$20 - 精确读取后面的 20 个字节
- 再读取结尾的
\r\n
所以正文中出现:
cpp
空格
换行
中文
标点
括号
都不会破坏协议边界。
RESP 的解析代码在:
- src/kvs_resp.c(//192.168.137.132/sambashare/2026/9.1_kvstore/9.1-kvstore/src/kvs_resp.c)
kvs_resp_get_complete_len()kvs_resp_parse_one()
四、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;
应聘者:
因为 key 和 value 指向的是网络请求缓冲区。
请求处理完成以后:
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 * 有几个问题:
- 无法区分内容中的
'\0'和真正结束。 - 需要依赖
strlen()。 - key 中的特殊字节无法准确比较。
- value 返回时无法知道准确长度。
- 网络协议和存储层的边界不一致。
所以使用:
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 长度确定每个参数的边界,命令层把
argv和argv_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_t或uint32_t/uint64_t。这些是我下一步会继续完善的地方。