Redis为什么这么快?这个话题几乎每个后端同学在面试前都背过,而只要聊到快,就一定绕不开 Redis 的线程模型,尤其是 6.0 之后被反复讨论的 “Redis 多线程”。说实话,网上讲这题的文章不少,但大部分版本要么停留在 “Redis 是单线程的” 这个旧结论上,要么把多线程吹成 “Redis 终于支持并发执行命令了”,这两种说法都不准确,而且很容易误导人。这篇文章我想把整条线完整串一遍:快在哪三层底层支撑、单线程模型内部到底怎么工作、6.0 引入的多线程改了什么又没改什么,以及线上 Redis 变慢时怎么用最快的路径定位问题。适合正在准备面试、做中间件选型、或者被生产环境慢查询坑过的朋友参考。
1. 先搞清楚:Redis 的“快”到底来自哪三层
很多人一说 Redis 快,张口就是 “因为它基于内存”。这个答案没错,但太粗了。Redis 快是一个系统工程,至少三层叠加出来的结果:内存存储、高效数据结构、事件驱动模型。缺了哪一层,都不可能做到单机十万甚至几十万的 QPS。
1.1 第一层:数据住在内存里,这是快的地基
这一层最好理解。内存随机访问的耗时大概在几十纳秒到一百纳秒这个量级,而普通 SSD 的随机读耗时是几十微秒到几百微秒,传统机械硬盘更是直接到毫秒级。纳秒、微秒、毫秒,每跳一个单位就差三个数量级,内存比机械硬盘快了三到四个数量级,比 SSD 也快了两三个数量级。
打个比方,内存访问就像你直接走到食堂窗口,师傅把餐盘递给你;SSD 相当于你去便利店,得走过去、扫码、付款;而传统磁盘就像你点了外卖,得等商家备餐再等配送。Redis 把全部数据放在内存里,天然就赢在了起跑线上。
不过这里得泼一盆冷水:Redis 把所有数据放内存,意味着成本很高。同样 1GB 数据,放内存可能比放 SSD 贵几十倍,所以 Redis 不适合做全量冷数据存储,它的定位从来都是缓存和热数据加速层。这也是为什么后来有了 SSD 上的存储引擎,但那是另一个话题。至少在用 Redis 的时候,你得想清楚:这份数据值不值得占内存。
1.2 第二层:数据类型背后的数据结构不是拍脑袋选的
面试常问的 Redis 数据类型就那五种:String、Hash、List、Set、ZSet。热词里也反复出现 “redis 数据类型”,但很多人记了命令却忽略了每种类型底层的数据结构,这才是 Redis 快的一个关键点。
- String 底层用的是 SDS(简单动态字符串),而不是 C 语言裸字符串。SDS 记录了字符串长度,获取长度是 O(1),而且能避免缓冲区溢出,修改字符串时按需扩容。
- Hash 在元素少时用 ziplist 或 listpack 压缩存储,节省内存;元素多了之后转换成哈希表,读写还是 O(1)。这里有个渐进式 rehash 的设计,扩容搬数据不是一次性完成,而是分摊到每次增删改查,避免了长时间阻塞。
- List 在 3.2 之前是 ziplist + linkedlist,3.2 之后用 quicklist 把两者结合,既省内存又保证两端操作是 O(1)。7.0 开始又用 listpack 优化。
- ZSet 用跳表 + 哈希表实现。跳表让范围查询和按分值排序都接近 O(log N),哈希表保证按 member 查询分值是 O(1)。
- Set 底层是 intset 或哈希表,整数集合用紧凑数组,内存和速度都优秀。
为什么要专门强调数据结构?因为同一份数据,你用 List 存和用 ZSet 存,做范围查询的复杂度完全不一样。Redis 快不只是 “内存里查得快”,还在于各种操作都被设计成 O(1) 或 O(log N),很少出现 O(N) 的拖油瓶。这也是它敢用单线程跑命令的底气之一。
1.3 第三层:I/O 多路复用与无锁设计,决定了并发上限
内存和数据结构解决的是单次操作快不快,但一个服务器要同时面对几十万个客户端连接,如果每个连接分配一个线程去处理,线程上下文切换开销就能把 CPU 打满。Redis 选择的是 I/O 多路复用机制,在 Linux 上就是 epoll,一个线程可以同时监听成千上万个 socket 上的读写事件,哪个连接来了数据就处理哪个,没有数据就休眠等待,CPU 利用率极高。
这里有一个特别重要的推论:因为命令执行是单线程的,多个命令之间不需要加锁,也不存在竞争条件。这省掉的不是一星半点开销。多线程并发编程里,锁竞争、上下文切换、缓存行失效,每一项都是性能杀手。Redis 相当于用 “串行执行命令” 换来了绝对的并发安全,同时又用 epoll 把并发连接的处理能力拉满。
三层合在一起,才能说清楚 “Redis 为什么这么快”。只讲内存,解释不了高并发下的连接处理;只讲 epoll,解释不了单条命令为何这么快。这也是面试时候比较加分的回答方式:不是背结论,而是拆层次。
2. 单线程到底在干什么:一次命令背后的完整链路
既然标题是 “线程模型”,那必须把单线程模型聊透。很多文章反复说 “Redis 单线程高性能”,但到底是怎么个单线程法,背后的事件循环是什么样,很多人其实没完全搞明白。
2.1 先破除一个误解:Redis 单线程不是说进程里只有一个线程
这是我在面试里经常反问候选人的点。Redis 进程里其实有不少线程和子进程,比如:
- 主线程:负责处理命令请求。
- 后台线程:负责 AOF 刷盘、懒释放大 key、以及 4.0 之后的一些后台删除任务。
- fork 出的子进程:RDB 持久化、AOF 重写会 fork 子进程来做。
- 从 6.0 开始,还有可选的 I/O 读写线程。
所以 “Redis 是单线程” 这句说全了应该是:Redis 处理命令执行的线程只有一个,所有客户端发来的命令在这个主线程上排队、依次执行,不存在命令并行执行。而网络 I/O、文件 I/O 这些操作,早就有独立的线程或子进程在帮忙了。
这个澄清很重要,因为它在后续分析性能瓶颈、排查问题的时候决定了思路。比如有人误以为 Redis 支持多线程命令执行,于是写一堆 CPU 密集型操作丢进去,结果发现 Redis 还是卡,那就是对模型理解错了。
2.2 事件循环 + epoll:一个线程怎么伺候几万个连接
Redis 主线程的核心是一个事件循环,不断做两件事:等待事件、处理事件。这里的事件分两类:
- 文件事件:socket 上可读、可写。这是命令请求和响应事件。
- 时间事件:定时任务,比如过期键清理、每秒统计。
文件事件由 I/O 多路复用器监听。什么是 I/O 多路复用?简单说,原来你要等 100 个客户端发数据,如果一个连接一个线程,就要 100 个线程;现在一个线程调用 epoll_wait,内核帮它盯着这 100 个 socket,只要有任何一个可读或可写,就返回一个就绪列表,线程一个接一个处理,处理完再回到 epoll_wait 继续等。
用餐厅打比方:单线程模式不是一个服务员只服务一桌客人,而是一个服务员站在大厅中间,同时关注所有桌子的动静,哪桌举手就过去服务,服务完回到大厅中间继续观察。这个服务员当然不可能同时给两桌炒菜,但翻台速度极快,所以整体服务能力并不差。
Linux 上 epoll 之所以厉害,是因为它内部用红黑树维护需要监听的 fd,用就绪链表记录发生事件的文件描述符,内核只返回有事件的那些 socket,而不是像 select/poll 那样每次把上万 fd 全扫一遍。Redis 的源码里在 ae_epoll.c 中就是对这个机制做了一层封装。其他平台上也有对应实现,Mac 上是 kqueue,Windows 上没有 epoll,只能用 select 或者 IOCP 之类的方案,这也是为什么网上搜 “redis windows 下载” 出来的 Windows 版本性能一般,而且官方一直是建议生产环境跑在 Linux 上。
2.3 一条 GET 命令在内核和用户态之间的完整流转
我特别喜欢把一次命令的完整路径画出来讲,这样最能体现单线程模型的工作方式。以最简单的 GET 为例:
- 客户端发送
GET key,到达服务器的网卡,内核把数据放进对应 socket 的接收缓冲区。 - Redis 主线程调用 epoll_wait,内核发现这个 socket 可读,返回就绪事件。
- Redis 从事件循环中取出该文件事件,调用 read 从 socket 读入请求数据。
- 解析协议。Redis 用的是 RESP 协议,TCP 流上可能是多条命令粘在一起,所以解析器要能处理粘包、半包。
- 解析出命令后,在命令表里找到 getCommand 函数,执行查找 key 并返回 value。
- 把结果按 RESP 协议编码,写入当前 socket 的发送缓冲区,注册写事件。
- 回到 epoll_wait 继续等待,同时内核把发送缓冲区的内容通过 TCP 返回给客户端。
注意,第 5 步在主线程上执行,它是严格串行的。也正因为串行,Redis 在处理命令时不需要考虑两个客户端同时修改同一个 key 的竞争问题。这一整个流程里真正消耗 CPU 的部分,主要是第 3、4、6 步的读写和协议解析,而第 5 步通常是内存里的哈希查找,非常快。
这也就能解释为什么 Redis 在吞吐量上可以做到单实例十几万 QPS:它把大量 CPU 资源集中在一件事上,没有锁、没有上下文切换、没有调度开销,剩下的就是纯网络回环 + 内存读写。
2.4 单线程的代价:哪些命令会把整个 Redis 拖垮
了解了单线程执行模型,你就能推出一个结论:一旦某个命令执行时间很长,后面所有命令都得排队等它。这是 Redis 最典型的 “宕机式变慢” 场景,原因通常是下面几类:
- 一次性返回大量数据的命令:比如
KEYS *,在几百万 key 的实例上会阻塞主线程好几秒。正确做法是用SCAN分批遍历,或者干脆避免。 - 操作大 key:比如一个 List 有几十万条数据,直接
LRANGE key 0 -1,或对超大 Hash 执行HGETALL,不仅主线程卡住,网络传输也是大问题。 - 聚合和排序类命令:
SORT、ZUNIONSTORE这类命令如果操作的集合很大,耗时会非常明显。 - 不合理的过期策略:如果同时有大量 key 在同一秒过期,Redis 周期性的过期清理会花不少时间扫描删除,造成时间事件挤压。
- fork 阻塞:RDB 持久化或者 AOF 重写会 fork 子进程,内存越大 fork 耗时越长,期间主线程也会被短暂挂起。
所以单线程模型虽然好,但要求使用者必须自律。Redis 官方一直强调 “Don’t run slow commands”,这不是开玩笑,是血泪教训。后面我会专门讲排查慢命令的方法。
3. Redis 6.0 的多线程到底改了什么
聊完单线程的机制和代价,就到了标题的重点:Redis 多线程。这里需要非常明确地说一件事:Redis 6.0 引入的多线程,不是多线程执行命令,而是多线程处理网络 I/O。这个区别如果不搞清楚,后面全是一笔糊涂账。
3.1 为什么单线程这么香,Redis 还要引入多线程
既然上面把单线程说得这么好,为什么还要改?答案很简单:随着连接数和单次请求体积上升,网络 I/O 开始成为瓶颈,而 CPU 还没跑满。
Redis 的瓶颈从来不是 CPU 算力,而是网络读写和协议解析。早期单线程模型里,主线程既要 accept 新连接,又要 read 请求数据,还要 parse 协议、执行命令、write 回包。当客户端数量多、单个请求的 value 很大(比如几 KB 甚至几十 KB 的对象),或者使用 pipeline 批量请求时,read + write + 协议解析会占用大量主线程时间,主线程被网络 I/O 拖住,真正执行命令的时间占比反而下降了。
打个比方:一个厨师(主线程)本来是炒菜的(执行命令),现在餐厅生意好,他还得自己去菜市场进货(read)、洗菜切菜(解析协议)、把菜端上桌(write),忙完这一圈,真正炒菜的时间没多少了。多线程 I/O 就像是加了几个帮厨:进货、洗菜、端菜都有专人干,厨师只专注炒菜。
所以 Redis 官方在 6.0 引入多线程 I/O 的目标很明确:把 read、write、协议解析这些网络操作挪到独立的 I/O 线程上,让主线程把精力放在命令执行上,同时仍然保持命令执行阶段的单线程串行。
3.2 多线程 I/O 的工作机制:读线程、写线程和主线程怎么分工
Redis 6.0 的 I/O 多线程并不是把连接固定分配给某个线程去处理,而是一个按事件分发的协作模型。简化概括一下:
- 主线程负责 accept 新连接,然后在第一次读事件把某个客户端 socket 分配给一个 I/O 线程。
- I/O 线程负责从 socket 中读取数据、解析出完整的命令对象,放入队列。
- 主线程从队列中取出命令,逐个执行。这一步依然是单线程、串行、无锁。
- 命令执行完成后,主线程不直接写回,而是把响应结果挂在队列上,由 I/O 线程负责写回客户端。
你可能会问:既然命令执行还是单线程,那多线程 I/O 到底有什么意义?意义就在于,在高并发读多写少的业务下,read 和 write 的耗时可能占整个请求处理的一半甚至更多。把这部分时间分散到多个线程,主线程就能腾出来执行命令,整体吞吐自然就上去了。
而且因为命令执行还是串行的,Redis 不需要为 key 的读取和修改加锁,不存在数据竞争问题,一致性依然和单线程一样干净。这是整个设计里最天才的一点:在不破坏单线程执行模型的前提下,把并行的收益吃到了网络层。
3.3 配置与实测:io-threads 该开多少才合理
Redis 6.0 之后,redis.conf 里多了两个关键配置:
# 开启 I/O 多线程,默认不开启,设置为大于 1 的值才生效 # 建议最多 8 个,按 CPU 核数调整 io-threads 4 # 是否开启读线程,默认 no;开启后读请求也由 I/O 线程处理 io-threads-do-reads yes这里有几个实测下来的感受:
io-threads不是越大越好。官方建议一般 2 到 4 就够,极端场景 8 也够用,不建议超过 8。因为 I/O 线程之间还有任务分配和队列同步的开销,线程太多收益反而下降。io-threads-do-reads默认是 no,如果你不是特别明确的流量模型,建议先不开。开了之后,读请求的解析会从主线程挪到 I/O 线程,但如果你的业务大部分请求都是几字节的小 key,这点收益其实很小。- 这个配置必须重启 Redis 才生效,不能热修改。
- 开启前最好用
redis-benchmark或者真实业务流量做压测对比。我见过有人配置完 io-threads 8,结果因为网络包太小、QPS 反而不如单线程的案例。不要盲目拍脑袋。
从实际经验看,多线程 I/O 最适合的是:单个 value 比较大、客户端连接数多、存在 pipeline 批量请求的场景。如果你就是一台普通的 4 核 8 核机器,业务以小 key 为主,开不开差别可能不大,偶尔还会因为线程调度带来轻微延迟波动。
3.4 多线程不是万能药:什么时候开了反而没收益
这一段是纯经验之谈,也是面试里很容易被追问到的问题。Redis 多线程 I/O 能提升性能,但它的收益边界非常明显:
- 如果瓶颈是 CPU 本身,比如你的命令执行链路里有大量复杂操作、有大 key 遍历,那么多线程 I/O 帮不了忙,因为命令执行还是单线程串行的,该卡还是卡。
- 如果单次请求的 value 很小、网络包都是几十字节,那么 read/write 耗时占比极低,多线程 I/O 收益有限,因为主线程大部分时间本来就在执行命令,而不是等网卡。
- 如果部署机器的 CPU 核数很少,比如只有 2 核,你开 4 个 I/O 线程,线程之间互相抢 CPU,反而拖慢整体性能。
- 如果开启了
io-threads-do-reads但业务中读多写少且请求量大,可能有任务分发不均的问题,因为 I/O 线程读取完成后,还是要等主线程逐个执行命令,队列再长也只能排队。
所以,多线程 I/O 是在 “网络 I/O 挤占命令执行时间” 这个特定瓶颈上的补丁。它解决的是特定场景的短板,不是让 Redis 变成多线程执行器。这也是面试官最爱问的一句话:Redis 6.0 的多线程,多的是 I/O,不是命令执行。
4. 排查 Redis 变慢的实操实录:从现象到结论
聊完理论,进入实战。我在排查线上 Redis 变慢问题的时候,基本有一套固定的流程,不靠猜,全靠看数据。
4.1 第一步:先确认 Redis 是“网络慢”还是“命令慢”
遇到 Redis 变慢,先别急着改配置。打开三个终端,分别跑三条命令:
# 查看当前 OPS 和命令耗时统计 redis-cli info stats | grep -E "instantaneous_ops_per_sec|total_commands_processed" # 检测本机到 Redis 的网络延迟,跑 10 秒 redis-cli --latency # 检测 Redis 进程本身的固有延迟(受内核调度、虚拟化等影响) redis-cli --intrinsic-latency 100如果--latency很高,但--intrinsic-latency正常,说明问题在网络链路,重点看客户端到服务端之间的网络设备、带宽、连接数。如果--intrinsic-latency很高,说明问题出在 Redis 进程本身,比如机器内存不够触发 swap、CPU 抢占、或者主线程被慢命令卡住。
这一步能帮你把问题从 “整个链路” 缩小到 “Redis 自身”。
4.2 第二步:用 --bigkeys 和 commandstats 揪出大 Key 与热 Key
如果确认是 Redis 进程自身慢,最常见的元凶就是大 key 和热 key。直接扫:
# 扫描大 key redis-cli --bigkeys # 查看所有命令的调用次数和耗时占比 redis-cli info commandstats--bigkeys跑一遍会把每种数据类型里最大的几个 key 列出来。看到大 key 之后,先确认业务是否真的需要这么大一个对象,如果不需要,就分拆、压缩,或者把 value 换到对象存储。特别要注意的是,这个命令本身也会遍历整个键空间,建议在业务低峰期执行,或者用 SCAN 手写脚本慢慢扫,避免对线上造成影响。
commandstats则告诉我们究竟是哪个命令在疯狂高频执行。之前我排查过一个案例,某个业务在一个 for 循环里对同一个 key 调了上千次 GET,单条命令都是微秒级,但累计下来把主线程占满了。这种问题不是 Redis 本身慢,是客户端用错了。
另外,真正常见的还有过期 key 集中清理。Redis 的过期键删除有个 lazy free 选项,如果遇到大批 key 同时过期导致卡顿,可以考虑用CONFIG SET lazyfree-lazy-expire yes把过期删除动作放到后台线程,但这个只能缓解,不是根治,最好还是把过期时间打散,避免集中过期。
4.3 第三步:看懂 SLOWLOG,把慢命令拉到阳光下
排查慢命令最直接的武器是 SLOWLOG。Redis 默认把超过 10000 微秒(10ms)的命令记录到慢日志,可以这样查:
# 查看最近 10 条慢命令 redis-cli slowlog get 10 # 查看慢日志当前长度 redis-cli slowlog len默认 10ms 的阈值其实偏低配置,线上建议你自己调整。比如对于核心缓存,我希望任何命令都能在 1ms 内完成,那可以把阈值改小,现场抓慢命令:
# redis.conf 或 CONFIG SET 修改 slowlog-log-slower-than 1000 slowlog-max-len 256慢日志里最关键的是命令内容和耗时参数。看到LRANGE、SMEMBERS、HGETALL、KEYS这类命令上榜,基本都不用犹豫,直接找业务方改代码。如果你发现慢命令只是一些很普通的 GET/SET,那就要结合当时的 OPS 和机器负载综合判断,可能是瞬间流量太高,或者内存碎片严重导致的换页。
4.4 常见误区速查:客户端多线程不等于 Redis 多线程
这个主题下我强烈建议你存一份常见误区对照表,因为很多人、包括一些工作几年的工程师都会在这些点上踩坑:
| 常见说法 | 实际情况 |
|---|---|
| Redis 是单线程的,所以无法并行处理请求 | 服务端命令执行是单线程串行的,但网络 I/O、持久化、异步删除都有线程/子进程参与;客户端依然可以并发连接 |
| 客户端开了多线程,Redis 服务端也会多线程处理 | 客户端多线程只代表并发发送请求,服务端依然排队逐个执行,瓶颈依然可能在服务端主线程 |
| Redis 6.0 支持多线程了,所有命令可以并行执行 | 6.0 的多线程只在网络读写和协议解析阶段生效,命令执行依然是单线程 |
| 机器核数多,io-threads 配大点没问题 | io-threads 过大反而增加同步开销,建议 2~8,最多别超过 8,还要结合实际压测 |
| 只要不加慢命令,单线程就永远不卡 | 大 key、集中过期、fork 持久化、swap 内存不足,都会造成主线程阻塞 |
| 用 KEYS 遍历没多大问题 | 在几百万 key 上 KEYS 可以阻塞主线程秒级,必须用 SCAN |
这张表可以直接当面试和实战笔记用。我见过有人把客户端连接池从 50 调到 500 之后,发现 Redis QPS 不升反降,最后才发现是服务端单线程执行不过来,连接多了反而增加上下文切换开销。这就是典型的没分清客户端并发和服务端并发。
5. 面试和项目里怎么把“线程模型”讲明白
这部分算是很多人的刚需。毕竟热词里 “redis 面试题”“多线程面试题” 常年挂在热搜上,说明大家都在背这一题。
5.1 3 分钟讲清 Redis 线程模型:一套可复用的回答框架
面试官问 “Redis 为什么快?说说线程模型”,理想的回答不是背一段八股文,而是分四条线清晰推进:
- 先给结论:快在内存、数据结构、IO 模型、无锁设计四个层面。
- 讲清旧模型:Redis 6.0 之前,命令执行是单线程的,通过 I/O 多路复用(epoll)监听海量连接,事件循环内串行执行命令,避免了锁竞争和上下文切换。
- 转折到新模型:6.0 之后,官方发现网络读写与协议解析成了主线程瓶颈,于是引入多线程 I/O,把 read/parse/write 交给独立 I/O 线程,主线程仍然只做命令执行。所以 Redis 的 “多线程” 是多在网络层,不是多在执行层。
- 补一句生产经验:多线程 I/O 适合大 value、高连接数、pipeline 场景,配置 io-threads 要按核数和压测结果来,不是越大越好。同时一定要避开 KEYS、大 key、集中过期这些单线程模型下最容易翻车的操作。
这套框架的好处是既有高度又有细节,而且展示了你对版本演进和线上场景的理解。比生硬地背 “单线程 + 多路复用 + 内存” 三个词强得多。
5.2 项目实战心得:单线程模型下怎么把业务代码写好
最后说点真实的项目体会。理解了线程模型之后,你写业务代码的思路应该改变:
- 尽量用 pipeline 批量操作。客户端一次发送多条命令,相比逐条发送,能把多次 RTT 变成一次,对主线程的 read/write 压力也小很多。
- 需要原子性时用 Lua 脚本,一条脚本在 Redis 内是原子执行的,既保证并发安全,又能减少多次 RTT。
- 用 SCAN 代替 KEYS,用 SSCAN/HSCAN 代替 SMEMBERS/HGETALL 全量获取。遍历要分批,切忌一把梭。
- 给 key 设置过期时间时,加入随机偏移量,避免大量 key 在同一秒过期。
- 上线前最好用
redis-cli --bigkeys扫一次,把明显的大 key 问题在测试环境就暴露出来。 - 监控上盯住 slowlog 和 instantaneous_ops_per_sec 这两个指标,前者看有没有异常命令,后者看流量变化是否符合预期。
这些点是我在多次线上事故里一点点攒出来的。Redis 本身其实很皮实,大多数“Redis 变慢”都是使用姿势的问题,而不是 Redis 性能不行。线程模型决定了它的天花板,而你怎么用决定了你能否够到这个天花板。
回到标题那句话:Redis 为什么这么快?内存是地基,数据结构是骨架,I/O 多路复用和无锁设计是引擎,6.0 的多线程 I/O 则是在保持单线程执行模型的基础上,把网络 I/O 这块短板补上。理解了这个演进过程,你以后再遇到 Redis 性能问题,就不会只知道加内存和重启了。