2024 年我们的 Redis 出过一段时间的"幽灵故障":监控上每隔一阵子,就有一批接口集体超时几百毫秒,过一会儿又自己好了,毫无规律。Redis 的 CPU、内存、连接数看着都很正常,慢日志里却躺着几条诡异的命令——一个 HGETALL 执行了 80 毫秒,一个 DEL 执行了 200 多毫秒。Redis 是单线程的,一条命令卡 200 毫秒,意味着这 200 毫秒里所有其他请求都得排队干等。顺着这两条慢命令查下去,我们挖出了两个一直潜伏着的问题:一个体积大到离谱的"大 key",和几个被疯狂访问的"热 key"。投了几天把大 key、热 key 彻底治理了一遍,本文复盘这次实战。
问题背景
业务:Redis 单实例 + 多个业务共用,缓存 + 计数 + 排行榜
事故现象:
- 接口每隔一段时间集体超时几百毫秒,随后自愈
- Redis CPU/内存/连接数监控看着都正常
- 慢日志里有 HGETALL 耗时 80ms、DEL 耗时 200ms 的记录
现场排查:
# 1. 看 Redis 慢日志
127.0.0.1:6379> SLOWLOG GET 5
1) 1) (integer) 33
2) (integer) 1718000000
3) (integer) 213ms <- DEL 一个 key 卡了 213 毫秒
4) 1) "DEL"
2) "page:cache:hot_list"
2) ...
3) (integer) 81ms
4) 1) "HGETALL"
2) "user:online:set"
# 2. 看那个被 HGETALL 的 key 有多大
127.0.0.1:6379> HLEN user:online:set
(integer) 2841902 <- 一个 hash 里塞了 284 万个 field!
127.0.0.1:6379> MEMORY USAGE user:online:set
(integer) 386500128 <- 占了 368 MB
# 3. 看那个被 DEL 的 key
127.0.0.1:6379> LLEN page:cache:hot_list
(integer) 1200000 <- 一个 list 里 120 万个元素
根因:
1. 存在体积巨大的"大 key":单个 key 几百 MB、上百万元素
2. Redis 单线程,操作大 key 的命令会长时间阻塞整个实例
3. DEL 大 key 时,内存回收同步进行,阻塞尤其严重
4. 还有几个被超高频访问的"热 key",压垮了所在的分片
修复 1:先认清大 key 的危害
=== 什么算"大 key" ===
大 key 不是说 key 的名字长,而是 key 对应的【value 很大】:
- String 类型:value 超过 10KB 就要警惕,超过 1MB 是明确的大 key
- Hash/List/Set/ZSet:元素数量过多(上万、几十万、上百万),
或所有元素加起来体积很大
我们这次的 user:online:set 一个 hash 284 万 field、368MB,
是教科书级别的大 key。
=== 大 key 为什么危险:Redis 是单线程 ===
Redis 处理命令是单线程的,一次只能干一件事。
对大 key 的操作天然就慢:
- HGETALL 一个 284 万 field 的 hash,要一次性把它们
全部取出、序列化、通过网络发出去 —— 这期间
【整个 Redis 都在为这一条命令服务,别的请求全部排队】
- 这就是我们接口"集体超时又自愈"的真相:
某一刻有人操作了大 key,Redis 卡住,一批请求超时;
操作做完,Redis 恢复,故障"自愈"。
=== 大 key 的其它危害 ===
1. 网络拥塞:一次返回几百 MB,网卡和带宽瞬间被打满
2. 内存不均:集群模式下,大 key 所在的那个分片
内存占用畸高,其它分片很空 —— 数据严重倾斜
3. 删除阻塞:DEL 一个大 key,Redis 要同步回收
它占的全部内存,这个回收过程也是阻塞的
4. 持久化/主从受影响:大 key 让 RDB、AOF 重写变慢
修复 2:怎么把大 key 找出来
# === 办法 1:redis-cli --bigkeys 扫描 ===
# 它会遍历所有 key,采样找出每种类型里最大的那些。
# --bigkeys 用 SCAN 渐进遍历,不会阻塞实例,生产可用。
redis-cli -h 127.0.0.1 -p 6379 --bigkeys
# 输出示例:
# [00.00%] Biggest hash found 'user:online:set' has 2841902 fields
# [12.34%] Biggest string found 'config:full' has 2400031 bytes
# 注意:--bigkeys 看的是"元素个数 / 字节数",
# 它找的是"元素最多的",不完全等于"内存占用最大的"。
# === 办法 2:对可疑 key 用 MEMORY USAGE 看真实占用 ===
redis-cli MEMORY USAGE user:online:set
# 返回这个 key 实际占用的字节数(含 Redis 内部开销)
# === 办法 3:自己用 SCAN 渐进遍历,绝不要用 KEYS * ===
# KEYS * 会一次性遍历所有 key,本身就是个会阻塞的"大操作"。
# 生产环境一律用 SCAN,游标式分批遍历:
redis-cli --scan --count 100 | while read key; do
type=$(redis-cli TYPE "$key")
mem=$(redis-cli MEMORY USAGE "$key")
echo "$mem $type $key"
done | sort -rn | head -20
# 把占用最大的 20 个 key 揪出来
# === 办法 4:离线分析 RDB 文件(对线上零影响)===
# 用 rdb 分析工具(如 rdb_bins / redis-rdb-tools)
# 把备份的 RDB dump 出来做内存分布报告:
rdb --command memory dump.rdb --bytes 10240 -f memory.csv
# 生成每个 key 的内存占用 CSV,适合做全量盘点
# === 落地:把大 key 扫描做成例行巡检 ===
# 写个定时脚本,每天凌晨低峰跑一次 --bigkeys,
# 超过阈值的 key 告警出来,别等它酿成事故才发现。
修复 3:大 key 怎么拆
// === 核心思路:把一个大 key,拆成多个小 key ===
// 化整为零,让每次操作只碰其中一小块。
// === 场景 1:巨大的 hash —— 按 field 哈希分片 ===
// 原来:user:online:set 一个 hash 装 284 万 field
// 拆成:user:online:set:0 ~ user:online:set:255 共 256 个小 hash
// 用 field 的 hash 决定它落到哪个分片
public class ShardedHash {
private static final int SHARD_COUNT = 256;
private String shardKey(String bizKey, String field) {
int idx = (field.hashCode() & 0x7fffffff) % SHARD_COUNT;
return bizKey + ":" + idx;
}
public void hset(String bizKey, String field, String value) {
redis.opsForHash().put(shardKey(bizKey, field), field, value);
}
public String hget(String bizKey, String field) {
return (String) redis.opsForHash()
.get(shardKey(bizKey, field), field);
}
}
// 每个小 hash 约 1 万 field,单次操作飞快。
// 代价:失去了"一次 HGETALL 拿全部"的能力 ——
// 但你本来就不该对 284 万 field 做 HGETALL。
// === 场景 2:大 String —— 评估它该不该进 Redis ===
// 一个 2.4MB 的 config:full,塞进 Redis 本身就值得商榷。
// 选项 A:拆成多个小 key,按业务模块切
// 选项 B:大对象根本不适合 Redis,放对象存储/本地文件,
// Redis 里只存一个指针/版本号
// === 场景 3:大 List/Set —— 限制长度 + 分片 ===
// 比如"最近浏览记录",不该无限增长。
// 每次 LPUSH 后 LTRIM,只保留最近 N 条:
public void addRecent(Long userId, String item) {
String key = "recent:" + userId;
redis.opsForList().leftPush(key, item);
redis.opsForList().trim(key, 0, 99); // 只留最近 100 条
}
// 大 key 治理的第一原则:从源头上不让它长大。
修复 4:热 key——另一个隐形杀手
=== 大 key 和热 key 是两回事 ===
- 大 key:一个 key 的 value 体积大(284 万 field)
- 热 key:一个 key 的 value 可能很小,但它被
【访问得极其频繁】(每秒几万、几十万次)
两者都能拖垮 Redis,但机理不同,解法也不同。
=== 热 key 为什么危险 ===
Redis 集群把数据按 slot 分散到多个分片上。
正常情况下,流量均匀打到各个分片,大家一起扛。
但一个超热的 key,它只可能在【某一个】分片上。
于是这一个分片要独自承受这个 key 的全部访问压力,
它的 CPU 被打满、它先扛不住 —— 哪怕集群整体看
还很空闲。这叫"分片热点",是集群扩容也解决不了的:
你加再多分片,这个 key 还是只在那一个分片上。
=== 典型的热 key 场景 ===
- 秒杀商品:全平台流量瞬间涌向同一个 sku 的库存 key
- 热点新闻/爆款:某条内容被疯狂刷,它的缓存 key 变热
- 全局配置:某个所有请求都要读的开关 key
=== 怎么发现热 key ===
1. redis-cli --hotkeys(需 maxmemory-policy 为 LFU)
2. monitor 命令抽样(注意 monitor 本身有性能开销,
只能短时间用)
3. 业务侧埋点:在缓存访问的代码里统计 key 的访问频次
4. 云厂商的 Redis 一般自带热 key 分析面板
修复 5:热 key 怎么解
// === 解法 1:多级缓存,用本地缓存挡住热 key ===
// 热 key 的特点是"读多写少、值不常变",最适合本地缓存。
// 在应用进程内加一层 Caffeine,请求先查本地,
// 本地没有才查 Redis —— 热 key 的绝大部分流量
// 被本地缓存拦在了应用层,根本到不了 Redis。
private final Cache localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(5)) // 短过期,容忍 5s 不一致
.build();
public Object getHotData(String key) {
Object v = localCache.getIfPresent(key);
if (v != null) return v; // 命中本地缓存
v = redis.opsForValue().get(key); // 回源 Redis
if (v != null) localCache.put(key, v);
return v;
}
// 代价:本地缓存有几秒的数据不一致窗口,
// 热 key 多是配置/热点内容,这点延迟通常可接受。
// === 解法 2:热 key 打散成多个副本 ===
// 给热 key 复制出 N 个带后缀的副本,分散到不同分片:
// hot:product:888 -> hot:product:888#0 ... hot:product:888#9
// 读的时候随机选一个副本读,把压力摊到 10 个分片上。
public Object getWithReplica(String baseKey, int replicaNum) {
int r = ThreadLocalRandom.current().nextInt(replicaNum);
String replicaKey = baseKey + "#" + r;
Object v = redis.opsForValue().get(replicaKey);
if (v == null) {
v = loadFromDb(baseKey);
// 写回所有副本(写时要更新全部副本)
for (int i = 0; i < replicaNum; i++) {
redis.opsForValue().set(baseKey + "#" + i, v,
Duration.ofMinutes(10));
}
}
return v;
}
// 适合"读极热、写极少"的 key。写多了,维护多副本一致很麻烦。
// === 解法 3:热 key 探测 + 自动本地缓存 ===
// 成熟方案(如 JD hotkey)能实时探测出哪些 key 变热,
// 自动把它们推送到各应用节点做本地缓存,无需人工预判。
// 适合"事先不知道哪个 key 会热"的场景(如突发爆款)。
修复 6:删除大 key 的正确姿势
# === 一个致命的坑:别直接 DEL 大 key ===
# DEL 是【同步】的:它要在这一条命令里,把这个 key
# 占用的全部内存回收掉。删一个 368MB 的 hash,
# 这个回收过程会阻塞 Redis 几百毫秒 ——
# 我们慢日志里那条 213ms 的 DEL,正是这么来的。
# === 正解 1:用 UNLINK 代替 DEL ===
# UNLINK 是【异步】删除:它只把 key 从键空间里摘掉
# (这一步很快),真正的内存回收交给后台线程慢慢做,
# 不阻塞主线程。Redis 4.0 起支持。
redis-cli UNLINK user:online:set
# === 正解 2:开启 lazy free,让删除默认异步 ===
# 在 redis.conf 里配置,让各种场景下的删除都走异步:
# lazyfree-lazy-server-del yes (DEL/SET 等隐式删除)
# lazyfree-lazy-expire yes (key 过期时的删除)
# lazyfree-lazy-eviction yes (内存满淘汰时的删除)
# lazyfree-lazy-user-del yes (让 DEL 命令本身也异步)
# === 正解 3:对集合类大 key,渐进式分批删 ===
# 如果非要在老版本上删,或想更可控,就分批删元素:
# 一个大 hash,每次 HSCAN 取一批 field,HDEL 删掉,
# 循环直到删空,每批之间留点间隙,避免长时间占用。
// 渐进式删除一个大 hash 的示例
public void deleteBigHash(String key) {
ScanOptions options = ScanOptions.scanOptions().count(500).build();
try (Cursor> cursor =
redis.opsForHash().scan(key, options)) {
List
优化效果
指标 治理前 治理后
=============================================================
最大单 key 体积 368 MB(284万 field) 每分片约 1.5MB
HGETALL 慢命令 81ms,阻塞全实例 拆分后单次 < 2ms
DEL 大 key 213ms 同步阻塞 UNLINK 异步,主线程无感
接口集体超时 每隔一阵抖动一次 消失
热 key 所在分片 CPU 打满 本地缓存拦截后回落
集群内存倾斜 单分片畸高 各分片均衡
大 key 删除方式 DEL 同步 UNLINK + lazy free
大 key 发现机制 出事才知道 每日 bigkeys 巡检告警
治理过程:
- 定位大 key/热 key 根因:0.5 天
- 大 key 拆分(hash 分片 + list 限长):2 天
- 热 key 接入本地缓存 + 多副本:1.5 天
- 删除改 UNLINK + 开启 lazy free:0.5 天
- bigkeys/hotkeys 例行巡检脚本 + 告警:0.5 天
避坑清单
- 大 key 指 value 体积大:String 超 1MB,集合类元素上万到上百万都是大 key
- Redis 单线程,操作大 key 的命令会长时间阻塞整个实例,导致接口集体超时
- 集群下大 key 造成内存倾斜,某个分片畸高,加分片也救不了
- 找大 key 用 redis-cli --bigkeys(基于 SCAN 不阻塞),绝不要用 KEYS *
- 大 key 拆分:大 hash 按 field 哈希分片,大 list/set 用 LTRIM 限长 + 分片
- 热 key 是 value 不大但访问极频繁,只在一个分片上,会单点压垮该分片
- 热 key 首选多级缓存,用应用内本地缓存把绝大部分流量拦在 Redis 之前
- 读极热写极少的热 key 可打散成多副本,分散到不同分片,读时随机选副本
- 删除大 key 别用同步的 DEL,用异步的 UNLINK,并在配置里开启 lazy free
- 大 key/热 key 要做例行巡检,每日低峰扫描超阈值告警,别等出事才发现
总结
这次 Redis 的治理,治的是两个名字只差一个字、却完全不同的毛病——大 key 和热 key。它们最初都藏在那几条不起眼的慢日志里,而慢日志之所以值得我盯着看,是因为我先想明白了一件最关键的事:Redis 是单线程的。这五个字看似是一句老生常谈的八股,可它正是理解这次所有问题的总钥匙。单线程意味着 Redis 在任意一个时刻,只能、也只在为一条命令服务,所有其他请求都在后面老老实实排队。平时这不是问题,因为 Redis 的命令通常都是微秒级的,排队几乎感觉不到。可一旦有某一条命令要花掉几十、几百毫秒,那么在这段时间里,整个 Redis 实例对所有其他客户端来说,就等于"假死"了。我们那个"接口集体超时、过一会儿又自己好了"的幽灵故障,真相一点都不玄:某一刻,有人对一个 368MB、装着 284 万个 field 的巨型 hash 执行了 HGETALL,Redis 卡住了,这期间打过来的所有请求一起超时;等这条命令终于做完,Redis 恢复正常,故障就"自愈"了。所谓自愈,不过是那条慢命令执行完了而已。先说大 key。大 key 危险的根源,就在于它和单线程的化学反应——一个体积巨大的 value,任何针对它的读取、序列化、网络传输、乃至删除时的内存回收,都是慢的,而每一次慢,都是对整个实例的一次集体阻塞。治理大 key 的核心思路其实朴素得很,就是化整为零:把一个装了几百万 field 的大 hash,按 field 的哈希值拆散到几百个小 hash 里,让每一次操作都只触碰其中很小的一块;把一个会无限增长的 list,用 LTRIM 死死按住,只保留最近的 N 条。但我后来想得更深一层:大 key 治理真正的第一原则,不是"等它长大了再拆",而是从设计的那一刻起,就不允许任何一个 key 拥有"无限长大"的可能——任何一个会随业务持续累积、却没有上限约束的集合 key,都是一颗迟早会引爆的定时炸弹。再说热 key,它和大 key 是另一个维度的问题。热 key 的 value 可能小得可怜,只是一个商品库存数字、一个配置开关,但它被访问的频率高到吓人。它的危险来自 Redis 集群的分片机制:集群把数据按 slot 打散到很多分片上,本意是让大家分摊压力,可一个 key 无论多热,它的所有副本逻辑上都只能落在某一个确定的分片上,于是这个分片就要独自扛下这个热 key 的全部流量,它的 CPU 被打满、它第一个倒下,而此时集群里其他分片可能还闲得很。这就是为什么热 key 问题靠"加机器、加分片"是解决不了的——你扩容得再厉害,那个热 key 还是孤零零地待在它原来那一个分片上。对付热 key,最有效的一招是多级缓存:在应用进程自己的内存里加一层本地缓存,让请求先问本地,绝大部分对热 key 的访问就被拦截在了应用层,根本走不到 Redis 跟前;对于那些读极热、写极少的热 key,还可以把它复制成很多个带后缀的副本,人为地把它打散到不同分片上,读的时候随机挑一个副本读,把那股集中的压力摊开。最后,这次复盘还纠正了我一个很具体的坏习惯——删大 key 用 DEL。DEL 是一个同步命令,它要在这一条命令的执行过程里,把目标 key 占用的全部内存一寸一寸地回收干净才肯返回,删一个几百 MB 的 key,这个回收过程本身就能阻塞 Redis 几百毫秒。正确的做法是用 UNLINK 替代 DEL,UNLINK 只负责把 key 从键空间里飞快地摘除,真正耗时的内存回收则甩给后台线程慢慢去做,主线程毫无感知;更进一步,直接在配置里把各种 lazy free 选项打开,让 Redis 在过期淘汰、隐式删除等所有场景下,都默认走异步回收。这次治理之后我给团队定下的最重要的一条规矩,其实和具体技术无关:大 key 和热 key,绝不能等到它们酿成线上事故、逼着你去翻慢日志的时候才被发现。我们写了例行巡检脚本,每天在业务低峰期用 --bigkeys 和热 key 分析扫一遍,任何 key 的体积或访问频次一旦越过预设的红线,就立刻告警。Redis 用起来太顺手了,顺手到我们常常忘记它身后那个单线程的、脆弱的真相;而大 key 和热 key,就是这个真相迟早会寄给你的两张账单——你要么主动地、定期地去查账,要么被动地、在某个深夜被它叫醒。
—— 别看了 · 2026