Files
cs-note/hzh/REDIS/僵尸Key防护.md

6.3 KiB
Raw Permalink Blame History

tags, create time
tags create time
redis
memory
ttl
expiration
best-practices
2026-06-01 10:30

僵尸 Key 防护

概述

僵尸 Key 是指不再有新请求但仍长期占用 Redis 内存的 Key。它们通常是因为 EXPIRE 设置失败或遗漏导致的——虽然单个 Key 不大,但随着时间积累会消耗越来越多的内存,最终引发 OOM。

对应薄弱点 F13:令牌桶中 EXPIRE 的保护机制及其计算方法。

一、什么是僵尸 Key?

flowchart TD
    Day1["📅 第 1 天<br/>用户 user123 访问了 API"] --> Create["创建 Key<br/>rate:limit:user123:daily"]
    Create --> EXPIRE["设置 EXPIRE<br/>TTL = 86400s (1天)"]
    
    Day2["📅 第 2 天"] --> Active{"user123 还访问吗?"}
    Active -- "是" --> Renew["正常续期 ✓"]
    Active -- "否" --> Expire["✅ Key 自动过期删除 ✓"]
    
    Day3["📅 第 3 天"] --> NoExpire{"如果 EXPIRE 没生效?"}
    NoExpire --> Zombie["❌ Key 仍然占用 ~500B<br/>但已无实际用途"]
    
    zombieCount["Zombie Count 增长"] --> Accumulate["1000 users × 500B = 500KB<br/>10000 users → 5MB<br/>..."]
    
    style Expire fill:#c8e6c9
    style Zombie fill:#ffebee
    style Accumulate fill:#ffcdd2

典型场景

场景 原因 结果
EXPIRE 在 Lua 脚本外面 原子性被破坏,EXPIRE 可能不执行 Key 永久存在
Lua 脚本中的 EXPIRE 报错 条件判断导致跳过 Key 不过期
代码忘记写 EXPIRE 开发疏忽 Key 永不消失
Redis 内存满导致无法写入 OOM 后部分操作失败 新增 Key 没设置 TTL

二、F13 考点详解:令牌桶的 EXPIRE 保护机制

这是你最薄弱的地方之一。令牌桶的 Lua 脚本中有一行关键的 EXPIRE:

-- 令牌桶 Lua 脚本(节选)
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)
return {allowed, math.floor(remaining * 1000 / rate)}

2.1 为什么是 math.ceil(capacity / rate) * 2?

这行公式的含义是:填满整个桶所需的时间 × 2

参数 含义 示例值
capacity 桶的最大容量(最大令牌数) 100
rate 每秒补充的令牌数 10/s
capacity / rate 从空到满需要的时间 100 / 10 = 10 秒
* 2 留一倍余量 20 秒
math.ceil(...) 向上取整(Redis EXPIRE 需要整数秒) 20

逻辑推导:

如果一个用户在 t=0 时创建了限流 Key,之后再也不来了:
  - 最晚的可能活动时间:t = capacity/rate(桶从空逐渐加满的时间)
  - 但如果用户在 t=0 就用完了所有令牌,然后不再来...
  - Key 实际上从最后一次活动就开始倒计时了
  
所以 EXPIRE 应该从"最后一次有活动的时刻"开始算。
我们已经通过 HMSET 更新时间戳保证了这一点。
但为什么要乘 2?因为:
  1. 用户可能在某个时间点刚好用完令牌
  2. 然后又过了一段时间才再来
  3. 这段时间内,桶已经在慢慢补充令牌了
  4. ×2 给了足够的缓冲,确保即使是最长的空闲间隔也能覆盖

2.2 数值演示

capacity rate capacity/rate ×2 后的 TTL 说明
100 10/s 10s 20s 一般 API 限流
1000 100/s 10s 20s 高频接口
10000 100/s 100s 200s 高配额用户
100 1/s 100s 200s 低频长周期

[!tip]- 这个公式的直觉理解

"一个桶要多久才能从空变成满?"— 答:capacity/rate。

在这个时间内肯定能看到用户的活动迹象。如果没有活动,2 倍时间就足以让它安全过期。既不会太短(刚设完就过期),也不会太长(僵尸太久)。

三、排查与清理

3.1 查找僵尸 Key

# 方法1: 检查特定前缀的 Key 是否没有 TTL
redis-cli --scan --pattern "rate:*" | while read key; do
    ttl=$(redis-cli TTL "$key")
    if [ "$ttl" = "-1" ]; then
        echo "ZOMBIE: $key"
    fi
done

# 方法2: Go 程序批量扫描(见 SCAN 文档)

3.2 手动清理

# DEL 同步删除(小 Key)
DEL rate:limit:zombie_user_001

# UNLINK 异步删除(大 Key,不阻塞)
UNLINK rate:sliding:large_zombie_key

3.3 预防 Checklist

prevention_checklist:
  lua_scripts:
    - "所有涉及数据写入的 Lua 脚本,必须包含 EXPIRE"
    - "EXPIRE 应该在 Lua 内部(保证原子性),不能放在外面"
    - "使用合理的 TTL 计算公式: ceil(max_lifetime / rate) × safety_factor"
  
  code_review:
    - "新加的 Key 必须有对应的 EXPIRE"
    - "INCR + EXPIRE 组合必须是原子的(Lua 或管道内的 MULTI/EXEC)"
    - "禁止用 INCR 后立即 EXPIRE 的两个独立命令(非原子,中间可能被其他操作打断)"
  
  monitoring:
    - "监控 DBSIZE 趋势(keys 总数)"
    - "定期检查未设置 TTL 的 Key 数量"
    - "告警阈值: 未过期 Key 占比 > 5% 触发告警"

四、INCR + EXPIRE 的原子性问题

// ❌ 错误:非原子操作
count := rdb.Incr(ctx, key)          // Step 1: 自增
rdb.Expire(ctx, key, windowSeconds)  // Step 2: 设置过期
// 如果 Step 2 失败(网络抖动、超时),Key 就永远不会过期!

// ✅ 正确:Lua 脚本内原子化
const luaScript = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
`
result := rdb.Eval(ctx, luaScript, []string{key}, windowSeconds).Int()

五、常见误解

误解 真相
"Redis 会自动删除过期的 Key" 是的,但前提是设置了 TTL。没设置 TTL 的 Key 永远不会自动删除
"LRU 淘汰会帮我清理僵尸 Key" LRU 基于访问频率,僵尸 Key 可能因为曾经活跃过而暂时"免死"
"重启 Redis 就能清空" 生产环境不能随便重启,且 AOF 持久化会在重启后恢复这些 Key
"TTL 很短没关系,反正很快会过期" 10000 个 Key × 1 秒 TTL = 瞬时内存峰值,仍可能造成压力

关联笔记