☰
Redis为什么快?内存、数据结构与单线程IO多路复用协同解析
2026/10/7 11:23:02 网站建设 项目流程

说实话,Redis 是我接触过的最容易上手、也最容易被误解的中间件。很多人一听到“Redis 为什么快?”,第一反应就是“因为它是内存数据库”。这个答案不能算错,但它只戳到了表面。光是把数据放在内存里,并不能让一个系统自动变得高性能——如果每个请求进来都要在对象锁、线程切换、磁盘刷盘、序列化上浪费几毫秒,再快的内存也救不回来。

我工作前几年一直忙着“用 Redis”,后来因为线上频繁出现命令超时,才开始真正研究“Redis 凭什么快”这个问题。回过头来看,Redis 的快是一整套设计取舍的结果:内存存储、高效数据结构、单线程模型、IO 多路复用、异步持久化,以及一整套禁止你用错的边界约定。这篇文章我想从一条命令的完整旅程讲起,把每个环节到底是怎么为“快”服务的拆开讲清楚。无论你是刚接触 Redis 的新手,还是已经被线上 Cache 问崩溃过的老开发,按这个思路去理解,应该都不会再被面试官问倒。

1. 想弄清为什么快,先看一次请求是怎么跑完的

1.1 一条命令从发起到返回,中间经历了什么

你在客户端里敲下GET user:name:123然后回车,几毫秒之内你就收到了结果。这中间实际发生了四步:

  1. 客户端把命令编码,通过网络发送到 Redis 服务端。
  2. 服务端通过网络模块读取并解析命令。
  3. 根据 key 在内存中的数据结构里查找对应数据。
  4. 执行写入或读取操作,把响应数据编码后通过网络返回给客户端。

这个过程看起来平淡无奇,但如果你仔细想,会发现每一步都有潜在的“慢点”:网络传输有延迟,读取数据可以从内存或磁盘上拿,查找数据可能遍历链表也可能直接哈希定位,执行命令时如果涉及阻塞操作就会卡住后续请求。

Redis 厉害的地方是,它把每一步都优化到了一定程度:网络模块用多路复用减少无效等待,数据全部放内存避免磁盘 IO,查找数据用“哈希表 + 跳表 + 整数集合”等结构把复杂度压低,执行命令时用单线程模型避免锁竞争。四步互相配合,才有了我们感知到的“秒回”。

1.2 内存只是起点,真正快的是整套取舍

不少技术文章喜欢把 Redis 的快归纳成“内存数据库”,用“内存比硬盘快 10 万倍”来解释。这话没错,但如果把同样的数据放进一个用 Java HashMap 做的服务里,再用独立进程通过网络去访问,你能达到 Redis 的吞吐量吗?大概率不能。

因为 HashMap 只解决了“查找快”,但没有解决网络接入、并发竞争、数据持久化、内存淘汰等问题。Redis 的快,是建立在物理内存之上的一整套工程化方案:它用单线程消除并发复杂度,用 IO 多路复用让一个线程同时服务海量连接,用自定义的数据结构尽可能减少内存碎片和重分配,甚至在持久化这种“拖后腿”的场景里,也通过 fork 子进程、异步刷盘等机制,尽量不阻塞主线程处理命令。

所以,每次我在面试里听到“Redis 快是因为内存”这句话,都会追问一句:“那 Redis 7 在阿里云上跑到的百万级 QPS,是靠内存大就能支撑起来的吗?”这时候对方通常就会卡住。这正是我写这篇文章的原因——我们需要从架构层面把这件事看完整。

2. 内存根基:为什么 RAM 比磁盘快几个数量级

2.1 物理层面的差距:纳秒 vs 毫秒

先聊一个基础事实。CPU 访问 L1 缓存大约需要 1ns,访问内存(DRAM)大约需要 100ns,访问 SSD 大约需要 0.1ms 到 0.2ms,访问机械硬盘更是几十毫秒起步。

用人话翻译一下:一次内存访问比一次 SSD 访问快大约 1000 倍,比机械硬盘快 100000 倍以上。Redis 把数据全部放在内存里,意味着它天然避开了这个存储层级里最慢的两级。即使 SSD 本身已经很快,但它毕竟要走文件系统、驱动、协议栈,而内存芯片只要通过内存总线就能被直接寻址。

这也是为什么说“Redis 是内存数据库”不是一句废话,它是所有快的前提。但仅仅有内存还不够,还得让数据在内存里被高效地组织和管理。比如 Java 的 HashMap 在频繁扩容、哈希碰撞严重时会有明显的性能下降,Redis 则在设计哈希表、字符串对象时做了大量针对内存和缓存友好性的优化,具体我在下一章展开。

2.2 内存昂贵易失,Redis 如何用淘汰与持久化保住速度

内存快,但它有两个天然的毛病:贵、断电即丢。所以一个把全部数据都放在内存里的数据库,必须解决两个问题:数据怎么不丢?内存存不下时怎么办?

针对“不丢”,Redis 提供了 RDB 快照和 AOF 追加日志两种持久化方案。针对“存不下”,Redis 提供了多种内存淘汰策略,比如allkeys-lru、volatile-lfu等等。很多人以为持久化会拖慢 Redis,实际上 Redis 把持久化做成了低频、异步、不阻塞主线程的操作——RDB 通过 fork 子进程配合写时复制生成快照,AOF 则利用操作系统的 Page Cache 先写内存缓冲,再按策略异步刷盘。这样既保证了重启后数据能恢复,又不会让持久化环节成为性能瓶颈。

内存淘汰策略这里也有一个容易被忽视的“快”设计:Redis 默认使用的近似 LRU,并不是传统意义上精确 LRU。精确 LRU 需要维护一个双向链表,每次访问都要移动节点,这在高并发场景下开销太大。Redis 用采样近似的方式,只对少量 key 进行采样比较,牺牲一点点命中率,却换来了极高的执行效率。这种“为了快,可以接受小概率偏差”的取舍在很多地方都能看到,也是后续讲数据结构时反复出现的主题。

3. 数据结构优化:把每次操作的时间复杂度和内存开销一起压下来

3.1 五种基本类型的底层编码,一张表看懂

Redis 对外提供五种基本数据类型:String、List、Hash、Set、ZSet。很多人用过它们,却没注意过它们底层是“一码多型”的。所谓一码多型,就是同一个对外类型,在不同场景下会使用完全不同的内部编码。

我整理过一张常用表,建议直接收藏:

外部类型底层编码使用场景
Stringint / embstr / raw计数、缓存对象、分布式锁
Listquicklist(压缩列表 + 双向链表)消息队列、最新列表
Hashlistpack / hashtable存储对象属性、购物车、字典场景
Setintset / hashtable标签、去重、交集并集运算
ZSetlistpack / skiplist + hashtable排行榜、延时队列、范围查询

注意到一个关键点:数据和规模不同,编码就会自动切换。保存一个整数型的 String,Redis 可能直接以数字形式存,不建多余对象;Hash 里字段很少时用 listpack,这种连续内存块既省内存又对缓存友好;字段多了才升级成 hashtable。ZSet 在元素多时用“跳表 + 哈希表”的组合,跳表保证有序范围查询 O(logN),哈希表保证按成员查分数 O(1)。

这种“自适应编码”让 Redis 在数据量不大时,内存占用和 CPU 缓存命中率都表现优秀,而数据量变大后又能平滑切换到更通用的结构。很多本地缓存在这一步就输了。

3.2 为什么压缩列表、跳表、整数集合能带来质变

单独看一种结构可能感受不深,但横向对比就很明显。

先看 String。Redis 没有直接使用 C 语言的字符串,而是封装了一层 SDS(简单动态字符串)。SDS 在头部保存了字符串长度和使用容量,因此获取长度是 O(1) 而不是像 C 字符串那样要遍历到\0。另外,当你对字符串做拼接或修改时,SDS 会预分配一部分内存,减少后续realloc的次数,这正是“少一点内存操作就快一点”的典型例子。

再看 List。早年 Redis 的 List 是双向链表,但双向链表每个节点都要单独 malloc,节点之间靠指针连接,内存碎片多、缓存命中率低。后来优化成 quicklist:每个节点里挂一个压缩列表,也就是把多个元素压缩进一段连续内存里。这样既保留了两端快速 push/pop 的特性,又大幅减少了节点数量和内存分配次数。

ZSet 用的跳表也值得一提。跳表本质上是在有序链表上增加多层索引,让查找从 O(N) 降到 O(logN)。实现上比平衡树简单,而且范围查询时能自然顺序遍历。Redis 选它的原因就是:实现简单、性能足够、并发下不需要复杂旋转操作。

总结一下就是,Redis 在数据结构上始终在追求“用更少的内存分配和更低的复杂度完成同样的操作”。这种微观层面的抠,积累到宏观层面就是惊人的吞吐差异。

3.3 编码转换的阈值,Redis 是怎么定的

每种编码之间都有明确的切换阈值,这些阈值在redis.conf里可以配置。比如:

  • list 的 ziplist 条目数默认不超过 128,单个值不超过 64 字节;
  • set 的 intset 默认最多 512 个整数元素;
  • zset 的 ziplist 默认最多 128 个元素,每个值大小不超过 64 字节。

超过阈值后,Redis 会触发编码升级,从紧凑结构换成通用结构。为什么阈值取这些值?本质上是内存开销和时间开销的权衡。压缩列表/listpack 是用一段连续内存存储一系列元素,一旦元素过多,插入和删除时移动数据的成本就会上升;但元素少时,它又比哈希表、跳表省内存,还能利用 CPU 缓存一次性加载。

这里有一个我在实际项目里遇到过的问题:有人为了省内存,把一个 field 特别多的 Hash 硬塞进小编码,结果字段超过阈值后突然变成 hashtable,性能抖动明显。后来加监控才发现是编码转换导致的。所以理解阈值不是死记参数,而是要知道 Redis 在背后默默帮我们做了自适应决策,而我们最好让它工作在合理的数据规模下,不要人为把阈值调得过高。

4. 单线程加多路复用:Redis 的快引擎

4.1 单线程为什么在如今的多核时代依然能打

这是“Redis 为什么快”里最反直觉的一点。都 202x 年了,CPU 都是动辄八核十六核,Redis 主版本却长期用单线程,而且还能跑出十万甚至几十万的 QPS,它为什么能这么打?

核心原因是:Redis 的命令执行几乎都在内存里完成,单条命令的时间通常是微秒级,极端情况下也就百微秒级。在这种背景下,多线程带来的线程切换、锁竞争、上下文切换开销,反而可能比命令本身还要贵。

我做过一个不严谨的小实验:用 Java 写一个并发 HashMap 结构,加读多写少的锁,压测时发现吞吐量和延迟抖动都不如 Redis 干净。原因不在于 HashMap 本身慢,而在于锁竞争和线程调度把简单的内存操作拖慢了。

单线程还有一个隐藏优势:因为同一时间只有一个命令在修改数据,所有操作天然原子,不需要为复杂的数据结构加锁。例如INCR、LPOP这类复合操作,在单线程模型下不需要额外事务就能保证原子性,这既简化了开发,又消除了锁等待。这才是单线程真正厉害的地方——不是性能高,而是复杂度低,低复杂度反过来成就了稳定高速。

4.2 epoll 是怎么做到“一个线程照看上万连接”的

单线程能处理命令,但网络 IO 的阻塞问题不解决,照样会卡。假设 Redis 只有一个线程,它每 accept 一个连接就等一下消息,那其他连接全都排队等着,这显然不现实。

Redis 的解决方案是 IO 多路复用。它通过 epoll 这样的系统调用,让一个线程同时监听成千上万个 socket 的可读可写事件。程序可以把所有连接交给内核去观察,一旦某个连接有数据到达,内核就通知 Redis:“这个 fd 可以读了,要不要处理一下?”Redis 主循环拿到事件列表后,逐个处理这些就绪事件,期间不会因为等待某个无消息的连接而阻塞。

用人话比喻,这就像一个餐厅只有一个服务员,他不用挨桌问“你要点菜吗”,而是站在门口看哪桌客人举手示意。服务员的效率取决于“手举得快不快”,而不是“一桌桌瞎转悠消耗时间”。模式本身简单,但配合内存数据结构,就形成了极高的事件处理吞吐。

我测试过一个小应用:单线程 epoll 模型下,每秒接受几千个连接事件完全没问题;而如果每个连接都起一个线程处理,几千线程光是切换上下文就能把 CPU 吃满。这就是 Redis 在网络层节能的做法。

4.3 Redis 6.0 之后的多线程 IO:哪里变了,哪里没变

Redis 6.0 引入了多线程 IO,很多人误以为 Redis 变成了多线程数据库。实际上,多线程只发生在网络数据读写和协议解析这个阶段,而真正执行命令、操作数据结构的,依然是那个单一的主线程。

为什么要这么做?因为在万兆网卡、大包请求、高并发连接的场景下,主线程即使不执行业务逻辑,光是调用 read/write 把数据从内核缓冲区拷到用户态,再解析成命令名和参数,本身就可能占用不少 CPU。把这些网络读写的活儿分给多个 IO 线程,主线程就能把更多时间留给真正的命令执行。

这个变化最大的价值是:它没有破坏单线程模型的原子性,因为命令执行依然串行;同时又提高了网络吞吐的上限。所以你在生产环境里开启多线程 IO,并不会出现普通多线程并发编程里那些数据竞争问题。官方默认配置io-threads 4,实际搭建时我会先用基准测试压一遍再决定要不要启用,不是所有场景都需要开线程,因为线程多了也有调度成本。

5. 把额外开销做成异步:持久化和主从复制不那么“伤”

5.1 RDB 与 AOF:快照和日志如何尽量不阻塞主线程

Redis 天天强调快,但它毕竟需要持久化。如果每次写命令都同步刷盘,再快的内存也要被拖垮。Redis 在这里的设计思路是“能异步就异步,能减负就减负”。

RDB 持久化时,Redis 会 fork 出一个子进程,子进程负责把内存里的全量数据写成二进制快照,父进程继续处理请求。fork 之后,父子进程共享同一份内存,利用操作系统的写时复制(Copy-on-Write)技术,父进程后续修改数据时才会复制对应的内存页,未修改的数据页面只是被读取,并不额外开销。这样生成快照对主线程的阻塞几乎可以忽略,通常只消耗 fork 时拷贝页表的那一点时间。

AOF 则是另一种思路:把每次写命令追加到日志文件末尾。为了避免每次写都刷盘带来的延迟,AOF 有三种策略,其中默认的everysec是把数据先写入系统缓冲区,每秒调用一次 fsync 强制落盘。也就是说,极端情况下可能丢失最后 1 秒数据,但换来的是绝大多数情况下写入几乎无额外等待。我在线上经常看到有人为了“绝对安全”把 AOF 设成always,结果写入耗时明显上升,事实上多数业务场景根本不需要这种级别的安全级别。

5.2 主从复制、读写分离:让读流量不再压在主库上

如果说持久化解决的是可靠性,那么主从复制解决的是吞吐量扩展。Redis 支持一主多从,主节点负责写,从节点异步拉取主节点的变更流,然后应用到自己的内存里。

在这种架构下,读请求可以全部打到从节点上,主节点只需要集中精力处理写命令和热点数据。这对“读多写少”的业务特别友好。举个例子,我之前维护过一个博客系统的热门文章缓存,读 QPS 极高,单节点 CPU 动不动满了,后来加了两台从库,把 Redis 客户端的读流量路由指到从节点,主节点负载立刻降到了 20% 以下,整体延迟反而更稳定了。

需要提醒的是,从节点复制是异步的,这意味着主节点刚写入的数据,从节点可能还有几十毫秒的延迟才可见。某些强一致场景需要绕过缓存直接读数据库,或者等待写后读同步。这是我踩过的一个坑,后面会在问题排查部分再提。

6. 实际线上“Redis 变慢”的真相:不是 Redis 慢,是用法不对

6.1 大 Key、慢命令、反序列化:最常见的三个性能杀手

我参与过的性能排查里,几乎每次“Redis 突然变慢”最终都指向同样三类问题。

第一类是大 Key。一个 String 值有几 MB,甚至一个 Hash 里有几万个 field,读一次就要在内存中搬运大量数据,网络传输时间直线上升。这类操作会拖慢单个请求,还可能导致主从复制的缓冲积压。排查时可以用redis-cli --bigkeys快速扫描,也可以自己写脚本统计。我的常用思路是:如果单个 key 的 value 超过 1MB,就要考虑拆分、压缩,或者改用 Hash 分片存储。

第二类是慢命令。比如KEYS *,它要遍历全库的 key,复杂度 O(N),在大 key 数量下会阻塞 Redis 主线程,让所有命令排队。类似的高危命令还有SMEMBERS、HGETALL、ZRANGE在大范围时的高耗时。生产环境我会禁用或者改造为 SCAN 类增量遍历命令。通过SLOWLOG GET 10可以直观地查看最近最耗时的命令,再配合INFO commandstats统计每个命令的执行次数和总耗时,基本能定位到元凶。

第三类是序列化和反序列化导致的开销。很多团队把 Java 对象直接序列化成 JSON 存进 Redis,取出来再反序列化。如果对象嵌套很深、字段又多,一次序列化耗几百微秒是常事。而这种开销看似是客户端的事,实际上服务器端吞吐上不去时,检查链路会发现 CPU 全花在这上面。我会建议尽量用 kryo、protobuf 这种更紧凑的序列化方案,或者干脆按字段拆成 Hash 存储,减少整体传输量。

6.2 缓存穿透、击穿、雪崩,以及三个经典解法

这一节几乎是被问烂的面试八股,但工程上真心重要,因为任何一个没处理好,都会让 Redis 从“快”变成“慢”。

穿透是大量请求查一个不存在的 key,Redis 里没有,请求直接打到数据库上,瞬间把数据库打崩。经典解法是布隆过滤器,先把所有可能存在的数据放进布隆过滤器,如果过滤器判断不存在,就直接短路返回,不再查库。布隆过滤器本身用位数组加多次哈希,空间小、查询快,和 Redis 的“快”理念非常搭。

击穿是指某个热点 key 恰好过期,同时有大量请求涌进来,全部落到数据库上。解法之一是加互斥锁,用 Redis 的SET key value NX EX做分布式锁,第一个线程去重建缓存,其他线程短暂等待;另一种方法是用逻辑过期,不给 key 设置物理过期时间,而是在 value 里塞一个过期时间戳,当发现逻辑过期时,后台异步刷新缓存,这样不会阻塞读请求。

雪崩是大量 key 在同一时间段集中过期,或者整个 Redis 宕机,导致大规模请求瞬间打到数据库。解法包括给缓存过期时间增加随机抖动(比如 5 分钟到 10 分钟之间随机),设置多级缓存,以及高可用部署。分布式锁在这里也能配合预热的流程,避免重建缓存时打爆下游。

我之前在项目里对这三个问题都踩过坑,到最后总结的经验是:不要把 Redis 当成无条件可靠的存储层,它快但也很脆。保护了 Redis,其实就是在保护整个链路的速度。

7. 最后再分享一点个人体会

自己从“会用 Redis”到“理解 Redis 为什么快”花了很长时间,中间踩过不少坑。我最大的体会是:Redis 的快从来不是靠某一个特性,而是靠一整套互相咬合的设计。从内存、数据结构,到单线程和 IO 多路复用,再到异步持久化和主从复制,每个环节都在为“让主线程专注于最该做的事”服务。

如果你也想更彻底地理解这个系统,我特别建议做两件事:一是打开源码把dict的哈希表扩容和 SDS 的扩容逻辑读一遍,你会发现它的每个判断都写得很克制;二是在测试环境跑一遍redis-benchmark,分别测试直连、走代理、不开 AOF、开启 AOF 之后的数据差异,感受不同配置对快的影响有多大。

“Redis 为什么快”也许是个面试题,但真正研究下来,它是我们理解中间件设计哲学的一扇很好的门。希望这篇文章能帮你把这扇门推开,也欢迎你在评论里聊聊自己遇到过最诡异的 Redis 性能问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询