【系列:MiniKV 原理剖析 · 第 5 篇】

导读: 当你用 Redis 客户端发一条命令,背后发生了什么?TCP 是字节流,没有消息边界------一次 recv 可能收到多条命令(粘包),也可能一条命令分三次到达(半包)。本文从零实现 MiniKV 的 RESP2 服务器:socket 生命周期管理、send_all 处理部分发送、pending buffer 解决粘包/半包、RESP2 协议五种类型编解码、命令分发。读完你就能写一个能跑的 KV 服务端。

系列回顾:从存储到网络

MiniKV 系列已经走过四篇。前四篇我们完成了存储核心:LRU 淘汰策略、TTL 过期机制、线程安全保证。但一个 KV 存储要真正可用,必须暴露网络接口------这就是本篇的主角:Server 与 Protocol 模块。

回顾一下架构分层:

复制代码
┌─────────────────────────────┐
│        客户端 (redis-cli)     │
└──────────────┬──────────────┘
               │ TCP 字节流
┌──────────────▼──────────────┐
│      Server (socket 管理)    │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│   Protocol (RESP2 编解码)    │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│   Storage (LRU/TTL/线程安全) │
└─────────────────────────────┘

网络层是存储层与外部世界的桥梁。今天要解决的三个核心问题:socket 生命周期管理、TCP 粘包/半包处理、RESP2 协议编解码

一、Server 的 socket 生命周期

先看最基础的:一个 TCP 服务器怎么从创建到接受连接。server.cpp 的 run() 方法展示了完整流程:

cpp 复制代码
listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);   // ① 创建
::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, ...);  // ② 端口复用
::inet_pton(AF_INET, bind_address_.c_str(), &address.sin_addr);  // ③ IP转换
::bind(listen_fd_, ..., sizeof(address));          // ④ 绑定
::listen(listen_fd_, 64);                          // ⑤ 监听(64 backlog)
::accept(listen_fd_, nullptr, nullptr);            // ⑥ 接受连接

六个步骤,每一行都有讲究。

SO_REUSEADDR 是救命稻草 。没有它,服务端重启时会遇到 Address already in use------因为前一个进程的 socket 还处于 TIME_WAIT 状态。这个选项告诉内核:端口可以立即复用。

backlog=64 表示未 accept 的连接队列上限。如果并发连接超过 64,多余的会被内核拒绝。生产环境通常设更大,但 64 对 MiniKV 足够。

优雅停止 :stop() 方法调用 shutdown(SHUT_RDWR)close()。shutdown 先切断读写通道,确保正在处理的数据完成,然后 close 释放资源。直接 close 会粗暴地断开所有连接。

二、send_all:处理部分发送

写网络代码最容易踩的坑:send() 一次没发完。

内核发送缓冲区是有限的。当数据量超过缓冲剩余空间,send() 只发送部分数据就返回。新手常犯的错误:只调用一次 send() 就认为发送完毕,导致对端收到残缺数据。

cpp 复制代码
bool send_all(int fd, const std::string& data) {
    std::size_t sent = 0;
    while (sent < data.size()) {
        const ssize_t count = ::send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
        if (count <= 0) return false;
        sent += static_cast<std::size_t>(count);
    }
    return true;
}

核心逻辑:循环发送,直到全部数据发出。每次 send() 返回实际发送的字节数,更新偏移量继续发。

MSG_NOSIGNAL 是个容易被忽略的细节。当对端已关闭连接,send() 会触发 SIGPIPE 信号------默认行为是杀死进程 。加上 MSG_NOSIGNAL 后,send() 返回 -1,由代码处理错误,而不是进程直接崩溃。

三、pending buffer:TCP 粘包与半包

这是本篇的核心,也是面试高频考点。

TCP 是字节流协议 ,没有消息边界。发送方调两次 send() 发 "HELLO" 和 "WORLD",接收方可能一次 recv() 就收到 "HELLOWORLD"(粘包),也可能分三次收到 "HEL"、"LO"、"WORLD"(半包)。

MiniKV 的解法:pending buffer + 按 '\n' 切分

cpp 复制代码
std::string pending;
char buffer[4096];
while (running_.load()) {
    const ssize_t count = ::recv(client_fd, buffer, sizeof(buffer), 0);
    if (count <= 0) break;
    pending.append(buffer, count);   // 先累积
    std::size_t newline = 0;
    while ((newline = pending.find('\n')) != std::string::npos) {
        std::string request = pending.substr(0, newline);
        pending.erase(0, newline + 1);
        if (!request.empty() && request.back() == '\r') request.pop_back();
        if (!send_all(client_fd, execute(request))) return;
    }
    if (pending.size() > 64 * 1024) {   // 防御恶意超大请求
        send_all(client_fd, Protocol::error("request is too large"));
        return;
    }
}

处理逻辑分三步

  1. recv() 的数据先 append 到 pending,不急着处理
  2. 循环查找 '\n',找到就切出一条完整请求,执行并返回
  3. 剩余数据留在 pending,等下次 recv() 再处理

这样粘包(一次收到多条)和半包(一条分多次)都被统一处理。粘包时 while 循环会连续切出多条;半包时数据不足,find 找不到 '\n',自然留到下次。

64KB 上限是防御机制。恶意客户端可以发无限长的数据耗尽内存。超过 64KB 直接返回错误并断开连接。

四、RESP2 协议:五种类型

RESP2(REdis Serialization Protocol 2)是 Redis 使用的协议。MiniKV 完整实现了五种类型:

类型 编码 示例
Simple String + 开头 +OK\r\n
Error - 开头 -ERR msg\r\n
Integer : 开头 :1\r\n
Bulk String $长度\r\n数据\r\n $5\r\nhello\r\n
Nil $-1\r\n 键不存在
Array *数量\r\n+各元素 *2\r\n$3\r\nfoo\r\n...

Bulk String 是重点。它包含长度前缀,接收方先读长度,再读对应字节数------这天然解决了粘包问题。但 MiniKV 的协议设计更简单:每条命令以 '\n' 结尾,用行分隔符切分。

Nil 与 Bulk String 的区别 :GET 不存在的键返回 $-1\r\n(Nil),而不是空字符串 $0\r\n\r\n。客户端能区分「键不存在」和「键值为空」两种语义。

五、命令分发:execute 方法

协议解析完成后,进入命令分发。execute 方法根据命令类型路由到不同处理逻辑:

命令 响应
PING +PONG\r\n
SET k v +OK\r\n
GET k $len\r\nv$-1
DEL/EXISTS/EXPIRE/TTL :N
KEYS *N\r\n...
STATS 数组
未知命令 -ERR

参数数量校验 是隐藏的坑。SET 需要 2 个参数,GET 需要 1 个。参数数不对要返回 -ERR wrong number of arguments,而不是崩溃或静默失败。

大小写不敏感SETset 等价。解析时统一转小写再比较,这是协议测试里明确要求的。

六、测试验证

protocol_test.cpp 覆盖三个核心场景:

  1. 大小写不敏感SET key valueset key value 返回相同结果
  2. 空输入拒绝:空字符串不崩溃,返回错误
  3. RESP2 编码正确性:每种类型的编码字节序列与规范完全一致

测试是网络层的安全网。粘包处理、边界条件、异常输入------这些逻辑单靠手工测试很难覆盖全。

小结

本篇完成了网络层:socket 生命周期、send_all 保证完整发送、pending buffer 解决粘包/半包、RESP2 五种类型编解码、命令分发。

核心收获:TCP 是字节流,消息边界需要自己定义。MiniKV 用 '\n' 做分隔符,pending buffer 累积数据,按行切分------简单但有效。

  • socket 生命周期:socket → SO_REUSEADDR → bind → listen(64) → accept,stop() 用 shutdown+close 优雅退出
  • send_all:循环发送直到全部数据发出,MSG_NOSIGNAL 防 SIGPIPE 杀进程
  • pending buffer:recv 累积 → 按 '\n' 切分完整请求 → 剩余留待下次,统一解决粘包与半包
  • RESP2 五种类型:Simple String/Error/Integer/Bulk String/Nil/Array 的编码格式
  • 命令分发:参数数量校验 + 大小写不敏感

下一篇预告

《60 行测试框架的魔法:C++ 静态注册模式》 ------网络层讲完,进入测试体系。MiniKV 的测试框架只有约 60 行,却实现了"零手动注册"的自动测试收集:TEST(name) 宏 + Registrar 静态构造 + 全局 registry。这篇拆解这个精巧的静态注册模式,以及它背后的 C++ 初始化顺序原理。

参考文献与引用

📥 下载完整源码 :如需整个工程的源码,请在下面的链接下载:

https://download.csdn.net/download/ganxin7932508/93241722


觉得有用?点个关注,持续获取优质内容。欢迎留言讨论。

相关推荐
kdxiaojie1 小时前
Linux 驱动研究 —— SDIO (1)
linux·运维·笔记·学习·sdio
豆沙沙包?2 小时前
C++-程序的内存模型(P84-P88)
java·jvm·c++
jufeng13072 小时前
【系列:MiniKV 原理剖析 · 第 8 篇(完结篇)】
linux·c++·log4j·软件工程·makefile
qetfw2 小时前
Debian OpenSSL 搭建 CA 证书服务:签发、吊销与客户端信任
linux·debian·openssl·ca
ARoger_miu572 小时前
opsf笔记
网络·网络协议
Embedded-Xin2 小时前
中间件—zenoh零基础入门
linux·中间件·rust·机器人·自动驾驶·嵌入式
caimouse2 小时前
tcpip.sys 网络层 (IP) 详细分析
网络·网络协议·tcp/ip
VL——MOESR2 小时前
【LuoguP1967】货车运输【生成树】【倍增】
c++·算法·题解·倍增·生成树