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

178 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 天<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
-- 令牌桶 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