178 lines
6.3 KiB
Markdown
178 lines
6.3 KiB
Markdown
|
|
---
|
|||
|
|
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
|