单线程跑 10 万 QPS,这句话在 Redis 相关的技术讨论里几乎成了“经典考题”。每次聊到它,总有人会先愣一下:单线程凭啥快?我的机器 8 核 16 线程,跑个业务接口连 2000 QPS 都费劲,Redis 单线程能跑 10 万,是不是测试有问题?
先说结论:这个数字不是营销口号,我在自己的测试环境里也稳定复现过。Redis 能跑到这个量级,靠的不是“线程多”,而是把性能瓶颈几乎全部绕开了。这篇文章我会把架构层面的核心逻辑拆开讲:为什么单线程是合理选择、10 万 QPS 是怎么来的、单线程模型的天花板在哪、哪些场景会让它从 10 万直接跌到几百,以及如果你想亲自验证,应该怎么测才能得到一个有说服力的数字。
如果你正准备 Redis 面试、做架构选型,或者只是好奇 Redis 内部到底怎么工作,这篇文章都值得读完。那些网上流传的“因为内存操作所以快”之类的说法,太笼统了,真正的原因要一层层往下拆。
1. 10 万 QPS 这个数字是怎么来的,它到底意味着什么
1.1 先给 QPS 一个基准:什么指令能跑到 10 万
Redis 官方文档里有一个性能描述,说单实例在普通硬件上可以达到每秒 10 万次以上的简单读写请求处理能力。这里有个关键词容易被忽略:简单请求。
什么叫简单请求?就是你向 Redis 发一个GET、SET、LPUSH、SADD这种单键、短命令、不需要复杂聚合的指令。我用redis-benchmark实测过,在 2 核 4G 的云主机上跑SET和GET,50 并发、100 万请求总量,QPS 基本落在 9.5 万到 12 万之间。而当你改测SORT、KEYS、LRANGE 1000这类命令,QPS 立刻掉到 1 万以下,甚至只有几千。所以讨论“10 万 QPS”时必须先说清指令类型。
注意:
KEYS这种命令从来都不是为生产环境设计的。它要遍历整个键空间,数据量越大越慢。线上一般用SCAN代替,就是因为它不会一次性阻塞事件循环。
另一个容易误导人的地方,是 QPS 和吞吐量的区别。10 万 QPS 意味着一秒钟要完成 10 万次请求-响应往返,但这不意味着每秒能搬运 10 万个大对象。如果你塞一个 5MB 的字符串进去,吞吐量可能很高,但 QPS 会惨不忍睹。因为单个命令的耗时拉长了,单位时间能处理的请求数自然就少了。
1.2 硬件条件对数字的影响比想象中大
还有一个影响 QPS 的关键变量:测试客户端到 Redis 服务端的网络链路。
我自己做过对比测试,同样的 Redis 实例,客户端和服务端在同一台机器上跑回环地址测试,QPS 可以稳定 10 万+。一旦把客户端放到另一台云主机上,走内网访问,QPS 会明显下降——倒不是带宽不够,而是网络往返的延迟(RTT)拉长了单次请求的耗时。
假设服务端处理一个请求只需要 0.02ms,但网络 RTT 要 0.2ms,并发数不够高时,CPU 大量时间在等网络数据。这时 QPS 上不去,问题根本不在 Redis 本身。Redis 之所以适合做缓存,有个重要前提是它和业务服务端尽量部署在同一可用区内,网络延迟要足够低。
所以,10 万 QPS 本质上说的是:Redis 服务端处理逻辑本身极快,瓶颈已经被压缩到网络和客户端侧。
2. 单线程并不吃亏:从上下文切换和锁竞争聊起
2.1 多线程有成本,而且成本不小
很多人的直觉是:多核时代,不用满多核就是浪费,单线程必然跑不过多线程。这个直觉在计算密集型场景里基本成立,但一旦涉及并发访问共享数据,情况就完全不同了。
Redis 的数据是所有客户端共享的。如果做成多线程,两个线程同时修改同一个 key,就必须加锁。而锁竞争会导致线程阻塞、唤醒、上下文切换。一次上下文切换的耗时大约在微秒级别,看起来不多,但在 Redis 这种单次操作几十微秒的场景里,一次上下文切换可能让处理耗时翻好几倍。
更麻烦的是,加锁会带来严重的不可预测性。某个请求的响应时间可能因为锁等待从 1ms 变成 20ms,这在缓存场景里是可以接受的,但在大量微服务互相调用、超时时间设置很紧的场景里就非常难受。Redis 的单线程模型相当于从根本上消灭了“并发修改同一个 key”的问题——不存在竞争,就不需要加锁,所有操作天然串行化。
类比一下:多线程像厨房里好几个厨师同时做菜,听起来高效,但只有一个灶台、一把菜刀。厨师们要互相等着用灶台,为了抢刀还得喊号子。单线程则是一个厨师把所有菜按顺序做完,没有了沟通和争执成本,出菜节奏反而稳定。
2.2 为什么不把“读”做成多线程
这里有个很容易想到的优化方向:写操作串行化没问题,但读操作能不能多线程?Redis 6.0 之前确实没有这么做,核心原因有两点:
- Redis 的性能瓶颈不在 CPU 上,而在网络 I/O 和内存访问上。单纯把读请求拆到多线程,CPU 使用率不会成为瓶颈,锁和同步的开销反而会摊薄收益。
- Redis 的所有数据结构都假定了“单线程访问”这个前提。一旦引入并发读,所有底层结构都要重新设计,付出的复杂度代价远大于收益。
Redis 6.0 之后引入的多线程,本质上也不是让命令逻辑并行执行,而是把网络 I/O 的读写拆到多个线程里做,命令的执行依然在单线程事件循环里串行完成。这个设计很聪明:把最耗时的部分(系统调用、数据拷贝)并行化,核心数据结构的并发安全问题则完全没引入。
2.3 单线程带来的额外红利:原子性和简单性
单线程模型还有一个很少被提到的优势:每个命令天然原子。
这个特性太重要了。对于一个 key 的INCR操作,在多线程环境下必须加锁才能保证不丢更新,但在 Redis 里根本不需要考虑并发写冲突。基于这个特性,Redis 才提供了不需要额外分布式锁的原子操作,也确实因此诞生了分布式锁等高级用法。
同时,单线程让 Redis 源码的可维护性大大提升。你不用去追查复杂的死锁和竞态条件,调试和性能分析都简单得多。很多人低估了这种“简单性”对稳定性带来的价值——一个并发 Bug 可能在极端流量下才会触发,排查难度极高,而纯单线程的模型把这类问题出现的概率降到了零。
3. 藏在 I/O 模型里的秘密:epoll 和事件循环的配合
3.1 传统的阻塞式 I/O 为什么不够用
很多服务端程序慢,不是业务逻辑慢,而是被阻塞在 I/O 上了。一个客户端连接上来了,程序去读数据,如果采用阻塞式读取,线程会一直趴在那里等客户端把数据发过来。这个过程里 CPU 几乎什么都没干,纯等在浪费时间。
用多线程解决这个问题是常规方案:一条连接一个线程,哪条连接有数据,哪条线程就工作。但线程数量一多,CPU 又要花大量时间在线程调度和上下文切换上。连接数上万的时候,这种“一个连接一个线程”的模型直接崩溃——线程切换开销远大于实际工作开销。
Redis 用的是完全不同的思路:一个事件循环处理所有连接。
3.2 epoll 帮 Redis 解决了什么问题
Redis 在 Linux 平台上依赖 epoll 机制来感知 I/O 事件。epoll 的核心能力是:当某个 socket 上有数据可读、或者可以写入数据时,内核会主动通知应用程序。
Redis 的执行过程类似这样:
- 主线程在 epoll 上等待,调用
epoll_wait阻塞自己。 - 一旦有客户端请求到来,内核返回可读事件列表。
- Redis 逐个处理这些事件,读请求、执行命令、写响应。
- 处理完一批后,重新回到等待状态。
整个过程只有一个线程,但这个线程完全没有浪费在等待单个连接的数据上。它就像一个前台服务员,同时接待 100 桌客人——谁举手示意了就过去服务一下,服务完立刻回到门口等下一个举手的人。客人不叫,服务员就一直等着,期间不消耗什么资源。
关键差别在阻塞式 I/O 那里:一个服务员只盯一桌客人,其他桌没人管;桌数多了,服务员的人数也要跟着增加。而 Redis 的单线程加 epoll 组合,可以让一个线程管理成千上万个连接。
3.3 事件循环里那个永不退出的主循环
这部分我展开一点代码层面的逻辑,帮助你把 Redis 的架构印在脑子里。
核心部分是一个while循环,大致这样:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS); } }在aeProcessEvents里,会先调用aeApiPoll(Linux 上封装的是epoll_wait),拿到就绪的文件描述符列表,然后按序执行回调。读事件触发后,Redis 会从连接中读取请求、解析协议、调用命令处理函数,最后把响应写到输出缓冲区里。
这个模型的好处是事件驱动:没有任何一条指令是 CPU 主动去轮询某个连接有没有数据的,全部是被动等待内核通知。CPU 只在有实际事件发生时才会被唤醒干活,空闲时则安全地阻塞在epoll_wait上,不占用任何 CPU 资源。
这也是为什么 8 核机器上跑 Redis,你经常能看到只有 1 个核跑满,其他核悠闲地闲着。这是在设计上刻意为之的结果,而不是资源浪费。
你可能会问:那能不能把所有命令执行也分散到多核上?答案是可以,但复杂度极高——数据结构的并发访问、操作顺序的一致性、锁粒度的设计等等都是巨大工程。为了把单核 CPU 上的性能从 10 万 QPS 提升到 20 万,去承受这样级别的复杂度,对一个“缓存工具”来说并不划算。业界有 Pika、KeyDB 这类多线程 Redis 兼容实现,但 Redis 官方选择了一条更稳的路,这也侧面说明了工程上的取舍不是只有“线程越多越好”。
4. 高性能的另一个支柱:纯内存操作与精心设计的数据结构
4.1 内存和磁盘的差距,远超大多数人的直觉
如果让你比较一块 NVMe SSD 和一块普通内存谁快,你可能会觉得都是纳秒/微秒级,差别不大。但真实数据是:内存访问延迟大约在 80-100 纳秒,而 SSD 的随机访问延迟通常在 20-100 微秒。也就是说,一次 SSD 随机读的耗时是内存访问的几百倍。
传统数据库为了保证数据不丢失,每次写入都要刷磁盘。MySQL 哪怕做了很多优化,一次事务提交的fsync也要等磁盘把数据落稳。而 Redis 的所有读写都在内存里完成,没有任何磁盘 I/O 参与主流程,所以单条指令的耗时非常短,可以控制在微秒级别。
持久化这件事,Redis 是通过后台线程完成的(RDB 快照、AOF 重写都由子进程/线程来做),绝不阻塞主线程的请求处理。这是架构层面比较高明的一点:把会拖慢主流程的操作全部放到后台,让主线程专心做纯内存的数据读写。
注意:这里说的是“主线程不直接做磁盘 I/O”,不是“不产生磁盘 I/O”。AOF 默认每秒刷盘 (
appendfsync everysec),如果机器突然断电,最多丢 1 秒的数据。想要更高的数据安全性,可以改成always,但每次写入都会触发磁盘同步,QPS 会明显下降——这就是数据安全与性能之间的经典权衡。
4.2 三种底层结构决定了操作效率
Redis 的高性能不只因为数据在内存,更重要的是读写路径上的数据结构都是精心设计的。大部分场景下,复杂度都是 O(1) 或 O(logN)。
几个最核心的结构:
| 使用场景 | 底层结构 | 时间复杂度 | 关键设计 |
|---|---|---|---|
| 字符串 | SDS(简单动态字符串) | O(1) 获取长度 | 预先记录 len,避免 strlen 遍历 |
| 列表 | quicklist / listpack | 两端操作 O(1) | 分段压缩存储,折中内存和性能 |
| 哈希 | listpack / hashtable | 平均 O(1) | 渐进式 rehash,避免一次性复制开销 |
| 有序集合 | skiplist + hashtable | O(logN) | 跳表做排序,哈希做值查找 |
举个例子,Redis 里的字符串不是 C 语言传统的char*,而是一个叫 SDS 的结构体。它有单独存储长度的字段,所以获取字符串长度是 O(1) 的操作。C 字符串因为没记录长度,strlen必须从头遍历到尾,字符串越长越慢。
再比如有序集合 ZSet,Redis 用了跳表(skiplist)加哈希表的组合。跳表保证有序性和范围查询的效率,哈希表则保证可以 O(1) 通过成员名查到分数。这两个结构配合,才让 ZAdd、ZRangeByScore 都保持高效的性能。
4.3 渐进式 rehash:秒级扩容背后的“延迟消化”策略
哈希表在数据量增长时会发生 rehash,也就是扩容搬迁。很多系统在 rehash 时因为一次性搬完所有数据,导致明显卡顿。Redis 用了渐进式 rehash来解决这个问题。
过程是:当负载因子达到阈值时,Redis 会创建一个新的更大的哈希表,但不立即把旧数据全部搬过去。每次对哈希表的增删改查操作,都会顺手搬移一小部分数据。整个搬迁过程分摊到了多次指令执行中,客户端几乎感知不到延迟尖刺。
这种“把大任务拆成小块、在事件循环里逐步消化”的思路,是 Redis 单线程架构能保持稳定延迟的核心设计哲学之一。不仅是 rehash,Redis 的过期 key 清理、大 key 删除(
UNLINK)等操作也遵循同样的模式。凡是可以延后做的,就不会堵在请求之间一次做完。
5. 协议与网络交互:高性能的最后一个拼图
5.1 RESP 协议为什么解析起来很快
Redis 和其他服务端程序还有一个很大的不同:它的通信协议非常朴素,没有复杂繁重的序列化框架,不需要像 HTTP 那样解析 Header、处理各种分隔符和转义规则。
Redis 使用的 RESP 协议很简单,消息体里每一行都很规整。例如:
*3\r\n $3\r\n SET\r\n $3\r\n foo\r\n $3\r\n bar\r\n*3表示后面有三个参数,$3表示接下来一个字符串长度是 3,内容单独一行。
这种设计让解析逻辑非常简单。解析器可以用极少的指令完成工作,不需要状态机来回跳转。现代 JSON 解析器处理一个小 JSON 可能需要上百纳秒,而 RESP 的一个短命令解析只需几十纳秒。
5.2 Pipelining:把 RTT 吃掉,QPS 立刻翻倍甚至更高
如果说协议简单是“省力”,那么 Pipelining 就是“批量提效”。
在未开启 Pipelining 的情况下,客户端发一次请求要等一次响应。这个等待包含了完整网络 RTT。如果 RTT 是 0.2ms,你一个线程一秒钟最多发 5000 个请求——哪怕 Redis 处理只用了 0.01ms。
Pipelining 的思路是:客户端一次把多个命令连续发送给服务器,不用等每个命令的响应。服务器按顺序执行完这些命令,把所有的响应一次性返回。这样网络等待的时间被压缩到了最低,吞吐量自然大幅提升。
我自己实测过一组数据:不开启 Pipeline,单线程循环发 1 万条 SET,QPS 约 8 千到 1 万;开启 Pipeline 并一次性批量发 100 条请求,QPS 可以到 8 万以上。差别非常夸张,而 Redis 端的处理逻辑几乎没有任何改动,纯粹省掉了网络交互的等待成本。
对于客户端来说,Pipeline 是最容易被忽视的性能红利。很多语言库(比如 Python 的 redis-py、Java 的 Lettuce)都支持 Pipelining,但默认不开启。高吞吐场景下把这个开关打开,比换 Redis 硬件还要管用。
另一个相关操作是MGET、MSET这类批量命令,也可以减少网络往返,但它们和 Pipeline 的使用场景略有不同:批量命令相对更“原子”,Pipeline 更灵活,可以一次批量执行多种不同类型命令。
6. 单线程模型最大的软肋:不能让任何一条命令“卡住”
6.1 10 万 QPS 的前提是每条命令都要在微秒级跑完
单线程模型有一个致命弱点:整个事件循环串行执行命令,只要有一条命令执行得慢,后面的所有请求都要等它。
打个比方:服务员招待 100 桌客人,手速飞快,正常情况下一切顺利。但突然有一桌客人开始点一桌满汉全席,服务员必须花 10 分钟站在那桌旁边等厨师做完,其他 99 桌全被晾着。Redis 单线程的阻塞问题,和这个场景几乎一模一样。
哪些操作会导致这种“满汉全席”?
KEYS *:遍历全部 key,数据量大时直接几十秒阻塞。- 对大 key 执行
DEL:如果这个 key 的底层是包含几百万个元素的 big key,删除操作会连续释放大量内存,阻塞主线程。 LRANGE一个大列表的全量数据:取出几十万条数据,处理时间呈线性增长。HGETALL一个大哈希:同理。- 错误的
ZRANGEBYSCORE查询:范围过大时,处理规模不受控制。 - 执行 Lua 脚本里有慢逻辑:脚本是在 Redis 事件循环中执行的,一个复杂的循环脚本能让整个 Redis 卡死到客户端连接超时。
6.2 官方给出的几个应对手段
为了解决大 key 删除阻塞问题,Redis 4.0 之后增加了UNLINK。它不是马上一整块删除数据,而是把释放工作安排到后台线程异步执行,可以被认为是针对特定场景的“异步化”方案。同理FLUSHDB也有异步版本FLUSHDB ASYNC。
真正写业务代码时,更靠谱的做法是主动避开大 key。单个 key 集合类数据建议控制在特定规模以内,比如 list 或 hash 的元素不超过 5000;检查线上大 key 可以用--bigkeys扫描,不用自己写脚本逐条排查。
Redis 6.0 引入了CLIENT PAUSE、COMMAND等一堆排查用的命令,但比这些更重要的是意识层面的问题:别把 Redis 当成万能的,有些命令会让整个架构从“秒回”变成“卡死”。
6.3 Redis 6.0 引入多线程,是新架构吗?
很多文章说“Redis 6.0 终于支持多线程了”,这个说法有误导性。
Redis 6.0 多线程只发生在两个阶段:
- 从客户端 socket 中读取请求数据。
- 把响应数据写回客户端 socket。
核心的执行命令逻辑绝对还是在单线程事件循环里,不会出现两个线程同时修改同一个键的情况。所以 Redis 6.0 的整体模型可以这么概括:网络 I/O 并行化,命令执行串行化。
这种设计解决的是“当并发连接数大、网络包频繁时,CPU 在系统调用和内存拷贝上消耗太多”的问题。它提升的是大并发连接场景的吞吐能力,并不是把慢命令变成快命令。像KEYS、LRANGE这种本身执行慢的逻辑,依然会阻塞主线程。
7. 实操复盘:如何在你自己的环境里安全地验证这些结论
7.1 redis-benchmark 从入门到读懂结果
Redis 自带了一个压测工具redis-benchmark,这是最省事的验证方式。
# 测 10 万次 SET 请求,50 个并发连接 redis-benchmark -h 127.0.0.1 -p 6379 -t set -n 100000 -c 50 # 测多种常见命令组合 redis-benchmark -h 127.0.0.1 -p 6379 -t set,get,lpush,lrange -n 100000 -c 50 # 用管道模式,把请求分组批量发送 redis-benchmark -h 127.0.0.1 -p 6379 -t set -n 100000 -c 50 -P 16-P 16的意思是每个 pipeline 批次包含 16 条命令,它会明显抬高 QPS。这个参数很能说明 Pipeline 对性能的巨大影响。
有一点要提醒:redis-benchmark的默认结果是包含了网络开销的“端到端”数字。如果压测工具和 Redis 在同一台机器,回环网络延迟低,测出来的 QPS 会比较高;如果把压测工具放到远程机器,结果里会多出网络 RTT,数值会下降。这不是 Redis 变慢了,是测量链路变长了。
7.2 我实测中遇到的一个典型误区
很多人压测时会发现一个奇怪现象:并发从 50 提高到 200,QPS 没涨多少,甚至略有下降。这很正常——Redis 单线程处理能力已经到上限,增加并发只会让客户端自己去排队,反而可能因为上下文切换增多导致轻微回退。
另外值得关注的输出项是延迟分布。redis-benchmark会给出平均延迟、P50、P99 等指标。你会发现 P99 大概率比平均值高出不少,这是网络抖动和客户端调度导致的普遍现象,不一定是 Redis 自身不稳定。真正要关注的,是请求期间是否出现大的延迟毛刺,比如某个请求突然耗时超过 100ms——那大概率说明有慢命令或大 key 阻塞了主线程。
7.3 生产环境验证性能的“正确姿势”
如果想要更接近真实场景的压测,我更推荐用 memtier_benchmark 进行测试,它对 Redis 命令的支持更丰富,控制更精确。
memtier_benchmark -s 127.0.0.1 -p 6379 \ --protocol=redis \ --test-time=60 \ --clients=50 \ --threads=4 \ --ratio=1:10 \ --pipeline=8这句话的含义是:用 4 个线程,每线程 50 个连接,模拟读写比例 1:10(读多写少的场景),每批次管道大小 8。生产上的访问模型大多不是纯 SET/GET,这个工具可以把你自己的业务读写比带进去,测出来的数据更有参考价值。
最后一点提醒:生产实例压测前一定要确认 Redis 的
maxmemory设置,以及用了哪种maxmemory-policy。如果内存不够触发淘汰策略,压测结果会被淘汰逻辑污染,测出的 QPS 完全不反映真实性能。
架构层面的核心收获
回头看整个架构,Redis 能做到单线程 10 万 QPS,靠的不是某一个单独的原因,而是一套层层配合的设计:
- 数据放在内存里,访问延迟极低。
- epoll 事件驱动让一个线程能管理海量连接,不浪费 CPU 去等待 I/O。
- 数据结构复杂度低,每条命令都能在微秒级完成。
- RESP 协议简单,命令行解析开销小。
- 单线程天然规避了锁竞争和上下文切换。
- 可能有阻塞风险的场景都尽量异步化或延后处理。
我自己在实际运行中有个体会,Redis 的性能是有上限的,但大部分生产事故根本不是它性能不行,而是使用者把它用错了:明明该批量读取的用了循环,单线程模型最怕的就是这种循环内逐条操作大 key。Redis 单线程能跑 10 万 QPS,但经不起你用循环发 1000 次慢请求去‘优化’。想理解 Redis,看懂这段单线程架构的取舍逻辑,比背 100 个面试题都管用。
你可能会说要高可用,那就上主从、上哨兵、上集群——那是另一个大话题,等下一篇“架构解析(下)”我再展开讲讲主从复制和集群是怎么在保证一致性的同时,延续这套高性能内核的。