REDIS · EVENT LOOP · REACTOR · EPOLL · IO THREADS · PIPELINE

一个服务员,靠一块呼叫铃面板,照看上万张桌子

Redis 执行命令只用一个线程。它能同时服务上万个连接,靠的是 IO 多路复用:把所有连接交给操作系统的 epoll 盯着,哪个连接有数据来了,epoll 就告诉它,它再一个一个去处理。命令本身都在内存里,每条只要几微秒,所以一个线程也忙得过来。

上万个客户端连接,大部分时间都没数据

epoll:呼叫铃面板
fd 17fd 28fd 36

只把有数据的连接交出来

主线程:一个一个执行
SET k vGET aINCR n

同一时刻只执行一条命令,所以每条命令天然是原子的

主线程event loop

读请求、执行所有命令、写回复、跑定时任务,都是它。

它卡一下,所有人都卡
呼叫铃epoll

操作系统帮忙盯着所有连接,只报告有动静的。

macOS 上是 kqueue
连接socket · fd

每个客户端一个,设成非阻塞,读不到就立刻返回。

默认最多 10000 个
帮手bio · I/O 线程

后台释放内存、刷盘,6.0 起还能帮忙读写网络。

但从不执行命令

一个线程,为什么还这么快

单实例常见每秒十万级请求。瓶颈一般在网络和内存,不在 CPU。

全在内存里一次操作几十到几百纳秒,不用等磁盘。
没有锁只有一个线程改数据,不用加锁,也没有锁竞争。
没有线程切换不用在成千上万个线程之间来回切,CPU 缓存也更热。
IO 多路复用一个线程就能等所有连接,谁来数据处理谁,不空等。
一次请求的时间花在哪(数量级示意,对数刻度:左端 100 纳秒,右端 1 秒)
执行一条 GET
约 1 µs
一次系统调用 read / write
约 1~5 µs
同机房网络来回
约 0.1~0.5 ms
一条 KEYS *(百万 key)
几百 ms

条长每多七分之一,时间就多 10 倍。正常命令执行的时间远小于网络来回,所以加线程去执行命令帮不上多少;一条慢命令却能顶几十万条正常命令。

IO 多路复用:一个服务员 + 呼叫铃

网络读写的老办法是一个连接一个线程,线程调 read 等数据,没数据就一直睡着。连接一多,线程就多到扛不住。多路复用是另一个思路:一个线程把所有连接交给内核,内核告诉它哪些可以读了。

一桌一个服务员(阻塞 IO)

点菜干等干等干等干等干等

1 万个连接要 1 万个线程,每个线程一份栈内存,大部分时间在睡觉,切换起来还很费 CPU。

一个服务员 + 呼叫铃(多路复用)

按铃按铃
面板亮了:桌 1、桌 4 → 服务员只去这两桌

一个线程,调一次 epoll_wait,拿到所有“有数据的连接”,逐个处理完再回来等。

select、poll、epoll:每次等待要做多少活
连接数:
其中有数据的:10 个
selectpollepoll
最多盯多少连接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遍历全部 keySCAN 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 其实不止一个线程

    “单线程”说的是执行命令的只有一个线程。别的活早就有帮手了,只是它们从不碰命令执行。

    很早就有 · bio 后台线程关文件、AOF 刷盘、释放内存AOF 每秒一次的 fsync 在这里做;4.0 起 UNLINK、FLUSHALL ASYNC、惰性删除的内存也在这里释放。
    fork · 子进程写 RDB、重写 AOF不是线程,是复制出来的子进程,靠写时复制共享内存(Redis 第 02 篇)。
    6.0 起 · I/O 线程帮忙读请求、写回复默认关闭。打开后,读 socket、解析协议、写回复可以多个线程并行,命令仍由主线程一条条执行。
    打开 I/O 线程后,一轮事件循环怎么分工
    ① 分发连接② 读 + 解析③ 执行命令④ 写回复 主线程分给各线程也分一份全部由它也分一份 I/O 线程 1等并行等并行 I/O 线程 2等并行等并行 I/O 线程 3等并行等并行

    网络读写占大头时(连接多、value 大)才有明显收益;小命令为主时提升有限。Redis 8 又重写了 I/O 线程的实现,但命令执行仍是单线程。

    redis.confRedis 6 / 7
    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 把很多条命令一次发过去,回复也一次拿回来。

    发 100 条 SET,网络来回 0.5 ms(按同一比例画)
    一条一条发
    ≈ 50 ms
    Pipeline
    ≈ 0.6 ms

    每段浅色是一次网络来回,深色是执行。Pipeline 还减少了系统调用次数,Redis 一次 read 就能读到一大批命令。

    PipelineMULTI / 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 那边仍是一个线程在执行命令,慢命令照样堵所有人
    没变的:命令单线程执行epoll 多路复用慢命令堵所有人Pipeline 省来回

    常踩的坑

    单线程的代价,就是任何一件慢事都会让所有人一起等。

    有人在线上执行了 KEYS *

    遍历几百万个 key,整个实例卡住几百毫秒到几秒。用 SCAN;还可以用 rename-command KEYS "" 或 ACL 禁掉它。

    删一个大 key,接口超时一片

    DEL 在主线程里一个个释放元素。用 UNLINK,或打开 lazyfree-lazy-user-del(Redis 第 05 篇)。

    BUSY Redis is busy running a script

    Lua 脚本跑太久,其他命令只能收到这个错误。脚本里别遍历大量 key;只读脚本可以 SCRIPT KILL,写过数据的只能 SHUTDOWN NOSAVE。

    开了 io-threads,QPS 没怎么涨

    I/O 线程只并行网络读写,不并行执行命令。小命令为主时网络不是瓶颈;线程数也别超过核数。想多用几个核,就多开实例或上集群。

    循环里一条条 GET,接口慢得离谱

    每条命令一次网络来回。用 MGET、HMGET,或者 Pipeline 一次发出去。

    16 核的机器,Redis 的 CPU 只到 100%

    命令执行只用一个核。要用满机器,就在一台机器上跑多个实例,或者用集群分片(Redis 第 03 篇)。

    ERR max number of clients reached

    默认最多 10000 个连接。几十个应用实例各配一个大连接池,很快就满。用 Lettuce 共享连接,或者把池子调小。

    Jedis 多线程共用一个连接,读到别人的回复

    Jedis 连接不是线程安全的。用 JedisPool,或者换 Lettuce。

    在 Lettuce 共享连接上用了 BLPOP,所有请求都卡住

    阻塞命令会占住这个连接。走 Spring 的 RedisTemplate 时会自动换独立连接;直接用 Lettuce 原生 API 时,要自己单独开连接。

    客户端说慢,SLOWLOG 里什么都没有

    慢日志只算执行时间。慢在排队(前面有慢命令)、网络、fork 停顿或客户端自己的线程池上。用 LATENCY DOCTOR 和客户端监控一起查。