ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis单线程为何性能依旧高?核心原理与工程实践解析

Redis单线程为何性能依旧高?核心原理与工程实践解析 大家好我是你们的技术博主。今天我们来聊一个非常经典、也是面试中被问烂了的问题为什么 Redis 使用单线程性能却依然这么高很多刚接触 Redis 的同学都会有这个疑惑CPU 发展这么快多核这么普遍为什么 Redis 还坚持用单线程单线程处理请求难道不会白白浪费 CPU 资源吗更矛盾的是Redis 的性能又是出了名的强官方数据能达到每秒十万甚至更高的 QPS。这不禁让人好奇它到底是怎么做到的这篇文章将会从 Redis 的设计原理、IO 多路复用、数据结构优化、底层内存操作等多个角度把这个问题彻底讲清楚。无论是你准备面试还是在实际项目中想要优化 Redis 使用这篇文章都应该能帮助你把这块知识补全。我会尽可能用通俗的语言 原理拆解 代码演示的方式把整个底层逻辑讲透。文章会先回顾一下 Redis 单线程的基本概念然后重点拆解它为什么能这么快再把线程模型从历史版本到 6.0 之后的变化也讲一下。最后回答几个高频疑问并给出工程上的使用建议。建议先收藏再慢慢阅读。1. Redis 的单线程到底是指什么要理解“Redis 为什么用单线程还这么快”第一步得先搞清楚Redis 所谓的“单线程”究竟是哪种单线程1.1 单线程并不等于只有一个进程很多同学会把“单线程”理解成 Redis 整个服务只有一个线程在干活。这个理解其实不够准确。Redis 作为一个服务器程序它在运行时会包含多个模块。实际上主线程负责接收客户端连接、解析命令、执行命令、返回响应。这个线程是单线程的。后台线程4.0 之后引入负责一些比较耗时的异步操作比如unlink key、flushdb异步删除大 key、AOF 刷盘等。BIO 线程处理文件关闭、缓冲刷新等辅助工作。主从复制模块、持久化模块也有自己的子进程或线程。所以更准确的说法是Redis 命令的执行阶段是单线程的。也就是说每一条 Redis 命令从客户端发出到在服务端执行完成这一个过程不会有多线程并发执行命令的情况。1.2 为什么命令执行必须是单线程Redis 的核心数据结构都是线程不安全的。比如 List、Hash、ZSet 这些结构在设计上就没有加锁。如果多个线程同时修改同一个 key 的结构就需要加锁而加锁会带来大量上下文切换和锁竞争开销。为了保证原子性和避免锁竞争Redis 作者选择了最简单的方案所有命令在同一个线程内排队执行。因为单线程天然没有并发问题所以每个命令的执行都是原子操作不需要考虑锁的问题。这也是 Redis 早期设计的一个核心优势。// 这是一个非常简化的示意Redis 源码中事件循环是核心 // 单线程意味着同一时刻只有一个命令被解析并执行 void processCommand(client *c) { // 从输入缓冲区中取出一个命令 // 查找命令对应处理函数 // 执行命令 // 返回结果 }你可以把 Redis 的主线程理解成一个“只接待一个顾客的柜台”所有请求排队来一个一个处理永远不会有插队和资源竞争。2. Redis 性能高的核心原因拆解知道了“单线程”的具体含义之后下面进入整篇最核心的部分为什么单线程的 Redis性能反而这么高2.1 基于内存的数据存储这是 Redis 性能高的最基本前提。普通的关系型数据库比如 MySQL数据最终存储在磁盘上。即使是 SSD磁盘的随机读写延迟也在几十微秒到几百微秒的级别而且还需要考虑磁头寻道、页缓存失效等问题。为了减少磁盘 IOMySQL 还会引入 Buffer Pool但仍然无法完全规避磁盘物理特性的限制。Redis 则完全不同它的所有数据都保存在内存中。内存的访问速度通常只有几十纳秒比磁盘快几个数量级。我们来看一组非常粗略的对比数据存储介质典型访问耗时数量级差距CPU 寄存器~0.3 ns基数内存 RAM~50-100 ns慢 1-2 个数量级SSD随机读~0.01-0.1 ms慢 4-5 个数量级机械硬盘随机读~5-10 ms慢 6-7 个数量级当数据完全放在内存时单线程执行命令也不会遇到磁盘等待的问题。这是 Redis 单线程可以支撑高并发的基础。否则如果每个命令都需要等待磁盘写一次单线程模式下性能会直接崩溃。在实际场景中Redis 之所以能以单线程支撑起很高的 QPS主要因为它把“最耗时的数据查找和修改”全部放到了内存操作里省掉了磁盘 IO 的开销。2.2 非阻塞 IO 与多路复用机制这是“单线程还能高并发”最关键的技术支撑。假设 Redis 采用最简单的阻塞式网络模型主线程卡在read()函数上等待客户端发送数据同时只能服务一个连接。那它的并发能力就几乎为零。但实际 Redis 用的是IO 多路复用IO Multiplexing技术。IO 多路复用的核心思路是一个线程同时“盯着”多个 socket 连接当某个 socket 有数据可读时立刻去处理它。这样主线程就不用被某个慢速客户端阻塞住了。Linux 系统提供了三种经典的 IO 多路复用机制selectpollepollRedis 在 Linux 平台上默认使用的是epoll。epoll与select、poll相比最大的优点是可监听的文件描述符数量不受限制并且可以做到事件驱动只通知就绪的文件描述符不需要每次都遍历全部连接。用一句话概括 Redis 的网络处理模型Redis 主线程通过事件循环将网络 IO 等待的时间压缩到极致。在等待连接和数据到达时主线程并不是空等而是继续去处理其他已经就绪的事件。下面用伪代码帮助理解 Redis 事件循环的核心流程// 事件循环的简化示意 while (1) { // 通过 epoll 统一监听多个 socket 上的读写事件 // 这是一个阻塞调用但不会只等待某一个连接 int numEvents epoll_wait(epollFd, events, MAX_EVENTS, timeout); for (int i 0; i numEvents; i) { if (events[i].isReadable) { // 有新命令到达读取客户端数据 readFromClient(events[i].socket) // 在主线程里执行命令 processCommand(...) } if (events[i].isWritable) { // 向客户端写回结果 writeToClient(events[i].socket) } } }这里要强调一下epoll 是操作系统提供的机制它负责告诉 Redis “哪些 socket 已经就绪了”。Redis 主线程只需要对这些就绪事件做响应不需要去轮询所有连接因此效率非常高。所以Redis 的“单线程”并不是被网络 IO 阻塞的死板单线程而是将 IO 事件与命令执行统一在同一个事件循环里处理的单线程模型。这种模型可以同时维护大量客户端连接而性能不会随连接数线性下降。2.3 避免上下文切换与锁竞争多线程程序在带来并行处理能力的同时也会引入两个很头疼的问题上下文切换开销操作系统在多个线程之间切换需要保存和恢复状态。频繁的上下文切换会消耗大量 CPU。锁竞争开销多个线程访问共享数据时需要加锁。锁竞争激烈时线程可能被挂起、唤醒反而比单线程更慢。Redis 选择单线程执行命令直接避免了这两类开销没有线程切换命令执行始终在一个线程内完成CPU 不需要频繁切换执行流。没有锁竞争所有操作天然串行数据一致性很好保证。你可能觉得多线程可以并行跑多个命令但 Redis 的核心操作本身已经足够快微秒级如果多线程处理锁等待和切换成本反而可能超过并行带来的收益。这里有一个非常直观的结论在 Redis 这类内存操作为主的系统中性能瓶颈通常不在 CPU而在网络 IO 和内存带宽。单线程已经能满足性能要求强行上多线程反而增加复杂度。2.4 高效的数据结构与底层编码Redis 的高性能除了依赖单线程和事件循环还离不开它对数据结构底层的精益求精。通常我们说 Redis 有五种基本数据类型StringListHashSetZSet但这五种数据类型在 Redis 内部实际是由不同的底层编码实现的。Redis 会根据元素个数、元素大小、使用场景自动选择合适的存储结构。我们举几个例子String 类型底层可能是int编码整数时、embstr编码短字符串、raw编码长字符串。如果存的是纯数字Redis 直接用整数运算省去字符串解析的开销。List 类型在 Redis 3.2 之前List 根据元素数量和大小在ziplist和linkedlist之间切换。3.2 之后引入了quicklist它是多个ziplist后改为listpack组成的双向链表。这种设计既提高了内存利用率又减少了链表的指针开销。Hash 类型元素少时使用ziplist或listpack元素多时切换为hashtable。小编码下Hash 可以做到极低的内存占用。ZSet 类型元素少时使用ziplist/listpack元素多时切换为skiplist hashtable。跳表Skip List作为有序数据结构查找、插入、删除的时间复杂度都能保持在对数级别而且实现比平衡树简单非常适合排行榜这种场景。下面给一个简单的编码查看命令# 查看 key 的底层编码 redis-cli # 创建一个 Hash 对象先只放少量元素 127.0.0.1:6379 HSET user:10086 name tom (integer) 1 127.0.0.1:6379 OBJECT ENCODING user:10086 listpack可以看到当 Hash 元素很少时Redis 并没有直接使用 hashtable而是使用了紧凑的内存编码。这种精细的控制让 Redis 在数据量较大时也能凭内存读写保持高速。2.5 单线程与原子性省去事务开销Redis 单线程模型一个意外的收益是每条命令天然具备原子性。在并发环境下如果两个线程同时对同一个 key 执行“读-修改-写”流程就需要加锁或者使用事务。而在 Redis 单线程模型下命令是一条接一条执行的不会出现两条命令交替执行的情况。这意味着INCR命令不需要任何锁就可以保证并发递增不会出问题。使用 Lua 脚本时多个命令组合操作也是在单线程中执行天然原子。对于业务系统来说使用 Redis 的原子命令可以避免很多分布式锁的误用场景。我们看一个简单的INCR示例# 同时有多个客户端执行 INCR # 因为命令执行是单线程串行的所以结果一定正确 127.0.0.1:6379 SET page:visit 0 OK 127.0.0.1:6379 INCR page:visit (integer) 1 127.0.0.1:6379 INCR page:visit (integer) 2 127.0.0.1:6379 INCR page:visit (integer) 3如果是多线程模型要保证 INCR 正确就需要加锁或者使用原子操作。Redis 直接用单线程模型规避了这个问题。所以从这个角度看单线程并不是 Redis 的缺陷而是它在“高性能 简单可靠”之间做出的选择。3. 性能验证用实际环境感受 Redis 单线程性能理论讲了很多下面我们来一点实际的。为了验证 Redis 单线程下依然能保持高性能我们可以借助 Redis 自带的压测工具redis-benchmark来做一次简单的测试。3.1 环境准备如果你本机还没有安装 Redis这里给出最常见的 Docker 启动方式# 拉取 Redis 镜像版本根据实际环境选择这里以 7.x 为例 docker pull redis:7 # 启动 Redis 容器 docker run --name redis-test -d -p 6379:6379 redis:7 # 进入容器使用 redis-cli 或直接本机连接 redis-cli -h 127.0.0.1 -p 6379 ping注意如果你在本地直接安装 Redis可以使用包管理器# Ubuntu / Debian sudo apt update sudo apt install redis-server # CentOS / RHEL sudo yum install redis # macOS brew install redis如果只是单纯学原理直接用 Docker 是最省事的方式。3.2 执行基准测试Redis 自带的redis-benchmark可以很方便地测试不同命令的 QPS。redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t get,set -q参数说明-c 50模拟 50 个并发连接。-n 100000总共发送 10 万条请求。-t get,set只测试 get 和 set 命令。-q安静模式只输出最终结果。在我本地 Docker 环境跑一次结果类似SET: 104712.55 requests per second, p500.239 msec GET: 108339.11 requests per second, p500.231 msec可以看到即使在 Docker 环境下Redis 单线程也能轻轻松松达到每秒 10 万级别的读写操作。如果把网络延迟再调优、使用物理机、开启多线程 IO后面会提到这个数字还会更高。这个测试也再次印证单线程并没有成为 Redis 的性能瓶颈。3.3 如果使用内存较慢的介质会怎样如果你把 Redis 的持久化策略配置得比较激进比如每次写命令都实时刷盘appendfsync always由于磁盘写入较慢QPS 会明显下降。这说明Redis 单线程的性能上限很大程度上取决于底层操作是否涉及磁盘。纯内存操作的 Redis单线程完全够用。因此在实际生产中我们通常会在性能和数据安全之间做权衡比如使用everysec刷盘策略。4. 既然单线程够用为什么 Redis 6.0 要引入多线程前面讲了很多单线程的优势这时候你可能会有疑问那为什么到了 Redis 6.0官方又加入了多线程呢这不是自相矛盾吗其实并不矛盾。Redis 6.0 引入的多线程并不是用来执行命令的而是用来处理网络 IO 读写的。4.1 Redis 6.0 多线程 IO 的背景随着 Redis 性能不断提升单线程模式下命令执行的时间占比越来越低而网络读写数据包的时间占比却越来越高。尤其是大 key、大批量数据读取时socket 读写消耗会明显增加。由于网络 IO 涉及系统调用和数据拷贝如果放到单独线程中去做主线程就能更专注地执行命令。这就是 Redis 6.0 引入多线程 IO 的原因。官方在设计时非常克制主线程仍然负责命令执行。多线程只负责调用read()从 socket 读取数据、调用write()向 socket 写数据。命令的解析和执行依然是单线程。这样的设计既保持了单线程命令执行的原子性和简单性又通过并行 IO 提升了整体吞吐量。4.2 如何开启多线程 IORedis 6.0 默认多线程 IO 是关闭的。你可以通过配置项开启# 在 redis.conf 中设置 io-threads 4 # 开启多线程 IO如果是读请求为主可以开启 # 设置为 yes 表示开启读线程 io-threads-do-reads yes注意几个要点io-threads不是越大越好通常建议设置为 CPU 核数的 2-4 倍以内。如果你的机器只有 4 核设置成 8 很可能反而性能下降。官方建议当你的 Redis 实例吞吐量没达到瓶颈时不必开启多线程。4.3 新版本线程模型的总结版本命令执行网络 IO后台任务Redis 3.x 及以前单线程单线程少数 BIORedis 4.0单线程单线程引入后台删除等异步线程Redis 5.x单线程单线程同上Redis 6.0单线程可配置多线程 IO同上Redis 7.x单线程可配置多线程 IO优化更完善同上可以看到Redis 官方在“命令执行”这个核心环节始终保持单线程是刻意为之。多线程是一种辅助手段用来解决网络 IO 的瓶颈。5. 围绕“Redis 单线程”的高频疑问这部分整理几个大家平时讨论最多的问题也是面试中比较容易延伸的点。5.1 单线程会不会浪费 CPU 多核资源确实会浪费一部分 CPU 算力但 Redis 的性能瓶颈通常不在 CPU 计算上。比如你在 8 核机器上运行 Redis默认情况下只有一个核心在处理命令。如果你的业务量很大Redis 的 CPU 使用率可能会达到 100%这时单线程就会成为瓶颈。解决办法有几种开启 Redis 6.0 的多线程 IO。在一个机器上部署多个 Redis 实例比如 6379、6380、6381。使用 Redis Cluster把数据分散到多个节点。所以如果单核 CPU 跑满了不要指望 Redis 自己把负载分摊到多核需要在架构层面做处理。5.2 单线程为什么还比多线程更快这要分场景。Redis 的命令耗时普遍在微秒级如果引入多线程线程间上下文切换成本可能比命令本身耗时还高。需要加锁保护共享数据锁竞争又带来堵塞。代码复杂度增加维护和调试成本变大。因此在内存操作场景下单线程的“无锁、无切换”优势非常明显。这也是 Redis 选择单线程的根本原因。5.3 一个命令执行很慢会不会阻塞所有请求会。这是 Redis 单线程最需要警惕的问题。比如KEYS *命令在大 key 数量下会扫描全库阻塞主线程。SMEMBERS获取大集合所有元素可能导致网络阻塞。执行一个非常大的GET比如几 MB 的字符串也会影响其他请求。使用FLUSHALL清空所有数据如果没有开启异步删除也会阻塞。生产环境中常见的优化手段使用SCAN代替KEYS *。使用HSCAN、SSCAN、ZSCAN分批扫描。批量删除大 key 时使用UNLINK代替DEL。避免在 Redis 中存储超大 value。# 避免使用 KEYS推荐使用 SCAN 127.0.0.1:6379 SCAN 0 MATCH user:* COUNT 100 1) 10 2) 1) user:11 2) user:23 3) user:1015.4 大 key 对单线程的影响到底有多大大 key 不仅占用内存多更重要的是它会让单线程在处理该 key 的命令时卡住。举个例子一个包含几百万元素的 Hash如果执行HGETALLRedis 需要在主线程中把全部数据序列化、并通过 socket 发送给客户端。这个过程的耗时可能达到秒级。在这段时间里其他所有请求都会被阻塞。所以排查大 key 非常有必要。可以使用 Redis 自带的--bigkeys参数redis-cli --bigkeys也可以通过 SCAN 命令结合类型判断自己写脚本扫描。总之控制大 key 是使用 Redis 单线程模型下的必修课。5.5 Redis 为什么不用协程协程确实能减少上下文切换但 Redis 的命令执行本来就是内存级的快速操作还没有到需要协程来提升并发的地步。而且协程需要依赖语言或运行时支持C 语言实现的 Redis 如果引入协程复杂度会大幅提升收益却并不明显。所以 Redis 的作者选择了最朴素也最可控的事件循环模型。6. 工程实践中的性能与稳定性建议了解了 Redis 单线程的原理我们在项目中使用 Redis 时也应该根据它的特性做出最佳实践。6.1 避免执行耗时命令单线程模型下任何耗时的命令都是“全局灾难”。要牢记一个原则线上 Redis 只保留耗时低于毫秒级的命令超过 1ms 的操作都要警惕。常见耗时不友好的命令KEYS *SMEMBERS大集合HGETALL大 HashZRANGE大范围LRANGE key 0 -1建议使用SCAN家族分批获取。拆分大 key 为小 key。使用HRANDFIELD、SRANDMEMBER代替全量获取。6.2 控制 key 的大小单个字符串 value 建议控制在 10KB 以内超出时考虑压缩或拆分为多个 key。对于集合类型单个 key 的元素数量也不宜过大。比如一个 List 保存百万条消息虽然 Redis 能存下但每次读取都会比较痛苦。建议在写入前就评估 key 的大小建立一套规范数据类型建议单 key 上限备注String10KB超过建议压缩或拆分Hash1 万 field 以内超大 Hash 不易维护List1 万元素以内长列表建议使用 StreamSet1 万元素以内大 Set 交集并集计算耗时ZSet1 万元素以内排行榜场景视情况放宽6.3 合理使用 Pipeline 和 Lua 脚本因为 Redis 单线程是串行执行命令的所以网络延迟会被放大。如果你的业务需要执行多条 Redis 命令每一轮请求都单独走一次网络那么大部分时间都消耗在 RTT往返时延上了。使用 Pipeline 可以一次发送多条命令减少网络交互次数// 以 Java 的 Jedis 为例 Jedis jedis new Jedis(127.0.0.1, 6379); Pipeline pipe jedis.pipelined(); for (int i 0; i 1000; i) { pipe.set(key: i, value: i); } pipe.sync();如果你需要保证多条命令原子执行可以使用 Lua 脚本。由于 Redis 单线程执行 Lua所以脚本内的操作天然原子不会出现并发穿插。# 一个简单的原子操作 Lua 示例 redis-cli EVAL return redis.call(set, KEYS[1], ARGV[1]) 1 mykey hello6.4 在正确的场景使用 Redis 分布式锁很多人一提分布式锁就想到 Redis但要注意Redis 分布式锁适合高并发、性能要求很高的场景。在要求绝对一致性、不允许锁失效的场景应该考虑 ZooKeeper 或数据库锁。Redis 分布式锁最经典的核心就是SETNX 过期时间# 加锁 SET lock:order:1001 requestId NX EX 10 # 解锁使用 Lua 保证原子性 EVAL if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end 1 lock:order:1001 requestId注意点不要用SETNX和EXPIRE分开设置因为如果设置过期时间失败锁永远不会释放。锁的 value 一定要用唯一标识避免误删别人的锁。过期时间要设置合理避免业务执行时间过长导致锁提前释放。6.5 持久化与单线程的取舍Redis 持久化默认是 RDB 与 AOF 两种方式。RDB 会 fork 子进程生成快照主线程不受阻塞。AOF 默认everysec刷盘性能影响较小。如果你的业务要求数据绝对不丢设置appendfsync always性能会明显下降。生产环境建议开启 AOF使用everysec。同时保留 RDB 用于快速恢复。不要在高峰期执行BGSAVE或BGREWRITEAOF太频繁。大实例做持久化时注意内存和磁盘容量。6.6 合理设置内存淘汰策略Redis 作为缓存使用时不可能无限存数据。当内存达到maxmemory上限时Redis 会根据配置策略淘汰数据。# 设置最大内存 maxmemory 4gb # 设置淘汰策略 # volatile-lru: 对设置了过期时间的 key 使用 LRU # allkeys-lru: 对所有 key 使用 LRU # volatile-ttl: 优先淘汰剩余 TTL 短的 key maxmemory-policy allkeys-lru在单线程模型下淘汰策略如果触发大量扫描或随机删除也会影响性能。所以内存估算和淘汰策略要提前规划好。7. 总结与下一步学习方向写到这里关于“Redis 为什么用单线程却依然快”这个问题相信已经有了非常清楚的答案。总结一下核心要点Redis 的单线程是指命令执行阶段由单线程串行处理但网络事件监听借助了 IO 多路复用机制。Redis 的性能主要来源于数据存储在内存中访问速度远超磁盘数据库。单线程避免了上下文切换和锁竞争让命令执行的原子性和性能得到双重保障。Redis 底层数据结构进行了大量编码优化小数据用紧凑编码大数据用高效结构有效减少了内存占用和操作耗时。Redis 6.0 引入的多线程 IO 只是优化网络读写并没有破坏命令执行单线程的原则。单线程模型下要特别注意大 key、慢命令和持久化配置避免阻塞主线程。对于下一步的学习我建议你按这个顺序进阶深入学习 Redis 事件循环源码ae.c、networking.c。理解 Redis 持久化机制RDB、AOF以及 fork 子进程的底层原理。通过redis-benchmark压测不同命令建立对性能的量化感知。学习 Redis Cluster 的原理和数据分片策略。在真实项目中多关注淘汰策略、内存分析、大 key 扫描和慢日志积累实战排错能力。# 查看 Redis 慢日志 redis-cli slowlog get 10# 设置慢日志阈值单位是微秒 slowlog-log-slower-than 10000如果你的服务中突然出现 Redis 卡顿第一时间查看慢日志、查看大 key、确认是否执行了耗时命令通常就能快速定位问题。真正把 Redis 用好不是背会几条命令而是理解它的设计边界并在这个边界内做最合理的工程决策。希望这篇文章对你有帮助。如果有任何疑问欢迎在评论区留言交流。下篇文章我会继续深入 Redis 的其他高频面试点比如持久化原理、缓存击穿与穿透的解决方案、分布式锁的坑欢迎保持关注。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进