Memcached 完全指南 / 第 7 章:LRU 淘汰策略
第 7 章:LRU 淘汰策略
7.1 为什么需要淘汰策略?
Memcached 使用固定大小的内存池。当内存用尽且没有空闲 chunk 时,必须淘汰旧数据为新数据腾出空间。
内存用尽时的选择:
├── 策略 A:拒绝新写入 → 返回 SERVER_ERROR
├── 策略 B:淘汰最旧的数据 → LRU ✓(Memcached 默认)
└── 策略 C:随机淘汰 → 不可预测
7.2 经典 LRU(Single LRU)
在 Memcached 1.5 之前,每个 Slab Class 只维护一条 LRU 链表:
HEAD ←→ [Item A] ←→ [Item B] ←→ [Item C] ←→ [Item D] ←→ TAIL
最近访问 最久未访问
(安全) (淘汰候选)
LRU 操作规则
| 操作 | LRU 链表动作 |
|---|---|
get 命中 | Item 移到链表头部(标记为最近使用) |
set 新值 | 新 Item 插入链表头部 |
delete | 从链表中移除 Item |
| 内存不足 | 从链表尾部开始扫描,淘汰过期或最久未使用的 Item |
经典 LRU 的问题
场景:大量短 TTL 的临时数据 + 长期缓存的热数据
经典 LRU:
HEAD ← [tmp1,1s] ← [tmp2,1s] ← [hot1,3600s] ← [hot2,3600s] ← TAIL
↑
问题:tmp1/tmp2 大量涌入,占据链表头部,
导致 hot1/hot2 被推向尾部并被淘汰!
这就是 "cache pollution"(缓存污染)问题。
7.3 LRU Maintainer 模式(推荐)
Memcached 1.5+ 引入 lru_maintainer 模式,将单条 LRU 拆分为四条独立的 LRU 链表。
四级 LRU 结构
每个 Slab Class 维护四条 LRU 链表:
┌──────────────────────────────────────────────┐
│ HOT LRU │
│ 最近频繁访问的 Item,通常是"热数据" │
│ HEAD ← [A] ← [B] ← [C] ← TAIL │
├──────────────────────────────────────────────┤
│ WARM LRU │
│ 被访问过但不够热,从 HOT 降级下来 │
│ HEAD ← [D] ← [E] ← TAIL │
├──────────────────────────────────────────────┤
│ COLD LRU │
│ 从未被访问或很久未访问,默认新 Item 进入此处 │
│ HEAD ← [F] ← [G] ← [H] ← [I] ← TAIL │
├──────────────────────────────────────────────┤
│ TEMP LRU │
│ TTL 很短的临时 Item(默认 ≤ 61s + 有足够Item)│
│ HEAD ← [T1] ← [T2] ← TAIL │
│ 独立淘汰,不影响其他 LRU │
└──────────────────────────────────────────────┘
Item 在 LRU 间流转
新 Item 进入:
├── TTL ≤ 61秒(且 Item 数量足够多)→ TEMP LRU
└── 其他情况 → COLD LRU
get 命中:
├── 在 COLD 中命中 → 提升到 WARM
├── 在 WARM 中命中 → 留在 WARM(头部)
├── 在 HOT 中命中 → 留在 HOT(头部)
└── 在 TEMP 中命中 → 留在 TEMP(头部)
降级(由 lru_maintainer 线程执行):
├── HOT 满 → 尾部 Item 降级到 WARM
├── WARM 满 → 尾部 Item 降级到 COLD
└── COLD 满 → 尾部 Item 被淘汰(eviction)
启用 lru_maintainer
# 启动时启用
memcached -o lru_maintainer
# 与 slab_automove 配合使用(推荐组合)
memcached -o lru_maintainer,slab_automove,slab_reassign
查看各级 LRU 状态
echo "stats items" | nc localhost 11211
# STAT items:1:number 523 ← Slab Class 1 的总 Item 数
# STAT items:1:number_hot 100 ← HOT LRU 中的 Item 数
# STAT items:1:number_warm 150 ← WARM LRU 中的 Item 数
# STAT items:1:number_cold 250 ← COLD LRU 中的 Item 数
# STAT items:1:number_temp 23 ← TEMP LRU 中的 Item 数
# STAT items:1:age 1234 ← COLD LRU 尾部 Item 的年龄(秒)
# STAT items:1:evicted 50 ← 被淘汰的 Item 数
# STAT items:1:evicted_nonzero 40 ← 非 0 TTL 被淘汰的 Item 数
# STAT items:1:evicted_time 300 ← 最近被淘汰 Item 的年龄
# STAT items:1:outofmemory 5 ← 内存分配失败次数
# STAT items:1:tailrepairs 10 ← LRU 尾部修复次数
7.4 TEMP LRU 详解
TEMP LRU 的选择条件
Item 进入 TEMP LRU 的条件(全部满足):
1. TTL > 0
2. TTL ≤ 临时阈值(默认 61 秒)
3. 当前 Slab Class 的 Item 总数 > 临时数量阈值
# 自定义 TEMP TTL 阈值
memcached -o lru_maintainer,temp_ttl=120
# 默认值: temp_ttl=61
TEMP LRU 的优势
场景:验证码缓存(TTL=60秒)+ 用户信息缓存(TTL=3600秒)
不使用 TEMP LRU:
验证码大量涌入 → 占据 COLD LRU 前端 → 用户信息被淘汰
结果:热数据丢失,大量回源
使用 TEMP LRU:
验证码 → TEMP LRU(独立淘汰)→ 不影响 WARM/COLD
用户信息 → COLD/WARM LRU → 安全
7.5 LRU Crawler(LRU 爬虫)
LRU Crawler 是一个后台线程,定期扫描 LRU 链表尾部,清理已过期的 Item。
工作原理
LRU Crawler 扫描流程:
1. 从某个 Slab Class 的 LRU 尾部开始
2. 检查每个 Item 的 TTL
├── 已过期 → 删除 Item,释放 chunk
└── 未过期 → 跳过
3. 每次扫描一批 Item(默认 15ms 工作 + 10ms 休眠)
4. 扫描完所有 Class 后,等待一段时间再开始下一轮
启用 LRU Crawler
# 启用爬虫
memcached -o lru_crawler,lru_maintainer
# 自定义爬虫参数
memcached -o lru_crawler,lru_maintainer,lru_crawler_sleep=200,lru_crawler_tocrawl=0
| 参数 | 默认值 | 说明 |
|---|---|---|
lru_crawler_sleep | 100 | 每批 Item 扫描后的休眠时间(ms) |
lru_crawler_tocrawl | 0(自动) | 每次扫描的 Item 数量上限 |
手动触发 LRU Crawler
# 启动手动爬虫模式
memcached -o lru_crawler,lru_crawler_sleep=0
# 手动触发爬虫(通过 stats 命令)
echo "lru_crawler crawl 1,2,3" | nc localhost 11211
# OK
# 查看爬虫状态
echo "stats settings" | nc localhost 11211 | grep lru_crawler
# STAT lru_crawler true
7.6 过期机制详解
两种过期检查方式
方式一:惰性检查(Lazy Expiration)
get key → 检查 TTL → 过期则删除并返回 NOT_FOUND
优点:不需要额外线程
缺点:未被访问的过期 Item 占用内存
方式二:主动检查(Active Expiration)
LRU Crawler 后台扫描 → 发现过期则删除
优点:及时回收内存
缺点:需要额外 CPU 开销
Memcached 默认使用方式一,可选启用方式二(推荐)。
过期时间计算
# exptime = 0: 永不过期
set key1 0 0 5
value
# exptime ≤ 2592000 (30天): 相对时间(秒)
set key2 0 3600 5 # 3600秒后过期
value
# exptime > 2592000 (30天): Unix 时间戳
set key3 0 1735689600 5 # 在指定时间过期
value
import time
# 计算过期时间戳
expire_at = int(time.time()) + 3600 # 1小时后
mc.set("key", "value", time=expire_at)
7.7 缓存失效模式
Cache Aside Pattern(旁路缓存)
最常见的缓存模式,由应用层管理缓存:
def get_user(user_id):
# 1. 先查缓存
cache_key = f"user:{user_id}"
data = mc.get(cache_key)
if data:
return json.loads(data)
# 2. 缓存未命中,查数据库
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if not user:
# 缓存空值,防止缓存穿透
mc.set(cache_key, json.dumps(None), time=60)
return None
# 3. 写入缓存
mc.set(cache_key, json.dumps(user), time=3600)
return user
def update_user(user_id, updates):
# 1. 先更新数据库
db.execute("UPDATE users SET ... WHERE id = %s", user_id, updates)
# 2. 再删除缓存(不是更新!)
mc.delete(f"user:{user_id}")
Read/Write Through Pattern
由缓存层统一管理读写:
class UserCache:
def __init__(self, mc, db):
self.mc = mc
self.db = db
def get(self, user_id):
data = self.mc.get(f"user:{user_id}")
if data:
return json.loads(data)
# 缓存层负责从 DB 加载
user = self.db.query_user(user_id)
self.mc.set(f"user:{user_id}", json.dumps(user), time=3600)
return user
def set(self, user_id, user):
# 先写 DB
self.db.update_user(user_id, user)
# 再写缓存
self.mc.set(f"user:{user_id}", json.dumps(user), time=3600)
Write Behind Pattern(异步写回)
import queue
import threading
write_queue = queue.Queue()
def async_writer():
"""后台线程:批量写入数据库"""
batch = []
while True:
try:
item = write_queue.get(timeout=1)
batch.append(item)
if len(batch) >= 100:
db.batch_update(batch)
batch = []
except queue.Empty:
if batch:
db.batch_update(batch)
batch = []
def update_user_async(user_id, user):
mc.set(f"user:{user_id}", json.dumps(user), time=3600)
write_queue.put({"id": user_id, "data": user})
7.8 缓存常见问题
缓存穿透(Cache Penetration)
问题:查询不存在的 Key,每次都回源数据库。
# 恶意请求不存在的用户 ID
GET user:999999999 → MISS → DB 查询 → 不存在
GET user:999999999 → MISS → DB 查询 → 不存在
GET user:999999999 → MISS → DB 查询 → 不存在
# 数据库被打爆
解决方案:缓存空值 + 布隆过滤器
def get_user_safe(user_id):
cache_key = f"user:{user_id}"
data = mc.get(cache_key)
if data is not None:
if data == "NULL": # 空值标记
return None
return json.loads(data)
user = db.query_user(user_id)
if user:
mc.set(cache_key, json.dumps(user), time=3600)
else:
# 缓存空值,短 TTL
mc.set(cache_key, "NULL", time=60)
return user
缓存击穿(Cache Breakdown)
问题:热点 Key 过期瞬间,大量并发请求同时回源。
import threading
_locks = {}
def get_hot_data(key, ttl=3600):
data = mc.get(key)
if data:
return json.loads(data)
# 分布式锁:只有一个请求回源
lock_key = f"lock:{key}"
if mc.add(lock_key, "1", time=10): # 获取锁
try:
data = load_from_db(key)
mc.set(key, json.dumps(data), time=ttl)
return data
finally:
mc.delete(lock_key)
else:
# 等待其他请求加载完毕
import time
for _ in range(100):
time.sleep(0.05)
data = mc.get(key)
if data:
return json.loads(data)
# 超时则直接查询
return load_from_db(key)
缓存雪崩(Cache Avalanche)
问题:大量 Key 同时过期,瞬间大量请求涌入数据库。
import random
def set_with_jitter(key, data, base_ttl=3600):
"""设置缓存时添加随机抖动,防止同时过期"""
jitter = random.randint(-300, 300) # ±5 分钟
ttl = max(60, base_ttl + jitter)
mc.set(key, json.dumps(data), time=ttl)
7.9 LRU 调优参数汇总
# 推荐的生产配置
memcached \
-o lru_maintainer \ # 启用四级 LRU
-o slab_automove \ # 自动 Slab 迁移
-o slab_reassign \ # 允许 Slab 重分配
-o lru_crawler \ # 启用 LRU 爬虫
-o temp_ttl=61 # TEMP LRU TTL 阈值
扩展阅读
小结
| 要点 | 内容 |
|---|---|
| 推荐配置 | lru_maintainer 启用四级 LRU(HOT/WARM/COLD/TEMP) |
| TEMP LRU | TTL 很短的 Item 独立淘汰,避免缓存污染 |
| LRU Crawler | 后台清理过期 Item,推荐启用 |
| 核心原则 | 新 Item 进 COLD,访问后提升到 WARM/HOT |
| 缓存失效 | 优先使用 Cache Aside + 缓存空值 + 随机 TTL 抖动 |