
Redis为什么快这个问题我一年至少要回答几十遍。面试的时候被问到带团队做技术分享的时候要讲排查线上慢查询的时候也要重新过一遍。网上能搜到的答案高度一致——基于内存、单线程、IO多路复用。这九个字背下来不难但是你有没有认真想过基于内存就快了吗单线程为什么反而比多线程快IO多路复用到底是怎么把成千上万个连接塞进一个线程的如果这些为什么答不上来那所谓的知道其实只是把结论复述了一遍。这篇文章我想站在一个常年和Redis打交道的使用者的角度把这道题拆开揉碎。我不会堆砌一堆背不完的源码而是沿着数据放哪、事件怎么收、谁来执行、用什么结构执行这条主线把每一层为什么快讲清楚。过程中会穿插我在生产环境里踩过的坑以及如果真的在面试中遇到这道题怎么答出层次感。无论你是刚接触Redis的新人还是已经用了几年的老手应该都能从里面找到一点对自己有用的东西。1. 这道题被问了上千遍但九字口诀只讲对了一半1.1 从背答案到讲原理面试官真正想看什么几乎所有Redis面试八股文都会收录这道题标准答法是内存 单线程 IO多路复用。我见过太多次这样的场景候选人把这九个字说得滚瓜烂熟我问一句为什么单线程不会成为瓶颈对方就卡住了再追问IO多路复用和普通阻塞IO区别在哪基本就开始绕圈子。这说明一个问题背答案是背不出来理解的而这道题恰恰是区分背过和懂的分水岭。我印象很深的一次有个候选人开头也是九字口诀但他说到单线程时主动补了一句Redis的命令执行确实是单线程但网络读写模块在6.0之后已经支持多线程我在配置文件里开过io-threads。就这一句整场面试的走向就完全不一样了。面试官真正想看到的是你能不能把三个名词背后的机制打通以及你对这个系统有没有超出文档之外的理解。1.2 先给一张全景图快是多个层面叠加的结果我给新人讲这道题的时候习惯用一条链路来画一条命令从客户端发出经过网络到达Redis服务端服务端在事件循环里读到数据解析RESP协议然后在内存中操作对应的数据结构最后把结果写回客户端。这条链路上每一个环节都有自己快的理由。网络收包用了IO多路复用避免了大量线程空转协议解析用的是RESP这种极简文本格式几乎没有解析开销命令执行直接操作内存数据完全绕开了磁盘寻道数据结构经过深度优化把常见操作的复杂度压得很低再加上单线程串行执行省掉了锁竞争和上下文切换。每一层都不是决定性的但每一层都在抠时间。Redis的快本质上是一环扣一环设计出来的结果而不是某一项特性单独创造的奇迹。理解了上面这五条你就拿到了全文的地图。1.3 顺带回答一个容易被绕进去的问题快是相对谁说的很多人把Redis快和Redis比MySQL快划等号这个说法太粗糙。我曾经在压测环境里做过对比MySQL走一次主键查询本地环境大概在1到3毫秒Redis同样的GET请求本地回环在0.1毫秒以内。差距确实明显但要注意这不是Redis设计得更高级而是两者的定位根本不同——MySQL要保证持久化、事务、复杂查询Redis把大部分能力砍掉只留高速读写。讨论为什么快之前先搞清楚快是相对于什么场景、什么代价而言的否则很容易得出以后所有存储都用Redis这种危险结论。2. 内存这层物理优势绕开了整条磁盘I/O链路2.1 一次随机访问从10毫秒降到100纳秒意味着什么先看一组非常直观的数据。传统机械硬盘做一次随机I/O寻道加旋转延迟大概在10毫秒级别SSD好很多但随机读写也要几十到几百微秒而内存的随机访问通常在100纳秒左右。我经常用下面这张表给团队讲差距存储介质随机访问耗时量级机械硬盘约10ms毫秒级固态硬盘0.1ms ~ 1ms百微秒级内存约100ns纳秒级三个数量级的差距意味着什么意味着同样一次数据访问磁盘还在磁头寻道的时候内存已经把结果算完好几次了。MySQL这类数据库为了拿到一行数据可能需要走B树索引经历若干次磁盘或页缓存层面的I/O单次查询几毫秒很正常。Redis把数据直接放在内存里读写一个键值就是一次甚至几次内存访问单命令延迟天然就在微秒甚至亚微秒级别。官方文档提过一个数字一台普通服务器上单实例QPS可以轻松到10万以上压测跑几十万也不稀奇这份成绩的底座就是数据就在身边不需要去磁盘搬。2.2 顺着内存数据库往下想查询流程被砍掉了多少步同样是存储系统关系型数据库的查询要经历解析SQL、生成执行计划、走索引、回表、缓冲池换页、最终返回结果这么一长串流程。而Redis只有一条极短的路径读请求进来解析协议根据key计算哈希定位数据返回结果。中间没有查询优化器没有表结构没有索引页没有锁等待队列。它不是把某一个环节优化得飞快而是把整条链路砍得足够短。打个比方MySQL像一家大医院挂号、分诊、检查、开药、取药每个环节都有流程成本Redis更像一个随身药盒药就装在盒子里打开就有。这个比方可以解释很多现象——为什么Redis对简单查询特别擅长而一旦你想在Redis里做复杂聚合、多条件过滤它立刻就变得不那么好用了。因为那些能力本来就是被它主动砍掉的。2.3 别把内存优势想当然数据规模、淘汰和持久化的边界不过有一点必须提醒内存快不代表可以无限塞数据。我见过有人把几十GB的数据全放Redis结果内存淘汰风暴、持久化fork耗时、内存碎片接踵而来。内存是有限且昂贵的资源Redis适合放热数据、放需要极低延迟访问的数据而不是当磁盘用。生产环境里设定合理的maxmemory和淘汰策略比你追求再多几千QPS重要得多。另外内存本身是易失的所以快的另一面是持久化成本。RDB快照、AOF重写都需要fork子进程fork带来的页表复制和写时复制都可能制造延迟毛刺。这块我放到第6节专门讲你现在只需要记住一个结论Redis的快建立在数据全部驻留内存这个前提上。前提被破坏——比如内存不足触发频繁淘汰、或者频繁刷盘——快就会缩水。3. 事件驱动与IO多路复用一个线程服务十万连接的底层玩法3.1 从阻塞IO到非阻塞IO为什么不能让每个连接独占一个线程很多人对IO多路复用这个词有距离感我先把它放回一个具体场景里。如果Redis用的是最朴素的阻塞IO模型一个线程只能同时处理一个连接上的读或写。要让一万个客户端同时连上来就需要一万个线程。这一万个线程各自阻塞在read上操作系统光是维护线程上下文就累得够呛更别说线程频繁切换带来的CPU开销。更致命的是阻塞模型下大量线程其实是在空等——等网络数据到达等客户端发下一条命令。这种大部分人都在睡大觉但每个人都要占一张床的做法连接数一多效率就极低。所以高并发的网络服务几乎都不会走一个连接一个线程的老路。3.2 select、poll、epoll从轮询扫描到事件通知IO多路复用的核心思想是把每个连接轮流检查有没有数据这件事集中到一个系统调用里。select和poll就是这样诞生的内核帮你遍历所有关注的socket发现有数据可读或可写的就返回给你。但select有个著名限制默认最多监听1024个fd每次调用还要把整个fd集合从用户态拷贝到内核态然后线性扫描全部fd复杂度O(n)。连接一多光扫描就受不了。epoll是Linux下的进化版它做了三件关键的事一是在内核里维护一个事件表不需要每次重复拷贝fd集合二是用回调机制只有真正就绪的socket才会进入就绪队列三是应用层调用epoll_wait时直接取出就绪事件复杂度O(1)。这一下子把看有没有事的成本从线性扫描变成了直接拿结果。Redis在Linux平台上的IO多路复用底层用的就是epoll在其他系统上则对应kqueue、evport或select这就是为什么epoll经常和Redis绑定在一起被提到。3.3 aeEventLoopRedis事件循环内部到底怎么转只讲epoll还不够因为Redis并不是简单调epoll而是在上面包了一层事件循环。这个循环在源码里叫aeEventLoop入口是aeMain函数。它的工作方式概括起来就三件事计算最近的时间事件还有多久到期把这个差值作为epoll_wait的超时时间调用epoll_wait拿到当前已经就绪的文件事件按顺序处理这些文件事件再回头执行到期的定时事件。文件事件包括新连接到达、客户端有数据可读、结果可以写回等时间事件则负责serverCron这类周期任务比如过期键清理、内存统计、持久化触发。这套机制最关键的地方在于Redis永远只处理已经有事的socket不轮询、不空转把CPU都花在真正的命令处理上。这也是它能用单线程扛住高并发的直接原因。我们写代码的时候也可以借鉴这套思路能用事件驱动解决的并发问题就不要动不动上多线程。4. 单线程的反直觉与Redis 6.0的多线程改革4.1 多线程没让Redis更快的原因锁、上下文切换和一致性问题到这里通常会出现一个灵魂拷问既然要快为什么不用多线程让好几个CPU核心一起处理命令这个问题的答案非常反直觉对于Redis的典型负载多线程不但不能提速反而会引入一堆额外成本。首先是锁。如果多个线程同时修改同一个key对应的数据结构就必须加锁加锁意味着竞争竞争意味着等待和重试。其次是上下文切换线程调度要保存和恢复寄存器、栈指针切换本身就要消耗CPU周期。然后是缓存局部性单线程访问数据时热数据大概率都在CPU各级缓存里多线程在不同核心之间来回切换缓存反复失效。最后还有一致性和复杂度问题事务、分布式锁这些语义在并发模型下会变得非常难保证。所以很长一段时间里Redis坚持单线程执行命令这是把性能、复杂度、正确性放在一起权衡后的主动选择而不是无脑迷信单线程。4.2 单线程为什么扛得住内存操作本身就在纳秒量级单线程能撑起每秒十万级QPS关键前提是单个命令的执行时间足够短。一次内存读写大约100纳秒一条Redis命令哪怕要执行几十次内存操作整体也在微秒量级。单核CPU处理这些任务绰绰有余真正吃掉时间的是网络收发包、内存数据拷贝这些外围操作。这个观点Redis作者表达得很明确在内存数据库的场景里CPU不是核心瓶颈内存带宽和网络才是。与其费尽心思做并发控制不如把所有计算资源集中到单线程里让命令按顺序执行得干干净净。顺序执行还有一个附带好处命令天然串行化根本不需要考虑两个线程同时改一个数据这类问题很多复杂的正确性难题直接消失。用工程的话说这是用确定性换性能而且在这个场景里几乎没有损耗。4.3 多线程IORedis 6.0之后单线程三个字已经不严谨不过Redis是单线程这句话放到今天已经不够准确了。Redis 6.0开始引入多线程IO默认关闭需要手动在redis.conf里配置io-threads和io-threads-do-reads。这套改动的目标不是加速命令执行而是加速网络数据的读写。前面说过真正占时间的是网络收包和回包于是Redis把从socket读数据和把结果写回socket这两件事分摊到多个线程上而命令的解析和真正执行仍然在主线程串行完成。举个例子压测时如果单核的网络处理能力被打满多线程IO可以借助多个核心分担这段开销让整体吞吐继续往上走。但这并不改变命令执行的单线程本质共享数据的并发安全问题依然不存在因为执行阶段始终是同一个线程。以后再聊到Redis单线程记得补一句命令执行单线程6.0之后网络IO可以多线程既严谨又显得你跟进过版本演进。5. 藏在数据结构里的性能SSD再快也算不过来这些账5.1 SDS字符串O(1)的len和二进制安全内存快只是地基Redis之所以在很多场景下比别的内存方案更顺手还因为它把常见的数据结构做到了极致。先看最简单的字符串类型。C语言的字符串用\0结尾取长度得遍历整个字符串拼接要手动管理内存里面存二进制数据还会被截断。Redis没直接复用C字符串而是自定义了SDS结构里面直接存了len字段获取长度是O(1)而且它是二进制安全的存图片、序列化对象都没问题。更妙的是空间预分配策略字符串拼接时SDS会多分配一些空间避免频繁触发内存重分配。这些细节单个看都不起眼但Redis里大部分读写都是字符串操作积少成多就成了实打实的性能差异。从这一点也能看出Redis的设计风格不为宏大架构炫技而是把最热门的路径抠到极致。5.2 跳表、quicklist、intset为不同数据规模准备的加速方案有序集合ZSet是Redis里最被人称道的结构之一。它的底层用跳表加哈希表的组合让ZADD、ZRANGE这类操作稳定在O(logN)。为什么不直接用平衡树一个常见解释是跳表实现简单、调试容易而且对于范围查找、逆序查找跳表的双向链表结构非常友好。在单线程模型下跳表随机层级生成也不会引入并发维护的复杂度。列表和集合也各有讲究。List在元素少时用紧凑的listpack编码元素多了再转成链表加listpack的组合结构这样既省内存又保证头尾操作的效率。Set元素少时用intset——一个有序整数数组因为整数比较和二分查找都极快元素变多或类型变复杂才升级为哈希表。下面这张表可以帮助理解数据类型小数据量编码大数据量编码关键操作复杂度StringSDSSDSO(1)Listlistpackquicklist头尾O(1)Hashlistpackdict渐进式rehashO(1)SetintsetdictO(1)ZSetlistpackskiplist dictO(logN)Redis这种小数据用紧凑结构、大数据用通用结构的思路让它在真实业务里兼顾了内存占用和访问速度。这也是为什么同样的数据量放在Redis里比放在我们自己写的Map里往往更快——人家的内存布局是精心调过的。5.3 渐进式rehash与惰性释放把阻塞时间拆碎哈希表扩容是另一个经典问题。普通实现里数据量翻倍时一次性rehash几百万个键的搬迁会让服务卡顿几百毫秒。Redis的字典扩容采用渐进式rehash扩容时维护两张哈希表每次增删改查只搬迁一小部分数据把一次大搬迁拆成很多次小搬迁直到全部完成。这样单次操作的最坏耗时被控制住了不会出现让人无法接受的延迟尖峰。同样的思路体现在懒删除上。Redis 4.0之后提供了UNLINK命令删除大key时不是立刻释放内存而是交给后台线程慢慢清理。这个设计非常实用以前要删除一个包含几百万元素的大keyDEL会阻塞事件循环换成UNLINK之后主线程只做一个标记实际内存回收在后台完成延迟毛刺就消失了。Redis追求的东西在这里看得很清楚——不是某一瞬间的极限吞吐而是单次操作的延迟稳定。6. 快不等于没软肋我在线上遇到的几种Redis变慢6.1 大key和O(N)命令是如何拖垮事件循环的说完了快必须说说它不快的场景。单线程模型最大的死穴就是任何一条命令执行时间过长后面排队的命令全部遭殃。我线上排查过几次Redis延迟飙升最后几乎都指向同一个原因O(N)命令打在了大key上。举个典型场景一个列表里存了几十万条消息有同事顺手执行了LRANGE 0 -1或者对一个大哈希执行HGETALL。这些命令要遍历所有元素再把几十MB数据通过网络发给客户端。执行期间事件循环被占住其他读写的延迟直接飙到秒级。再比如KEYS *在几百万key的实例上跑一次整个实例卡死几秒钟是常有的事。后来我要求团队对线上Redis直接禁用KEYS统一用SCAN迭代同时定期用redis-cli --bigkeys扫描超过阈值的大key单独治理要么拆分要么改造业务逻辑。这个教训花了一次线上事故的代价才换来希望你不用重走一遍。6.2 fork与COW持久化制造延迟毛刺的真实原因第二个容易忽略的变慢点来自持久化。Redis做RDB快照或者AOF重写时需要调用fork创建一个子进程。fork本身要复制父进程的页表如果实例内存很大比如几十GB这个复制过程会消耗大量CPU导致一瞬间的延迟上升。这还不算完。子进程在写快照期间如果父进程持续有写命令进来触发写时复制每复制一页内存都会额外占用CPU和内存。我见过一个极端案例内存接近打满的实例上开启AOF重写延迟毛刺持续了很长时间。后来我们总结了几个规避手段高峰期避免触发BGSAVE和AOF重写在从节点上执行持久化主节点专注服务读多写少的业务把数据切小让单实例内存控制在合理范围内。记住一点Redis的快不是免费的持久化策略会直接侵蚀它的性能边界。6.3 网络层面的隐性成本RTT、Pipelining与连接数第三种慢不那么容易察觉是网络交互模式带来的。Redis延迟再低也架不住客户端一条条发命令、一条条等结果。如果业务代码在循环里反复读写Redis每次读写都是一次完整的网络往返即便Redis端只花几十微秒网络RTT却可能占掉几毫秒。这时Redis再快也没有用瓶颈在客户端和网络。解决办法通常有三个层次。第一用Pipelining把一批无关命令打包发送减少往返次数第二能用Lua脚本或Redis 7.0之后引入的函数把多条命令合并成一次交互就合并第三连接数不能无限涨连接太多会带来内存和上下文切换开销Redis默认maxclients是10000线上压到几千连接时就要考虑连接池复用。我记得有个业务做过一次优化把几百次循环读写改成pipeline批量提交接口延迟从几十毫秒直接降到个位数毫秒。Redis本身没变变的是网络交互方式。7. 回到Redis为什么快一份可以拿去面对的完整回答7.1 四层递进物理存储、事件驱动、并发模型、数据结构如果现在有人问我这道题我会按四个层面组织回答。最底层是物理存储Redis把数据放在内存绕开了磁盘I/O的毫秒级延迟这是所有快的前提。往上是网络模型IO多路复用加事件循环让一个线程用极低开销管理海量连接没有线程空转没有无谓的上下文切换。再往上是执行模型单线程串行执行命令绕开了锁竞争保证了缓存局部性也让复杂度模型变得极其清晰。最上层是数据结构SDS、跳表、渐进式rehash、惰性删除把每个操作的复杂度压到最低同时保证延迟稳定。这四个层面不是孤立的而是彼此咬合的。内存快所以单线程够用单线程所以不需要锁不需要锁所以数据结构和命令执行可以做得更简单结构简单高效又反过来让单线程能扛住更大压力。这是一个自洽的飞轮。7.2 顺带补一刀如果面试官追问那它什么时候会慢把为什么快讲完面试官大概率会追问那什么时候会慢。这时候千万别只说不知道这是一个展示实战深度的机会。顺着刚才的框架往下说就行当内存使用逼近上限淘汰策略频繁执行时性能会波动当大key被执行O(N)命令或被删除时事件循环被阻塞当持久化fork和写时复制叠加高峰流量时会出现延迟毛刺当客户端频繁建连、网络RTT过高时吞吐上不去。还可以补一句排查手段用SLOWLOG看慢命令用redis-cli --latency看端到端延迟用INFO stats观察瞬时QPS和内存碎片率基本能定位大部分问题。这一段讲完面试官通常会相信你不是背题而是真的在线上跟Redis打过交道。7.3 一个可以立刻上手的验证玩法理论讲再多不如自己压一把。装好Redis之后先用最朴素的方式感受一下它的快。本地终端执行下面两条命令redis-benchmark -c 50 -n 100000 -t set,get redis-cli --latencyredis-benchmark的输出会直接告诉你每秒能跑多少SET和GETredis-cli --latency则持续统计端到端延迟分布。我第一次跑的时候看到本地回环下绝大多数请求都在亚毫秒级才真正理解了内存数据库这几个字的分量。我建议你在压测时故意塞一个几万元素的大key然后另开一个终端执行HGETALL或者LRANGE 0 -1观察另一个终端里其他命令的延迟毛刺有多明显。做过这个实验之后你对单线程和数据要小而精这两句话的理解会比读十篇博客都深。最后分享一个我自己的习惯每次性能压测之前先跑一遍redis-cli --bigkeys看看实例里有没有埋着大key炸弹再开始压。很多看似玄学的毛刺其实在压测开始之前就已经在现场等着你了。