【系列: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


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

相关推荐
zx_7414848132 分钟前
【Linux入门】Linux 安装:VMware Workstation Pro 安装 CentOS 7 保姆级图文教程
linux·centos
Persistent的粽子!44 分钟前
C++:类与对象(二)
开发语言·c++
myy-learn1 小时前
31-TCP并发
服务器·网络·tcp/ip
程序员夏洛1 小时前
HTTP 请求包含哪些内容,请求头和请求体有哪些类型?
网络·网络协议·http
啊阿狸不会拉杆1 小时前
《计算机网络-自顶向下方法》3.9 小结 · 课后习题 · Wireshark实验
网络·测试工具·wireshark
Chengbei111 小时前
SRC报告成稿 Skill(SRC/0day 提交稿、分层验证门、Step 式 PoC、截图铁律)
网络·人工智能·安全·web安全·自动化·系统安全
智购科技无人售货机厂家1 小时前
2026自动售货机备用电池管理系统设计:从充放电保护到健康监测的工程实践~YH
大数据·linux·单片机·嵌入式硬件·微信
小雪崩1 小时前
嵌入式学习 day39:TCP并发服务器模型
linux·服务器·c语言·网络·学习·tcp/ip
Doraemomo1 小时前
网络编程-复习
网络
蒸蒸yyyyzwd1 小时前
cpp 选手备战秋招学习笔记 day23
c++·求职招聘