
1. 快在哪里先给“快”建立参照系做后端这几年不管是在公司内部的技术分享还是出去面试Redis 为什么快这个问题几乎快被问烂了。大部分人的答案就三板斧内存存储、单线程避免了锁竞争、IO多路复用。这答案对不对对但这三个点加起来只回答了一半的“快”。我自己的理解里Redis 之所以快本质上是把从客户端发出命令到内核写回响应的这条完整链路上每一个环节的浪费都压缩到了极致。它不是靠某个单一技术的降维打击而是靠一整套“所有机制都为低延迟服务”的工程哲学。为了搞清楚这件事我用官方自带的 benchmark 工具做过一组对比放在同样一台低配虚拟机上直接读写 MySQL同一台机器InnoDB走网络连接简单 SELECT 一条记录QPS 大概在 2000~5000 之间波动延迟在 1~5ms 左右。直接读写 Redis同样是网络连接走本地回环地址SET/GET 混合压测QPS 可以轻松跑到 8 万到 12 万延迟 P99 稳定在 0.3ms 以内。这两个数字一摆出来差距是数量级的不是简单快三五倍。问题来了内存硬盘的差异确实存在但 Redis 7.0 里如果你开启 AOF 持久化每次写命令实际上也要落盘为什么它还是比大部分“内存型”的数据库中间件快又为什么 Redis 官方要在 6.x 引入多线程 IO这些问题的答案恰恰藏在“内存数据库”这块招牌的背面。所以这篇东西我不想写口语化的入门把玩也不想罗列命令我想把“Redis 为什么快”拆成一个工程系统的四条主干线单线程事件循环、底层数据结构、全局哈希与渐进式 rehash、工程化细节的取舍。每一条线我都尽量结合源码层面的认知和本地实验的现象来讲最后聊聊实战里遇到过的一些“Redis 变慢”的真凶顺便给一套排查思路。先提醒一句如果你只是想背面试题那这篇文章对你来说细节太多但如果你是打算在自己项目里把 Redis 压榨到极致或者排查线上偶发超时后面这些底层原理是绕不开的。2. 单线程事件循环真正拉开差距的调度模型2.1 为什么单线程 Redis 反而快很多人第一次听到“Redis 是单线程”时第一反应是单线程不是应该更慢吗我当年也是这么想的直到后来自己用 C 写了一个简单的多线程内存 KV 服务才发现多线程在这类场景里有多坑。先算一笔账CPU 处理一条简单命令比如字符串 GET的纯计算时间大概在 100ns 到 1μs 这个量级而一次线程上下文切换的成本大约是 1~3μs一个线程因为争抢锁进入阻塞再被唤醒开销轻松到 10μs 以上。换句话说在多线程模型下如果每条命令只有极其短暂的计算量那么锁竞争、上下文切换造成的开销远大于计算本身。这就像你开一辆跑车结果每 50 米就要过一道收费站排队交费车速再快也没用。Redis 核心命令执行路径是单线程意味着同一时间只有一条命令在执行这就带来了两个关键收益第一没有锁。所有数据结构都是线程封闭的不需要为并发安全付出任何额外代价第二没有了上下文切换的浪费。对纯内存操作来说单核顺序执行反而能拿到最高的确定性延迟——每次请求的耗时曲线都很平稳不会突然因为调度抖动产生几十毫秒的尖刺。再有就是实现层面的优势单线程模型下所有命令都是原子性的不需要引入复杂的锁机制很多业务里依赖的 INCR、LPUSH 这类复合操作才能天然具备原子性。多线程方案实现同样的语义复杂度会成倍上升而且很容易引入隐蔽的竞态问题。我之前写的那个小 KV 服务就是这样加锁之后吞吐量不仅没涨反而因为锁竞争频繁出现超时那是非常糟糕的体验。2.2 IO 多路复用与事件驱动的完整链路单线程模型有个前提它必须能高效地同时处理成千上万的客户端连接。这里靠的正是 IO 多路复用。传统 BIO 模型是“一个客户端一个线程”Redis 如果这么做单线程就废了。它用的是 I/O 多路复用机制——在 Linux 上就是 epollMac 上是 kqueueWindows 上则是 select 的封装配合一个以 aeEventLoop 为核心的事件循环框架。为了搞清这个机制我专门在本地用 strace 追踪过 Redis 启动后的系统调用能看到它在等待客户端连接时阻塞在 epoll_wait 上而不是忙轮询。用大白话讲Redis 把“我要等着处理你的请求”变成了“有事情你叫我没事情我去休息”。当客户端有数据到达时内核会通过回调机制把对应的 fd 标记为可读Redis 的主线程被唤醒后一次性把这一批就绪事件全部拿出来逐个执行回调函数。这套链路串起来后一条简单的 SET 命令在 Redis 内部会经历四个阶段客户端连接注册fd 加入 epoll 关注列表epoll_wait 返回检测到该 fd 可读主线程调用 readQueryFromClient从内核 socket 缓冲区读取数据到输入缓冲区这一步是整条链路里唯一需要拷贝数据的地方解析命令、执行命令、把响应写回输出缓冲区然后注册写事件由事件循环下一次遍历时真正发送给客户端。整个过程不需要任何加锁、不需要线程切换、也不需要阻塞等待磁盘 IO除非开启 AOF。所以哪怕 Redis 每秒处理十几万次请求主线程也能忙得过来而且 CPU 占用曲线是均匀的。这也是为什么后来很多人说“Redis 的性能瓶颈在网络上、不在 CPU 上”尤其当你开启 AOF 并且没有使用批量写优化时磁盘 fsync 才是最大的瓶颈。2.3 多线程的引入与边界不过如果你用过 Redis 6.0 或 7.x会发现官方配置里出现了 io-threads 这个参数默认关闭。很多人迷惑不是说单线程才快吗怎么官方还给了多线程开关实际上 Redis 6.0 的多线程只做一件事网络数据的读写和协议解析。具体来说主线程仍然负责命令执行但 socket 数据的 read、write、协议解析这类无状态操作可以从主线程剥离出来交给一组 IO 线程去并行处理。命令真正被解析出来后还是要回到主线程串行执行的。这样既保留“计算单线程”带来的无锁优势又能扛住更大的网络吞吐尤其对于 100k 的并发连接单线程收包发包会成为瓶颈这时的 IO 多线程价值比较明显。我自己在生产环境压测过8 核实例开启 io-threads 4在大量小命令比如只 SET/GET短字符串的纯网络场景下吞吐提升了 30% 到 40% 左右。但在命令本身比较重比如 ZRANGEBYSCORE 取出大量数据时性能提升并不明显因为主要耗时已经转移到命令执行而命令执行仍然串行。这一点在开启前先想清楚Redis 的定位是一个低延迟内存计算引擎不是一个万能并行加速器。3. 数据结构快的不止是内存更是内存里的组织方式3.1 五种类型与底层六种结构的对应关系如果说事件循环是“门面”那 Redis 真正贴身肉搏的功夫全在数据结构上。同样是内存数据库有的项目用 HashMap 存一切数据量一大就出现内存膨胀、遍历耗时的现象Redis 则针对不同场景给每种类型设计了不同的内部编码。先看全景对应关系我整理了本地 Redis 7.0 里 object encoding 命令的实测结果数据类型可选内部编码触发条件实测默认配置stringint、embstr、raw8字节以内整数用 int44字节以内短字符串用 embstr更长用 rawlistquicklist7.0 中由 ziplist linkedlist 演化默认所有 list 都是 quicklist节点内压缩列表hashlistpack7.0 替换原 ziplist、hashtable字段数 ≤ 128 且每个字段长度 ≤ 64 字节时用 listpacksetintset、hashtable全部为整数且元素个数 ≤ 512 时用 intsetzsetlistpack、skiplist dict节点数 ≤ 128 且成员长度 ≤ 64 字节时用 listpack这个表格值得反复看。它说明 Redis 的快不只是因为数据在内存里更是因为它会根据实际数据形态自动选择最紧凑的底层结构尽量减少内存占用从而提升 CPU 缓存命中率。内存少一点意味着随机访问时缓存命中的概率高一点这个优势在千万级 key 的实例上会被放大得很明显。3.2 字符串、列表、哈希内部实现的演进逻辑拿 string 来说Redis 自己没有直接用 C 语言的 char*而是包装了一个 SDSSimple Dynamic String。它的头部记录了已用长度和未使用长度所以获取字符串长度是 O(1) 的不像 C 的 strlen 要遍历整个字符串。这也让 Redis 在修改字符串时可以预分配空间避免频繁 realloc。更重要的是SDS 是二进制安全的内部可以包含 \0 字符所以你能把一张图片的字节数组直接塞进去。list 的演进最能体现 Redis 对“空间换时间还是时间换空间”这个问题的态度。早期版本里元素少的时候用压缩存储元素多了直接用双向链表。双向链表的缺点是每个节点都要保存前后指针和元数据内存开销大而且节点分散在内存各个角落遍历时 CPU 缓存命中低。所以后来版本把这两个方案统一成了 quicklist本质是“多个连续的小块压缩存储 块之间用双向指针串联”既保留了压缩存储节省内存的特性又避免了单个超大连续内存块带来的分配和扩容问题。到了 Redis 7.0listpack 进一步替代了 ziplist从根上解决了 ziplist 的级联更新问题——这个问题在旧版本里一旦出现往头部插入数据时可能触发整条链的重新分配耗时暴涨。hash 和 zset 也有类似的设计小数据量时用紧凑的 listpack数据量超过阈值就自动升级为真正的哈希表或跳表。这些“自动降级/升级”的决策几乎不需要用户干预Redis 全部帮你做完了。这也是很多人用 Redis 用得很舒服的原因你不需要像用传统数据库那样去手动设计索引结构。3.3 跳表为什么压过了红黑树在 zset 这个类型上Redis 选了跳表skip list而不是红黑树这是很多人会忽略的细节。如果只做单点查询红黑树和跳表差别不大都是 O(log N)但 zset 的核心操作是“按分数范围查数据”和“按排名查数据”。跳表因为底层是多层有序链表范围查询时只需要在最高层快速找到起点然后沿着底层链表向右遍历就行了连续的内存访问模式对 CPU 缓存非常友好。红黑树要实现同样操作得做中序遍历还要记录前驱后继节点复杂度高得多。另外跳表的实现思路比红黑树的旋转平衡要简单直观Bug 率低也方便在并发场景下做局部的锁粒度控制。Redis 的作者自己说过用跳表的原因之一就是“它实现起来足够简单而且性能完全够用”。在工程里简单可靠往往比理论上的绝对最优更值钱。你在 Redis 源码里还会看到zset 实际上同时用一个 dict 和一个跳表来存储同一批数据dict 用来支持按 member 查分数的 O(1) 操作跳表用来支持按分数排序和范围操作。这就是“用空间换时间”的典型案例——为了两种高频操作都达到最优复杂度宁可多存一份索引。4. 全局哈希表与渐进式 rehash扩容不拖垮在线服务4.1 哈希表为什么要“渐进式”搞明白了底层数据结构还得看 Redis 最大的一张表——保存所有 key 的全局哈希表。这个 dict 结构本质上和 Java 的 HashMap 很像一个数组加若干链表Java 8 之后是红黑树key 通过 MurmurHash 或 siphash 映射到桶上。问题是当哈希表里的元素越来越多负载因子升高后必须扩容扩容就要把旧数组里的所有元素重新哈希到新数组里。如果这个操作一次性完成对于有千万级 key 的实例rehash 期间单次请求延迟可能飙升到几十毫秒甚至上百毫秒。Redis 的解法是渐进式 rehash扩容时新数组先分配好但旧数据不一次性搬完。每次对字典执行增删改查时顺便把旧数组中的一个桶迁移到新数组分摊到后续每一次请求里。这就好比搬家时不是找一天把所有家具一次性搬到新家而是每天搬几件搬完之前旧家新家同时用等东西全搬过去了再彻底停用旧地址。在源码里有一个关键的判断条件当哈希表负载因子大于 1 时Redis 会尝试扩容但如果是持久化过程中或者有子进程在跑负载因子要超过 5 才会扩容。为什么呢因为 rehash 期间RDB 子进程通过操作系统写时复制机制共享内存页如果频繁 rehash内存复制会成倍增加子进程内存占用飙升甚至可能触发系统的 OOM。这个细节在面试中很少被提到但它恰恰涉及 Redis 在“快”与“稳定”之间的权衡。4.2 rehash 过程的实测表现与调优经验我在本地构造过一个包含 500 万个 key 的实例然后连续插入大量新 key 触发扩容。整个扩容期间我用 redis-benchmark 观察 SET 请求的延迟正常情况下 P99 在 0.2ms 左右但在 rehash 进行的过程里P99 会短暂爬升到 1ms 左右然后又回落到正常水平。这说明渐进式 rehash 确实把最坏情况分摊了但并没有完全消除延迟抖动。如果你的业务对延迟非常敏感并且能预估 key 的数量级最好的办法是提前初始化哈希表大小。关于提前初始化我个人的建议是在应用启动阶段如果你知道数据规模大概在 100 万条左右可以先执行一次 DEBUG SETACTIVE_EXPIRE 之类的心跳预热——其实不准确。更直接的做法是在低峰期用大量 SET 命令预先填充一批占位 key然后删除让 dict 提前扩容到合适大小避免在高峰期触发一次大 rehash。这个方法有点土但我实测下来很有效比事后调大内存、观察慢日志要省心得多。还有一点要注意rehash 过程中新旧两个哈希表同时存在这意味着内存占用会短暂上涨。在低配机器上如果 maxmemory 已经设得很紧刚好遇到扩容极端情况下会发生内存分配失败Redis 直接进入 OOM 拒绝写入的错误状态。所以容量规划时最好给 Redis 预留 20% 以上的内存余量不要贴着上限跑。5. 工程细节的胜利从协议、持久化到缓存设计Redis 如何把“最快路径”走到底5.1 RESP 协议轻量文本协议的低开销很多性能问题其实是协议太重导致的。想想看如果用 HTTP 做缓存服务每请求都要带一堆 Header光是解析请求行和请求头就消耗不少 CPU。Redis 用的 RESPREdis Serialization Protocol协议非常节省它以各种字符开头标识数据类型比如 表示简单字符串、- 表示错误、: 表示整数、$ 表示批量字符串、* 表示数组每条命令和响应都用 CRLF 分隔几乎没有冗余信息。这个设计还有一个隐藏优势它也让批量操作变得极其自然。比如通过管道pipeline发送 1000 条 SET 命令客户端不需要等待每一条的响应而是一次性把 1000 条命令的字节流写入 socketRedis 主线程解析完一条执行一条最后再把 1000 个响应一次性打包返回。我本地实测过普通模式单线程客户端循环 SET 1 万条数据耗时约 1.2 秒改用 pipeline同样 1 万条数据耗时直接降到 30 毫秒左右。这个数量级的差距对批处理场景极其有价值。代价是协议没有实现像 protobuf 那样的二进制压缩但这恰恰是 Redis 的可读性好、调试简单的来源。你用 telnet 都能直接敲协议指令跟 Redis 交互排查问题的时候非常方便。这算是一种取舍牺牲一点极端的网络带宽利用率换来解析简单、调试直观和超高吞吐。5.2 持久化策略对性能的影响RDB、AOF 与合体如果 Redis 只做缓存那它有多快取决于你给它多少内存。可一旦开启持久化“快”就要打折扣。这时候不同持久化策略的选择会对吞吐和延迟产生截然不同的影响。RDB 是快照持久化父进程通过 fork 一个子进程由子进程把内存数据写入磁盘。因为利用了写时复制父进程在 fork 之后可以继续处理请求只有在 fork 的瞬间会有一次短暂的停顿这个停顿和实例内存大小成正比我见过一个 16GB 的实例fork 瞬间延迟飙升到 2 秒以上。RDB 加载时是直接把快照文件导入内存速度非常快但它的问题是可能丢失自上次快照之后的所有更新。AOF 是追加写日志每一条写命令都会追加到日志文件末尾。默认配置下Redis 会把命令先写入操作系统缓冲区由系统刷盘如果设置 appendfsync always则每条命令都执行一次 fsync这是最安全的但每秒只能撑住万级别的写请求设置为 everysecRedis 会每秒执行一次 fsync这是性能和持久化之间比较合适的平衡点绝大多数业务场景我都推荐这个配置。还有一个容易踩的坑是 AOF 重写。AOF 文件无限增长后Redis 会 fork 一个子进程重写一份精简的 AOF 文件。如果实例很大重写过程会导致比较高的内存和 CPU 占用而且 fork 期间的停顿无法完全避免。在 Redis 7.0 里AOF 文件被拆成了基础文件加多个增量文件的 multi-part 结构重写期间新写入的命令会进新的增量文件不会阻塞基础文件的写入过程这块比旧版本好很多。如果让我给一个配置建议不是缓存而是存储层的话RDB AOF everysec 组合是最常见的纯对账类场景比如积分流水可以开启 always但你的写入 QPS 就得做好下降一半以上的心理准备。压测时需要把这些持久化因素包含进去纯粹的 get/set 基准测试对生产环境参考意义有限。5.3 客户端与连接池跑得快也要“不堵车”Redis 服务端再快如果客户端不会用整体延迟依然很难看。最典型的问题就是频繁创建和销毁连接。Redis 的连接建立和销毁也有开销尤其是 TLS 加解密场景下一次握手就可能消耗几毫秒。正确的做法是用连接池维持一批长连接反复使用连接池大小不是越大越好太小会导致请求排队太大则可能在某些客户端库实现里引入额外的线程调度开销。这里有一个很实用的经验通用业务场景下每实例的连接池配 20~50 个连接就基本够用了。真正需要关注的是“‘慢命令’不能占住连接不放”——比如一次执行一个超大集合的 SMEMBERS或者一个 key 里存了 10MB 的字符串执行一次 GET 就得传输半天这个连接被占住的时间远超正常请求。所以连接池的配置经常要和单个命令的耗时上限一起设计我曾经见过一个服务因为某个 key 被写入了一兆字节的数据导致连接池里的几十个连接全部被这种慢查询卡住后续新请求全部超时最后只能靠熔断和限流才能恢复。另外如果你用的是 Redis Cluster客户端还要处理 MOVED 和 ASK 重定向。好的客户端库比如 Lettuce 或 Jedis 的集群模式会自动维护槽位映射但跨节点的 pipeline 性能会下降很多因为不同槽位的命令会被打散到不同连接发送。所以尽量让 key 的分布业务上均衡而不是依赖集群把你的命令自动“优化”。6. 常见坑位与实战经验Redis 变慢的几类真凶6.1 大 key、热 key 与慢命令的三重夹击Redis 再快也怕某些特定写法。我遇到过线上事故最集中的就是大 key 和热 key 同时存在。先说大 key。假设某个 hash 里有 100 万个字段你用 HGETALL 一次全取出来执行时间会达到秒级。在这一两秒内单线程 Redis 里所有其他请求都会排队等它执行完于是整个系统瞬间出现大量超时。不光是 HGETALLKEYS 命令、LRANGE 一个大列表、SMEMBERS 一个超大集合都有同样的效果。排查方法很简单在低峰期执行 redis-cli --bigkeys它会扫描整个实例给出每个类型里最大的几个 key 的分布情况。我在本地一跑经常能看到某些历史遗留 key 已经膨胀到几十 MB相当触目惊心。再说热 key。某个 key 每秒被访问几万次导致 CPU 在单线程事件循环里长时间执行读命令整体延迟上升。解决办法之一是加本地缓存或多级缓存层把热 key 的读压力从 Redis 剥离如果必须读 Redis可以给 key 增加后缀拆成多个副本客户端随机选取一个来读相当于把读压力分散到多个 key 上。但这需要业务层具备最终一致性的容忍度不能缓存一些强一致要求的数据。慢命令是另一个隐蔽陷阱。官方的慢查询日志默认只记录执行时间超过 10000 微秒10ms的指令实际线上一般要调低到 1000 微秒甚至 500 微秒否则很多几毫秒的慢操作根本不会被发现。相关命令是 SLOWLOG GET 和 CONFIG SET slowlog-log-slower-than 1000一旦开启你就能看到哪些命令在持续拖慢事件循环。在我的经验里真正的凶手往往是复杂的范围查询、很大的批量 mget、以及执行 Lua 脚本时脚本里写了慢循环——这些都在压测中才能暴露。6.2 内存碎片率、swap 与 fork 阻塞底层系统因素的影响除了使用姿势的问题Redis 变慢有时候并不是 Redis 自身代码的锅而是操作系统层面出了问题。第一个是内存碎片。当 Redis 频繁执行写入、删除、过期淘汰时jemalloc 分配的连续内存可能出现大量碎片表现为 used_memory 不高但 RSS 居高不下。MEMORY DOCTOR 命令可以检查 fragmentation 指标。碎片率在 1.0 到 1.5 之间是正常的如果超过 1.5你就需要考虑在业务低峰期执行内存清理或重启实例让内存重新整理。当然重启是下策最好先通过优化 key 的过期策略、减少频繁的更新操作来预防。第二个是 swap。如果系统可用内存不足操作系统的 swap 机制会把 Redis 的部分内存页交换到磁盘上。一旦发生这种情况Redis 的延迟会突然变得极其不稳定因为每次访问被换出的页都要走磁盘。这里有个命令值得留意INFO memory 里的 used_memory 和 used_memory_rss两者差距过大再配合 vmstat 看 si、so 列是否持续非零就能判断是不是 swap 了。一旦确认立刻扩容内存或迁移实例。第三个是 fork 阻塞。这个问题在前面提过再展开一点RDB 快照和 AOF 重写的子进程都是通过 fork 创建的。fork 本身不是拷贝所有内存而是拷贝页表但内存越大页表越大fork 的耗时就越长。我在一个 20GB 的实例上观测过fork 一次要卡住主线程接近 3 秒。解决办法是把持久化任务分散到多个从节点上执行主节点只负责读写从节点负责跑 RDB 或 AOF 重写实在不行可以把 RDB 的 schedule 策略调到系统负载低的时间窗口。6.3 排查 Redis 延迟问题的实用清单结合我自己的排障经验如果你生产环境里的 Redis 变慢不要急着重启按这个顺序查能省很多时间先看基础指标INFO commandstats 看哪类命令耗时占比最高INFO cpu 看主线程 CPU 是否打满。开启并调低慢查询阈值SLOWLOG GET 100重点看一下耗时 TOP 的命令从命令本身找问题。扫描大 keyredis-cli --bigkeys 找到体积最大的 key检查业务逻辑里是否一直在无脑追加数据。检查内存与系统层MEMORY DOCTOR、INFO memory、vmstat确认不是 swap 或内存碎片问题。检查持久化子进程INFO persistence 看是否有正在进行的 RDB 快照或 AOF 重写结合最近一次 fork 的耗时判断是否影响了主线程。检查网络redis-cli -i 1 -r 120 --latency 观察整体延迟分布如果客户端与 Redis 之间跨机房网络距离本身就有硬延迟。这套流程走下来绝大多数“Redis 为什么突然变慢”的问题都能定位到一个具体环节而不是靠经验盲猜。7. 我对“Redis 快”这件事的最终理解回到标题那句话Redis 之所以快并不仅仅因为它是内存数据库。内存只是必要条件真正的充分条件是它在 IO 模型、数据结构、内存分配、持久化策略、协议设计这几个维度上都把“低延迟”作为最高优先级来取舍。单线程消灭了锁竞争IO 多路复用消除了阻塞等待紧凑的数据编码提升了缓存命中率渐进式 rehash 避免了扩容抖动轻量协议和 pipeline 压低了传输开销——这些东西叠加在一起才有了那个让人印象深刻的 QPS 数字。我自己在实际排查 Redis 性能问题时最大的体会是Redis 确实很快但快不是免费的它对你的使用方式极其敏感。一个设计不当的 key 结构、一条无意识的大范围查询、一次不合理的持久化配置都可能在某个瞬间击穿它的“快”。理解了底层机制之后你会对它抱有更合理的预期也更有把握在系统性性能优化时找到正确的杠杆点。最后再分享一个我个人的小习惯每次在新环境部署 Redis 之后我都会第一时间用 redis-benchmark 和 redis-cli --latency 记录一份基线数据存到文档里。这不是为了炫耀数字而是为了给以后排查“是不是环境变了”留一个对照参照。性能问题最怕的就是没有基线一旦线上延迟异常手里有一份历史数据定位效率能翻一倍。