Redis vs Memcached:选缓存别再只看名气
问题
面试官问:"你们项目用 Redis 还是 Memcached?为什么?"
这个问题表面考技术选型,实际在试探你对缓存中间件的底层理解。如果只答"Redis 功能更强,所以选 Redis",面试官会追问——那 Memcached 存在的意义是什么?什么场景下 Memcached 反而比 Redis 合适?
核心差异
1. 数据结构
Redis 提供 5+ 种数据结构(String、Hash、List、Set、ZSet、HyperLogLog、Bitmap、Stream),而 Memcached 只支持 String 类型——准确说是 key-value 键值对,value 是字节数组,由客户端自行序列化/反序列化。
这个差异决定了业务上限:Redis 能在服务端直接做交集、排序、计数器、消息队列,Memcached 只能做纯 KV 存取,所有逻辑推到客户端。
# Memcached 实现一个简单的计数器要在客户端做
# 而且不是原子的,并发下必丢数据
import memcache
mc = memcache.Client(['127.0.0.1:11211'])
def increment_counter(key):
# 问题:这不是原子操作
current = mc.get(key) or 0
mc.set(key, current + 1)
# 两个请求同时跑,结果会少计数
# Redis 一个 INCR 搞定
r.incr('counter') # 原子操作,单线程安全2. 持久化
Memcached 完全不持久化,数据全部在内存中,重启即丢失。Redis 支持 RDB 快照、AOF 日志、混合持久化,重启后能从磁盘恢复数据。
有人会说"缓存丢就丢了,回源数据库就行"。但实践中,缓存重建的代价不可忽略——如果缓存 20GB,重启后所有请求瞬间穿透到数据库,数据库扛不住。Redis 持久化在重启场景下是救命的。
Redis 7.0 的 multi-part AOF:旧版 AOF rewrite 是单线程 fork + 写临时文件,大实例 rewrite 时磁盘 IO 冲击大。7.0 改为多部分 AOF,将 rewrite 拆成多个小文件并行写入,大幅降低 rewrite 耗时和 IO 峰值。
3. 高可用
Redis 有完整的高可用方案:
- 主从复制:异步/半同步,从节点可读
- 哨兵:自动故障转移,客户端感知拓扑变化
- Cluster:数据分片 + 自动故障转移
Memcached 没有原生高可用。节点挂了,数据就丢了,只能靠客户端侧的一致性哈希来做容错和重新分布。这是早期很多 Memcached 集群的痛点。
一个真实踩坑:某厂 2019 年用 Twemproxy(Twitter 开源的 Memcached/Redis 代理)+ Memcached 集群做 session 存储。运维半夜扩容节点,一致性哈希 rehash 后大量 session 漂移,用户被踢下线,P0 事故。事后分析:Twemproxy 的一致性哈希没有虚拟节点,rehash 时约 30% 的 key 落到新节点,这些 key 在 Memcached 上不存在,全部穿透到数据库。当晚数据库 CPU 冲到 98%。
4. 内存管理
这是两者最本质的区别之一。
Memcached:Slab Allocator
Memcached 启动时预分配 Slab(内存块),每个 Slab 被切分成固定大小的 Chunk。存储时,value 被放入≥它大小的最小 Chunk 中:
Slab Class 1: chunk_size = 96B
Slab Class 2: chunk_size = 120B
Slab Class 3: chunk_size = 152B
...
Slab Class N: chunk_size = 1MB优点:几乎零碎片,不需要 GC。缺点:内部碎片——如果存 80B 的数据,它被放到 96B 的 Chunk 里,浪费 16B。如果 value 大小分布不均匀,浪费比例可能很高。
Slab 的另一个坑:Slab Calcification(钙化)
如果某个 Slab Class 的 Chunk 被写满,即使其他 Slab Class 还有大量空闲内存,Memcached 也不允许跨 Class 借用。这导致一种诡异的场景:
Slab 1 (96B): 已用 10%,有 90% 空闲
Slab 2 (120B): 已用 100%,新写入 100B 的 key 被 LRU 驱逐,即使机器还有 40GB 空闲这就是所谓的 Slab Calcification。线上排查方法:
# 查看各 Slab 使用情况
echo "stats slabs" | nc 127.0.0.1 11211 | grep -E "^(STAT 1:|STAT 2:|STAT 3:|STAT 4:|STAT 5:)" | head -20
# 输出解读
# STAT 1:chunk_size 96 — 该 Slab 的 Chunk 大小
# STAT 1:used_chunks 80 — 已用的 Chunk 数
# STAT 1:free_chunks 5 — 空闲 Chunk 数
# STAT 1:total_chunks 10922 — 总分块数
#
# 如果某个 Slab 的 used_chunks 接近 total_chunks
# 但其他 Slab 还有很多空闲,就是钙化了Redis:jemalloc
Redis 使用 jemalloc 按需分配内存,类似 malloc 的升级版,预分配多个大小区间的 Arena,减少内存碎片。jemalloc 在 malloc/free 频繁的场景下表现优于 glibc 的 ptmalloc。
# 估算 Memcached 内存浪费
# 假设 value 大小分布均匀(64B ~ 1024B)
# Memcached chunk sizes: 96, 120, 152, 192, 240, 304, 384, 480, 600, 752, 944, 1184
# 平均浪费 ≈ 15-20%
# 60GB 机器,实际有效存储 ≈ 48-51GB5. 淘汰策略
Memcached 只有 LRU(最近最少使用),而且是逐 Slab Class 级别的 LRU——每个 Slab 内部独立 LRU,不跨 Slab 淘汰。这意味着如果某个 Slab Class 满了,它只能淘汰自己 Class 内的 key,不能借用其他 Slab 的空闲内存。
Redis 有 8 种淘汰策略(LRU/LFU/random/ttl + 全量/volatile 两种范围),灵活性高很多。特别是 LFU,在热点数据场景下比 LRU 更准确。
LRU vs LFU 的实战差异:
场景:中午 12:00 大促,商品 A 是顶流,被频繁访问。
12:00 - 12:05 商品 A 被访问 10000 次
12:06 - 12:10 大量冷门商品写入,Redis 缓存满了,触发淘汰
LRU 策略:商品 A 在 12:05 被访问后,到 12:10 已经 5 分钟没被访问
→ 被认为"最近最少使用"→ 被淘汰
→ 12:11 下一个请求打到商品 A,缓存穿透
→ 数据库瞬间多 10000 QPS
LFU 策略:商品 A 的访问频率远超其他 key
→ 即使有 5 分钟的"静默期",计数仍然很高
→ 不会被淘汰
→ 缓存命中率更高Redis 的近似 LFU 实现(volatile-lfu / allkeys-lfu)使用 Morris Counter(对数衰减计数器),不是简单的计数器+时间戳,空间只占 4bit,但能区分 100 次/分钟和 1 次/分钟的访问频率差异。
6. 网络模型
| 特性 | Redis | Memcached |
|---|---|---|
| IO 模型 | 单线程 + epoll/kqueue 多路复用 | 多线程(Master + Worker 线程池) |
| CPU 利用 | 单核(6.0+ 多线程 IO 仅处理网络读写) | 多核 |
| 并发瓶颈 | CPU 单核瓶颈 | 锁竞争、缓存一致性 |
Redis 6.0 多线程 IO 的细节(面试高频):
Redis 6.0 引入 io-threads,但只处理网络读写,命令执行仍然是单线程:
请求到达 → IO 线程池(多线程读 socket) → 主线程(单线程执行命令) → IO 线程池(多线程写 socket)配置方式:
# redis.conf
io-threads 4 # 开启 4 个 IO 线程(不包括主线程)
io-threads-do-reads yes # 读也走多线程注意:io-threads 不是越大越好。实测经验:
- 4 核机器:io-threads 设为 2-3,QPS 提升约 30-40%
- 8 核机器:io-threads 设为 4-5,QPS 提升约 50-60%
- 超过 8 个 IO 线程后收益递减,因为锁竞争(主线程和 IO 线程之间要加锁转移数据)开始抵消多线程收益
Redis 7.4+ 的演化:社区在探索更多并发路径,但核心设计哲学仍然是"命令执行尽量单线程,避免锁复杂性"。
Memcached 多线程的内部锁竞争:
Memcached 的多线程模型是 Master-Worker:
Master 线程监听端口 → 接受连接 → 分发给 Worker 线程
每个 Worker 有自己的 epoll 实例,独立处理一组连接
问题:Worker 线程之间共享全局 LRU 链表
→ 每次淘汰/写入都要获取全局锁
→ 在 64 核机器上,实测锁争用导致 CPU 利用率只有 30-40%
→ 真正的吞吐量瓶颈在全局锁,不在 CPU这也是为什么后来有 libmemcached 客户端做一致性哈希来分散写入,但 LRU 锁仍然是 Memcached 多线程模型的天花板。
7. Memcached 的 CAS 乐观锁(很多人不知道)
Memcached 支持 CAS(Check-And-Set),通过一个 64bit 的 CAS token 实现乐观锁:
import memcache
mc = memcache.Client(['127.0.0.1:11211'])
# 第一次读取,拿到 CAS token
result, cas_token, _ = mc.gets('user:123:balance')
current_balance = int(result)
# 其他线程可能在此时修改了值
new_balance = current_balance - 50
# CAS 写入:如果 cas_token 变了,说明被并发修改了,写入失败
success = mc.cas('user:123:balance', str(new_balance), cas_token)
if not success:
# 冲突处理:重试或报错
print("CAS 冲突,可能有并发修改")Redis 的 WATCH + MULTI 也能实现类似效果,但中间操作多,不如 CAS 一条命令干净。不过 Memcached 的 CAS 不支持跨 key 的事务,Redis 的 WATCH 可以同时监控多个 key。
场景对比
选 Memcached 的场景
# 伪配置:Session 存储
# 特点:纯 KV,不需持久化,不需复杂数据结构
cache:
engine: memcached
servers:
- 10.0.1.1:11211
- 10.0.1.2:11211
# 多线程 + 多核 CPU = 高吞吐
# 重启丢 Session 可接受(用户重新登录)典型场景:Session 存储、页面片段缓存、纯 KV 闪存。
核心理由:多线程模型在同等机器配置下吞吐更高。纯 KV 场景下 Redis 的丰富数据结构用不上,持久化也不需要,这时候 Memcached 更轻量。
选 Redis 的场景
# 需要复杂数据结构
cache:
engine: redis
# 排行榜(ZSet)
# 布隆过滤器(Bloom Filter)
# 消息队列(Stream)
# 分布式锁(SETNX)
# 限流计数器(INCR + EXPIRE)几乎所有现代应用都选 Redis,因为它的生态太丰富了:
- 用 ZSet 做排行榜,Memcached 做不到
- 用 SETNX 做分布式锁,Memcached 的 add 命令只能做简单锁
- 用 Stream 做消息队列,Memcached 没有队列概念
- 用 HyperLogLog 做 UV 统计,亿级数据只要 12KB
Agent 场景下的缓存选型
祥哥正在转 Agent 工程师,Agent 场景下缓存选型有几个特殊考量:
1. Tool Call 结果缓存
Agent 调用外部工具(如搜索、API)的结果通常可以缓存一段时间:
# Redis:支持 TTL 自动过期,适合缓存搜索结果
r.setex(f"search:{query_hash}", ttl=300, value=json.dumps(result))
# Memcached:也支持 expire,但没有数据结构区分
# 所有搜索缓存、会话上下文、状态都混在一个 namespace 里
# 运维排查困难2. Agent 会话状态
Agent 的长期记忆、会话上下文需要持久化——Agent 会话可能持续几小时甚至几天,Memcached 重启就丢,Redis 可以持久化到磁盘。
3. 限流/频率控制
Agent API 调用需要限流(比如每分钟最多调 10 次 GPT),Redis 的 INCR + EXPIRE 一条命令搞定,Memcached 需要客户端做 CAS 乐观锁。
结论:Agent 场景下 Redis 是唯一合理的选择。
一个被低估的差异:运维成本
Redis 运维成本比 Memcached 高得多:
- Redis 需要配置持久化策略(RDB 频率、AOF rewrite 阈值)
- 需要监控 fork 耗时(大实例 fork 可能阻塞几十毫秒)
- 需要关注大 Key、热 Key
- 集群模式下需要管理 Hash Slot 迁移
Memcached 几乎就是"启动即忘"——装好,配好内存上限,跑起来,不用持久化,不用管集群,挂了就挂。
但硬币的另一面:Redis 挂了能恢复,Memcached 挂了全丢。
生产实践
用 stats slabs 检查 Memcached 内存浪费
# 连上 Memcached
echo "stats slabs" | nc 127.0.0.1 11211
# 输出示例
STAT 1:chunk_size 96
STAT 1:chunks_per_page 10922
STAT 1:used_chunks 5000
STAT 2:chunk_size 120
STAT 2:chunks_per_page 8738
STAT 2:used_chunks 8000
# 如果 Slab 1 的 used_chunks 很少,但 Slab 2 满了
# 说明 value 大小集中在 96-120B 区间,浪费较多用 INFO 检查 Redis 内存
redis-cli INFO memory
# 关注指标
# used_memory: 实际分配
# used_memory_rss: 进程 RSS
# mem_fragmentation_ratio: used_memory_rss / used_memory
# 比值 > 1.5 说明内存碎片严重迁移策略
如果正在从 Memcached 迁移到 Redis:
import redis
from pymemcache.client.base import Client
# 双写阶段
mc = Client(('localhost', 11211))
r = redis.Redis()
def get(key):
# 先读 Redis
val = r.get(key)
if val is not None:
return val
# 回源到 Memcached
val = mc.get(key)
if val is not None:
r.set(key, val, ex=3600)
return val
def set(key, val, ex=3600):
# 双写
r.set(key, val, ex=ex)
mc.set(key, val, expire=ex)总结
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | 5+ 种 | 仅 String |
| 持久化 | RDB/AOF/混合 | 无 |
| 高可用 | 主从/哨兵/Cluster | 无原生支持 |
| 内存管理 | jemalloc 按需分配 | Slab Allocator(有钙化问题) |
| 网络模型 | 单线程 + 多路复用(6.0+ IO 线程辅助) | 多线程(全局 LRU 锁瓶颈) |
| 淘汰策略 | 8 种(含 LFU Morris Counter) | 逐 Slab LRU |
| 乐观锁 | WATCH + MULTI | CAS 原生支持 |
| 适用场景 | 通用缓存 + 数据结构 | 纯 KV 缓存 |
一句话结论:新项目无脑选 Redis。存量 Memcached 集群如果只是纯 KV Session 存储且吞吐量高,迁移成本大于收益,可以保留。面试中能说出"Memcached 的 Slab 内部碎片和钙化""Redis 6.0 多线程 IO 只处理网络读写""Morris Counter 做 LFU 计数"这三个细节,已经比 95% 的候选人深了。