一个 800MB 的 Redis Hash 让我们整个集群在双十一前一周的演习里卡了 10 分钟,核心交易接口 P99 从 50ms 飙到 11 秒,告警群里 200+ 条告警刷屏。这不是教科书里的「不要存大 key」就能避免的——那个大 key 是我们 3 年前埋下的,平时一直没事,流量一上来才显形。
这篇文章把整个排查 + 修复过程写下来:怎么发现是它、怎么不停服迁移走、修完之后我们在所有项目里强制加的检测脚本。看完你能把这种「定时炸弹」从你的 Redis 里挖出来。
故障现场
压测开始 8 分钟后,Redis 监控显示一个奇怪的现象:
- OPS 没涨多少(从 8w 涨到 12w,远没到性能瓶颈)
- CPU 单核 100%,其他核闲着
- 慢查询日志 (
slowlog) 里HGETALL/HKEYS占了 90% - 所有命令 RT 都被拖累,普通 GET 都要 200ms+
第一反应是 Redis 是单线程模型,某条慢命令把整个线程占着。直接登上去看:
# 看正在执行的命令
redis-cli -h r-xxx.cache.aliyuncs.com -p 6379
> CLIENT LIST
id=1234 addr=10.1.2.3:43210 fd=8 name= age=180 idle=0 flags=N db=0 ... cmd=hgetall
id=1235 addr=10.1.2.4:43222 fd=9 name= age=178 idle=0 flags=N db=0 ... cmd=hgetall
id=1236 addr=10.1.2.5:43234 fd=10 name= age=175 idle=0 flags=N db=0 ... cmd=hgetall
# ... 几十个 HGETALL 在排队
# 看慢日志
> SLOWLOG GET 10
1) (integer) 4892
(integer) 1731312456 # 时间戳
(integer) 8721000 # 执行 8.7 秒
1) "HGETALL"
2) "user_followers:10086"
2) (integer) 4891
(integer) 1731312445
(integer) 8654000
1) "HGETALL"
2) "user_followers:10086"
# ... 同一个 key,反复被慢查
问题 key 浮出水面:user_followers:10086。这个 10086 是个内部账号,我们 3 年前的「关注/粉丝」功能用 Hash 存它的粉丝列表 —— 每个粉丝一个 field。当年这个账号 5000 粉丝,完全没问题。三年过去……
# 查这个 key 的实际大小
> DEBUG OBJECT user_followers:10086
Value at:0x7f8a... refcount:1 encoding:hashtable serializedlength:807512384 lru:5421...
# serializedlength 是序列化后字节数,这个 key 有 800MB
# encoding 是 hashtable(不是 ziplist)说明 field 数量超过 hash-max-ziplist-entries
# 看 field 数量
> HLEN user_followers:10086
(integer) 4827392
# 480 万粉丝
480 万个 field 的 Hash,任何一个 HGETALL 都要遍历 480 万次。Redis 单线程被它锁死,所有其他命令都得排队。
原理:Redis 单线程模型的隐式假设
大 key 危害的根源是 Redis 的两个特性:
- 核心命令处理是单线程:从 socket 读 → 解析 → 执行 → 写回 socket,整个流程一个线程串行做。
- 很多命令的复杂度是 O(N):
HGETALL/SMEMBERS/LRANGE/KEYS/HKEYS/HVALS,N 就是元素数量。
这俩组合起来:只要有一个 O(N) 命令在大 key 上跑,整个 Redis 实例的所有客户端都会被堵住。即使你访问的是另一个完全不相关的 key,也要排队等着。
用 mermaid 画清楚阻塞过程:
第一招:别用 KEYS,用 SCAN 找出所有大 key
线上要找出所有大 key,不能用 KEYS *(它本身就是 O(N) 全量遍历)。正确做法是 SCAN + 抽样:
# 错误:KEYS * 会把整个实例锁住几分钟
redis-cli KEYS '*' # 千万别在线上用
# 正确:SCAN 游标遍历,每次只取 1000 个 key
redis-cli --scan --pattern '*' --count 1000
# 更好:用 redis-cli 内置的 bigkey 扫描(只读 + 抽样)
redis-cli -h xxx -p 6379 --bigkeys
# 输出:
# [00.00%] Biggest hash found so far 'user_followers:10086' with 4827392 fields
# [00.13%] Biggest string found so far 'session:abc123' with 8421 bytes
# ...
# Summary
# -------- summary -------
# Sampled 1234567 keys in the keyspace!
# Total key length in bytes is ...
# Biggest hash found 'user_followers:10086' has 4827392 fields
# Biggest list found 'queue:retry' has 12389 items
# Biggest set found 'tag:hot' has 234567 members
# memkeys 工具更准(基于 RDB 文件离线分析,不影响线上)
rdb -c memory dump.rdb --bytes 1024 -f memory.csv
# 输出每个 key 的实际内存占用,排序后看 top 100
跑完发现我们集群里有 47 个 1MB 以上的 Hash / Set,其中 6 个超过 100MB,1 个就是这个 800MB 的怪物。
第二招:在线拆分大 Hash,不停服
不能直接 DEL user_followers:10086 —— 800MB 的 key 删除本身就要几秒钟,会阻塞所有客户端。正确做法:
- 双写:新粉丝同时写老 key 和按 user_id 分片的新 key
- 后台搬:用
HSCAN把老 key 数据逐批迁到分片 key - 切流量:读路径切到新 key
- 渐进删除:用
UNLINK(异步删除)而不是DEL
分片策略很简单 —— 按粉丝 user_id 模 1024 分桶:
import redis
from typing import Iterator
r = redis.Redis(host='r-xxx', port=6379, decode_responses=True)
BIG_KEY = 'user_followers:10086'
SHARD_PREFIX = 'user_followers:10086:shard'
SHARD_COUNT = 1024 # 拆 1024 个小 hash,每个约 4700 个 field
def shard_key(follower_uid: int) -> str:
return f'{SHARD_PREFIX}:{follower_uid % SHARD_COUNT}'
# === 阶段 1: 后台搬迁(独立脚本,慢慢跑)===
def migrate(batch_size: int = 500):
cursor = 0
migrated = 0
while True:
cursor, batch = r.hscan(BIG_KEY, cursor=cursor, count=batch_size)
if batch:
pipe = r.pipeline(transaction=False)
for follower_uid, value in batch.items():
pipe.hset(shard_key(int(follower_uid)), follower_uid, value)
pipe.execute()
migrated += len(batch)
print(f'migrated {migrated} fields, cursor={cursor}')
if cursor == 0:
break
print(f'done, total {migrated}')
if __name__ == '__main__':
migrate()
关键参数 count=500:每批扫 500 个 field,这样每次 HSCAN 操作只占用 Redis 主线程几毫秒,完全感知不到。整个 480 万搬完大约 25 分钟,中间业务无感。
双写代码也要短平快:
def add_follower(blogger_uid: int, follower_uid: int):
"""新关注:同时写两份 - 老 key + 新 shard key"""
pipe = r.pipeline(transaction=False)
if blogger_uid == 10086: # 只对大 V 做双写
pipe.hset(f'user_followers:{blogger_uid}', follower_uid, '1')
pipe.hset(f'user_followers:{blogger_uid}:shard:{follower_uid % SHARD_COUNT}',
follower_uid, '1')
else:
pipe.hset(f'user_followers:{blogger_uid}', follower_uid, '1')
pipe.execute()
def is_follower(blogger_uid: int, follower_uid: int) -> bool:
"""读:大 V 走分片 key,其他走老 key"""
if blogger_uid == 10086:
return r.hexists(f'user_followers:{blogger_uid}:shard:{follower_uid % SHARD_COUNT}',
follower_uid)
return r.hexists(f'user_followers:{blogger_uid}', follower_uid)
这个方案的好处:写多一份(性能略降但能扛),读直接定位到分片(O(1) HEXISTS),完全避开 HGETALL。
第三招:最后的 UNLINK 不是 DEL
所有读路径切到分片 key 后,老 key 可以删了。这里有个细节:
# DEL 是同步删除,800MB 的 key 删除时主线程会阻塞 2-5 秒
DEL user_followers:10086
# UNLINK 是异步删除(Redis 4.0+),主线程只解除引用,真正回收内存在后台
UNLINK user_followers:10086
# OBJECT FREQ 看一下 key 是不是真的没人访问了再删
OBJECT FREQ user_followers:10086 # 需要 maxmemory-policy 设为 allkeys-lfu
# 配合一段 redis-cli 脚本批量 UNLINK 多个旧 key
redis-cli --scan --pattern 'user_followers:*' --count 1000 | \
awk '{print "UNLINK "$1}' | \
redis-cli --pipe
UNLINK 是大 key 治理的必备命令。我们后来发现项目里很多代码还在用 DEL,做了一次全局替换。
为什么 ziplist 编码会突然变成 hashtable
Hash / List / Set / ZSet 在小数据量时用紧凑的 ziplist 编码(内存占用小、缓存友好);超过阈值后转成 hashtable / linkedlist / skiplist。阈值由这几个参数控制:
# redis.conf
hash-max-ziplist-entries 128 # Hash field 数 ≤ 128 用 ziplist
hash-max-ziplist-value 64 # field 值长度 ≤ 64 字节用 ziplist
list-max-ziplist-size -2 # List 单个 ziplist 节点 ≤ 8KB
set-max-intset-entries 512 # 整数 Set 用 intset(更小)
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
有个反直觉的细节:一旦从 ziplist 升级到 hashtable,即使 field 数量后来再降到 128 以下,也不会自动降回 ziplist。所以一旦因为某次异常写入「冒头」了,就回不去了。
第四招:写一个 bigkey 周期巡检脚本
事故后我们写了一个每天扫一次的脚本,发现大 key 自动告警:
#!/usr/bin/env python3
"""每天扫一次 Redis,发现 >5MB 的 key 告警到企业微信"""
import redis
import requests
import os
from datetime import datetime
THRESHOLD_BYTES = 5 * 1024 * 1024 # 5MB
THRESHOLD_FIELDS = 10000 # 10k 个 field
r = redis.Redis(host=os.environ['REDIS_HOST'], port=6379)
def scan_bigkeys():
alerts = []
cursor = 0
while True:
cursor, keys = r.scan(cursor=cursor, count=1000)
for key in keys:
t = r.type(key)
size_bytes = 0
size_fields = 0
if t == b'string':
size_bytes = r.strlen(key)
elif t == b'hash':
size_fields = r.hlen(key)
elif t == b'list':
size_fields = r.llen(key)
elif t in (b'set', b'zset'):
size_fields = r.scard(key) if t == b'set' else r.zcard(key)
if size_bytes > THRESHOLD_BYTES or size_fields > THRESHOLD_FIELDS:
alerts.append({
'key': key.decode(),
'type': t.decode(),
'bytes': size_bytes,
'fields': size_fields,
})
if cursor == 0:
break
return alerts
def notify(alerts):
if not alerts:
return
text = f'⚠️ Redis 大 key 巡检 {datetime.now():%Y-%m-%d}\n'
for a in sorted(alerts, key=lambda x: x.get('bytes', 0) + x.get('fields', 0) * 100,
reverse=True)[:20]:
text += f'- {a["key"]} ({a["type"]}): {a["bytes"]:,}B / {a["fields"]:,}fields\n'
requests.post(os.environ['WEBHOOK_URL'], json={'msgtype': 'text', 'text': {'content': text}})
if __name__ == '__main__':
alerts = scan_bigkeys()
notify(alerts)
放到 K8s CronJob 里凌晨 3 点跑,4 个月后零生产事故。
第五招:Pipeline / Lua / 二级缓存改造
从这次事故里我们还学到几条防御性规则:
- 读大集合用 SCAN 系列,不用 GETALL 系列:
HSCAN/SSCAN/ZSCAN都是游标式,不会阻塞主线程 - 批量操作用 Pipeline:把多个命令一次发出去,网络往返从 N 次变 1 次
- 复杂事务用 Lua:Redis 保证 Lua 脚本原子执行,但要控制脚本本身的复杂度
- 热数据再加一层本地缓存:像粉丝判断这种,本地用 Caffeine 缓存 5 秒,大幅降低 Redis 压力
// Java 端用 Caffeine 做本地缓存,Redis 兜底
LoadingCache<String, Boolean> followerCache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofSeconds(5)) // 接受 5 秒延迟
.build(key -> {
String[] parts = key.split(":");
long bloggerUid = Long.parseLong(parts[0]);
long followerUid = Long.parseLong(parts[1]);
return redisTemplate.opsForHash().hasKey(
"user_followers:" + bloggerUid + ":shard:" + (followerUid % 1024),
String.valueOf(followerUid));
});
public boolean isFollower(long bloggerUid, long followerUid) {
return followerCache.get(bloggerUid + ":" + followerUid);
}
事故复盘的 6 条结论
- Redis 单线程模型决定了:任何一个 O(N) 命令都是核弹。N 在写入时 1000,3 年后可能 1000 万,代码不变但风险已经今非昔比。
- "够用就好"是技术债的根源。上线时 5000 粉丝写 Hash 完全够用,但没人想过 3 年后的事情。
- 大 key 治理三件套:DEBUG OBJECT 看大小,HSCAN/SSCAN 流式遍历,UNLINK 异步删除。
- 分片是大 key 治理的最稳方案。1024 个小 hash 比 1 个超大 hash 不止快 1024 倍 —— 因为单线程串行模型。
- 巡检比应急重要。每天扫一次,4 个月没事故 ≫ 一次事故扛半夜。
- 本地缓存能挽救你大半。Caffeine + Redis 的二级架构在我们厂里挽救了不止 Redis 一个组件。
当晚演习被记一次黄牌。但从结果看,这次「演习时炸"远比"大促真挂"代价小得多。再次感谢压测同学。
附:今晚就能跑的 4 行 bigkey 排查命令
# 1. 在线扫一遍大 key(只读,但会有 IO)
redis-cli -h $HOST -p $PORT --bigkeys
# 2. 单个 key 看实际占用
redis-cli -h $HOST -p $PORT DEBUG OBJECT <key>
# 3. 看最近 10 条慢日志
redis-cli -h $HOST -p $PORT SLOWLOG GET 10
# 4. 看当前连接执行的命令
redis-cli -h $HOST -p $PORT CLIENT LIST | awk -F'cmd=' '{print $2}' | sort | uniq -c | sort -rn
把这 4 行存进收藏。下次再被半夜叫起来,5 分钟之内你能知道是不是大 key 的事。
—— 别看了 · 2026