vault backup: 2026-06-01 00:35:50
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user