最近在技术群里被问到最多的一个问题就是:Redis 明明是单线程的,凭什么能扛住 10 万 QPS?很多刚接触 Redis 的开发者一听到“单线程”这个词,下意识就觉得它和高并发不搭边,甚至有人问我“是不是 Redis 内部用了多线程但对外宣传是单线程”。其实 Redis 能跑到 10 万 QPS,并不是靠堆线程堆出来的,恰恰是因为它在设计上做了极其精准的取舍,把“单线程执行模型”这个看似劣势的特性,变成了一套非常高效的工作方式。
这篇文章作为“Redis 实用技巧”系列的架构解析上篇,我会从事件循环、内存存储、数据结构、命令执行链路这几个角度,把“单线程 Redis 为什么快”这件事彻底讲明白。不管你是准备面试、还是在生产环境排查性能问题,又或者只是单纯好奇 Redis 的内部实现,这篇都值得花几分钟看完。看完你会发现,所谓高并发,真正靠的不是“并行”,而是“减少不必要的工作”。
1. 为什么单线程反而是个优点:先看懂设计者的账本
1.1 你看到的瓶颈,和 Redis 实际面临的瓶颈不是一回事
大多数时候我们聊多线程,之所以觉得“线程越多越好”,是因为大家都默认任务会吃满 CPU。比如图片处理、视频转码、复杂计算,这类任务属于 CPU 密集型,多核确实能带来立竿见影的收益。但 Redis 的核心场景是内存读写,操作的是已经存在内存里的数据,CPU 对单个命令的计算量非常小,真正的时间往往花在网络 I/O、数据拷贝和上下文切换上。
如果你把所有精力都放在“如何把 Redis 改成多线程”,你会发现最直接的结果是:线程多了,锁也多了,线程间为了抢共享数据互相等待,CPU 频繁切换线程上下文,缓存命中率下降,性能不升反降。这是当年 Memcached 用多线程、但 Redis 坚持单线程的核心理由之一——在内存操作这个场景里,串行执行不仅不慢,反而更可控。
1.2 省下三笔大开销:切换、锁、缓存失效
CPU 在处理一个线程时,需要把当前线程的上下文保存下来,再加载下一个线程的上下文,这个动作就叫上下文切换。上下文切换本身是有代价的,而且一旦线程数超过 CPU 核数,切换会变得非常频繁。Redis 用单线程执行命令,意味着命令执行过程里没有上下文切换,CPU 可以专注于把当前命令处理完,再处理下一个。
锁竞争也是多线程模型里最头疼的问题。多个线程同时修改同一个哈希表、同一个链表,必须有锁来保护,否则会出现数据错乱。而单线程天然不需要锁,因为命令是一条一条执行的,不会有两个线程同时往同一个 key 上写数据。这带来的好处不仅是快,更重要的是所有命令天然具备原子性——你不需要额外加事务,执行 INCR、LPUSH 这类操作时,中间不会被其他命令插进来。
还有一个容易被忽略的点:CPU 缓存。现代 CPU 有 L1、L2、L3 多级缓存,单线程执行时,热点数据很容易一直留在 CPU 缓存里,读取速度极快。一旦多线程切换,缓存里的数据会被反复淘汰,每次都要回到内存里取数据,反而慢了。
1.3 单线程模型的隐藏红利:确定性和可观测性
多线程程序难调试,因为执行顺序无法预知,BUG 经常是“偶发”的。Redis 单线程模型下,命令执行顺序就是客户端发送顺序,任何时刻只会有一个命令在跑,排查问题的时候只需要关注事件循环里发生了什么,就能复现绝大多数性能问题。这也是我特别喜欢在生产环境里用 Redis 的原因之一——它的行为是高度确定性的,出了故障可定位性很好。
2. 核心细节拆解:一条命令从进入到返回,走过了一条什么样的路
2.1 非阻塞 I/O + 多路复用:单个线程管上万连接的关键
很多人有个误区,觉得“单线程”就是一次只能服务一个客户端。实际完全不是这样。Redis 单线程能服务成千上万个连接,靠的是I/O 多路复用。
可以这样理解:传统阻塞式网络模型里,每个连接都需要一个线程专门等着,数据没来就阻塞住,线程白白浪费。而 Redis 的做法是,一个线程同时盯着所有连接,内核告诉他“哪个连接有数据可读”,他就去处理哪个连接。Linux 下这个机制叫 epoll,macOS 和 BSD 下叫 kqueue,Redis 自己封装了一层事件驱动库,屏蔽了不同操作系统的差异。
整个工作流程是这样的:Redis 主线程进入一个事件循环,不断问内核“有没有新消息来了”,如果某个客户端发来了一条命令,事件循环就把这个连接标记为可读,然后读取数据、解析命令、执行命令、把结果写到输出缓冲区,再继续下一轮循环。整个过程里没有一个连接会阻塞主线程等待数据,都是“有数据了就处理,没数据就继续看别的”。
所以 Redis 单线程真正在做的不是“一次服务一个请求”,而是“一次服务所有请求中已经就绪的那一个”,这个效率比每个线程盯一个连接高得多。假如有 1 万个空闲连接,阻塞式模型需要 1 万个线程蹲守,变成多路复用后,一个 Redis 进程就够了。
2.2 内存里的数据天生就快:一次磁盘 I/O 的时间能做几百万次内存访问
Redis 之所以能有十万级 QPS,最根本的物质基础还是它把数据全部放在了内存里。内存随机访问的延迟一般在 80 纳秒到 100 纳秒级别,而普通 SSD 随机读的延迟在 20 到 100 微秒,机械硬盘更是在毫秒级别。也就是说,内存访问比 SSD 快几百倍,比机械硬盘快几万倍。
这就出现了一个很关键的设计取向:Redis 牺牲了“数据持久化绝对可靠”这一点,把绝大部分时间都省下来服务请求。磁盘只负责定期把内存里的数据落盘,或者以追加日志的方式记录写操作,而且这些操作都尽量做成异步,不让主线程等着。换句话说,一个 Redis 请求在内存里很快就完成了,磁盘 I/O 都靠后台线程或者子进程去处理,主线程自然有能力不停接收新请求。
2.3 底层数据结构设计:每个命令都尽量做到 O(1) 或者 O(log N)
内存快是一方面,数据结构设计也得跟上。Redis 内部并没有直接用原生的字符串、链表、哈希表去存储数据,而是针对自己的使用场景做了大量定制。比如字符串用的是 SDS(Simple Dynamic String),获取长度是 O(1),而且可以避免 C 语言字符串里常见的缓冲区溢出问题;哈希对象底层有 ziplist 和 hashtable 两种编码,小数据用紧凑结构,大数据自动切换;有序集合底层用跳跃表加哈希表,让 ZRANGE、ZSCORE 这些操作都保持高效的复杂度。
正因为每种数据类型背后都有一套针对性优化的结构,Redis 才能做到大部分命令都是 O(1) 或者 O(log N)。你想想,如果执行一个 GET 还需要遍历链表,就算放在内存里也不可能跑出十万 QPS。所以“快”不是一个单点结果,而是每一层都做了极致优化的综合结果。
2.4 轻量级通信协议:解析快、传输省
Redis 的客户端和服务器之间用的是 RESP(Redis Serialization Protocol)协议。这个协议非常简单,只有几种类型,比如简单字符串、错误、整数、批量字符串、数组。命令就是数组,数组里的每个元素都是批量字符串。因为格式固定、没有复杂的语义解析,Redis 解析一条命令的开销非常低,CPU 大部分精力都能留给真正的数据操作。
这点和关系型数据库相比尤其明显。一个 SQL 要经过词法分析、语法分析、生成执行计划等一系列流程,解析代价非常高。Redis 的协议简单到可以手写,省掉的解析时间积少成多,在高并发场景下差别很大。另外,RESP 协议还有内联命令格式和管道支持,客户端可以一次发送多条命令,减少网络往返,这也是吞吐量提升的重要途径。
3. 实测一次:10 万 QPS 到底怎么跑出来的
3.1 先用 redis-benchmark 看一组直观数据
Redis 自带的 redis-benchmark 工具,是测试 Redis 性能最直接的途径。我在一台普通测试机上跑过大量测试,单实例、纯内存、不开启 AOF、走本机回环地址,SET/GET 这类简单命令的 QPS 基本都能稳定在 10 万以上。比如下面这条命令:
redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 1000000 -c 50 -P 16参数含义:-t指定测试命令,-n指定总请求数,-c表示并发连接数,-P表示管道批量大小。把管道参数调大,吞吐量会非常可观,因为每个 RTT(网络往返时间)能带上 16 条命令,网络等待被摊薄了。
不过这里要提醒一句,redis-benchmark 的数字是理想值,不等于生产环境真实值。真实业务里命令多样,有慢查询,有大 value,有持久化开销,还隔着真实的网络链路,不能拿 benchmark 结果直接当作容量评估依据。但它可以帮助你了解这台机器的 Redis 性能上限,也能帮你验证配置是否正常。
3.2 单线程事件循环在高并发下的真实处理节奏
假设现在有 1000 个客户端同时连上了 Redis,事件循环不会挨个去询问“你有没有消息”,而是通过 epoll 一次性拿到“就绪事件列表”。这就好比一个接待员同时守着一排窗口,哪个窗口有客户来了,他就过去接待,没人来的窗口他根本不需要管。每个命令的处理时间非常短,往往只有几十微秒,所以一秒钟内他可以来回处理几万个事件。
需要注意的一点是,Redis 的“单线程”指的是命令执行线程是单个的,并不代表整个进程只有一个线程。Redis 在后台还有用于持久化的线程、用于异步删除大 key 的线程、用于关闭文件描述符的线程等。从 Redis 6.0 开始,还增加了多线程 I/O,就是把 socket 读写这部分工作从主线程里面拆出去,让多个线程并行处理“网络数据的读取和写回”,但最终的命令执行依然是在主线程串行完成。
用一句好记的话总结:Redis 6.0+ 的“多线程”只是让网络收发更快,命令执行还是“一个一个来”。这样设计的好处是,既能利用多核优势处理海量网络连接,又保留单线程执行模型带来的原子性和确定性,是最稳妥的折中方案。
3.3 真正决定 QPS 的并不只是 Redis 本身,还有客户端和网卡
再快的 Redis,如果客户端不会用,也发挥不出来。实际压测时你会发现,QPS 上限常常卡在网络和客户端侧。比如启用了 TCP Nagle 算法,小包会被合并后才发送,延迟增加,整体吞吐下降;比如客户端频繁创建销毁连接,握手开销占了大头;再比如单条连接上一条一条发命令,没有使用 pipeline,每发一条都要等一个 RTT。
生产环境里想让 Redis 跑出高 QPS,我的建议是:连接池必须要用,连接复用是基础;高吞吐场景尽量启用 pipeline,把多条命令打包发送;关闭 Nagle 算法,让数据包尽量立即发送。另外,网卡中断处理、CPU 频率、内存频率都会影响最终数字。10 万 QPS 从来不是 Redis 单方面的事,而是 Redis、网络、客户端三方配合的结果。
下面这张表是我整理的不同场景下,QPS 差异的主要来源:
| 影响因素 | 低 QPS 场景 | 高 QPS 场景 |
|---|---|---|
| 网络模型 | 每请求一连接 | 长连接 + 连接池 |
| 命令发送 | 单条发送 | pipeline 批量发送 |
| TCP 参数 | 默认 Nagle | 关闭 Nagle 降低延迟 |
| 命令类型 | 复杂命令、大 key | 简单命令、小 value |
| 持久化配置 | AOF always | AOF everysec 或关闭 |
4. 单线程模型下的避坑指南:这些操作千万别乱用
4.1 慢命令是单线程模型最大的敌人,没有之一
因为所有命令都是串行执行的,一条命令耗时长,后面所有命令都得等它。单线程最怕的就是慢命令。真正慢的命令往往不是 O(N) 那么简单,而是那些把大量数据一次性取回来或者一次性删除的操作。比如 KEYS 命令,它需要遍历整个键空间匹配模式,数据库里如果有几百万个 key,执行一次就能把 Redis 卡住几百毫秒,线上服务基本就雪崩了。
正确的替代方案是使用 SCAN 命令,游标式地分批遍历,每次返回少量 key,不会长时间阻塞。类似的,HGETALL 用于大哈希、SMEMBERS 用于大集合、LRANGE 用于大列表,也要尽量避免。如果确实需要取全量数据,要么缩小小 key 的规模,要么分批次取。
我在生产环境见过最典型的案例:代码里用 KEYS 做缓存清理,业务量一上来,Redis 每隔几分钟卡一次。后来改成 SCAN 分批删除,问题立刻消失。
4.2 大 Key 的杀伤力往往是延迟和内存的双重爆炸
大 key 指的是单个 key 对应的 value 特别大,比如一个哈希里有几十万个字段,一个列表里有几百万个元素。这种 key 的危害,一是任何针对它的命令都可能耗时很久,阻塞整个 Redis;二是内存占用高,影响持久化和复制效率;三是删除的时候如果直接 DEL,同样会阻塞主线程。
排查大 key 可以直接用 Redis 自带的工具:
redis-cli --bigkeys它会扫描整个实例,按数据类型统计出最大的 key 有哪些。对于确实存在的大 key,删除时不要用 DEL,可以异步删除:
UNLINK mykeyUNLINK 只在主线程里做很小的整理动作,真正的内存释放交给后台线程。这是 Redis 4.0 之后我非常推荐的操作,能有效避免删除大 key 造成的阻塞。
4.3 阻塞式命令要谨慎,别让“等”变成“卡”
Redis 里还有一类命令,设计初衷是“阻塞等待”,比如 BLPOP、BRPOP。客户端执行 BLPOP 时,如果列表为空,它会一直阻塞到超时或者有新数据进来。这个阻塞等待本身并不会占用 CPU,但是一旦有多个客户端同时在阻塞,Redis 需要维护这些等待关系,生产环境中如果一个列表的消费速度跟不上生产速度,等待的客户端会越来越多,事件循环压力也会变大。
尤其要注意的是,阻塞命令如果在主线程上执行且参数不设置超时,客户端可能长时间占着一个连接不释放,连接数被大量消耗,最终影响其他正常请求。所以要给阻塞命令都配上合理的超时时间,并且充分评估消费能力,不要让生产速度和消费速度严重失衡。
4.4 持久化也尽量别抢主线程的时间
很多人以为 Redis 开了持久化,就是每次写操作都刷磁盘。事实并非如此,具体要看配置。RDB 快照是 fork 一个子进程去生成快照文件,主线程继续服务,fork 瞬间会有一次阻塞;AOF 的写盘频率由 appendfsync 决定,如果配置成 always,每条命令都 fsync,性能会显著下降。推荐配置是 appendfsync everysec,每秒刷一次,既保证安全性,又把对主线程的影响降到最低。
另外,Linux 内核的 fork 机制采用写时复制(Copy-On-Write),正常情况下内存占用不会翻倍。但如果开启了大量写操作,子进程持有了快照,父进程修改内存页时就会触发复制,内存占用会上升,需要提前留足内存余量。生产环境中我见过因为内存不足导致 fork 失败,Redis 直接拒绝写入的情况,所以不要忽略了持久化带来的隐性内存开销。
4.5 延迟排查三板斧:SLOWLOG、LATENCY、redis-cli --latency
当你怀疑 Redis 变慢时,别急着盲目优化,先定位问题。先看慢日志:
SLOWLOG GET 20慢日志会记录执行时间超过阈值的命令,默认阈值是 10000 微秒,也就是 10 毫秒。生产环境建议把这个阈值调低一些,比如 5000 微秒,能更早发现隐患。命令执行超时通常是慢命令、大 key、或者 CPU 争抢导致的,可以直接从 SLOWLOG 里找到元凶。
如果慢日志里找不到明显问题,可以使用 Redis 提供的延迟监控工具:
redis-cli --latency这个命令会持续采样,显示网络往返延迟。如果延迟出现明显尖峰,结合慢日志和系统监控,基本就能定位是 Redis 内部问题还是网络问题。还有一个经验是,查看INFO commandstats,统计每个命令的调用次数和耗时占比,很多“隐藏的慢命令”在总量上不明显,但平均耗时很高,能从 commandstats 里看出来。
一些个人经验收个尾
做了这么多年缓存和中间件相关的项目,我对 Redis 的体会是:它的快不是魔法,而是一套清晰的“减法哲学”——用内存替代磁盘,用事件驱动替代阻塞等待,用高效数据结构和简单协议减少每个请求的 CPU 开销,用单线程换掉锁和上下文切换的代价。这套设计在大多数业务场景下都非常合理,因为大多数请求本来就是微秒级的小操作,瓶颈从来不在 CPU,而在网络和客户端。
我也踩过不少坑。早期接手一个项目时,发现 Redis 的 QPS 上不去,第一反应是加机器、加内存,后来用 SLOWLOG 一看,满屏都是 KEYS 和 HGETALL 扫大哈希,把执行时间从微秒级拖到了几十毫秒。把代码改成 SCAN 和小批量处理之后,单实例 QPS 直接翻了两倍多。所以说,理解单线程模型的应用边界,比盲目升级硬件更值钱。这篇文章侧重架构层面的设计原理,后面如果再聊“下”篇,我打算结合实例讲讲如何通过监控、调参和优雅的客户端用法,把 Redis 每个 10 万级 QPS 长期稳定在可控范围内。