买一台 32 核的服务器,装上 Redis,打开监控——你会看到 31 个核几乎在闲着。
这不是配置错了。Redis 由 Salvatore Sanfilippo(网名 antirez)在 2009 年发布,名字是 REmote DIctionary Server 的缩写,最初只是为了给他自己的实时分析产品 LLOOGG 解决写入瓶颈。从那一版起,所有客户端的所有命令,都由同一个线程按先来后到的顺序一条一条执行。
这个决定在当年就显得不合时宜——多核已经普及,同期的数据库都在往线程池的方向走。但它反而成了 Redis 最难被替代的地方:不是"虽然单线程但还挺快",而是"因为单线程所以能做到某些多线程系统做不到的事"。
这篇文章要讲清楚的是:它究竟买到了什么,代价记在谁的账上,以及在哪些时刻这笔买卖会亏本。
破除误解
误解一:Redis 快是因为数据在内存里。
内存只是必要条件,不是解释。同样在内存里,KEYS * 一样能把一台生产实例卡死几秒钟。真正的算式是这样的:官方延迟文档给出的数据是,Redis 处理一条普通命令的时间在亚微秒量级,而一次 1 Gbit 以太网往返约 200 微秒,Unix 域套接字也要 30 微秒左右。也就是说,在一条 GET 的整个生命周期里,"算"这一步占的时间不到千分之一,其余全花在网络和系统调用上。既然 CPU 根本不是瓶颈,加线程去抢 CPU 自然也换不来什么。单线程不是勇敢,是对自己瓶颈位置的准确判断。
误解二:Redis 是一个彻头彻尾的单线程进程。
官方文档的原话是 "Redis uses a mostly single threaded design"——注意那个 mostly。早在 2.4 版本,Redis 就有后台线程负责慢速磁盘操作(主要是 fsync 和关闭文件描述符);4.0 加了惰性释放线程;6.0 加了 I/O 线程。用 top -H 看一个跑着的 redis-server,永远不止一个线程。单线程指的只有一件事:修改数据的那一步。 这条边界一直没被打破,后面会看到,正是它撑住了整个模型。
误解三:Redis 6 引入 I/O 线程之后,它就变成多线程数据库了。
没有。I/O 线程搬走的是 read(2)/write(2) 系统调用和协议解析,命令执行仍然严格串行。所以打开 io-threads 之后,INCR 依然原子,Lua 脚本依然不会被打断,一条 KEYS * 依然会让所有客户端一起等。吞吐变了,并发语义一点没变——这正是这次改动最克制的地方。
一个请求在 Redis 里走过的路
Redis 的调度核心是一个不到一千行的事件库 ae.c,antirez 当年嫌 libevent 太重,自己写了一个。它在编译期按平台挑选底层机制:Linux 用 epoll,BSD 与 macOS 用 kqueue,Solaris 用 evport,都没有就退回 select。
I/O 多路复用(I/O multiplexing)是这里唯一的关键概念。打个比方:一栋楼有一万扇门,传统做法是每扇门配一个门卫(每连接一线程),绝大多数门卫整天无事可做,却要占编制、要轮班交接。多路复用的做法是撤掉门卫,装一块指示灯面板——谁按了门铃谁的灯亮,一个人盯着面板,哪盏灯亮就去开哪扇门。
select 和 epoll 的差别就在面板怎么做。select 每次调用都要把全部一万个文件描述符交给内核重新扫一遍,是 O(n) 的,而且 FD_SETSIZE 通常只有 1024。epoll(Linux 2.6,2003 年)改成内核侧长期维护一份注册表和一个就绪队列,调用时只返回真正有事的那几十个,代价与活跃连接数成正比,与总连接数无关。kqueue(FreeBSD 4.1,2000 年)是同一思路的更早版本。
主循环 aeMain 转起来是这样的:
epoll_wait 拿到就绪的 fd
→ readQueryFromClient 从 socket 读字节
→ processInputBuffer 按 RESP 协议切出命令
→ processCommand 查表、校验参数、执行
→ addReply 把回复写进客户端输出缓冲
beforeSleep(进入下一次等待之前)
→ 把输出缓冲刷给 socket
→ 把 AOF 缓冲 write 到文件
→ 处理到期的阻塞客户端
```除此之外还有一个定时任务 serverCron,频率由 hz 决定(默认 10,即每 100 毫秒一次),负责过期采样、哈希表渐进式 rehash、内存统计、复制心跳这些杂务。整个 Redis 的时间,就被切成"处理一批就绪事件 + 干一点后台杂务"的无限循环。
注意这个循环的性质:它没有抢占。 一条命令一旦开始执行,就必须跑完,中间没有任何机制能把它停下来让别人先走。这是后面所有优点和所有事故的共同来源。
单线程买到了什么
一、没有锁,也不需要有
多线程操作共享数据结构时,每一次哈希表插入、每一次链表摘除,都要在互斥锁的保护下进行。锁的开销不只是加锁解锁那几十纳秒,还有锁竞争时的线程挂起与唤醒、缓存行在多个核之间来回失效(false sharing)、以及为了减小锁粒度而引入的分段设计。
Redis 把这些全部略过了。它的字典、跳表、压缩列表全是普通的单线程数据结构,没有原子指令,没有内存序(memory ordering)问题,也没有 ABA 问题。省下的不只是运行时开销,更是正确性推理的成本——一个只有一个访问者的数据结构,不会有并发 bug。
二、没有上下文切换,缓存留得住
主线程长期霸占一个核,L1/L2 缓存里装的一直是它自己的数据结构。多线程模型下,线程被调度器换出再换回,缓存已经被别人冲刷干净了;线程在核之间迁移还会连 NUMA 局部性一起丢掉。这部分收益不写在任何基准测试的标题里,却实实在在。
三、原子性变成了免费赠品
这是最被低估的一条。因为不存在"两条命令交错执行"这种状态,每一条 Redis 命令天然是原子的:
INCR不需要 compare-and-swap,它就是原子的。SETNX(不存在才设置)可以直接当分布式锁的获取动作。LPUSH+BRPOP组成的队列不会丢消息,也不会被两个消费者同时取走。- Lua 脚本和 7.0 引入的 Functions 整段原子——脚本跑到一半时,没有任何别的命令能挤进来。这是 Redis 能当限流器、当去重器、当抢购扣减器的根基。
顺带解释一个常见困惑:Redis 的 MULTI/EXEC 事务为什么没有回滚?因为它压根不是隔离机制。MULTI 只是把命令攒成一批,EXEC 时一次性顺序执行;批内不会被打断这件事,是单线程送的,不是事务实现的。既然没有并发干扰要挡,也就没有"回滚到某个一致状态"的需求——命令要么语法错误在入队时就被拒,要么执行时报错但后面的照跑。
| 维度 | 单线程串行执行(Redis) | 多线程共享数据(典型关系库) |
|---|---|---|
| 数据结构 | 普通结构,无同步原语 | 需要锁/无锁结构 |
| 单命令原子性 | 天然具备 | 需显式加锁或事务 |
| 尾延迟来源 | 队头阻塞(一条慢命令拖垮全部) | 锁竞争、线程调度抖动 |
| 扩展方式 | 多进程 + 分片 | 加线程、加核 |
| 故障放大 | 一条坏命令影响所有客户端 | 通常局限在相关行/页 |
| 调试难度 | 可复现,顺序确定 | 竞态难复现 |
这个设计成立的两个前提
单线程不是普适真理,它挂在两根柱子上:
前提一:每个操作都足够快。 Redis 的数据结构选型全部围绕这一点——字符串 O(1)、哈希字段 O(1)、列表两端 O(1)、跳表范围查询 O(log n + m)。官方文档为每条命令都标注了复杂度,这不是学术洁癖,是安全须知。
前提二:瓶颈在网络与内存,不在 CPU。 官方 FAQ 里那句"CPU 很少成为 Redis 的瓶颈,Redis 通常受限于内存或网络",正是整个设计的前提声明。
于是横向扩展的答案也就定了:不是加线程,是加实例。一台 32 核机器上跑 8 个 Redis 进程,或者用 Redis Cluster(3.0,2015 年)把键空间切成 16384 个哈希槽分布到多个节点。每个实例内部依然是那个干净的单线程模型,复杂度被推到了分片层。这不是妥协,这是同一个设计的另一半。
什么时候这套模型会崩塌
前提一旦不成立,单线程的优点会瞬间翻转成缺点。因为没有抢占,任何一条慢命令都是全局停顿——这在排队论里叫队头阻塞,在运维现场叫"Redis 又挂了"。
大 key 与 O(n) 命令
官方延迟文档专门点名了 KEYS:它应当只用于调试。在一个有一千万键的实例上执行 KEYS user:*,主线程要遍历整个键空间,期间所有其他客户端全部排队。2.8 版本引入的 SCAN 是解药——它用游标做分批迭代,采用反向二进制迭代来保证在哈希表 rehash 期间也不漏不重,每次只返回一小撮键,把一次长停顿摊成许多次短停顿。
| 危险命令 | 复杂度 | 替代方案 |
|---|---|---|
KEYS pattern | O(n),n = 键总数 | SCAN 游标迭代 |
SMEMBERS bigset | O(n) | SSCAN 分批 |
HGETALL bighash | O(n) | HSCAN,或按业务拆成多个 key |
LRANGE key 0 -1 | O(n) | 分页取,限定区间 |
DEL bigkey | O(n),释放每个成员 | UNLINK(4.0+) |
FLUSHALL | O(n) | FLUSHALL ASYNC |
SORT/SUNIONSTORE 大集合 | O(n log n) 起 | 移到副本上跑,或改数据模型 |
大 key 是这里的头号杀手:一个装了两千万成员的 Set,读它慢,删它同样慢——DEL 要逐个释放内存对象。antirez 在 2015 年的《Lazy Redis is better Redis》里给出了答案:4.0 引入 UNLINK 和一组 lazyfree-* 选项,把内存释放交给后台线程,主线程只需把 key 从字典里摘下来(O(1))。但要注意两点:这些选项默认全是 no,需要自己打开;而且它只解决删除,创建和读取大 key 依然堵在主线程上。
大批键在同一秒过期
Redis 清理过期键有两条路:被动(访问到才发现过期)和主动(定时循环)。主动循环每 100 毫秒跑一次,每轮随机采样 20 个设置了过期时间的键,删掉其中已过期的;如果发现超过 25% 已过期,就立刻再来一轮。正常情况下这每秒只清理约 200 个键,毫无存在感。但如果业务用 EXPIREAT 把几十万个键设成同一个整点过期,这个自适应循环就会连续空转,把主线程拖住。缓存 key 的过期时间要加随机抖动,理由就在这里——它同时也是防缓存雪崩的做法。
fork 抖动
后面讲持久化时会看到,Redis 靠 fork() 出子进程来写盘。而 fork 本身要复制父进程的页表。官方文档算过一笔账:Linux/AMD64 上内存页是 4 KB,一个 24 GB 的实例需要 24 GB / 4 KB × 8 字节 = 48 MB 的页表,fork 时这 48 MB 必须现场分配并拷贝,这段时间主线程完全停住。
官方给出的实测(取自 INFO 的 latest_fork_usec)差异惊人:
| 环境 | 内存 | fork 耗时 |
|---|---|---|
| 物理机(Xeon @2.27GHz) | 6.9 GB | 62 毫秒 |
| VMware 虚拟机 | 6.0 GB | 77 毫秒 |
| EC2 新型实例(Xen) | 1 GB | 10 毫秒 |
| EC2 旧型实例(Xen) | 6.1 GB | 1460 毫秒 |
| Linode(Xen) | 0.9 GB | 382 毫秒 |
这也是为什么 Redis 部署手册第一条永远是关闭透明大页(transparent huge pages)。THP 把页粒度从 4 KB 放大到 2 MB,写时复制的单位跟着放大 512 倍:主线程改动几千个分散的键,就可能把几乎整个进程的内存复制一遍,延迟与内存占用同时爆炸。
单核天花板
一旦命令本身开始吃 CPU——大量复杂 Lua 脚本、单值几十 MB 的序列化、开启 TLS 后的加解密——单线程就成了硬顶。此时监控上会出现一个很有辨识度的画面:机器整体 CPU 利用率不高,但 redis-server 主线程那一个核钉在 100%。
排查工具是 SLOWLOG,默认记录执行超过 slowlog-log-slower-than(10000 微秒,即 10 毫秒)的命令。对一个正常延迟在微秒级的系统来说,10 毫秒已经是灾难阈值,不是警戒线。
持久化:把慢活赶出主线程
理解了"主线程一秒都不能被占用",就能理解 Redis 持久化为什么长成现在这样。写盘是毫秒级甚至秒级的操作,绝不能放进事件循环。Redis 的通用手法只有一个:fork 一个子进程,让它去慢慢写,父进程继续服务。
RDB:某一瞬间的完整快照
BGSAVE fork 出子进程,子进程把内存里的全部数据序列化成一个紧凑的二进制文件。写时复制(copy-on-write)保证子进程看到的是 fork 那一刻的一致视图:父进程后续的修改会触发页面复制,子进程手里仍是旧页。
默认触发规则写在 redis.conf 里,是三条"或"关系:3600 秒内至少 1 次修改、300 秒内至少 100 次修改、60 秒内至少 10000 次修改。
代价有三笔:两次快照之间的写入在崩溃时全部丢失;fork 的停顿如上表;以及写入越密集,COW 复制的页面越多,最坏情况下内存接近翻倍——给 Redis 规划内存时必须留出这份余量,否则快照期间触发 OOM killer,丢的就不只是数据了。
AOF:把命令记成流水账
AOF(Append Only File)追加记录每一条写命令。耐久度由 appendfsync 三档决定:
| 取值 | 含义 | 最坏丢失 | 代价 |
|---|---|---|---|
always | 每次写命令回复客户端前先 fsync | 接近 0 | 吞吐大幅下降,强依赖快盘 |
everysec(默认) | 每秒由后台线程 fsync 一次 | 约 1 秒 | 折中;fsync 未完成时主线程的 write 最多被推迟 2 秒 |
no | 交给内核决定 | 数十秒不等 | 最快,但耐久度不可控 |
那句"最多被推迟 2 秒"值得展开:Linux 上对同一个文件,fsync 进行中时 write 会阻塞。Redis 的处理是先缓冲、延后写,但拖到上限仍会硬着头皮调用——于是磁盘抖动就传导成了 Redis 的延迟毛刺。所谓"AOF 用后台线程所以不影响主线程",只在磁盘跟得上时成立。
AOF 会不断膨胀,所以需要 BGREWRITEAOF 重写——同样要 fork。两个优化值得知道:aof-use-rdb-preamble 默认为 yes,重写出的基底用 RDB 二进制格式,尾部才是命令日志,兼顾体积与恢复速度;7.0 改成多部件 AOF,用一个 manifest 清单管理 base 文件与若干 incr 文件,重写期间不再需要把新命令同时写进内存缓冲。
怎么选
| RDB | AOF(everysec) | 两者同开 | |
|---|---|---|---|
| 最坏数据丢失 | 上次快照之后的全部写入 | 约 1 秒 | 约 1 秒 |
| 重启恢复速度 | 快(直接反序列化) | 慢(要重放命令) | 优先读 AOF |
| 文件体积 | 小 | 大,需定期重写 | — |
| 对主线程的影响 | fork 停顿,间隔长 | fork 停顿 + 每秒 fsync 传导的抖动 | 两者叠加 |
| 适合场景 | 缓存、可容忍丢失、要快速冷启 | 当作主存储、丢一秒也心疼 | 生产默认 |
必须说清楚的是:这两者都不是备份,也都不保证零丢失。Redis 的主从复制是异步的——主库执行完命令就回复客户端,不等从库确认。WAIT 命令(3.0 引入)能阻塞到"至少 N 个从库已收到",但它等的是收到,不是落盘,也挡不住故障切换时的数据回退。真正的数据安全来自持久化、复制、异地备份三层独立防线,而不是把某一个参数调到最狠。
Redis 6 之后的 I/O 线程:改了什么,没改什么
到 2020 年,前面那个算式的两端都变了:网卡从 1 Gbit 涨到 10 Gbit 甚至更高,而单核频率早已停滞。于是"处理一条命令"里最贵的部分不再是网络等待,而是为成千上万个客户端反复调用 read/write、以及把字节流解析成 RESP 协议对象。这些工作有一个共同特点:它们不碰全局数据结构。
Redis 6.0(2020 年 4 月 30 日 GA)据此引入 I/O 线程。主线程仍然独占 epoll_wait 和命令执行,但在两个位置分岔:把待读的客户端分给一组固定 I/O 线程去 read 和解析,等它们全部完成后主线程合流、逐条执行命令;回复阶段同理,把输出缓冲分给 I/O 线程去 write。这是一种"分叉—合流"模型,不是线程池,也没有任务窃取。
几个务必记住的细节:
io-threads默认是 1,也就是关闭。开启是运维的主动选择。redis.conf的建议是:至少 4 核才值得开,且要留一个空核给后台任务——4 核用 3 个线程,8 核用 7 个。- 6.x 里
io-threads-do-reads默认no,即只把写线程化;读和协议解析仍在主线程。Redis 8.0(2025 年 5 月)重写为异步 I/O 线程后,这个选项不再生效,读写一律走线程。同期由 Redis 换许可证引发分叉、现归 Linux 基金会的 Valkey,在自己的 8.0 里走了同一条路。
没有改变的部分才是重点:
- 命令执行仍然完全串行,一次一条。
INCR、SETNX、Lua 脚本、MULTI/EXEC的原子性语义分毫未动。- 一条
KEYS *照样阻塞所有人——它的耗时在执行阶段,而执行阶段从来没有被并行化。 - 单个热点 key 依旧只能由一个核处理,分片也救不了。
把这十几年连起来看,方向一直没变:凡是不需要看全局状态的活,都往主线程外面搬——磁盘 fsync(2.4)、大对象释放(4.0)、socket 收发与协议解析(6.0 与 8.0)。唯独"改数据"这一步一次都没搬。因为一搬就要引入锁,而锁会把前面列举的全部好处——免费的原子性、可预测的顺序、没有竞态的数据结构——一次性还回去。
什么时候不该选它
诚实地列出这套模型失效的场景:
- 单 key 超热点:某个 key 每秒承受百万级访问。分片按 key 哈希,同一个 key 永远落在同一个节点的同一个线程上,加机器无效。只能改数据模型(拆分计数器)或在客户端加本地缓存。
- 命令本身 CPU 密集:重度 Lua、超大 value 的序列化、TLS 加解密。单核就是天花板。
- 需要跨 key 的强事务、复杂查询、二级索引:这是关系数据库的领域,硬用 Redis 拼出来的方案通常又慢又脆。
- 数据集远大于内存:Redis 的所有性能假设都建立在数据常驻内存之上,它不是磁盘数据库。
一句话概括这个系统的性格:Redis 用"每个操作都必须很快"这条铁律,换来了免费的原子性和可预测的延迟。它把复杂度从并发控制转移到了使用纪律上——纪律守得住,它稳定得惊人;守不住,一条命令就能让全站等待。
跨域连接
- 电信网络:一个线程守着上万条连接,这个想法是电信交换先证明可行的。电路交换给每一路通话独占一条线路,利用率极低;分组交换与统计复用则押注"绝大多数信道在绝大多数时刻是空闲的",于是用远少于用户数的资源服务所有人。epoll 就是这一赌注在操作系统里的版本——一万条连接中,任一毫秒真正有数据可读的往往只有几十条,"每连接一线程"是在为空闲付费。这条类比还给出了 Redis 优化的第一顺位:电信里传播时延不随带宽提升而下降,Redis 里网络往返(1 Gbit 以太网约 200 微秒)也不随命令变快而缩短,所以 pipeline、
MGET这类合并往返的手段,收益永远高于把命令本身再优化一倍。 - 概率论:Redis 在两个关键位置放弃了精确记账,改用随机采样。近似 LRU 淘汰不维护全局访问链表(那要在每次读写时改指针,且并发下必须加锁),而是每次随机抽
maxmemory-samples(默认 5)个键,淘汰其中最久未用的;主动过期同样是每 100 毫秒抽 20 个键,超过 25% 已过期就再抽一轮。这是典型的用概率保证换确定性延迟:样本量固定,所以单次操作的耗时有上界,代价是偶尔淘汰错一个键。对缓存而言,"偶尔多删一个"的成本几乎为零,而"偶尔卡顿 200 毫秒"的成本极高——采样之所以是正确答案,取决于这个代价不对称,而不是取决于它更省事。 - 工业工程与质量管理:单线程就是一条只有一台机器的产线,Redis 的运维守则几乎是车间调度规则的逐条翻译。排队论早就证明,在单服务台系统里,服务时间的方差比均值更能决定尾部等待——一个偶发的超长工序造成的排队,远比整体慢一点更伤。这正是
KEYS、大 key、批量同秒过期的破坏机理。作业车间可以用最短加工时间优先来压低平均等待,但 Redis 没有抢占、无法重排队列,于是只剩下车间的另一条老办法:在设计期就禁止长作业进场——禁用危险命令、拆分大 key、把慢查询赶到副本上跑。SLOWLOG在这个框架里等价于统计过程控制的控制图:它监控的不是均值漂移,而是超出上限的异常点。 - 安全工程:
appendfsync的三个取值不是性能开关,而是一张明码标价的风险预算表。安全工程要求把可接受损失先量化再选防护等级,always/everysec/no对应的正是"接近零/约一秒/不可控"三档恢复点目标。更重要的是共因失效这个概念:持久化与复制看似是两道独立防线,但快照要 fork、fork 触发写时复制、写时复制放大内存占用,而内存耗尽会同时打掉主库进程与它正在进行的备份——一个原因,两道防线一起失效。透明大页把这个耦合又放大了 512 倍。所以"多开一种持久化就更安全"是错的,只有当两道屏障不共享失效原因时,叠加才真正增加安全余量。 - 公地治理:一个 Redis 实例是典型的公共池塘资源——所有客户端共用那一个执行线程,谁都能取用,谁都无法被排除。公地悲剧在这里的形态非常具体:某个团队为了排查问题跑一条
KEYS *,成本由全站所有调用方均摊,而收益归他一人,个体理性叠加出集体灾难。Ostrom 对成功自治案例的归纳——边界清晰、监督可行、分级制裁——恰好能对上 Redis 的治理工具:ACL 与rename-command划定谁能用哪些命令,SLOWLOG与latency子系统提供低成本监督,client-output-buffer-limit断开失控连接、maxmemory-policy触发淘汰则是分级制裁。反过来说,因为操作系统层面没有任何调度器能在单线程内部做隔离,Redis 的多租户安全本质上依赖社会性约定而非技术强制——这也解释了为什么大型组织最终都会走向按业务拆实例:与其治理公地,不如取消公地。
参考文献
- Redis. Diagnosing latency issues. Redis 官方文档.(本文引用的 fork 实测表、24 GB 实例 48 MB 页表的计算、主动过期循环的 100 毫秒/20 键/25% 参数、
appendfsync三档取舍均出自此文) - Redis. Redis persistence. Redis 官方文档.(RDB 与 AOF 的机制与取舍、多部件 AOF)
- Redis. redis.conf(8.0 分支).(
io-threads默认值与线程数建议、save默认规则、aof-use-rdb-preamble与lazyfree-*默认值的一手出处) - Sanfilippo, S. Redis 6.0.0 GA is out! antirez.com, 2020 年 4 月 30 日.(I/O 线程随 6.0 发布的时间点)
- Redis. FAQ — Redis is single threaded. How can I exploit multiple CPU/cores? Redis 官方文档.("CPU 很少是瓶颈,Redis 通常受限于内存或网络"的官方表述)
- Kleppmann, M. Designing Data-Intensive Applications. O'Reilly, 2017, 第 7 章 "Actual Serial Execution".(把串行执行作为一类并发控制方案与两阶段锁、乐观并发控制并列比较)
延伸阅读
- Sanfilippo, S. Lazy Redis is better Redis. antirez.com, 2015——
UNLINK与惰性释放的设计自述,作者本人解释为什么"删除"值得单独搬出主线程。 - Redis. Scaling with Redis Cluster. 官方文档——16384 个哈希槽的分片模型,即单线程的横向扩展答案。
- Valkey. Valkey 8.0 Release Notes, 2024——分叉之后的另一条 I/O 线程实现路线,可与 Redis 8.0 的方案对照阅读。