https://github.com/zthinedge/TinyRedis/
1. 为什么 TinyRedis 选择单线程 Reactor
TinyRedis 是一个基于 C++17 实现的 仿Redis 项目。对这类内存型服务来说,网络层既要承担连接管理,也要把协议解析、命令执行和响应发送稳定地串起来 。
在项目当前阶段,我选择的是基于 epoll 的单线程 Reactor,而不是一开始就上多线程网络模型,原因主要有三点。
第一,当前目标不是把架构做复杂,而是先把主链路做对。
相比多线程 Reactor 或线程池模型,单线程事件循环更容易保证模块边界清晰,也更方便我验证请求在系统中的完整流动过程。
第二,单线程天然避开了共享状态带来的锁竞争。
TinyRedis 当前的核心路径是 net -> protocol -> command -> storage,把这条链路放在一个线程里顺序执行,能够减少并发同步成本,也更符合 Redis 这类系统在早期版本中的演进思路。
第三,epoll 很适合高并发下的大量连接管理。
它不需要像 select 那样做线性扫描,事件驱动的方式也和 Reactor 模型天然契合。
至于为什么当前采用 LT 而不是 ET,核心原因是项目当前优先级是正确性和可调试性。
LT 模式语义更直观,只要缓冲区里还有数据,内核就会继续通知;它可能带来更多事件唤醒,但实现容错更高,更适合项目前期把网络链路先跑通。
2. TinyRedis 网络层的整体流程
TinyRedis 的网络层核心类是 EpollServer。它同时负责:
- 初始化监听 socket
- 创建并维护 epoll 实例
- 管理客户端连接状态
- 驱动 RESP 协议解析
- 把请求转交给命令层
- 在可写事件到来时回写响应
整个请求链路可以概括为:
text
Client
|
v
listenFd / clientFd
|
v
epoll_wait
|
+--> acceptClients() // 新连接接入
+--> handleClientRead() // 读取请求
| -> RESPParser
| -> CommandParser
| -> CommandDispatcher
| -> writeBuf
|
+--> handleClientWrite() // 回写响应
-> send()
如果把它放进 TinyRedis 的整体架构里看,请求会继续沿着下面这条主线流动:
text
Client
-> EpollServer
-> RESPParser
-> CommandParser
-> CommandDispatcher
-> InMemoryDB
-> RESPEncoder
-> ClientSession::writeBuf
-> send()
这个设计的好处是职责分层非常明确:
net层只负责连接、事件和收发protocol层只负责 RESP 编解码command层只负责参数校验和命令分发storage层只负责真正的数据读写
从工程角度看,这比把所有逻辑塞进一个 socket 循环里更容易维护,也更适合后续继续扩展 AOF、复制和更多数据结构。
3. 核心实现分析:连接、读事件、写事件
3.1 新连接是怎么接入的
服务启动时,EpollServer::init() 会依次完成三件事:
- 初始化监听 socket
- 创建 epoll 实例
- 把
listenFd注册到 epoll
监听 fd 只关注 EPOLLIN,因为它只承担一件事: 接受新连接。
当 epoll_wait 返回的事件来源于 listenFd 时,TinyRedis 会进入 acceptClients()。
这个函数会循环执行 accept,直到返回 EAGAIN 或 EWOULDBLOCK,说明当前这一轮已经把内核 accept 队列里的连接取干净了。
新连接建立后,TinyRedis 会做两件关键的事情:
- 把 client fd 设置为非阻塞
- 为这个连接创建一个
ClientSession
ClientSession 当前很轻,但非常关键:
cpp
struct ClientSession {
RESPParser parser;
std::string writeBuf;
bool closeAfterWrite = false;
bool replica = false;
};
它说明 TinyRedis 对每个连接保存的,不只是一个 fd,还包括这个连接当前的处理进度。
比如,这个连接上次读到的数据有没有形成半包、还有没有没发完的响应、是不是需要延时关闭,都会放在 ClientSession 里统一管理。 这正是 Reactor 模型里"连接状态对象"的雏形。
3.2 读事件如何驱动整个命令执行链路
当某个 client fd 上出现 EPOLLIN 事件时,事件循环会调用 handleClientRead(fd)。
这个函数本质上做了四件事:
- 循环
recv读取 socket 数据 - 把字节流喂给
RESPParser - 尝试从缓冲区中连续解析出完整请求
- 把解析结果交给命令层执行,并把响应写入
writeBuf
它的主逻辑可以抽象成下面这样:
cpp
for (;;) {
n = recv(fd, buf, ...);
if (n <= 0) { ... }
session.parser.feed(buf, n);
for (;;) {
RESPObject obj;
if (!session.parser.parse(obj)) {
break;
}
CommandParser::toArgv(obj, argv, err);
reply = dispatcher_.dispatch(argv);
session.writeBuf += reply;
}
}
这里最值得分析的点,不是"调用了哪些函数",而是它解决了什么问题。
第一,它天然支持半包和粘包。
TCP 是字节流协议,应用层不能假设一次 recv 就正好拿到一条完整命令。TinyRedis 通过 RESPParser::feed 持续喂入数据,再通过 parse 尝试提取完整 RESP 对象,因此能够正确处理:
- 一条命令被拆成多次到达的半包
- 多条命令一次性到达的粘包
第二,它支持一次读事件中解析多条请求。
这意味着如果客户端一次性发来了多条 RESP 请求,TinyRedis 不会只处理第一条,而是会在一次事件处理中尽可能多地把完整请求都消费掉。
第三,协议层和命令层是解耦的。
网络层拿到的是原始字节流,协议层把它转成 RESPObject,命令层再把它转成 argv 并调用分发器。这样的分层比"边读边手写字符串判断命令"要干净得多。
第四,错误处理也被纳入了事件驱动模型。
如果 RESP 解析抛出异常,TinyRedis 不会直接崩掉,而是会向 writeBuf 里追加一条协议错误响应,并设置 closeAfterWrite = true。也就是说,它会优先把错误返回给客户端,再在写回完成后关闭连接。
这个设计很像真实服务端里的"优雅失败",而不是粗暴断开。
3.3 写事件为什么不能简单理解成 send 一次就结束
很多人刚写网络服务时,容易把"生成响应"直接等同于"发送完成"。
但在非阻塞 socket 下,这两者完全不是一回事。
TinyRedis 的做法是把响应先追加到 session.writeBuf,等 fd 上出现 EPOLLOUT 事件后,再由 handleClientWrite(fd) 负责真正发送。
它的逻辑可以概括为:
cpp
while (!session.writeBuf.empty()) {
n = send(fd, session.writeBuf.data(), session.writeBuf.size(), 0);
if (n < 0 && errno == EAGAIN) {
break;
}
session.writeBuf.erase(0, n);
}
if (session.writeBuf.empty()) {
updateClientEvents(fd, false);
}
这里有三个关键点。
第一,非阻塞写可能写不完。
如果内核发送缓冲区满了,send 可能只发出一部分数据,甚至直接返回 EAGAIN。所以服务端不能假设"一次 send 就能清空所有响应"。
第二,writeBuf 是应用层输出缓冲区。
它保证了未发送完的数据不会丢。只要 writeBuf 还没空,这个连接就应该继续关注 EPOLLOUT,等待下一次可写机会。
第三,发送完成后要取消对 EPOLLOUT 的关注。
如果响应已经写空,但还持续监听可写事件,那么 epoll 很可能会不断返回这个 fd,造成无意义的空转。TinyRedis 在 writeBuf 清空后把事件重新改回 EPOLLIN | EPOLLRDHUP,这是一个很典型也很必要的优化。
4. 这套实现解决了什么问题,还存在哪些局限
4.1 它解决了什么问题
站在 TinyRedis 当前阶段看,这套单线程 Reactor 已经很好地解决了几个核心问题。
首先,它把网络事件、协议解析和命令执行稳定地串了起来。
现在 TinyRedis 已经不是一个只能收发字符串的小 demo,而是一条完整的请求处理链路。
其次,它保证了模块边界清晰。
网络层不直接关心 SET/GET 的语义,命令层也不直接碰 socket 细节。这样的分层让后续演进 AOF、复制和更多数据结构时,改动范围更可控。
再次,它对当前项目复杂度非常合适。
单线程模型避免了锁和线程同步问题,也降低了调试门槛。对一个正在持续迭代中的内核型项目来说,这种"先做对,再做快"的策略是合理的。
4.2 它还存在哪些局限
当然,这套实现并不意味着没有代价。
第一,单线程吞吐一定有上限。
随着连接数、命令复杂度和响应体积增加,单线程 Reactor 最终会成为瓶颈。
第二,当前输出缓冲区基于 std::string。
这种实现简单直接,但面对大响应或高频写入时,可能会带来额外的内存拷贝和扩容成本。
第三,当前使用的是 LT 模式。
它实现简单,但在高负载场景下可能产生更多重复通知;如果未来追求更高性能,可以考虑进一步评估 ET 模式。
第四,当前还是单 Reactor 结构。
它还没有演进到主从 Reactor、多 IO 线程或者网络线程 + 工作线程池模型,因此在极端并发下扩展能力有限。
所以从工程视角看,TinyRedis 当前的网络层更适合被理解为"一个正确、清晰、可扩展的第一版实现",而不是"最终性能形态"。
6. 总结
TinyRedis 当前的网络层,本质上是一个基于 epoll 的单线程 Reactor。
它用一个事件循环把连接接入、请求读取、RESP 解析、命令执行和响应发送串成了一条完整主链路。
这套实现的价值,不只是"能跑起来",而是它已经具备了比较清晰的工程骨架:
- 连接有自己的状态对象
- 协议层和命令层被拆开
- 读写事件分离
- 输出缓冲区具备基本的非阻塞发送语义
对 TinyRedis 来说,这意味着后续无论是继续补数据结构、增强持久化,还是演进复制能力,网络层都已经有了一个可以持续扩展的基础版本。