做后端这些年,只要一聊缓存,Redis 和 Memcached 这俩名字基本跑不掉。每次技术评审,Redis 的“单线程 + 多路IO复用”和 Memcached 的“多线程 + 锁”都会被拉出来对比一轮,争论焦点永远是那个经久不衰的问题:单线程好还是多线程好。今天这篇,我想以自己的开发、压测和线上排障经历为线索,把这两种并发模型的原理差异、性能边界、适用场景掰开揉碎,给正在做缓存选型和准备面试的同行一份能直接抄作业的参考。别急着站队,先弄明白它们各自为什么这么设计。
1. 架构设计的核心思路拆解
1.1 Redis 为什么敢押注单线程
Redis 真正成型是在 2009 年前后,当时的服务器主流配置还是单核双核。作者 antirez 在设计时看得很明白:所有数据都在内存里,一次 key 查找和内存复制只需要几十到几百纳秒,真正耗用时间的是网络收发,而不是计算本身。既然 CPU 不是瓶颈,那为了所谓多核并行去引入多线程,就是拿高复杂度换一个并不稀缺的资源,这笔账不划算。
单线程带来的最大红利不是“快”,而是“没有锁”。哈希表、字典、过期列表、客户端连接列表,这些共享数据结构不需要加任何 mutex;你不用担心并发读写同一块内存产生脏数据;甚至断点调试都能从头到尾一步一步跟完。Redis 的 INCR/DECR 这类原子操作之所以是原子操作,靠的就是单线程天然串行,这个“免费礼物”让上层业务省掉了大量同步代码。
当然,单线程也常被吐槽“浪费多核”。这话只对了一半。线程切换是有成本的,每一次上下文切换都要保存现场、刷新缓存,如果线程密集还抢锁,开销更大。Redis 通过事件循环把所有请求串在一根主线程上,刻意避免了切换损耗,让 CPU 始终在处理就绪事件,而不是在排队等核空出来。在单核打满的前提下,它的 QPS 能做到很高,这本身就是效率的体现。
1.2 Memcached 转向多线程的真实动机
Memcached 比 Redis 更早出现,最早也是单进程单线程,处理逻辑同样基于事件循环。后来多核机器普及,团队发现机器经常一核忙死、其他核心闲置,有点暴殄天物,于是在 1.4 版本左右正式引入多线程 worker 池,试图把多核资源榨干。
它的多线程模型是:主线程负责 accept 新的 TCP 连接,然后把连接分给某个 worker 线程,后续这个连接上的所有读写都由该 worker 独占处理。网络收发因此天然分布到多个核上,资源利用率确实上去了,整体吞吐上限也比单线程时代高了一大截,这是多线程最实在的收益。
但多线程共享同一份内存池和哈希表,数据模型没变,要保证一致性就得上锁。哈希表操作有锁,item 引用计数有锁,LRU 维护有锁,统计计数器也有锁。一整套锁机制铺下来,临界区就成了新的排队点。并发越高、请求越集中,锁竞争越容易成为尾部延迟的隐形杀手。这个代价不是早期就能肉眼看见的,但一定会在高负载场景里浮现出来。
1.3 两种路线背后共同的取舍逻辑
把两种路线放在一起看,会发现它们其实殊途同归:都在“让 CPU 尽量少闲着”和“让共享数据尽量少打架”之间找平衡。Redis 选择消灭并发——事件循环把并发请求一个个排好队,主线程永远在处理就绪事件,自然不需要锁;Memcached 选择消化并发——多线程并行消费请求,同时用一把把锁守住共享结构的底线。两者没有谁更先进,只是对“资源瓶颈在哪里”做了不同判断。
这个判断直接决定了两者的性格。Redis 更像一个单线程的大管家,所有请求按序处理,稳定、干净、好预测;Memcached 更像一支多人的流水线团队,吞吐上限高,但一旦大家都去抢同一件工具,就会互相绊脚。选型之前先理解这个性格差异,比背结论有用得多。
2. 核心机制详解:IO多路复用与锁的正面对决
2.1 多路IO复用:一个线程伺候上万连接的工作方式
先纠正一个常见误区:多路IO复用不是 Redis 发明的,是操作系统早就提供的 IO 处理方式。它解决的核心问题是——怎么用一个线程同时监听成百上千个 socket 上的数据是否就绪。
我想拿饭店打个比方。传统阻塞 IO 模型是每个桌子配一个服务员,客人不来他也得站着干等,这叫一连接一线程;select 模型是一个服务员每隔几秒挨桌问“需要点菜吗”,客人没喊也被打扰,复杂度 O(n);epoll 模型是每张桌子装了个铃,客人一抬手服务员就收到定向通知,按需服务,复杂度接近 O(1)。Redis 选的就是第三条路,这就是“多路IO复用”的通俗解释。
落到实现上,Linux 上 Redis 用 epoll,mac/BSD 上可用 kqueue,只有在兼容层才会退化到 select。主循环每次调用 epoll_wait 拿一批就绪 fd,然后逐个执行对应回调。读请求、写响应、定时任务,全在这个循环里流转。所有命令都在这个循环里被排队执行,所以单线程并不代表性能差,而是代表没有无谓等待,尤其不会让 CPU 空转在阻塞调用上。
Redis 6.0 之后引入了 IO 多线程,把 socket 读写分摊给多个 IO 线程,但命令执行主逻辑仍然保持单线程。开启方式是在 redis.conf 里配置 io-threads 和 io-threads-do-reads 参数,默认关闭。这个折中很能说明问题:瓶颈在哪就优化哪,没必要为多线程而多线程。
注意:IO 多线程只解决网络读写的并行,命令执行和持久化仍在主线程,所以别以为开了 io-threads 就可以肆无忌惮跑慢命令。慢命令照样卡死整个实例。
2.2 多线程加锁:并发度提升背后的隐性成本
再来剖析 Memcached 的多线程模型。它的锁不是一把大锁锁全库,而是做了不少细化:哈希表操作有 item lock,每个 item 的引用计数有独立的锁保护,LRU 维护有 LRU 锁,统计计数器走原子变量。锁粒度细化到这种程度,已经比早期版本进步很多,但锁的存在本身就是成本。
加锁的隐性成本至少有三层。第一层是加锁解锁本身的原子指令开销,单条指令不多,但每秒几百万次就完全不是一个数量级;第二层是临界区排队带来的延迟抖动,锁被持有时,后来的请求只能等待,P99 就上去了;第三层是缓存行失效——多核 CPU 各自缓存同一块内存数据,一个线程修改后,其他核的缓存失效,下次访问要回主存重新拉。线程越多,第三层开销越明显,有时甚至比显式锁等待更坑。
当然,多线程不是只有坏处。当负载真正能分散到不同连接、不同 key 上,多核并行确实能拉高吞吐。Memcached 在纯 KV 缓存、读多写少、key 分散的大规模场景下,整体 QPS 上限经常比 Redis 单实例高。所以问题的关键从来不是“多线程对不对”,而是“你的请求模式能不能避开临界区”。
2.3 破误区:单线程和多线程从来不是性能的唯一变量
现在网上有两个流传很广的误区。一个是“单线程落后于多线程”,论据是 Redis 只吃一核。但实际生产里,Redis 单实例的性能瓶颈通常出现在内存带宽、网络带宽和连接数上,CPU 往往不是第一短板。一个是“多线程一定更稳”,但从线上经验看,Memcached 在热点集中、高并发场景下 P99 波动反而更明显,原因就是锁竞争。
真正值得关注的是三个变量:value 大小、连接数、热点分布。value 越大,内存复制和网络传输占比越高,多线程并行优势就越明显;连接数越多,Redis 事件循环的分批次处理反而越平滑;热点越集中,Memcached 锁等待越容易放大延迟。选型别只拿线程数当依据,把它放到自己的业务请求特征里看,才有意义。
3. 压测实录与场景选型建议
3.1 同一台机器上的四组压测数据
我自己在 4 核 8 线程、16G 内存的 Linux 服务器上,用同样的网络配置压测过 Redis 6.2 和 Memcached 1.6,并发连接用 10000,value 大小做了区分。数值仅供参考,硬件差异就能改变 30% 以上,但形状能说明问题。
| 压力模型 | Redis 6.2 单实例 | Memcached 1.6 单实例 |
|---|---|---|
| 10000 连接,key 分散,value 128B,SET | 约 10.5 万 QPS | 约 18 万 QPS |
| 10000 连接,key 分散,value 128B,GET | 约 12 万 QPS | 约 20 万 QPS |
| 5000 连接,集中请求 20 个热点 key,GET | P99 约 0.8ms | P99 到 2.5ms 以上 |
| 大 value 1MB 混合读写 | 吞吐约 1.2 GB/s | 吞吐约 1.6 GB/s |
看第一眼,Memcached 在多核机器上确实能拿到更高的 QPS,这是多线程模型在纯并行负载下的真实红利。但第三行才是关键:热点集中时,Memcached 的 P99 尾部延迟明显放大,锁竞争占了相当比例。这说明压测不能只测理想情况下的 QPS,一定要把热点模型测进去,否则上线后会被 P99 打脸。
3.2 选型决策:什么场景选 Redis,什么场景选 Memcached
选型其实不复杂,核心看两点:你要不要数据类型和分布式能力,你的负载能不能充分利用多线程。
Redis 的优势很明确:String、Hash、List、Set、ZSet、Stream 一套完整的数据结构,配合 Lua 脚本、事务、持久化、发布订阅,它能承担的远不止“缓存”这一个角色。分布式锁也是 Redis 的杀手锏场景,一句 SET key value NX EX 就能拿到锁,配合 Lua 的原子 check-and-set,业界围绕它长出了一整套分布式锁方法论。如果你需要这些能力,基本没有悬念,直接选 Redis。
Memcached 的性感点在于极简:就是纯 KV,slab 分配器对内存管理非常精细,内存碎片比例比 Redis 低,多线程在多核下能稳定吃满资源。适合的场景画像是:缓存量巨大、key 和 value 都很简单、可以接受全量淘汰、也不需要持久化的业务,比如存储商品 SKU 快照、图片元数据这类高吞吐但无状态的数据。
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | 丰富 | 纯 KV |
| 持久化 | RDB/AOF | 不支持 |
| 原子操作 | INCR/DECR/Lua | 仅有 INCR/DECR、CAS |
| 分布式锁 | 支持完善 | 不便实现 |
| 多核利用 | 单线程主逻辑 | 天然多线程 |
| 内存管理 | jemalloc | slab 分配器 |
| 集群方案 | Cluster、Sentinel 成熟 | 主要靠一致性哈希 |
3.3 混合使用:不是非此即彼
“二选一”其实是伪命题。我待过一个项目,核心会话缓存和数据预热用 Redis,量大、淘汰敏感度低、只要求 get/set 的图片特征缓存用 Memcached。Redis 负责业务语义,Memcached 负责吞吐,两个各司其职,反而比单一方案更稳,成本也可控。
现在很多团队用 Redis Cluster 把单机吞吐做上去,Memcached 的存在感确实下降了。但 Redis Cluster 的运维复杂度摆在那里,如果你只有几十上百 GB 的普通缓存需求,Memcached 一台机器顶上去,从成本和易用性角度依然有它的位置。选型前先把容量算清楚:缓存总量在 10G 以下、不需要数据结构,Memcached 怎么都够用;缓存之上还挂着业务复杂性,那就别省 Redis 这笔钱。
4. 源码浅读与线上排障实录
4.1 关键代码脉络:事件循环和 worker 线程池
从源码角度回头看,两者的核心差异一目了然。Redis 的入口在 server.c,启动后进入 aeMain 事件循环,所有事件处理都在这个循环里:
/* Redis 主流程概念示意 */ initServer(); aeMain(server.el); /* 进入事件循环,直到 shutdown */ aeDeleteEventLoop(server.el);aeMain 内部就是一个 while,不断调用 aeProcessEvents 处理就绪事件:
/* ae.c 概念示意 */ while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS); }aeProcessEvents 里通过 epoll_wait 拿就绪 fd,然后逐个分发回调。整个模型的核心就是把“等待事件”和“处理事件”串行化,单线程,无锁,简单到令人发指,也稳定到令人服气。
Memcached 的脉络相反:主线程 accept 连接,worker 线程组同时消费连接队列,处理命令,涉及共享结构时加锁。概念流程示意如下:
/* worker 线程概念示意 */ for (;;) { conn = conn_new_from_queue(); /* 取连接 */ if (conn == NULL) continue; process_command(conn); /* 处理命令 */ /* 访问共享哈希表前加锁 */ pthread_mutex_lock(&item_lock); it = item_get(key, nkey); pthread_mutex_unlock(&item_lock); }这两段代码基本就能解释各自的设计哲学:一个靠事件循环消灭并发问题,一个靠线程池并行消费并用锁兜底。源码里并不存在谁更优雅的问题,只看你的业务能不能匹配它的性格。
提示:看 Redis 源码建议从 ae.c 开始,几十行的核心循环比满屏的 dict 操作更能建立全局观;看 Memcached 源码建议从 thread.c 开始,先把连接分配和锁的关系理顺,再深入 slab 分配器。
4.2 线上排障:三个真实案例的排查思路
案例一,Memcached 锁竞争导致的 P99 飙升。之前有个业务把会话缓存放在 Memcached 上,某天大促流量上来,P99 从 1.5ms 涨到 6ms,QPS 却没降多少。用 perf top 抓到大量时间花在 lock contended 上,再配合压测复现确认就是热点 key 集中挤在少数 worker 线程的临界区里。解决手段是把热点 key 做了后缀拆分,把负载打散到不同线程,同时扩容实例,P99 回落到 2ms 左右。
案例二,Redis 慢命令阻塞事件循环。一条 KEYS * 在千万级 key 库上直接跑了 400ms,等于整个 Redis 停顿了 400ms,所有请求排队,外部表现为雪崩式超时。慢根因很好查,执行 SLOWLOG GET 就能看到具体命令和时间。处理方案是禁用 KEYS,排查时改用 SCAN 分批遍历;大 key 删除也改成 UNLINK,这个异步删除命令不会堵主线程。
案例三,连接数暴涨引发的连接风暴。某个 Go 服务把连接池 max 配置得太大,一启动就建立上千条连接,Redis 大部分 CPU 花在 accept 和连接管理上,业务 QPS 反而下降。解决方法是调小 maxIdle/maxActive,加上客户端超时和健康检查,连接复用率提上去后问题消失。这跟线程模型无关,但很容易和单线程性能问题混淆,排查时别一上来就怀疑 Redis 本身。
4.3 常见问题排查速查表
这个表是我平时排障时的固定动作,整理成速查版,方便直接贴到团队文档里。
| 现象 | 可能原因 | 排查手段 | 处理建议 |
|---|---|---|---|
| Redis QPS 突然断崖 | 慢命令卡主线程 | SLOWLOG、LATENCY DOCTOR | 禁用 KEYS,用 SCAN/UNLINK |
| Redis CPU 高但 QPS 不涨 | 大 value、连接过多 | redis-benchmark 对照 | 压缩 value、开 pipeline |
| Memcached P99 上涨 | 热点 key 锁竞争 | perf top、热点压测 | key 拆分、扩容实例 |
| Memcached 连接超时 | worker 线程排队 | 查看连接队列长度 | 调整 maxconns、缩减超时 |
| 请求缓存都命中但延迟高 | 网络栈或连接池配置问题 | ping 大包、ss 看队列 | 调 TCP 参数、检查连接池 |
排查的顺序也建议固定:先看慢日志,再看网络,再看锁竞争,最后才怀疑并发模型本身。大多数线上事故其实死于配置和慢命令,而不是模型选错了。
5. 回顾这次对比,我的几点实际体会
每次有人问我“Redis 和 Memcached 到底选谁”,我都会先反问回去:你的业务需要数据结构、持久化、分布式锁吗?需要就闭眼 Redis,不需要纯 KV 就看看容量和并发,Memcached 依然能打。单线程和多线程之争更多是面试官爱问的题,落地时真正决定上限的是数据形态、请求热点和运维能力。
压测时我也踩过不少坑,最开始只看平均值,后来被线上 P99 打脸才学乖。压测一定要覆盖热点集中、大 value、连接风暴这三种模型,只测均匀分布的 QPS 不仅没意义,还会误导选型。多线程不是万灵丹,单线程也不是原罪,想清楚自己系统的瓶颈到底在哪,再决定用哪种模型去匹配它。
最后说点私货:如果你已经在用 Redis,就不要再为“Redis 单线程不够用”这种说法焦虑了。单机不够就上集群,Redis 的横向扩展方案要比 Memcached 成熟得多;如果你确实在用 Memcached,也先别急着迁移,先把热点拆了、实例扩了、参数调了,很多时候它离瓶颈还远得很。架构选型比的是匹配度,不是嗓门。