2116 字
约 7 分钟
15
S12-A26 · 缓存的批量清理:`KEYS` + `DEL` 之外,还有别的选择吗

一次"商品下架"要做"清掉这个商品相关的所有缓存":详情、列表、价格、库存。开发写了一段"KEYS product:123:* 然后逐个 DEL"——上线后"下架一次,全站卡 300ms"——因为 KEYS 是 O(n) 全库遍历,而"下架"是他们业务里"高频操作".

"批量清理"有三条路:按前缀扫描(SCAN)、按集合记录(Set/ZSet)、以及带版本号(逻辑失效)。三条路的成本结构完全不同,而"最直观的那条(KEYS)恰好最贵".

1. 三条路的成本

方式 单次成本 维护成本
KEYS + DEL O(全库) 无
SCAN + UNLINK O(命中数) 分摊到多轮 无
集合/版本号 O(1)~O(命中数) 要"维护索引"

第一项是"高频操作里的灾难":"全库遍历"与"当前有多少 key"成正比第一项的"连锁"是"雪崩":"遍历期间所有命令排队" → "请求超时" → "客户端重试" → "更多命令排队"——**于是"一次下架"能"放大成"一次故障".
——而 Redis 是单线程执行,遍历期间所有请求排队.

"SCAN 循环"要"写成一个函数",否则"每处都写一遍、每处都可能漏":

// 三段"安全扫描"(游标 + COUNT + 批量删)
cur = 0
while True:
    cur, keys = r.scan(cur, match="product:123:*", count=500)
    if keys: r.unlink(*keys)            # UNLINK 而非 DEL
    if cur == 0: break
// 判据:单轮"500 个 key、耗时 < 5ms",总时长"与命中数成正比"

2. "SCAN"的三个注意

注意 说明
游标遍历"不保证一次拿全" 要"循环到 0"
期间"增删"可能漏/重 弱一致
集群模式"只扫当前节点" 要"每主节点各扫"

第三项的"修法"是"遍历所有主节点":CLUSTER NODES 拿主节点列表,逐个 SCAN——而"忘了这一步"的脚本,通常"上线三个月后"才被"用户说'下架了还能搜到'"发现".
第三项最常被漏:
"在集群里 SCAN 只返回本节点的 key"**——**于是"以为清完了,其实只清了 1/3".

"版本号"的"读路径"要多一次"取版本",而它"可以缓存":

// 三步"版本号"读写(伪代码)
1 读:n = local_cache.get("ver:"+id) or redis.get("ver:"+id) or 0
2 key = "product:{id}:v{n}:detail"
3 下架:redis.incr("ver:"+id)  → 旧 key 全部"逻辑失效"
// 判据:改版本"一次写",失效"全部 key",无需遍历

3. "集合/版本号"的三种做法

做法 说明
维护 Set 记"某商品的所有 key" 读时"多一次 SMEMBERS"(可缓存)
ZSet 按"业务 id"记 可"按时间清理"
版本号(key 里带 v) 清理=改版本(O(1))

第三项是"最优雅"的:"清缓存"变成"把版本号 +1"第三项的"实现"要"一处生成 key":"所有 key 都由'同一个函数'拼"——**否则"某个地方忘了加版本号",于是"它永远不失效".
——**旧 key 自然"过期"(靠 TTL),不必遍历.

// 三种"下架"的实现(同一件事)
1 KEYS product:123:* → DEL 每个            (阻塞,禁用)
2 SCAN 0 MATCH product:123:* COUNT 500     (可用,多轮)
3 版本号:key = product:{id}:v{n}:detail   (改 n → 旧 key 全失效)
// 判据:清理频率"高"就用 3,低就用 2

4. "版本号"的三个前提

前提 说明
版本号"能存"(DB/配置中心) 读时"取一次"
key 生成"统一走一处" 否则"漏改"
旧 key"有 TTL" 否则"积累垃圾"

第三项的"量级"值得算:"每天 100 次下架 × 每个商品 20 个 key × 每个 key 1KB = 2MB/天"——不大,但"没有 TTL"时会"永久累积".
第三项最容易被漏:
"改版本号"之后,旧 key 仍占内存**——**于是"靠 TTL 自然淘汰",而 TTL 必须"设过".

"大 key 判定"要在"删除之前"做,否则"删的时候才卡":

// 两种"提前发现"(低峰跑)
redis-cli --bigkeys                    # 采样统计(快)
redis-cli MEMORY USAGE product:123:detail   # 单 key 精确(慢但准)
// 判据:单 key > 10MB 或"元素 > 10 万" → 用 UNLINK,且"列入改造"

5. "删除方式"的三种选择

方式 特点
DEL 同步释放(大 key 会卡)
UNLINK 后台释放(推荐)
EXPIRE 设短 TTL 最省("逻辑删除")

第二项是"大 key"的正解:"释放内存"移到后台线程第二项的"误解"很常见:"UNLINK 之后 used_memory 没降"会被当成"没删掉"——**而实际是"后台在慢慢释放",观察 used_memory_lua / mem_fragmentation_ratio 更准.
——**而"内存不会立刻降",这个"看起来没删"常被误解.

"清理是否真的生效"要"用读路径验证",而不是"看 DEL 返回 1":

// 三步"验收"
1 DEL/UNLINK 返回"命中数"(不是"成功")
2 立刻"按业务 key 读一次":应为 miss(否则有本地缓存)
3 关注"命中率"曲线:清理后应"短暂下降再回升"
// 判据:第 2 步 miss,才算"对用户生效"

6. 排错:五种"批量清理问题"

现象 成因 处理方向
清理时全站卡 KEYS/大 DEL SCAN+UNLINK
只清了部分 集群只扫本节点 每主节点各扫
清理后"还读到旧值" 应用内存缓存 两级缓存都要清
内存没降 UNLINK 后台释放 观察 used_memory
旧 key 越积越多 无 TTL 统一设 TTL

第三项的"解法"是"两件事":"本地缓存 TTL 短(几十秒)" + "清理时发一条'失效消息'"——而"只做后者"要"消息不丢",成本明显更高.
第三项是"两级缓存"的经典:
"Redis 清了,但应用本地 Caffeine 还有"**——**于是"清了等于没清".

7. 边界:什么情况"KEYS 也能用"

场景 为什么 做法
独立实例(不与在线共用) 卡了不影响业务 可用
key 数量极少(< 1000) 遍历很快 可用
运维脚本(低峰跑) 无人等 可用
一次性排查 一次就完 可用

判断口径一句话:"这条清理命令,会不会"在用户请求的路径上""。最后一条与"容量"有关的边界:"清理"和"过期"要"分工":"TTL 负责'最终会消失',清理负责'立刻失效'"——**若"全靠清理",会有"永远清不到的角落";若"全靠 TTL",会有"下架后 10 分钟还能搜到".
会(下架、改价、发布触发清理),必须"O(命中数) 或 O(1)";不会(运维脚本、低峰维护),KEYS 只是"理论上不好"——而"下架时清缓存"这种"高频业务动作",是它最容易出事的地方.

8. 自测

  1. 三条"批量清理"路的成本结构差别是什么?为什么 KEYS 在线上最贵?
  2. 集群模式下 SCAN 有个什么陷阱?"以为清完"会怎样?
  3. "版本号"方案为什么是 O(1)?它的三个前提是什么?
  4. DEL 与 UNLINK 对"大 key"的差别是什么?"内存没降"是故障吗?
  5. "两级缓存"为什么会让"清了等于没清"?
  6. 判断"能否用 KEYS"的那个问题是什么?

补一句"演进"建议:"清理逻辑应当'只有一个入口'"——"下架""改价""删除"都调用同一个"失效函数"——否则"每加一个业务动作,就多一处'忘清某个 key'",而"漏掉的那个缓存"会以"用户看到旧数据"的形式出现。

而"一个入口"这件事,比选哪种删法更能决定"清得干净"。

9. 一句话总结

"批量清理"的核心不是"怎么删",而是"怎么让删除的代价可控":频率低就用 SCAN(O(命中数),分摊到多轮),频率高就上"版本号"(O(1),且不必遍历)。而 KEYS 只适合"独立实例 / 极少 key / 低峰脚本"这三种场合——**它不适合"写在业务代码里".

S12-A26 · 缓存的批量清理:`KEYS` + `DEL` 之外,还有别的选择吗
https://www.clxhxhhr.top/posts/6704/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。