前阵子帮某业务团队做压测复盘看到一组挺有意思的数据同一台 8 核机器、一样的压测脚本、满负荷跑半小时跑着“十二线程”的 Memcached 和跑着“单线程”的 Redis平均延迟和 P99 压到最后居然只差个位数某些混合读写场景下 Redis 反而更稳。当时团队同学的第一反应是“压测脚本是不是写错了”毕竟大家潜意识里都觉得多线程肯定比单线程强。这种“直觉失灵”在缓存中间件领域太常见了。Redis 和 Memcached 作为最常被摆在一起对比的两款缓存一个坚持单线程事件循环加多路 IO 复用一个选择多线程加锁表面上只是线程模型不同实际上背后是两套完全不同的取舍哲学。这篇文章我就把两者掰开揉碎讲清楚各自的模型长什么样、为什么这么设计、真实瓶颈在哪、不同场景下实测表现如何最后落到选型建议和调优避坑。不管你是刚开始接触缓存的新手还是要做技术选型的负责人应该都能从中拿到一些能直接用的东西。1. 两个模型的基本盘单线程执行和多线程并行的边界到底画在哪1.1 Redis 的“单线程”指的到底是什么Redis 所谓的单线程准确说是“命令执行”是单线程的。也就是说所有客户端发来的命令最终都在一个主线程里排队执行。从客户端视角看不管同时有多少连接、发多少请求Redis 的回应都是一个接一个的没有两条命令真正在同一时刻执行。但这不代表 Redis 进程里只有一个线程。它内部有后台线程比如负责 AOF 文件刷盘的后台线程、负责惰性删除大 key 的后台线程。Redis 6.0 之后还引入了多线程 IO把网络数据包的读写分散到多个线程不过命令的解析和执行仍然严格保持在主线程里。所以更准确的说法是Redis 是“单线程执行命令 多路 IO 复用接收连接/数据”外面看着单里面其实有分工。1.2 Memcached 的“多线程”指的又是什么Memcached 的多线程从架构上说是用一个主线程专门 accept 新连接然后把每个连接分发给一组 worker 线程。每个 worker 线程又有自己独立的事件循环处理分到的连接上的网络读写、命令解析和哈希表操作。你可以把它理解为若干个微型 Redis 并行跑每个线程各自服务一批客户端从宏观上看确实能同时处理多个请求。但既然是多个线程共享同一个哈希表、同一套 LRU 队列那么一个线程要往哈希表里插入数据时其他线程如果也要操作哈希表就得互相等待这就是锁的来源。1.3 一个容易歪楼的核心认知很多人一听到多线程就觉得“能同时干多件事肯定更快”。这句话在 CPU 密集型任务上基本成立但在缓存服务这种场景里要打很大折扣。缓存服务的核心负载是网络 IO 和内存访问一条命令真正让 CPU 使劲算的时间非常短绝大部分时间是花在等待网络数据到来、把数据从内核缓冲区拷贝到用户空间、把结果写回给客户端这些环节上。打个比方快递驿站。单窗口的驿站如果有一套高效的叫号系统就算同时涌进来 100 个人也能处理得井井有条多窗口的驿站如果每个窗口都要抢同一本登记簿人一多时间反而都耗在“谁先登记”上了。Redis 更像前者Memcached 更像后者。当然这只是简化说法真实情况复杂很多后面逐步展开。2. Redis 的单线程事件循环一句 GET 命令从进入到返回的完整旅程2.1 多路 IO 复用复用的其实是“等待”这里必须先讲清楚多路 IO 复用。早年写网络服务最简单的做法是一个连接来了就开一个线程这个线程阻塞在 read() 上等数据。连接少没问题连接一多线程数量跟着暴涨操作系统光是上下文切换就忙不过来而且大量线程阻塞在 read() 上CPU 并没有被有效利用。select、poll、epoll 这套机制解决的就是“如何用少量的线程去盯大量的连接”。核心思路是把一堆 socket 文件描述符交给内核让内核帮你盯着一旦哪个 socket 有数据可读、可写就通知你。这样应用层只需要一个线程循环调用 epoll_wait()就能管理成千上万个连接。Redis 用自己实现的 ae 事件循环库底层在不同平台分别封装了 epoll、kqueue 和 select。所谓单线程就是这一个线程在事件循环里不断做三件事等事件、读数据、执行命令然后回到等待。因为事件循环和命令执行都在同一个线程里才不会出现“这个线程在读数据另一个线程在改哈希表”这种需要加锁保护的并发问题。2.2 一条命令从进入到返回经历了什么用一个具体例子串一遍。客户端发来一条 GET foo事件循环从 epoll_wait() 中醒来发现某个 socket 可读。主线程通过 read() 把 socket 缓冲区里的数据读出来放到输入缓冲区。解析器解析出完整的命令文本GET foo。主线程执行命令从哈希表里取出 foo 对应的值。把结果写入 socket 的输出缓冲区TCP 数据由内核后续发出去。事件循环回到 epoll_wait()继续等待下一个事件。整个过程一气呵成没有第二个线程插手。所以 Redis 天然不存在“执行到一半被另一个线程改了数据”这种问题结构简单到可以闭着眼睛推演每一步。这里有个细节值得注意第 5 步的“写回”并不是直接把数据塞进 socket 就完事而是先写入输出缓冲区再由内核在合适的时机通过 TCP 协议栈发出去。Redis 用了一个技巧叫 deferred write也就是先尝试直接 send如果缓冲区满了再注册可写事件。这套逻辑同样是事件驱动的写了一半不会阻塞整个事件循环。2.3 单线程给排查问题带来的“确定性”这一点我实际是用了很久才真正体会到。多线程系统里很多问题只能在特定线程调度时序下才出现表现为偶发延迟毛刺、偶发超时、难以复现的内存错乱。而 Redis 的命令执行是严格串行的遇到问题可以非常确定性地根据命令执行顺序去推断不存在“两个线程同时操作一个 key”这种隐藏竞态。另外还有一点容易被人忽略单线程意味着没有锁没有上下文切换。别小看上下文切换一次切换虽然只有几微秒但在每秒几十万次操作里累积起来就是很大的开销。没有锁也就没有“锁饥饿”“锁重入”“活锁”这一堆衍生问题这一点在对比 Memcached 时会看得更清楚。3. 为什么单线程在缓存场景反而是优势从锁、上下文切换到瓶颈计算3.1 先算一笔瓶颈账很多人最爱问一个问题单线程不是浪费多核 CPU 吗我的回答是对大多数缓存场景来说CPU 本来就不是主要瓶颈。一次简单的 GET 命令哈希查找的 CPU 开销可能只有几十纳秒到几微秒网络传输和系统调用占了绝大部分时间。也就是说瓶颈在网络和内存带宽不在 CPU 核心数。举个例子。假设一个 Redis 实例跑在 8 核机器上单线程处理能力大约是每秒十几万次简单 GET。如果把这批请求拆给 8 个线程哈希表就必须加锁保护每个线程为了维持一致性要付出锁等待、缓存同步、内存屏障的开销最后能拿到的吞吐提升可能只有 3 到 4 倍但引入的复杂度是指数级的。Redis 作者一直以来的思路就是“牺牲部分多核并行能力换取实现的简洁和延迟的稳定”至少在命令执行层面这个取舍在很长一段时间内都是划算的。3.2 Redis 6.0 的多线程 IO一次试探也验证了瓶颈判断Redis 6.0 推出的多线程 IO其实直接印证了上面的判断。它的出发点不是“单线程执行命令太慢”而是“网络读写的系统调用在高并发大包场景下成了瓶颈”。所以它把 read() 和 write() 这两个环节拆给多个 IO 线程做命令执行依旧单线程。实测下来对于网络包比较大的场景开启 io-threads 之后吞吐有明显提升但对于纯小包的 QPS 场景提升并不明显因为瓶颈本来就不在系统调用上。这个演进路径恰恰说明Redis 团队对瓶颈的判断非常清晰知道在哪个环节加线程才有价值而不是无脑上多线程。3.3 单线程的软肋慢命令和大 key单线程模型最怕的是命令本身耗时太长。一条 KEYS 命令如果匹配了几百万个 key主线程就会死死卡在遍历上期间所有客户端的请求都被“冻结”。同样的对一个几百 KB 的大字符串做操作或者对一个超大哈希表执行 HGETALL都会让事件循环停滞很长时间。所以 Redis 社区一直强调尽量避免 O(N) 命令、避免大 key这背后就是为了保护单线程模型的稳定性。一旦有慢命令混进来Redis 的 P99 延迟瞬间就会被拉到一个很难看的数值。这不是 Redis 不行而是使用姿势不对。后面调优部分我还会专门展开。3.4 一个反直觉的结论单线程反而更可预测我调过很多次缓存系统一个很深的感受是缓存服务最怕的不是“不够快”而是“这次快、下次慢、不知道为什么慢”。多线程系统在负载升高时锁竞争和线程调度会把延迟分布打得很散就像一条原本平整的马路突然开始堵车你永远不知道哪一秒会被堵住。Redis 的单线程模型把所有操作串行化延迟分布非常集中绝大多数请求都落在很窄的时间区间里。这在业务侧的意义很大稳定的 P99 意味着你可以放心设置超时时间不用担心偶发毛刺把上游拖垮。真实的生产环境中这种“可预测性”有时候比峰值吞吐还值钱。4. Memcached 的多线程与锁共享哈希表决定了它的天花板上限4.1 多线程架构是怎么搭起来的Memcached 的多线程架构可以概括成“一个调度员一群干活的人”。主线程负责 accept 新连接然后通过分发机制把连接分配给不同的 worker 线程。每个 worker 线程运行独立的事件循环跟 Redis 主循环类似但它只处理自己的那批连接。这样做的好处很明显连接多了以后网络 IO 的压力被多个线程分担多核 CPU 确实被利用起来了。在连接数庞大、每个连接的请求频率不高的场景下Memcached 能把负载分散到多核总吞吐比单线程模型有优势。这也是它当年能火起来的重要原因毕竟那个年代大家最关心的是“能不能扛住海量连接”。4.2 全局哈希表锁那道绕不过去的门问题出在数据共享上。所有 worker 线程操作的是同一个全局哈希表这个哈希表是共享资源。为了保证同一时刻只有一个线程在往哈希表里插入、删除、查找Memcached 用了一把全局互斥锁通常称为 cache lock来保护。这意味着什么当大量线程同时做 SET 操作时它们会先排队拿这把锁拿到锁的人往里写其他人在锁外面等着。高并发小包 SET 场景下锁竞争会非常激烈线程越多竞争反而越严重最终出现“线程数翻倍吞吐不升反降”的尴尬局面。这正是很多人压测 Memcached 时遇到的第一个怪现象也是它被诟病最多的点。4.3 锁不止一把LRU 锁和其他共享资源的争夺除了哈希表锁Memcached 还需要保护 LRU 链表、统计信息、全局配置项等。每一把锁都有可能在极端情况下成为瓶颈。尤其是 LRU 维护每次访问一个 item 都要更新它的访问时间意味着每个 GET 操作都要碰一次 LRU 相关的锁。锁的粒度如果控制不好连读操作都会被拖慢。我这里要公道说一句Memcached 的锁设计并不是傻它保证了多线程下数据的一致性只是这种保证的代价是吞吐上限受锁竞争约束。Memcached 后续版本也做过一些优化比如更细粒度的锁、部分哈希通路的优化等但全局哈希表这把大锁始终没有完全去掉。原因很现实在如此高度共享的数据结构上做无锁化改造工程复杂度实在太高收益又不够确定性。4.4 多线程的收益什么时候能真正兑现Memcached 多线程的优势更多体现在 CPU 密集环节。比如 value 比较大时数据的序列化、内存拷贝、分配释放都消耗 CPU多线程能并行把这些开销分散到多核再比如连接数非常大的场景多个 worker 线程能更好地消化网络事件。所以你可以理解为Memcached 的多线程模型适合“请求本身需要一定 CPU 时间”的场景Redis 的单线程模型适合“请求本身极轻、但量极大”的场景。两者各有各的舒适区这也是它们能长期并存的原因之一。5. 三组实测对照小包高并发、大 value、海量连接下谁更扛得住5.1 场景一小 value 高并发读写压测配置8 核机器value 大小约 100 字节读写比例 2:1连接数 500压测时长 10 分钟。两边都按推荐参数配置Memcached 的线程数设为 8。结果很典型压力还没到极限时两者吞吐差距不大Redis 略高并发继续拉高后Memcached 的 P99 开始明显上抬峰值吞吐先于 Redis 到达天花板。原因就是高并发 SET 场景下全局哈希表锁竞争加剧线程上下文切换和锁等待把 CPU 消耗掉了。这里要强调一个压测常识不能只看平均延迟。平均延迟被大量快速请求拉低了真正的用户体验由 P99 甚至 P999 决定。Redis 在这种场景下 P99 表现稳定得多因为它根本没有锁等待这回事。5.2 场景二大 value 读写再压一个大 value 场景value 大小 100 KB读写比例 1:1。这时候局面反转了。Redis 单线程处理大 value 时内存拷贝和网络读写占用的时间显著拉长一个 100 KB 的网络大包在单线程里读写就很吃亏。Memcached 多个 worker 线程能并行处理不同连接上的大包总吞吐反而占优。Redis 6.0 之后开启 io-threads大 value 场景的差距会被拉近很多但 Memcached 的多线程天然更适合这类负载。如果你的业务缓存的是动辄几百 KB 的序列化对象、图片元数据Memcached 在这个指标上确实有理由入选。5.3 场景三海量连接低频请求第三种场景是几万个连接但每个连接的请求频率很低类似长连接服务的心跳。这种场景下Redis 的 epoll 加单线程模型非常能扛因为连接多但活跃事件少事件循环大部分时间是轻闲的。Memcached 的 worker 线程需要在线程间转移连接还要应对线程调度开销表现也还行但线程利用率参差不齐可能出现一部分线程很忙、另一部分很闲的情况。三条场景合起来看结论就很清晰高频小请求拼稳定选 Redis大块数据拼吞吐可考虑 Memcached海量连接看事件循环设计则是 Redis 更省心。5.4 关于压测数据的一句提醒做对比压测有几件事必须注意一是压测客户端本身不能成为瓶颈最好用性能足够高的机器二是固定连接数、请求大小、读写比例、过期策略只改一个变量才谈得上对比三是要记录多分位延迟不能只看平均值。我见过太多团队拿一份没控制变量的数据得出“XX 明显更牛”的结论上线就被打脸。我把两个组件在几个维度上的差异整理成一张表方便大家收藏维度Redis单线程 多路 IO 复用Memcached多线程 锁命令执行单线程串行天然无竞态多线程并行共享数据需要锁保护锁开销无锁全局哈希表锁、LRU 锁等存在竞争延迟稳定性P99 很稳定高并发下 P99 容易上翘大 value 吞吐单线程吃亏6.0 后可用 io-threads 缓解多线程并行天生占优数据结构丰富字符串、哈希、列表、集合、有序集合等仅 KV支持 CAS持久化RDB / AOF无纯内存内存管理依赖系统分配器多用 jemallocslab 分配内存不归还 OS适用定位内存数据平台纯缓存组件6. 选型实战与其纠结线程模型不如先想清楚你的业务要什么6.1 Redis 的胜场不是缓存是“数据结构服务器”Redis 现在几乎不适合再被当作“高级 Memcached”来理解。它丰富的数据结构、持久化能力、发布订阅、Lua 脚本、分布式锁、慢查询分析、集群方案让它更像一个通用的内存数据平台。如果业务需要的不仅仅是缓存能查能删而是排行榜要用有序集合、计数要用 INCR、去重要用 Set、简单消息队列用 List 或 Stream或者崩溃后希望恢复一部分数据、需要跨多个 key 的原子性操作——那么选择非常明确就是 Redis。单线程模型只是它众多特性中的一个基本面根本不构成选型阻碍。6.2 Memcached 的胜场极简和纯粹的缓存器Memcached 这么多年依然有人用靠的是“简单到极致”。它就是一个纯内存 KV 缓存支持最简单的过期淘汰内存用 slab 分配器管理对大量相同尺寸的小对象非常友好元数据开销比 Redis 的对象开销低不少。如果需求只是“把读出来的结果缓存一下、过期就删、重启不怕丢”Memcached 会是很轻量干净的组件。我还见过一些团队把 Memcached 用在临时缓存层比如缓存大尺寸 Profile 数据、图片缩略图二进制块这种场景下它的多线程吞吐能力确实能派上用场。它的 CAS 机制在部分防并发覆盖场景也够用。6.3 实际架构里两者并存的常见姿势比较大的业务系统里Redis 和 Memcached 共存并不罕见。比较典型的做法是核心数据、需要原子操作和数据结构支持的走 Redis纯静态的大对象、完全容忍丢失的走 Memcached。两者前面再做一层统一缓存封装对业务屏蔽底层差异按 key 前缀或者对象大小路由。这样既享受了 Redis 的功能深度又不浪费 Memcached 在大对象吞吐上的优势。当然如果团队维护成本敏感只留一个也完全合理绝大多数业务场景 Redis 单靠一个组件就能满足Memcached 更多是特定场景下的补充。7. 调优避坑清单两个模型下最容易翻车的事我替你踩过了7.1 Redis 单线程模型下的红线结合我实际踩过的坑给几条可以抄作业的建议明确禁止 KEYS 这类 O(N) 命令用 SCAN 替代。关注大 key用 redis-cli --bigkeys 定期扫描把大 key 拆小或者换存储方案。慢查询看 SLOWLOG把超过 10ms 的命令都揪出来优化。多核机器上单实例吃不满 CPU 是正常的想榨干多核可以直接起多个实例或者用 Redis Cluster 做分片而不是指望单个实例自动用满所有核。如果开了 io-threads先压测再确定线程数线程太多反而增加切换成本。# 快速查看当前实例的健康情况 redis-cli info stats | grep instantaneous_ops_per_sec redis-cli slowlog get 10 redis-cli --bigkeys7.2 Memcached 多线程模型下的坑Memcached 这边也有几件事要多加小心启动时用 -t 指定线程数不要无脑开到跟 CPU 核数一样多。实测某些机型上线程数超过 8 以后吞吐提升很小锁竞争反而加剧。slab 分配器的内存不会还给操作系统容易出现“内存碎片涨得很高但数据量不大”的假象要定期监控 slab 回收和调整增长因子。重启即全空设计降级方案时一定要考虑这一点别把 Memcached 当成有持久化能力的存储。单个 key 的 value 特别大或尺寸分布不均匀时slab 分配容易造成内存浪费需要先对业务 key 的尺寸分布有数。# 启动 Memcached 时限制线程数并观察命中与淘汰 memcached -t 4 -p 11211 -m 2048 -f 1.12 echo stats | nc 127.0.0.1 11211 | grep -E curr_connections|evictions|get_hits7.3 监控指标盯全别只盯命中率最后补充两个比较实用的经验。Redis 要盯 instantaneous_ops_per_sec、connected_clients、rejected_connections、慢日志Memcached 要盯 curr_connections、cmd_get / cmd_set 比例、evictions、get_hits 命中率以及锁竞争相关指标。命中率当然重要但它在两种模型下都只是结果指标不是根因指标。另一个经验是上线前一定要做一次“极限压测 宕机演练”。缓存组件最怕的不是慢而是在极端情况下表现诡异、无法收场。把连接池打满会发生什么Memcached 重启后流量会不会全部打到数据库Redis 触发了 OOM 淘汰策略后缓存命中率掉多少这些问题文档里很少写全靠自己压一遍才知道。我个人的体会是Redis 和 Memcached 的线程模型之争本质上不是“谁的技术更先进”而是“谁的设计哲学更适合你的流量特征”。单线程加多路 IO 复用换来了确定性和无锁的简洁多线程加锁换来了多核利用率和大包吞吐。搞清楚自己系统里跑的是小包高并发还是大对象低频选型答案基本就出来了。真到了需要做决定的那天别被“单线程落后”这种说法带着走回到数据、回到场景答案往往比想象中清晰。