一次"商品下架"要做"清掉这个商品相关的所有缓存":详情、列表、价格、库存。开发写了一段"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. 自测
- 三条"批量清理"路的成本结构差别是什么?为什么
KEYS在线上最贵? - 集群模式下
SCAN有个什么陷阱?"以为清完"会怎样? - "版本号"方案为什么是 O(1)?它的三个前提是什么?
DEL与UNLINK对"大 key"的差别是什么?"内存没降"是故障吗?- "两级缓存"为什么会让"清了等于没清"?
- 判断"能否用
KEYS"的那个问题是什么?
补一句"演进"建议:"清理逻辑应当'只有一个入口'"——"下架""改价""删除"都调用同一个"失效函数"——否则"每加一个业务动作,就多一处'忘清某个 key'",而"漏掉的那个缓存"会以"用户看到旧数据"的形式出现。
而"一个入口"这件事,比选哪种删法更能决定"清得干净"。
9. 一句话总结
"批量清理"的核心不是"怎么删",而是"怎么让删除的代价可控":频率低就用 SCAN(O(命中数),分摊到多轮),频率高就上"版本号"(O(1),且不必遍历)。而 KEYS 只适合"独立实例 / 极少 key / 低峰脚本"这三种场合——**它不适合"写在业务代码里".