2009 年,Salvatore Sanfilippo(antirez)在开发一个实时日志分析系统时,需要一个能够快速处理并发请求的数据存储。他做出了一个在当时看来"反直觉"的设计选择:用单线程模型处理所有请求。十五年后的今天,这个被命名为 Redis 的项目,单线程核心依然能够支撑每秒 10 万次以上的操作。

这个设计选择背后的逻辑,远比"单线程 vs 多线程"的简单对比要复杂得多。

内存才是真正的瓶颈

理解 Redis 单线程设计的第一步,是认识到现代计算机体系结构中一个关键事实:CPU 远比内存快。

L1 缓存的访问延迟大约是 1.2 纳秒,L2 缓存约 4 纳秒,L3 缓存约 13 纳秒,而主内存访问则需要 60-100 纳秒——差距接近两个数量级。当 CPU 需要等待数据从主内存加载时,它实际上是在空转。

Redis 作为一个内存数据库,其性能特征与传统的磁盘数据库有着本质区别。在 MySQL 或 PostgreSQL 中,一次磁盘随机读取可能需要 10 毫秒,这相当于 CPU 执行数百万条指令的时间。在这种场景下,多线程并发确实能显著提升吞吐量——当线程 A 等待磁盘 I/O 时,线程 B 可以继续执行。

但在 Redis 中,数据存储在内存里。一次 GET 操作的典型路径是:哈希表查找(O(1))、内存访问、返回结果。整个过程的计算量极小,大部分时间实际上花在网络 I/O 上——接收请求和发送响应。

antirez 在 2019 年的一篇博客中明确指出:在 Redis 的场景中,CPU 不是瓶颈,内存带宽和网络 I/O 才是。这意味着,即使给 Redis 加上多线程支持,在单机场景下也难以获得显著的性能提升——因为瓶颈根本不在 CPU。

单线程的隐藏优势

当所有人都在追求"更多线程、更多核心"时,Redis 的单线程模型反而获得了一些容易被忽视的优势。

零锁竞争的开销

在多线程程序中,当多个线程需要访问共享数据时,必须使用锁或其他同步机制。一个原子操作(如使用 CAS 指令)可能需要 40-100 个 CPU 周期,而一个有竞争的互斥锁操作可能需要上万个周期——包括上下文切换的开销。

Redis 的单线程模型完全消除了这个问题。每个命令的执行都是原子的,不需要任何同步原语。考虑一个简单的 HMSET 操作:它需要修改哈希表中的多个字段。在多线程环境下,这需要获取多个锁;而在 Redis 的单线程模型中,这只是顺序执行的一系列内存操作。

CPU 缓存亲和性

现代 CPU 的性能严重依赖缓存。当数据在 L1/L2 缓存中时,访问速度极快;当缓存未命中需要从主内存加载时,性能会急剧下降。

多线程程序面临一个棘手问题:当多个线程在不同核心上运行并访问同一份数据时,CPU 需要通过缓存一致性协议(如 MESI)来保证数据一致性。当一个核心修改数据时,其他核心的缓存行会被标记为无效,下次访问时必须重新加载——这就是所谓的"缓存行乒乓"效应。

Redis 的单线程模型天然避免了这个问题。所有数据操作都在同一个核心上执行,热点数据会稳定地驻留在该核心的 L1/L2 缓存中。这意味着,虽然 Redis 只用一个核心,但它能用得非常高效。

可预测的行为

单线程模型带来了另一个重要优势:确定性。在 Redis 中,命令严格按照接收顺序执行。没有并发竞争导致的时序问题,没有死锁风险,性能特征稳定可预测。

这对于调试和监控来说是巨大的优势。当出现性能问题时,通过 MONITOR 命令可以精确地看到命令的执行顺序。在多线程系统中,要实现同样的可观测性需要付出复杂得多的代价。

IO 多路复用:单线程如何处理并发

单线程模型的一个显而易见的问题是:如何处理大量并发连接?如果一个线程正在处理一个客户端的请求,其他客户端是否必须等待?

答案是 IO 多路复用。Redis 使用操作系统提供的 epoll(Linux)、kqueue(macOS/BSD)或 select(可移植后备)来同时监控成千上万个连接的状态。

Reactor 模式的实现

Redis 的事件处理采用经典的 Reactor 模式。其核心是一个事件循环:

sequenceDiagram
    participant Main as 主线程
    participant Epoll as epoll
    participant Handler as 事件处理器
    
    Main->>Epoll: epoll_wait() 阻塞等待
    Epoll-->>Main: 返回就绪的文件描述符列表
    
    loop 处理每个就绪事件
        Main->>Handler: 调用对应的事件处理函数
        Handler->>Handler: 读事件:读取并解析命令
        Handler->>Handler: 写事件:发送响应数据
    end
    
    Main->>Main: 处理时间事件(过期键检查等)
    Main->>Epoll: 继续下一轮 epoll_wait()

当一个新的客户端连接到达时,accept 处理器被调用,创建一个新的客户端对象,并将其 socket 注册到 epoll 中,监听读事件。当客户端发送命令时,socket 变为可读,epoll 通知主线程,主线程调用 readQueryFromClient 处理器读取数据、解析命令、执行命令,最后将响应数据写入客户端的输出缓冲区。

关键洞察在于:网络 I/O 的等待时间被完全消除了。主线程从不阻塞在某个特定的连接上——它只处理那些"准备好"的连接。在等待网络数据时,主线程可以去处理其他就绪的事件。

文件事件与时间事件

Redis 的事件循环处理两种类型的事件。文件事件对应网络 I/O:客户端连接、接收命令、发送响应。时间事件则用于处理定时任务,例如检查键是否过期、执行后台持久化、更新统计信息等。

在每一轮事件循环中,Redis 首先计算最近一个时间事件的发生时间,以此作为 epoll_wait 的超时参数。这确保了 Redis 能够及时处理定时任务,同时最大化地利用等待时间来处理 I/O 事件。

数据结构的极致优化

Redis 的高性能不仅仅来自于单线程模型,其数据结构设计同样功不可没。

压缩编码:用 CPU 换内存

对于小型集合,Redis 使用特殊的压缩编码来减少内存占用。一个只有几个字段的 Hash,会被存储为一个连续的字节数组(在 Redis 7.0 之前称为 ziplist,现在称为 listpack),而不是一个完整的哈希表结构。

哈希表的每个条目都需要存储指针、元数据和填充字节,对于小对象来说,这些开销可能比实际数据还大。压缩编码消除了大部分指针开销,虽然查找时间从 O(1) 变成了 O(N),但对于 N 很小的情况(默认阈值是 512 个元素),这个代价完全值得。

从 Redis 7.0 开始,这些阈值可以通过配置调整:

hash-max-listpack-entries 512
hash-max-listpack-value 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512

SDS:简单动态字符串

Redis 没有使用 C 语言的原生字符串,而是实现了自己的简单动态字符串(Simple Dynamic String, SDS)。SDS 在字符串头部记录了长度和剩余空间,使得获取长度变成 O(1) 操作,同时也避免了缓冲区溢出的风险。

更重要的是,对于整数,Redis 使用特殊的编码。当一个字符串可以被解析为整数时,Redis 会直接存储整数值而不是字符串,这在节省内存的同时也加快了操作速度。

跳表:有序集合的选择

对于有序集合(Sorted Set),Redis 使用跳表而不是平衡树。跳表的实现更简单,内存开销更小,在范围查询场景下性能与平衡树相当。跳表的每个节点平均只需要 1.33 个指针,而平衡树的每个节点通常需要两个指针。

Redis 6.0 的多线程抉择

在 Redis 6.0 中,antirez 终于引入了多线程支持——但仅限于网络 I/O,命令执行依然是单线程的。这个决定背后的考量值得深入分析。

网络 I/O 的瓶颈

随着网络带宽的提升和 10GbE/25GbE 网卡的普及,网络 I/O 在某些场景下确实成为了瓶颈。当客户端数量达到数千、每个请求/响应的数据量较大时,读写 socket 的 CPU 时间变得不可忽视。

Redis 6.0 的 Threaded I/O 允许配置多个 I/O 线程来并行处理网络读写:

io-threads 4
io-threads-do-reads yes

在实际测试中,开启 4-8 个 I/O 线程可以让吞吐量提升 50%-100%,具体效果取决于网络条件和请求模式。

为什么不同时多线程化命令执行?

命令执行的多线程化要复杂得多。Redis 的数据结构之间存在复杂的交互——一个 LPUSH 命令可能影响另一个客户端正在执行的 LPOP。要安全地并发执行这些命令,需要细粒度的锁,而锁的开销可能抵消并发带来的收益。

更重要的是,Redis 的单线程命令执行保证了原子性。一个 INCR 命令天然就是原子的,不需要额外的同步机制。如果引入多线程命令执行,要么需要加锁(带来性能开销),要么需要改变语义(破坏兼容性)。

antirez 在 2019 年的博客中明确表示:他更倾向于通过 Redis Cluster 实现横向扩展,而不是在单实例内部引入复杂的多线程命令执行

什么情况下单线程会成为瓶颈

尽管 Redis 的单线程模型在大多数场景下表现优异,但确实存在一些边界情况。

慢命令的危险

不是所有 Redis 命令都是 O(1) 的。KEYS * 命令需要遍历整个键空间,时间复杂度是 O(N)。在生产环境中执行这个命令,如果数据库中有数百万个键,整个服务器会阻塞数秒——在这期间,所有其他客户端的请求都会被卡住。

Redis 提供了 SCAN 命令作为替代方案。它使用游标迭代,每次只返回一部分结果,避免了长时间阻塞。但需要注意的是,SCAN 本身也不是完全无阻塞的——对于大型集合,单次迭代仍可能耗时较长。

大 Key 问题

一个包含数百万个元素的 List 或 Hash,其操作开销会显著高于小 Key。LRANGE mylist 0 -1 对于一个包含 1000 万元素的列表来说,意味着巨大的内存分配和数据拷贝。

最佳实践是避免创建过大的 Key,或者使用 HSCANSSCAN 等增量迭代命令。

持久化的影响

Redis 的 RDB 持久化需要 fork() 一个子进程来创建快照。虽然 fork() 本身很快(得益于写时复制技术),但对于内存使用量很大的 Redis 实例,fork() 可能会阻塞主线程数十毫秒甚至更长时间。

此外,如果开启了 AOF 的 always 同步策略,每次写操作都会触发 fsync,这会严重影响性能。实践中通常使用 everysec 策略,让后台线程每秒执行一次同步。

横向扩展:Redis Cluster

当单实例确实无法满足性能需求时,Redis 提供了 Cluster 模式进行横向扩展。Cluster 通过哈希槽将数据分片到多个节点,每个节点负责 16384 个槽位中的一部分。

这种方式保持了单线程模型的所有优势,同时通过增加节点数量来线性扩展吞吐量和内存容量。一个 6 节点的 Cluster(3 主 3 从)理论上可以提供 3 倍的单实例性能,同时保证高可用。

权衡与选择

Redis 和 Memcached 的对比经常被提起。Memcached 使用多线程模型,每个连接由一个独立的工作线程处理。在纯缓存场景、简单键值操作、高并发读取的情况下,Memcached 可能表现更好。

但 Redis 提供了丰富的数据结构(List、Hash、Set、Sorted Set、Stream 等)、持久化、复制、Lua 脚本、事务等功能。这些功能的价值往往超过了单线程模型在某些场景下的性能劣势。

更重要的是,Redis 的设计哲学是在正确的层面做正确的事情。与其在单实例内部引入复杂的多线程机制,不如通过 Cluster 实现更干净的横向扩展。这种选择保持了代码的简洁性和可维护性,同时也为运维提供了更清晰的边界。


Redis 的单线程模型并非设计缺陷,而是一个深思熟虑的架构决策。它利用了内存数据库的性能特征——计算简单、内存访问快、网络 I/O 是主要瓶颈——来选择最适合的并发模型。

当你下次看到 Redis 的监控面板显示单核心 CPU 使用率达到 100% 时,不必惊慌。这可能意味着你的 Redis 实例正在高效地利用那颗核心的每一个周期——没有锁竞争、没有上下文切换、没有缓存失效。如果你需要更高的性能,Redis Cluster 已经准备好了。

参考资料

  1. Redis Memory Optimization. Redis Documentation. https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
  2. The Engineering Wisdom Behind Redis’s Single-Threaded Design. https://riferrei.com/the-engineering-wisdom-behind-rediss-single-threaded-design/
  3. An In-Depth Look Into the Internal Workings of Redis. https://betterprogramming.pub/internals-workings-of-redis-718f5871be84
  4. Redis Analysis - Part 1: Threading model. https://www.romange.com/2021/12/09/redis-analysis-part-1-threading-model/
  5. Redis 6.0新Feature实现原理——Threaded I/O. https://jiekun.dev/posts/redis-tio-implementation/
  6. An update about Redis developments in 2019 - antirez. https://antirez.com/news/126
  7. Memory Optimization Patterns. https://redis.antirez.com/fundamental/memory-optimization.html
  8. Redis benchmark. Redis Documentation. https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/
  9. Redis Persistence. Redis Documentation. https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
  10. Why Redis is Fast: The Architectural Secrets Behind Its Blazing Speed. https://exabyting.com/blog/why-redis-is-fast-the-architectural-secrets-behind-its-blazing-speed/
  11. Redis Threading Model: Why “Single-Threaded” Is Misunderstood. https://dev.to/ricky512227/understanding-redis-threading-what-i-learned-the-hard-way-paf
  12. Redis Deep Dive Part 1: In-Memory Architecture and Event Loop. https://thuva4.com/blog/part-1-the-core-of-redis/
  13. Redis vs Memcached. Redis Documentation. https://redis.io/compare/memcached/
  14. Faster KEYS and SCAN: Optimized glob-style patterns. Redis Blog. https://redis.io/blog/faster-keys-and-scan-optimized/
  15. Diving Into Redis 6.0. Redis Blog. https://redis.io/blog/diving-into-redis-6/