Skip to content

事件循环与线程模型:单线程为什么快,多线程 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 RESET

SLOWLOG 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 MonitoringRedis 6.0 多线程 IO 设计说明

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1