一次 recv 并不等于一条命令
假设客户端打算发送两条命令:
SET count 1\nGET count\n
服务器可能先读到 SET co,下一次才收到后面的部分;也可能一次读到两条完整命令。TCP 提供有序字节流,但不保存应用消息边界,这一点可以对照 Linux TCP 手册。
MiniRedis 因此给每个连接保留输入缓冲。读到字节后追加进去,只取出包含完整换行的命令,最后半行留待下一次处理。遇到超长行还要按协议上限拒绝,否则一个不发送换行的连接就可能不断占用内存。
这个设计也解释了为什么不能用“这次 read 收到多少”判断业务请求结束。分帧是应用协议的责任。
可写只表示现在能写一点
非阻塞 socket 的 send 也可能只发送响应的一部分。当前实现保留输出字符串和已发送偏移:发出多少,偏移就推进多少;暂时写不进去时保留剩余部分,下次可写再继续。
可以把连接状态想成三个问题:
- 输入里还有没有完整命令?
- 输出里还有多少字节没有交给 socket?
- 对端是否还会发送数据,当前连接是否需要在排空输出后关闭?
select 用来等待读写就绪。当前项目没有使用 epoll,也没有为每个连接创建一个线程。理解这份跨平台实现后,再比较其他事件通知方式,才知道哪些问题属于接口差别,哪些始终需要应用自己处理。
一个连接不能独占一轮
即使所有 socket 都是非阻塞的,也可能出现不公平:客户端 A 连续发送大量完整命令,服务器一直忙着处理 A,客户端 B 的一个 PING 就只能等。
项目给每个连接设置分轮预算:每轮最多处理 32 条命令、读取 4 KiB、发送 64 KiB。用完预算就把执行机会交回事件循环,让其他就绪连接也能推进。这些数字是代码中的工作量限制,不是吞吐量测试结果。
但预算仍有边界。一次同步日志写入或 AOF 重写如果花了较长时间,同一线程上的其他命令还是需要等待。网络公平性不能代替对阻塞业务操作的分析。
只限制输入还不够
更棘手的客户端可能一直发 GET,却几乎不读响应。如果服务器不断执行请求,输出缓冲会越积越多。
当前实现看到输出积压达到执行水位后,会暂停这个连接的读入和命令执行;缓冲排空后才恢复。这样相当于把“下游读得慢”的信号传回“上游还能不能继续制造结果”。每连接待发送数据上限为 2 MiB,最大单次响应及相应预留空间共同参与约束。
这就是背压在项目中的具体落点。它不会让慢客户端变快,却可以阻止慢客户端消耗没有边界的应用缓冲。存储层的 maxmemory 管的是键值记账,不能拿它代替网络缓冲限制。
半关闭不是立刻丢弃回复
客户端发送完请求后,可以关闭自己的写方向,同时继续等待服务器的响应。服务器读到 EOF 时,如果还有完整命令和未发完的输出,就不应该马上把连接扔掉。
MiniRedis 会处理已经收到的完整命令,排空响应后再关闭;未构成完整行的尾部会被丢弃。QUIT 也需要让它之前的回复及 BYE 有机会发出,后续流水线命令则不再执行。
最适合用来检验理解的实验,是同时开两个连接:A 发送大响应请求后慢慢读,B 持续发送 PING。观察 B 是否仍能前进,再检查 A 的输出是否受限。项目里的 tests/concurrency_tests.py 就围绕多连接、半关闭和慢读取组织了回归场景。
面试中被问到“非阻塞为什么还需要背压”,我会从未发完的响应、每轮预算和内存上限讲起;每个名词都能在 src/server/server.cpp 找到对应状态。