主题
事件循环与线程模型:单线程为什么快,多线程 IO 是什么
本文是 Redis 系统学习系列的 L2 核心篇。前置:无。 学完可以配合面试题食用:08 为什么快、19 多线程 IO
单线程事件循环的骨架
Redis 启动后,主线程只做一件事:循环等待事件、处理事件。这个循环叫 aeMain,源码在 ae.c。
核心流程一句话概括:epoll_wait 拿到就绪事件 -> 按类型分发到回调函数 -> 处理完继续等下一批。
┌─────────────────────────────────────────────────┐
│ aeMain loop │
│ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 等待事件 │───▶│ 事件分发 │───▶│ 处理完成 │ │
│ │ epoll_wait│ │ read/write │ │ 回到等待 │ │
│ └──────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────┘四种事件类型:
- AE_READABLE:客户端发命令来了,触发读事件
- AE_WRITABLE:输出缓冲区可写,触发写事件
- AE_TIMER:定时任务(过期 key 清理、rehash 步进等)
- AE_BARRIER:保证读写顺序(先写后读,防止写事件饿死)
这个循环里没有任何「并发调度」——没有线程池,没有任务队列,没有锁。命令解析、执行、返回结果,全在同一个线程里串行完成。
单线程为什么还快
多数人第一次听到"Redis 单线程"会质疑:一个线程扛几十万 QPS?理解这需要看它省掉了什么。
| 对比项 | 多线程服务端 | 单线程 Redis |
|---|---|---|
| 上下文切换 | 线程切换有用户态/内核态切换成本 | 零 |
| 锁竞争 | 共享数据结构需要锁,CAS 或互斥锁有开销 | 不存在,不需要锁 |
| 缓存局部性 | 线程切换导致 cache miss | 大部分数据在 L1/L2 cache 命中 |
| 调度复杂度 | 线程调度本身消耗 CPU | 一个线程,无调度开销 |
加上 Redis 的核心操作都在内存中完成(一条 GET 命令就是一次 hash 查找 + 内存拷贝),真正的瓶颈不在 CPU 而是在网络 IO 和内存带宽。一个多线程的 Redis 在同样硬件上 CPU 跑得更满,但吞吐量不会成倍提升,反而因为锁、上下文切换有了额外开销。
Redis 单线程的瓶颈:峰值 QPS 受限于内存响应速度和网卡带宽,而不是 CPU 核数。这解释了为什么批量操作(mget/pipeline)比单条逐次发送吞吐更高——它们在减少网络往返这个真正的瓶颈。
哪些操作会阻塞事件循环
单线程意味着:如果某个命令执行时间太长,后面的所有命令都要排队等。下面这些操作是典型的"事件循环阻塞器":
慢命令(O(N) 操作)
KEYS *:遍历所有 key,百万级 key 的实例直接卡住几秒SMEMBERS:返回集合所有元素,大集合可能卡住HGETALL:同上,大 Hash 的噩梦LRANGE 0 -1:全量返回列表SORT:带有 BY 的复杂排序
大 key 操作
- 删除一个含有百万字段的 Hash:
DEL会 O(N) 删除所有元素(Redis 4.0 后用UNLINK异步删除解决) - 批量过期:同一秒内大量 key 过期,Redis 维护的过期字典会密集扫描与删除
阻塞性命令
BLPOP/BRPOP:阻塞等待列表元素,持有连接XREAD BLOCK:阻塞等待 Stream 消息SUBSCRIBE:订阅模式,该连接进入 Pub/Sub 模式后不再处理其他命令
系统级阻塞
BGSAVE/BGREWRITEAOF:fork 子进程时,fork 本身会阻塞父进程(大实例 fork 耗时可达百毫秒级)- 慢查询日志和延迟监控可以帮你定位这些阻塞点。
定位阻塞:Slowlog 和 Latency Monitor
Slowlog 实操
配置阈值(微秒):
bash
# 设置超过 10000 微秒(10ms)的命令记入 slowlog
CONFIG SET slowlog-log-slower-than 10000
# 最多保留 128 条
CONFIG SET slowlog-max-len 128查看慢查询:
bash
# 获取最近 10 条
SLOWLOG GET 10
# 输出示例:
# 1) 1) (integer) 13 # 唯一 ID
# 2) (integer) 1725000000 # Unix 时间戳
# 3) (integer) 45000 # 耗时 45 毫秒
# 4) "HGETALL" # 命令
# 5) "user:profile:10086" # key
# 6) "127.0.0.1:6379" # 客户端地址
# 7) "user-agent" # 客户端名称
# 清空 slowlog
SLOWLOG RESETSLOWLOG GET 告诉你哪条命令、哪个 key、耗时多少。如果 45ms 的 HGETALL 频繁出现,说明这个 Hash 太大了,需要拆分成多个小 Hash 或用 HSCAN 分批读取。
Latency Monitor 实操
Redis 内置了延迟监控,可以记录事件循环中超过阈值的延迟事件:
bash
# 启用:阈值 100ms
CONFIG SET latency-monitor-threshold 100
# 查看所有延迟事件
LATENCY LATEST
# 示例输出:
# 1) 1) "command" # 事件类型
# 2) (integer) 250 # 最近一次延迟峰值 250ms
# 3) (integer) 210 # 运行期间最大延迟 210ms
# 4) (integer) 3 # 发生次数
# 查看详细历史
LATENCY HISTORY command
# 1) 1) (integer) 1725000000
# 2) (integer) 250
# 2) 1) (integer) 1725000010
# 2) (integer) 110
# 查看图形报告
LATENCY GRAPH command常用的延迟事件类型:
command:命令执行超时fast-command:快速命令超时(通常意味着系统层面问题)fork:fork 子进程阻塞expire-cycle:过期 key 清理周期eviction-del:淘汰策略删除 key 时阻塞
Redis 6.0 多线程 IO:范围与边界
很多人误以为 Redis 6.0 变成了多线程。实际上,它只做了一个很小的改动:把网络 IO 的 read/write 系统调用放到了其他线程并行执行,命令执行仍然是单线程的。
┌──────────────────────────────────────────────────────┐
│ Redis 6.0+ 线程模型 │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ IO 线程 1 │──────▶ │ │
│ │ (read/write) │ │ │ │
│ └──────────────┘ │ 主线程(单线程) │ │
│ ┌──────────────┐ │ 命令解析 + 执行 + 回复 │ │
│ │ IO 线程 2 │──────▶ │ │
│ │ (read/write) │ └──────────────────────┘ │
│ └──────────────┘ │
│ ... 最多 128 个 IO 线程 │
└──────────────────────────────────────────────────────┘IO 线程只做一件事:把客户端请求数据从 socket 读到输入缓冲区,或者把输出缓冲区的数据写到 socket。数据在输入缓冲区里,主线程再去解析命令、执行、写结果到输出缓冲区,然后 IO 线程把结果发回给客户端。
为什么只改 IO 不改命令执行?
- 原子性不变:所有命令仍然是单线程执行的,所以
INCR不需要加锁,MULTI/EXEC事务语义不变 - 瓶颈在 IO 不在 CPU:对于大多数业务场景,Redis 的 CPU 负载不到 50%,瓶颈是网络吞吐。并行 IO 可以榨干网卡带宽
- 实现简单:不需要为每个命令引入锁或事务管理器
配置方式:
bash
# 配置 IO 线程数(默认值 4,建议不要超过机器 CPU 核数)
CONFIG SET io-threads 4
# 是否启用 IO 线程处理写事件(默认 no)
CONFIG SET io-threads-do-reads yes注意:多线程 IO 对吞吐的提升在小包场景最明显(几百字节的 GET/SET),对大包(MB 级 value)或低 QPS 场景几乎无感。如果机器只有 2 核,开 4 个 IO 线程反而会因为线程切换拖慢性能。
常见误区与小结
常见误区
- "Redis 单线程所以不支持并发请求" —— 错。单线程执行命令,但客户端连接通过 epoll 多路复用,并发连接数可以上万,只是每条命令串行执行而已
- "Redis 6.0 支持多线程后,原子性消失了" —— 错。IO 线程只负责网络读写,命令执行还是单线程,
INCR仍然是原子的 - "IO 线程数设得越高越好" —— 错。IO 线程数超过 CPU 核数会导致线程切换开销超过收益,建议 2-4 个
- "KEYS 命令只是慢一点,问题不大" —— 错。百万 key 的 KEYS 可能阻塞几秒,期间所有请求排队,生产环境应用 SCAN 替代
- "慢查询只关命令的事" —— 不全对。fork 耗时、过期清理、大 key 删除都可能阻塞事件循环,这些不在 slowlog 里,但可以用 latency monitor 看到
小结
- 单线程事件循环 = aeMain + epoll_wait + 事件分发,省掉了锁和上下文切换的开销
- 瓶颈在 IO 不在 CPU,单线程能扛数十万 QPS
- 事件循环阻塞的源头:O(N) 命令、大 key 操作、系统级 fork
- Slowlog 和 Latency Monitor 是排查阻塞的两把必备工具
- Redis 6.0 多线程 IO 只并行网络读写,命令执行仍是单线程,对开发者而言原子性行为不变
下一篇讲 32. 持久化实践:RDB、AOF 与混合持久化的取舍,从事件循环切换到持久化策略的权衡。
参考
参考:Redis 源码
ae.c/networking.c(6.0 多线程 IO 实现入口)、Redis 官方文档——Latency Monitoring、Redis 6.0 多线程 IO 设计说明