
说实话Redis 是我接触过的最容易上手、也最容易被误解的中间件。很多人一听到“Redis 为什么快”第一反应就是“因为它是内存数据库”。这个答案不能算错但它只戳到了表面。光是把数据放在内存里并不能让一个系统自动变得高性能——如果每个请求进来都要在对象锁、线程切换、磁盘刷盘、序列化上浪费几毫秒再快的内存也救不回来。我工作前几年一直忙着“用 Redis”后来因为线上频繁出现命令超时才开始真正研究“Redis 凭什么快”这个问题。回过头来看Redis 的快是一整套设计取舍的结果内存存储、高效数据结构、单线程模型、IO 多路复用、异步持久化以及一整套禁止你用错的边界约定。这篇文章我想从一条命令的完整旅程讲起把每个环节到底是怎么为“快”服务的拆开讲清楚。无论你是刚接触 Redis 的新手还是已经被线上 Cache 问崩溃过的老开发按这个思路去理解应该都不会再被面试官问倒。1. 想弄清为什么快先看一次请求是怎么跑完的1.1 一条命令从发起到返回中间经历了什么你在客户端里敲下GET user:name:123然后回车几毫秒之内你就收到了结果。这中间实际发生了四步客户端把命令编码通过网络发送到 Redis 服务端。服务端通过网络模块读取并解析命令。根据 key 在内存中的数据结构里查找对应数据。执行写入或读取操作把响应数据编码后通过网络返回给客户端。这个过程看起来平淡无奇但如果你仔细想会发现每一步都有潜在的“慢点”网络传输有延迟读取数据可以从内存或磁盘上拿查找数据可能遍历链表也可能直接哈希定位执行命令时如果涉及阻塞操作就会卡住后续请求。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排行榜、延时队列、范围查询注意到一个关键点数据和规模不同编码就会自动切换。保存一个整数型的 StringRedis 可能直接以数字形式存不建多余对象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 从“快”变成“慢”。穿透是大量请求查一个不存在的 keyRedis 里没有请求直接打到数据库上瞬间把数据库打崩。经典解法是布隆过滤器先把所有可能存在的数据放进布隆过滤器如果过滤器判断不存在就直接短路返回不再查库。布隆过滤器本身用位数组加多次哈希空间小、查询快和 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 性能问题。