--- tags: [redis, memory, ttl, expiration, best-practices] create time: 2026-06-01 10:30 --- # 僵尸 Key 防护 ## 概述 **僵尸 Key** 是指不再有新请求但仍长期占用 Redis 内存的 Key。它们通常是因为 `EXPIRE` 设置失败或遗漏导致的——虽然单个 Key 不大,但随着时间积累会消耗越来越多的内存,最终引发 OOM。 对应薄弱点 F13:令牌桶中 `EXPIRE` 的保护机制及其计算方法。 ## 一、什么是僵尸 Key? ```mermaid flowchart TD Day1["📅 第 1 天
用户 user123 访问了 API"] --> Create["创建 Key
rate:limit:user123:daily"] Create --> EXPIRE["设置 EXPIRE
TTL = 86400s (1天)"] Day2["📅 第 2 天"] --> Active{"user123 还访问吗?"} Active -- "是" --> Renew["正常续期 ✓"] Active -- "否" --> Expire["✅ Key 自动过期删除 ✓"] Day3["📅 第 3 天"] --> NoExpire{"如果 EXPIRE 没生效?"} NoExpire --> Zombie["❌ Key 仍然占用 ~500B
但已无实际用途"] zombieCount["Zombie Count 增长"] --> Accumulate["1000 users × 500B = 500KB
10000 users → 5MB
..."] 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 -- 令牌桶 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 ```bash # 方法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 手动清理 ```bash # DEL 同步删除(小 Key) DEL rate:limit:zombie_user_001 # UNLINK 异步删除(大 Key,不阻塞) UNLINK rate:sliding:large_zombie_key ``` ### 3.3 预防 Checklist ```yaml 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 的原子性问题 ```go // ❌ 错误:非原子操作 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 = 瞬时内存峰值,仍可能造成压力 | ## 关联笔记 - [[分布式限流]] — 排错指南中的内存持续增长章节 - [[内存持续增长排查]] — 系统性的内存问题排查流程 - [[SCAN命令]] — 用 SCAN 批量扫描僵尸 Key