Files

178 lines
6.3 KiB
Markdown
Raw Permalink Normal View History

2026-06-01 00:35:50 +08:00
---
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