前言
今天收到一条监控报警:生产环境的主 Redis 内存使用率直接飙到了 99.98%,而且监控大盘上的 Evictions(逐出 Key 数量)开始不断往上涨。
这个指标一旦报警其实挺危险的,因为我们这台实例配的淘汰策略是 allkeys-lru。也就是说,在内存完全打满的情况下,Redis 为了能继续写入新数据,会开始按 LRU 策略随机淘汰旧 Key。而这台 Redis 上同时还跑着用户的 Session、接口限流计数器、以及一些业务基础缓存,要是任由它这么无差别逐出,核心业务数据被误杀只是时间问题。
既然确认了 Redis 确实满了,第一步肯定不是慌着乱删,而是要先把情况摸清楚:分析内存占用分布 -> 找出大头 -> 制定安全清理方案 -> 根治隐患。
记录一下这次排查过程、基于命中率精准清理 50% 数据的实战脚本,以及事后复盘出来的几个架构问题。

一、线上不能盲猜:全库 Key 内存分布分析
面对一个被打满的生产 Redis,第一大忌就是凭感觉去删 key,第二大忌是图省事直接敲 KEYS * 或者 FLUSHDB(前者会导致单线程直接卡死,后者会把线上业务全干崩)。
必须先搞清楚到底是谁在吃内存。我写了个轻量级的 Python 脚本,通过 SCAN 游标分批遍历全库,采样统计每个前缀的 Key 数量和预估内存占用。
当时跑出来的全库内存分布大概是这样的(关键业务前缀已做脱敏):
| Key 前缀(脱敏) | Key 数量 | 数量占比 | 单 Key 均值 | 预估占用内存 | 业务性质 |
|---|---|---|---|---|---|
prod:search_emb:* |
58.5 万 | 22.8% | 14.5 KB | ~8.07 GB (87.5%) | 🔴 搜索 Query 向量缓存 |
prod:img_url:* |
106.3 万 | 41.4% | 537 B | ~544 MB (5.9%) | 图片预签名 URL 缓存 |
service_go:prod:* |
18.8 万 | 7.3% | 1.95 KB | ~350 MB (3.8%) | 微服务加速缓存 |
prod:card_cache:* |
3.8 万 | 1.5% | 2.03 KB | ~72.7 MB (0.8%) | 运营卡片缓存 |
prod:session:* |
42.8 万 | 16.7% | 136 B | ~55.5 MB (0.6%) | 用户 Session 状态 |
prod:qclass:* |
17.1 万 | 6.7% | 211 B | ~34.4 MB (0.4%) | 分类打标缓存 |
prod:user:* / LIMITS:* |
~3 万 | 1.2% | 100~400 B | ~15 MB | 用户信息、限流等核心业务 |
看到这个表,破案破得非常干脆:
整个 Redis 实例可用内存也就 9.2 GB 左右,光 prod:search_emb:* 这一个前缀就吃掉了整整 8.07 GB(占了 87.5%)!剩下的 Session 和限流等核心业务其实只占了很小一部分,完全是被这个大胃王给挤爆的。
用于分析全库分布的非阻塞脚本如下:
import redis
r = redis.Redis(host="your-redis-host", port=6379, socket_timeout=10)
cursor = 0
stats = {}
while True:
cursor, keys = r.scan(cursor=cursor, count=5000)
for k in keys:
k_str = k.decode("utf-8", errors="replace")
parts = k_str.split(":")
prefix = parts[0] + ":" + parts[1] if len(parts) >= 2 else parts[0]
if prefix not in stats:
stats[prefix] = {"count": 0, "sample_bytes": 0, "sample_cnt": 0}
stats[prefix]["count"] += 1
if stats[prefix]["sample_cnt"] < 50:
m = r.memory_usage(k)
if m:
stats[prefix]["sample_bytes"] += m
stats[prefix]["sample_cnt"] += 1
if cursor == 0:
break
total_keys = sum(s["count"] for s in stats.values())
print(f"Total keys: {total_keys}")
for p, s in sorted(stats.items(), key=lambda x: x[1]["count"] * (x[1]["sample_bytes"]/max(1, x[1]["sample_cnt"])), reverse=True):
avg_m = s["sample_bytes"] / max(1, s["sample_cnt"])
est_mb = (s["count"] * avg_m) / (1024 * 1024)
print(f"{p:<30} | {s['count']:>8} | {avg_m:>8.1f}B | {est_mb:>10.2f} MB")
二、翻看代码:这个大头能不能清理?
定位到前缀后,立刻去代码库里翻对应的实现。
发现这是在做语义搜索时,为了加速对用户搜索词(Query)调用大模型(LLM)计算出的 1024 维 Embedding 向量。
代码里的逻辑是标准的 Read-Through(旁路读穿) 模式:
- 用户输入搜索词,先去 Redis 查这个 Query 的 embedding 在不在;
- 如果命中(Cache Hit),直接拿存好的向量去向量库做检索;
- 如果未命中(Cache Miss),调用大模型 API 生成 1024 维向量,然后写回 Redis,并设置了一个 3 天(
ex=3*86400) 的 TTL。
这就解释了为什么会有 58 万个 key:在最近几天的搜索量下,用户搜索的词各不相同,3 天的积累硬生生堆出了 8GB 的向量数据。
能不能清?怎么清?
从业务逻辑上看,它本身就是个加速缓存,清掉不会引起任何数据不一致或业务报错——因为只要缓存没有,代码就会自动去 LLM 回源重新生成。
但能不能一脑门子全删光?不行。 如果一口气把 58 万个向量全抹掉,线上正在跑着搜索请求,瞬间全量 Cache Miss 会导致所有并发搜索全部涌向底层的 LLM 模型 API,瞬间把下游的大模型接口打出限流或者超时,反而引发更大的次生故障。
所以最好的解法是:既要把内存降下来,又不能全部清空,优先清理那些最冷门、很久没被访问的数据,保留热点缓存。
三、精细化清理:按命中率(Idle Time)精准清理最冷 50%
那么问题来了,Redis 里怎么判断哪些向量缓存是“很久没被命中过的冷数据”?
答案是利用 Redis 原生的 OBJECT IDLETIME key。
在 allkeys-lru 模式下,Redis 会给每个 key 记录一个 LRU 时钟。通过 OBJECT IDLETIME,可以精确查出这个 key 距离上一次被读取/写入已经过去了多少秒(这个查询只读时钟,完全不会改变 key 的访问时间)。
我们先用 Pipeline 跑了一下全量 58.5 万个向量 key 的空闲时间分布:
| 空闲时间(距上次命中) | Key 数量 | 占比 | 决策 |
|---|---|---|---|
| > 2 天(48小时以上无人访问) | 14.9 万 | 25.59% | 🔴 准备清理(极冷数据) |
| 1 ~ 2 天(24~48小时无人访问) | 14.4 万 | 24.72% | 🔴 准备清理(冷数据) |
| 12 ~ 24 小时 | 11.8 万 | 20.28% | 🟢 保留(温数据) |
| 6 ~ 12 小时 | 6.9 万 | 11.82% | 🟢 保留(活跃数据) |
| 1 ~ 6 小时 | 8.0 万 | 13.73% | 🟢 保留(高频热数据) |
| < 1 小时 | 2.2 万 | 3.86% | 🟢 保留(核心热数据) |
数据出来后非常清晰:空闲时间 $\ge 24$ 小时(整整一天以上没人搜过的词)正好占了 50.3%(29.4 万个)!
这 29.4 万个 key 大概率是一些冷门长尾搜索词,留着除了吃内存毫无收益。而剩下的 49.7% 都是在过去 24 小时内有命中记录的热门词。
执行安全异步清理
为了保证对生产业务 0 影响,我们使用 SCAN 游标 + Pipeline 批量判断 + UNLINK(后台线程异步释放内存,绝不阻塞主事件循环),每批 1000 个,批次间休眠 10ms:
import redis
import time
r = redis.Redis(host="your-redis-host", port=6379, socket_timeout=10)
# 阈值:24 小时以上未被命中的冷数据
IDLE_THRESHOLD = 86400
cursor = 0
total_scanned = 0
total_unlinked = 0
batch_to_delete = []
print(f"清理前内存: {r.info('memory').get('used_memory_human')}")
while True:
cursor, keys = r.scan(cursor=cursor, match="prod:search_emb:*", count=2000)
if keys:
pipe = r.pipeline(transaction=False)
for k in keys:
pipe.object("idletime", k)
idles = pipe.execute()
for k, it in zip(keys, idles):
total_scanned += 1
if it is not None and it >= IDLE_THRESHOLD:
batch_to_delete.append(k)
# 每凑齐 1000 个调用一次异步 UNLINK
if len(batch_to_delete) >= 1000:
r.unlink(*batch_to_delete)
total_unlinked += len(batch_to_delete)
batch_to_delete = []
time.sleep(0.01) # 短暂停顿,避免主线程抖动
if cursor == 0:
break
if batch_to_delete:
r.unlink(*batch_to_delete)
total_unlinked += len(batch_to_delete)
time.sleep(2)
print(f"清理完成! 共扫描 {total_scanned} 个,异步清理冷 Key {total_unlinked} 个")
print(f"清理后内存: {r.info('memory').get('used_memory_human')}")
清理效果
脚本跑了大约 20 秒:
- 扫描 585,152 个 Key,精准删除了 294,350 个冷 Key(占比 50.30%);
- Redis 内存从 9.19 GB 直降到 5.22 GB,瞬间省出 4.0 GB 的宝贵空间;
- 内存水位直接从 99.98% 降回 56.5%,监控大盘上的
Evictions彻底归零,Session 等核心业务警报完全解除; - 下游 LLM 接口没有出现任何流量冲击,平稳渡过危机。

四、事后复盘:暴露出哪些架构问题?
虽然问题在十几分钟内解决了,但这次内存打满事故,本质上暴露了之前架构设计上的几个硬伤:
1. 序列化方式极其奢侈(字符串 JSON vs 二进制 Pack)
去看了代码才发现,这个 1024 维的 float 向量写入 Redis 时,竟然直接调了 json.dumps(vector)。
一个 1024 维的浮点数组,转成 JSON 字符串后每个浮点数带一大堆小数位,加上逗号括号,单个 key 占了 14.5 KB ~ 21 KB!
但实际上,1024 个 float32 用 Python 的 struct.pack('<1024f', *vector) 压成二进制,只有固定的 4096 字节(4 KB),哪怕为了兼容做个 Base64 也就 5.5 KB。
如果一开始就用二进制 pack,同样的 key 数量,内存直接能砍掉 62%,原本 8GB 的数据直接能降到 3GB 以下。
2. 大体积加速缓存与核心业务共用实例(未做物理隔离)
这台 Redis 实例目前被当成了一个“大杂烩”: 既承担着搜索向量、URL 预签名等大体积、允许丢失、可重新计算的旁路加速缓存,又承担着 Session、用户信息、接口 Rate Limit 限流等小体积、低延迟、绝不能丢的核心业务。
一旦某个旁路缓存逻辑上线或者流量暴涨,就会立刻侵占整个实例的内存,导致核心业务的 key 被 LRU 驱逐。 这种设计显然不合理,未来必须把搜索/算法等高消耗缓存与核心业务 Redis 做物理拆分。
3. 实例规格本身偏小
当前这台主实例选型是 cache.r4.large(总物理内存 12GB,扣除 25% 预留内存后 Redis 可用只有 9.2GB)。
相比之下,我们的推荐系统等重度计算服务的 Redis 实例普遍都用到了 64GB 规格。随着搜索和 AI 功能的使用频率越来越高,9.2GB 的容量上限显然已经跟不上业务的发展节奏,后续升配或者迁移也是势在必行。
总结
线上排查高危组件的故障,节奏往往是:先用监控指标快速定性 -> 用只读/低开销手段定位根因 -> 权衡业务风险制定最小代价的止血方案 -> 最后从代码和架构层面堵死漏洞。
这次通过 LRU 空闲时间切一刀,既快速把内存使用率压回了安全线,又最大程度保住了业务的命中率。但更重要的是在复盘中把序列化压缩、实例物理隔离以及容量规划这些坑给踩实了。
本博客所有文章除特别声明外,均采用 @Oreoft 许可协议。转载请注明出处!
┌┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┐
├ 记得关注公众号:没有气的汽水 ┤
└┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┘