Skip to content

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 存取,所有逻辑推到客户端。

python
# 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 中:

text
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 借用。这导致一种诡异的场景:

text
Slab 1 (96B): 已用 10%,有 90% 空闲
Slab 2 (120B): 已用 100%,新写入 100B 的 key 被 LRU 驱逐,即使机器还有 40GB 空闲

这就是所谓的 Slab Calcification。线上排查方法:

bash
# 查看各 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。

python
# 估算 Memcached 内存浪费
# 假设 value 大小分布均匀(64B ~ 1024B)
# Memcached chunk sizes: 96, 120, 152, 192, 240, 304, 384, 480, 600, 752, 944, 1184
# 平均浪费 ≈ 15-20%
# 60GB 机器,实际有效存储 ≈ 48-51GB

5. 淘汰策略

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 的实战差异

text
场景:中午 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. 网络模型

特性RedisMemcached
IO 模型单线程 + epoll/kqueue 多路复用多线程(Master + Worker 线程池)
CPU 利用单核(6.0+ 多线程 IO 仅处理网络读写)多核
并发瓶颈CPU 单核瓶颈锁竞争、缓存一致性

Redis 6.0 多线程 IO 的细节(面试高频):

Redis 6.0 引入 io-threads,但只处理网络读写,命令执行仍然是单线程:

text
请求到达 → IO 线程池(多线程读 socket) → 主线程(单线程执行命令) → IO 线程池(多线程写 socket)

配置方式:

bash
# 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:

text
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 实现乐观锁:

python
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 的场景

yaml
# 伪配置:Session 存储
# 特点:纯 KV,不需持久化,不需复杂数据结构
cache:
  engine: memcached
  servers:
    - 10.0.1.1:11211
    - 10.0.1.2:11211
  # 多线程 + 多核 CPU = 高吞吐
  # 重启丢 Session 可接受(用户重新登录)

典型场景:Session 存储、页面片段缓存、纯 KV 闪存。

核心理由:多线程模型在同等机器配置下吞吐更高。纯 KV 场景下 Redis 的丰富数据结构用不上,持久化也不需要,这时候 Memcached 更轻量。

选 Redis 的场景

yaml
# 需要复杂数据结构
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)的结果通常可以缓存一段时间:

python
# 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 内存浪费

bash
# 连上 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 内存

bash
redis-cli INFO memory

# 关注指标
# used_memory: 实际分配
# used_memory_rss: 进程 RSS
# mem_fragmentation_ratio: used_memory_rss / used_memory
# 比值 > 1.5 说明内存碎片严重

迁移策略

如果正在从 Memcached 迁移到 Redis:

python
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)

总结

维度RedisMemcached
数据结构5+ 种仅 String
持久化RDB/AOF/混合
高可用主从/哨兵/Cluster无原生支持
内存管理jemalloc 按需分配Slab Allocator(有钙化问题)
网络模型单线程 + 多路复用(6.0+ IO 线程辅助)多线程(全局 LRU 锁瓶颈)
淘汰策略8 种(含 LFU Morris Counter)逐 Slab LRU
乐观锁WATCH + MULTICAS 原生支持
适用场景通用缓存 + 数据结构纯 KV 缓存

一句话结论:新项目无脑选 Redis。存量 Memcached 集群如果只是纯 KV Session 存储且吞吐量高,迁移成本大于收益,可以保留。面试中能说出"Memcached 的 Slab 内部碎片和钙化""Redis 6.0 多线程 IO 只处理网络读写""Morris Counter 做 LFU 计数"这三个细节,已经比 95% 的候选人深了。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。