
单线程跑 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和GET50 并发、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里会先调用aeApiPollLinux 上封装的是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 hashtableO(logN)跳表做排序哈希做值查找举个例子Redis 里的字符串不是 C 语言传统的char*而是一个叫 SDS 的结构体。它有单独存储长度的字段所以获取字符串长度是 O(1) 的操作。C 字符串因为没记录长度strlen必须从头遍历到尾字符串越长越慢。再比如有序集合 ZSetRedis 用了跳表skiplist加哈希表的组合。跳表保证有序性和范围查询的效率哈希表则保证可以 O(1) 通过成员名查到分数。这两个结构配合才让 ZAdd、ZRangeByScore 都保持高效的性能。4.3 渐进式 rehash秒级扩容背后的“延迟消化”策略哈希表在数据量增长时会发生 rehash也就是扩容搬迁。很多系统在 rehash 时因为一次性搬完所有数据导致明显卡顿。Redis 用了渐进式 rehash来解决这个问题。过程是当负载因子达到阈值时Redis 会创建一个新的更大的哈希表但不立即把旧数据全部搬过去。每次对哈希表的增删改查操作都会顺手搬移一小部分数据。整个搬迁过程分摊到了多次指令执行中客户端几乎感知不到延迟尖刺。这种“把大任务拆成小块、在事件循环里逐步消化”的思路是 Redis 单线程架构能保持稳定延迟的核心设计哲学之一。不仅是 rehashRedis 的过期 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 万条 SETQPS 约 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 提高到 200QPS 没涨多少甚至略有下降。这很正常——Redis 单线程处理能力已经到上限增加并发只会让客户端自己去排队反而可能因为上下文切换增多导致轻微回退。另外值得关注的输出项是延迟分布。redis-benchmark会给出平均延迟、P50、P99 等指标。你会发现 P99 大概率比平均值高出不少这是网络抖动和客户端调度导致的普遍现象不一定是 Redis 自身不稳定。真正要关注的是请求期间是否出现大的延迟毛刺比如某个请求突然耗时超过 100ms——那大概率说明有慢命令或大 key 阻塞了主线程。7.3 生产环境验证性能的“正确姿势”如果想要更接近真实场景的压测我更推荐用 memtier_benchmark 进行测试它对 Redis 命令的支持更丰富控制更精确。memtier_benchmark -s 127.0.0.1 -p 6379 \ --protocolredis \ --test-time60 \ --clients50 \ --threads4 \ --ratio1:10 \ --pipeline8这句话的含义是用 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 个面试题都管用。你可能会说要高可用那就上主从、上哨兵、上集群——那是另一个大话题等下一篇“架构解析下”我再展开讲讲主从复制和集群是怎么在保证一致性的同时延续这套高性能内核的。