REDIS · EVENT LOOP · REACTOR · EPOLL · IO THREADS · PIPELINE
一个服务员,靠一块呼叫铃面板,照看上万张桌子
Redis 执行命令只用一个线程。它能同时服务上万个连接,靠的是 IO 多路复用:把所有连接交给操作系统的 epoll 盯着,哪个连接有数据来了,epoll 就告诉它,它再一个一个去处理。命令本身都在内存里,每条只要几微秒,所以一个线程也忙得过来。
上万个客户端连接,大部分时间都没数据
只把有数据的连接交出来
同一时刻只执行一条命令,所以每条命令天然是原子的
event loop读请求、执行所有命令、写回复、跑定时任务,都是它。
它卡一下,所有人都卡epoll操作系统帮忙盯着所有连接,只报告有动静的。
macOS 上是 kqueuesocket · fd每个客户端一个,设成非阻塞,读不到就立刻返回。
默认最多 10000 个bio · I/O 线程后台释放内存、刷盘,6.0 起还能帮忙读写网络。
但从不执行命令一个线程,为什么还这么快
单实例常见每秒十万级请求。瓶颈一般在网络和内存,不在 CPU。
条长每多七分之一,时间就多 10 倍。正常命令执行的时间远小于网络来回,所以加线程去执行命令帮不上多少;一条慢命令却能顶几十万条正常命令。
IO 多路复用:一个服务员 + 呼叫铃
网络读写的老办法是一个连接一个线程,线程调 read 等数据,没数据就一直睡着。连接一多,线程就多到扛不住。多路复用是另一个思路:一个线程把所有连接交给内核,内核告诉它哪些可以读了。
一桌一个服务员(阻塞 IO)
1 万个连接要 1 万个线程,每个线程一份栈内存,大部分时间在睡觉,切换起来还很费 CPU。
一个服务员 + 呼叫铃(多路复用)
一个线程,调一次 epoll_wait,拿到所有“有数据的连接”,逐个处理完再回来等。
| select | poll | epoll | |
|---|---|---|---|
| 最多盯多少连接 | 1024(FD_SETSIZE) | 不限 | 不限 |
| 每次调用要把列表交给内核 | 是,整张位图拷进拷出 | 是,整个数组拷进拷出 | 不用,事先用 epoll_ctl 登记一次 |
| 怎么找到有数据的 | 自己挨个检查 | 自己挨个检查 | 内核直接返回就绪的那几个 |
| 每次的代价 | O(连接数) | O(连接数) | O(就绪数) |
Redis 编译时自动选最好的:Linux 用 epoll,macOS 和 BSD 用 kqueue,都没有才退回 select。Redis 用的是 epoll 的水平触发:没读完的数据,下次还会再报。
事件循环:主线程一圈一圈在干什么
Redis 自己写了一个很小的事件库 ae。主线程就是一个死循环:等事件、处理事件、跑定时任务,再等下一轮。一步步看 5 个客户端是怎么被一个线程服务的。
一个线程服务 5 个客户端
高亮的是这一步在执行的那一行。真实代码在 ae.c 的 aeMain / aeProcessEvents。
一条慢命令,所有人一起等
只有一个线程执行命令,所以一条慢命令执行期间,后面到的所有请求只能排队。200 毫秒里,每 5 毫秒来一个普通请求(每个只要 0.05 毫秒),第 40 毫秒混进来一条慢命令。看每个普通请求最后等了多久。
| 危险命令 | 为什么慢 | 换成 |
|---|---|---|
| KEYS pattern | 遍历全部 key | SCAN 0 MATCH … COUNT 100 分批 |
| DEL 大集合、FLUSHALL | 一个个释放元素 | UNLINK、FLUSHALL ASYNC,交给后台线程释放 |
| HGETALL / SMEMBERS / LRANGE 0 -1 | 集合多大就要返回多少 | HSCAN / SSCAN,或者只取一段 |
| SORT、SUNION、ZUNIONSTORE | 要计算整个集合 | 放到从节点上跑,或者在应用里算 |
| 耗时很长的 Lua 脚本 | 脚本执行期间不能插入别的命令 | 拆小;超过 busy-reply-threshold(默认 5 秒)后只能 SCRIPT KILL |
找慢命令:SLOWLOG GET 10(默认记录执行超过 10 毫秒的,最多 128 条)。注意它只算执行时间,不算排队和网络;客户端觉得慢、慢日志里却没有,就要看 LATENCY DOCTOR 和网络。
Redis 其实不止一个线程
“单线程”说的是执行命令的只有一个线程。别的活早就有帮手了,只是它们从不碰命令执行。
网络读写占大头时(连接多、value 大)才有明显收益;小命令为主时提升有限。Redis 8 又重写了 I/O 线程的实现,但命令执行仍是单线程。
io-threads 4 # 默认 1,即不开;4 核机器建议 2~3,8 核建议 6,不要超过核数
io-threads-do-reads yes # 默认只用 I/O 线程写回复,打开后读也并行
lazyfree-lazy-user-del yes # 让普通 DEL 也像 UNLINK 一样在后台释放(6.0 起)
省掉来回:Pipeline
执行一条命令只要 1 微秒,网络来回却要几百微秒。一条一条发,时间几乎全花在路上。Pipeline 把很多条命令一次发过去,回复也一次拿回来。
每段浅色是一次网络来回,深色是执行。Pipeline 还减少了系统调用次数,Redis 一次 read 就能读到一大批命令。
| Pipeline | MULTI / EXEC 事务 | Lua 脚本 | |
|---|---|---|---|
| 减少网络来回 | 是 | 命令照样一条条发,常和 Pipeline 一起用 | 是,一次发过去 |
| 中间不插别人的命令 | 不保证 | 保证 | 保证 |
| 能用上一条的结果 | 不能 | 不能(只能 WATCH 乐观锁) | 能 |
| 中途出错 | 出错的那条报错,其余照常 | 不回滚,已执行的不撤销 | 不回滚,已执行的不撤销 |
| 集群下 | 客户端按节点拆开发 | key 要在同一个槽 | key 要在同一个槽 |
Java 这边:JDK 8 和 JDK 17
Java 客户端也在用同一套多路复用。Lettuce 基于 Netty,Netty 在 Linux 上底下就是 epoll。
Spring Boot 2
JDK 8+- 默认客户端 Lettuce:一个连接多个线程共用,命令自然排成流水线,一般不需要连接池
- Jedis 一个连接不能多线程共用,要配 JedisPool
- BLPOP 这类阻塞命令、MULTI 事务,Spring 会给它们单独拿一个连接,免得堵住共享连接
- Java NIO 的 Selector 在 Linux 上的实现就是 epoll;Tomcat 的 NIO 连接器也靠它
Spring Boot 3
JDK 17+- 客户端用法不变,配置前缀改成
spring.data.redis.* - JDK 21 虚拟线程:写的是阻塞代码,JDK 底下把等待中的 socket 交给 epoll,线程本身被挂起不占平台线程
- 所以业务侧可以“一请求一虚拟线程”,Redis 那边仍是一个线程在执行命令,慢命令照样堵所有人
常踩的坑
单线程的代价,就是任何一件慢事都会让所有人一起等。
遍历几百万个 key,整个实例卡住几百毫秒到几秒。用 SCAN;还可以用 rename-command KEYS "" 或 ACL 禁掉它。
DEL 在主线程里一个个释放元素。用 UNLINK,或打开 lazyfree-lazy-user-del(Redis 第 05 篇)。
Lua 脚本跑太久,其他命令只能收到这个错误。脚本里别遍历大量 key;只读脚本可以 SCRIPT KILL,写过数据的只能 SHUTDOWN NOSAVE。
I/O 线程只并行网络读写,不并行执行命令。小命令为主时网络不是瓶颈;线程数也别超过核数。想多用几个核,就多开实例或上集群。
每条命令一次网络来回。用 MGET、HMGET,或者 Pipeline 一次发出去。
命令执行只用一个核。要用满机器,就在一台机器上跑多个实例,或者用集群分片(Redis 第 03 篇)。
默认最多 10000 个连接。几十个应用实例各配一个大连接池,很快就满。用 Lettuce 共享连接,或者把池子调小。
Jedis 连接不是线程安全的。用 JedisPool,或者换 Lettuce。
阻塞命令会占住这个连接。走 Spring 的 RedisTemplate 时会自动换独立连接;直接用 Lettuce 原生 API 时,要自己单独开连接。
慢日志只算执行时间。慢在排队(前面有慢命令)、网络、fork 停顿或客户端自己的线程池上。用 LATENCY DOCTOR 和客户端监控一起查。