
上一篇我们拆开了 RedisObject 和内部编码,知道一条 HGET 最终可能访问 listpack,也可能访问 hashtable。但在它摸到数据结构之前,请求还要先从网卡进入 Socket,被事件循环发现、读取、解析、校验和分派;执行完成后,响应也不一定能立刻写回客户端。
"Redis 为什么用单线程还这么快"是面试必问,但"纯内存 + I/O 多路复用"通常只是两个名词。继续追问就会暴露心智模型里的空白:多路复用到底复用了什么?Redis 6 的 I/O 线程是否会并行执行 GET?为什么 BLPOP 可以等几十秒却不阻塞其他请求,而一段 200 毫秒的 Lua 脚本会让所有客户端排队?Pipeline 又优化了哪一段?
这篇按一条 HGET 的完整生命周期展开:从连接建立、Socket 就绪和 RESP 解析开始,进入主线程的命令校验与执行,再走到响应缓冲和网络写回;随后划清 I/O 线程的并行边界,最后用慢命令、阻塞命令和生产排障把整条链串起来。
目录
- [一条 HGET 到底经过哪些阶段](#一条 HGET 到底经过哪些阶段)
- [Socket 与 I/O 多路复用:先找出谁有事](#Socket 与 I/O 多路复用:先找出谁有事)
- [读取与解析:TCP 字节流怎样变成 Redis 命令](#读取与解析:TCP 字节流怎样变成 Redis 命令)
- 命令执行:为什么核心路径仍然串行
- 响应写回:命令完成不等于客户端已经收到
- [Redis 6 I/O 线程:并行了什么,没有并行什么](#Redis 6 I/O 线程:并行了什么,没有并行什么)
- 谁会真正阻塞主线程
- 生产优化与排障:慢到底发生在哪一段
一、一条 HGET 到底经过哪些阶段
先把客户端执行下面这条命令的全路径铺开:
redis
HGET product:1001 price
从应用发起调用到拿到结果,至少会经过八个阶段:
text
应用编码命令
↓
请求经过网络到达 Redis Socket
↓
I/O 多路复用报告"这个连接可读"
↓
Redis 读取字节并解析 RESP
↓
查找命令、校验参数与运行条件
↓
执行 HGET,访问 RedisObject 和底层编码
↓
把响应写入客户端输出缓冲
↓
Socket 可写时发送,响应经过网络回到应用

这里最重要的第一层认识是:客户端看到的耗时,不等于 Redis 执行命令本身的耗时。 可以粗略拆成:
text
客户端总耗时
≈ 连接池等待
+ 客户端编码/解码
+ 请求网络耗时
+ Redis 排队等待
+ Redis 读取与协议解析
+ 命令执行
+ 响应写出与网络返回
假设 HGET 操作内存只花几十微秒,应用仍可能因为跨机房 RTT、连接池耗尽、客户端 GC、前序慢命令排队或大响应写回而看到几十毫秒。反过来,客户端超时也不代表 Redis 没执行成功:命令可能已经修改数据,只是响应在返回途中超时。对非幂等写操作盲目重试,就可能造成重复扣减。
第二层认识是,一条命令的不同阶段不一定由同一个线程完成。默认配置下,主线程承担绝大部分客户端网络处理和命令执行;开启 I/O 线程后,部分 Socket 读写和协议解析可以并行,但真正修改核心数据结构的命令仍回到主线程串行执行。
因此不能把 Redis 简化成"一个线程从头包办所有工作",也不能看到 I/O 线程就理解成"多线程并发改 Key"。先从网络入口看起:成千上万个连接到达后,主线程怎样知道该处理谁?
二、Socket 与 I/O 多路复用:先找出谁有事
Redis 启动后会创建监听 Socket,并把它注册到事件循环。新连接到达时,监听 Socket 变为可读,Redis 接受连接,为客户端创建状态对象,再把客户端 Socket 设置成非阻塞并注册读事件。
这里有两个关键词:
- 非阻塞 I/O :一次
read或write暂时无法继续时,线程不会一直睡在这个 Socket 上; - I/O 多路复用:让操作系统一次等待许多文件描述符,并返回当前已经就绪的那一批。
Linux 常用 epoll,macOS/BSD 常用 kqueue,Redis 的事件库会在不同平台选择合适实现,并向上提供统一的文件事件接口。应用层不需要为每个连接创建一个线程,也不需要不断把所有连接轮询一遍。
可以把它类比成医院叫号屏。一个医生对应一个连接线程的做法,是让医生守在每扇诊室门口等患者;I/O 多路复用则是所有患者先取号,系统一次告诉医生"3、8、12 号已经准备好"。医生仍然要逐个处理,但时间不会浪费在等待尚未就绪的人身上。
2.1 多路复用"复用"的是什么
复用的是 一个或少量线程对大量 Socket 就绪事件的等待能力,不是把一条命令拆给多个 CPU 并行计算。
text
client A Socket ─┐
client B Socket ─┼──→ epoll / kqueue ──→ 可读:A、C;可写:B
client C Socket ─┤ │
client D Socket ─┘ ▼
事件回调
操作系统只报告"现在读不会阻塞"或"现在可以继续写",不会替 Redis 解析 RESP、查找 Key 或执行 HGET。事件循环拿到就绪集合后,才调用相应的读写处理函数。
这也回答了一道常见面试题:I/O 多路复用解决高并发连接的等待问题,单线程命令执行解决共享数据结构的并发控制问题。 两者经常同时出现,但不是同一件事。
2.2 事件循环不只处理网络事件
Redis 的事件循环里大致有两类事件:
- 文件事件:监听连接、客户端 Socket 可读/可写、管道通知等;
- 时间事件:周期性运行服务器维护工作,例如更新统计、处理超时、推进过期检查和复制相关任务。
事件循环每轮并不是只执行一条客户端命令。它会等待就绪事件、处理一批网络工作,也会在合适时机执行周期任务,并在休眠前后完成 pending writes、I/O 线程协调等工作。具体函数名和阶段会随版本演进,但"事件驱动 + 非阻塞 Socket + 周期任务"这条主线不变。
Socket 可读只表示内核接收缓冲里有字节。TCP 不知道什么叫 HGET,下一步还要把任意切分的字节流还原成完整命令。
三、读取与解析:TCP 字节流怎样变成 Redis 命令
Redis 客户端通常使用 RESP(REdis Serialization Protocol)编码命令。HGET product:1001 price 对应的 RESP2 请求可以表示为:
text
*3\r\n
$4\r\n
HGET\r\n
$12\r\n
product:1001\r\n
$5\r\n
price\r\n
*3 表示数组有 3 个元素,后面的 $4、$12、$5 分别声明字符串字节长度。长度先行让解析器能处理二进制内容,不需要靠空格或 \0 猜边界。
3.1 一次 read 不等于一条命令
TCP 是字节流协议,不保留应用消息边界。一次读取可能只拿到半条命令,也可能拿到客户端 Pipeline 连续发送的十几条命令。Redis 因此为每个客户端维护查询缓冲区:
text
第一次 read:*3\r\n$4\r\nHGE
第二次 read:T\r\n$12\r\nproduct:1001\r\n$5\r\nprice\r\n
│
▼
query buffer 拼成完整请求
解析器检查数组长度、批量字符串长度和协议格式,只有拿到完整命令后,才构造参数数组并进入命令处理。半包会留在缓冲区等待下次可读事件;粘在一起的多条命令则可以被连续解析。
这说明网络编程里的"半包、粘包"不是 TCP 出错,而是字节流的正常现象。RESP 的长度字段和客户端查询缓冲共同完成应用层拆包。
3.2 Pipeline 优化的是哪一段
普通串行调用每发一条命令都等待响应,会支付多次网络往返:
text
请求1 → 响应1 → 请求2 → 响应2 → 请求3 → 响应3
Pipeline 把多条完整 RESP 请求连续写入连接,再批量读取按顺序返回的响应:
text
请求1、请求2、请求3 → 响应1、响应2、响应3
它主要减少 RTT 和系统调用次数,也提高每轮事件处理的批量效率。Pipeline 不会让三条命令在服务端并行执行,也不会自动赋予原子性。 即使某个版本在一次输入处理里恰好连续执行了一批命令,业务也不能把这种实现行为当成不可穿插的协议保证;确实需要原子批次时,应使用事务或服务端脚本。
Pipeline 也不是越大越好。一次堆几万条命令,会占用客户端查询缓冲、服务端输出缓冲和应用内存,并可能让单个连接长时间占据处理资源。生产上应按响应大小而不只是命令条数控制批次。
协议解析完成后,命令才真正进入核心执行路径。这个阶段为什么长期坚持主线程串行,就是下一节的重点。
四、命令执行:为什么核心路径仍然串行
以 Redis 7.4 源码中的主路径为例,函数名简化后可以串成:
text
readQueryFromClient
↓
processInputBuffer
↓
processCommandAndResetClient
↓
processCommand
↓
call
↓
具体命令函数,例如 hgetCommand
这些函数名不是需要背诵的 API,价值在于看清命令执行前后还有哪些工作。
4.1 执行前不只是查一张命令表
Redis 会先根据命令名找到命令定义,并进行一系列检查,常见包括:
- 参数个数和语法是否合法;
- 当前用户 ACL 是否允许执行;
- 实例是否处于加载、只读、OOM 或其他限制状态;
- 事务、脚本、阻塞状态下是否允许该命令;
- Cluster 模式下 Key 是否应由当前节点处理,是否需要返回
MOVED/ASK; - 命令涉及的 Key、读写属性和统计信息如何记录。
检查通过后,call 才调用具体命令实现。HGET 根据 Value 的 type 和 encoding 进入 listpack 或 hashtable 路径;ZADD 可能更新 listpack,也可能同时维护 dict 与 skiplist。上一篇的内部编码就在这里真正发挥作用。
4.2 串行执行换来了什么
核心数据结构修改由主线程串行完成,一条普通命令执行期间,不会被另一个客户端的普通命令插进来修改同一份数据。这带来两项收益:
- 命令实现不需要围绕全局 Key 空间和每种数据结构铺设复杂锁;
- 单条命令天然具备不可被普通命令拆开的原子执行边界。
例如两个客户端同时执行:
redis
INCR product:view:1001
服务端仍会排成先后两次完整的 INCR,不会让两次"读旧值---加一---写新值"交叉而丢失更新。
但要立刻补上边界:单条命令原子,不等于多条命令组成的业务流程原子。 客户端先 GET 库存、判断后再 DECR,两条命令之间仍可能插入其他客户端操作。Pipeline 只批量传输,也不解决这个问题;事务、Lua 和 Functions 留到第 7 篇展开。
4.3 写命令执行完还要传播
写命令修改内存数据后,Redis 还需要根据配置把操作传播到 AOF 和副本,并更新命令统计、慢日志、监控等信息。通常传播是写入相关缓冲或复制流,不代表每条命令都等待磁盘真正落盘、也不代表所有副本都已执行成功。
这就是为什么"主节点返回成功"与"数据已经永久落盘""副本绝不会丢这次写入"不是同一句话。AOF 刷盘策略、主从异步复制和故障窗口,会在《Redis 05 · 持久化:RDB 与 AOF 怎么保证数据不丢》和《Redis 08 · 主从复制:全量同步、增量同步与复制延迟》中继续拆。
命令函数生成响应后,生命周期仍未结束。响应先进入 Redis 管理的客户端缓冲,能不能立即发完,要看 Socket 和对端读取速度。
五、响应写回:命令完成不等于客户端已经收到
假设 HGET 找到价格 39900,Redis 会把它编码为 RESP 响应并加入客户端输出缓冲。事件循环会尝试把待发送内容写入 Socket;如果内核发送缓冲暂时满了,非阻塞 write 不会一直等,而是保留未发送部分并关注后续可写事件。
text
命令执行结果
↓
客户端输出缓冲
↓ Socket 当前可写多少就发送多少
内核发送缓冲
↓
网络
↓
客户端读取与解码
因此,服务端执行完命令到客户端真正收到之间仍有距离。这个距离会受响应大小、网络带宽、客户端读取速度和 TCP 拥塞影响。
5.1 慢消费者也会拖累 Redis
如果客户端只发请求却迟迟不读响应,Redis 只能暂存未发送数据,客户端输出缓冲会持续增长。一个连接的几 MB 看起来不多,乘上大量慢连接就会吞掉可观内存;复制和 Pub/Sub 这类持续推送连接尤其要关注积压。
Redis 提供不同客户端类别的输出缓冲限制。达到硬限制,或在规定时间内持续超过软限制时,服务器可以断开客户端保护自身。具体阈值应根据普通请求、Pub/Sub、副本流量分别设计,而不是全部设成"永不限制"。
5.2 大响应不只是网络问题
对大 Hash 执行 HGETALL、对大 Set 执行 SMEMBERS、用 LRANGE 0 -1 取完整历史,会同时产生多段成本:
- 主线程遍历和编码大量元素;
- 服务端为响应分配输出缓冲;
- Socket 长时间发送大包;
- 客户端分配内存并反序列化;
- 其他请求在主线程执行和网络带宽上受到影响。
所以"命令查询复杂度不高"不能推出"接口一定安全"。返回多少数据,本身就是复杂度的一部分。 生产接口必须分页或限制范围,不能把 Redis 当成可以无边界返回集合的本地 Map。
默认情况下,读取请求、解析协议、执行命令和写响应大多由主线程推进。网络吞吐成为瓶颈时,Redis 6 开始允许 I/O 线程分担部分工作,但分工边界比"Redis 变成多线程"准确得多。
六、Redis 6 I/O 线程:并行了什么,没有并行什么
Redis 6 引入可配置的客户端 I/O 线程,目的不是让多条命令并行修改数据,而是把比较耗 CPU 的 Socket 读写和部分协议解析分给其他线程。
官方配置中的两个关键项是:
conf
io-threads 4
io-threads-do-reads no
默认 I/O 线程关闭,也就是 io-threads 等效为只使用主线程。配置多个 I/O 线程后,默认主要并行处理 Socket 写出;把 io-threads-do-reads 设为 yes,还可以让线程分担读取和协议解析。

text
多个客户端
│
├── I/O thread 1:读取/解析、写响应 ──┐
├── I/O thread 2:读取/解析、写响应 ──┼──→ 主线程:命令执行
└── I/O thread 3:读取/解析、写响应 ──┘ │
▼
核心数据结构
6.1 为什么命令执行不顺便并行
一旦允许多线程同时修改 Key 空间、过期字典、内存统计、复制状态和各种复合结构,就需要引入锁、分片或更复杂的并发控制。短小内存命令原本只需几十微秒,锁竞争、缓存一致性流量和调度成本未必划算,还会破坏当前清晰的原子执行模型。
Redis 的选择是:网络 I/O 可以按客户端拆分,比较容易并行;核心命令仍在单一顺序中执行,保持数据结构实现简单。需要更多命令执行 CPU 时,通常通过多个实例、读写分离或 Cluster 分片横向扩展,而不是给单实例主执行路径无限加线程。
6.2 什么场景开启 I/O 线程才有帮助
I/O 线程更可能帮助这些场景:
- 实例有较高网络吞吐,大量 CPU 花在 Socket 读写和协议解析;
- 命令本身很轻,但连接数多、请求或响应体不算小;
- 机器有足够 CPU 核心,并且实测主线程网络处理已经接近瓶颈。
它解决不了这些问题:
KEYS、大范围集合运算、长 Lua 脚本占满主线程;- 单个热 Key 的业务争用和数据模型不合理;
- 内存不足、Swap、持久化 fork 抖动或磁盘延迟;
- 跨地域 RTT 和客户端连接池等待。
官方配置也明确建议:只有实例确实出现性能问题且 CPU 使用较高时再启用,并保留核心给主线程和系统;I/O 线程数量不是越多越好。Redis 7.4 配置还注明 io-threads 不能通过 CONFIG SET 在线修改,SSL 场景不使用这项能力。升级到具体 Redis 8.x 版本时,应再次核对对应发行版配置说明。
版本提示:I/O 线程从 Redis 6 引入。本文以 Redis 7.4 的核心线程模型和官方配置为基准,并与 Redis 8.0 的主命令串行边界对齐;不同小版本可能调整批处理和线程协调实现,但"网络 I/O 可并行、核心命令执行仍以主线程串行为主"是本系列关注的共同主线。
判断是否开启前,先用压测和 CPU profile 证明瓶颈在网络处理。若主线程时间主要花在命令函数里,加 I/O 线程只会增加协调开销,治不了根因。
七、谁会真正阻塞主线程
既然主线程串行执行命令,一条命令占用 100 毫秒,其他已经到达的命令就只能在后面等。这种排队会放大尾延迟:前面一个慢请求,后面可能不是一个请求变慢,而是一整批连接同时变慢。

7.1 容易占住主线程的操作
常见风险包括:
- 在大键空间执行
KEYS *; - 对超大集合执行
SMEMBERS、HGETALL或大范围集合运算; - 一次同步删除包含海量元素的大 Key;
- 运行时间不可控的 Lua、Functions 或模块命令;
- 一次生成、复制或发送巨量响应;
- 编码转换、扩容等边界写入恰好处理大量元素。
命令文档里的 O(N) 必须把 N 替换成线上规模。即使标称 O(1),也要看单元素大小、底层转换和响应字节数。DEL big-key 的参数只有一个 Key,不代表释放它内部百万个节点也是固定成本;对可异步释放的场景可评估 UNLINK,让后台线程承担实际回收工作。
7.2 为什么 BLPOP 不会把主线程卡住
BLPOP queue 30 看起来会"阻塞 30 秒",却不是让主线程在函数里睡 30 秒。队列为空时,Redis 记录客户端在等待哪个 Key,把客户端标记为 blocked,然后结束本次处理,事件循环继续服务其他连接。
text
BLPOP 到达 → 队列为空 → 登记等待关系 → 挂起这个客户端
│
其他客户端正常执行 ← 事件循环继续运行 │
│
LPUSH 到达 → 唤醒等待者 → 返回元素 ────────┘
超时到达或新元素写入时,Redis 再让该客户端恢复执行并返回结果。因此要区分:
- 阻塞客户端的命令:客户端暂时没有响应,但主线程可以继续处理别人;
- 阻塞事件循环的慢命令:主线程一直在计算、遍历或释放,所有客户端一起等。
这是面试中很容易答反的一点。BLPOP、XREAD BLOCK 等命令的"blocking"描述的是调用方等待语义,不等于服务器线程被 sleep 占住。
7.3 SCAN 也不是零成本
SCAN 把全量遍历拆成多次渐进调用,避免单次像 KEYS 一样扫完整个键空间,但每一轮仍要访问字典并返回结果,COUNT 也只是工作量提示而非精确条数保证。
正确用法是控制每批大小、调用频率和并发度,并完整处理游标;错误用法是几十个任务同时高频 SCAN,然后因为"它不阻塞"就认为没有影响。渐进式操作降低的是单次最坏停顿,不会把总工作量变成零。
知道谁会卡住主线程后,线上延迟问题就可以按生命周期分段定位,而不是看到 Redis 慢就先调 io-threads。
八、生产优化与排障:慢到底发生在哪一段
延迟排查最怕只看一个应用耗时数字。建议沿本文的链路逐层缩小范围。
8.1 先区分服务端执行慢还是端到端慢
Redis Slow Log 记录命令在服务端执行阶段超过阈值的事件,不包含客户端网络传输和发送响应等 I/O 时间。可以先查看:
redis
SLOWLOG LEN
SLOWLOG GET 20
如果应用报告 100 毫秒,而 Slow Log 没有对应慢命令,不能据此断言 Redis 完全正常,但排查重点应转向:主线程排队、网络 RTT、客户端连接池、DNS、应用 GC、大响应写出或 Slow Log 配置阈值是否合适。
如果 Slow Log 明确出现 HGETALL、集合运算或脚本,就回到数据规模和命令实现处理根因。不要只把阈值调高让告警消失。
8.2 再看命令、事件循环和客户端状态
常用观测入口包括:
redis
INFO commandstats
INFO latencystats
LATENCY LATEST
LATENCY DOCTOR
CLIENT LIST
commandstats帮助发现某类命令调用量和累计 CPU 时间异常;latencystats提供命令延迟分布信息,具体字段取决于版本;- Latency Monitor 用于记录 Redis 识别的延迟事件,使用前要按需求配置监控阈值;
CLIENT LIST可以观察阻塞、输出缓冲和连接状态,字段随版本可能变化。
客户端侧还可以使用 redis-cli --latency 观察到实例的往返延迟。它看到的是探测请求的端到端现象,不能替代 Slow Log;两者组合,才能区分"命令执行慢"和"往返慢"。
8.3 用症状快速定位方向
| 现象 | 优先检查 |
|---|---|
| 某一种命令持续很慢 | Slow Log、命令复杂度、Key 基数、响应大小、编码转换 |
| 所有命令偶发一起尖刺 | 主线程慢命令、fork、Swap、宿主机调度、持久化与事件循环延迟 |
| Redis 指标正常,只有某应用慢 | 连接池、客户端 GC、网络路径、超时与重试 |
| 输出缓冲持续增长 | 慢消费者、Pub/Sub/副本积压、大响应、网络带宽 |
| CPU 高但网络吞吐不高 | Lua、集合运算、热 Key、命令本身或后台维护工作 |
| 网络 CPU 高、命令都很轻 | Pipeline、批次大小、连接模型,再评估 I/O 线程 |
8.4 设计阶段就缩短每一段
比故障后调参数更有效的,是在设计阶段控制最坏路径:
- 客户端设置连接、读取和连接池等待超时,写操作重试必须幂等;
- 使用 Pipeline 摊薄 RTT,但限制每批命令数和总响应字节;
- 禁止无边界全量命令,按范围、分页或渐进扫描;
- 大 Key 删除优先评估异步释放,脚本必须有可证明的规模上限;
- 监控 P99/P999、Slow Log、输出缓冲和事件循环尖刺,不只看平均值;
- 只有确认网络处理是瓶颈后,再压测
io-threads的收益。
最终可以用一句话回答"Redis 的单线程模型":Redis 用事件循环和 I/O 多路复用管理大量非阻塞连接,把完整命令交给主线程串行操作核心数据结构;Redis 6+ 可以让 I/O 线程并行分担网络读写与可选协议解析,但不会把普通命令变成并行执行。
这一篇把 Redis 的"快"展开成了一条具体路径:多路复用先从大量 Socket 中找出就绪连接,查询缓冲和 RESP 解析把 TCP 字节流还原成命令,主线程完成校验并串行访问核心数据结构,响应再经输出缓冲和非阻塞 Socket 返回客户端。任何一段排队、放大或阻塞,都会出现在应用的总耗时里。
带走四句话:① I/O 多路复用复用的是等待大量 Socket 的线程,不是让命令并行计算;② Pipeline 减少网络往返,但不提供并行和原子性;③ Redis 6 I/O 线程主要分担 Socket 读写和可选协议解析,核心命令仍由主线程串行执行;④ BLPOP 阻塞的是调用客户端,长 Lua、大范围遍历和大 Key 处理才会真正占住事件循环。
下一篇,我们从"命令执行完"继续追问到"机器宕机后数据还在不在"------《Redis 05 · 持久化:RDB 与 AOF 怎么保证数据不丢》。RDB 快照、AOF 追加、fsync、AOF 重写和混合持久化分别解决什么问题,又会怎样反过来影响主线程延迟?