2024 年我们的 Redis 集群:6 主 6 从,内存上限 96GB,某天凌晨监控告警 — used_memory 从平时的 30GB 一路涨到 90GB,延迟 P99 从 1ms 飙到 80ms,部分节点开始触发 maxmemory 淘汰,业务大面积超时。紧急扩容压住后,投了两周做 Redis 内存治理,内存稳定回落到 28GB,P99 回到 1ms 以内,再没扩过容。本文复盘 Redis 大 key、热 key、过期淘汰、数据结构选型、内存碎片的完整实战。
问题背景
集群:Redis 7.0,6 主 6 从,Cluster 模式
内存:单节点 16GB,集群共 96GB
QPS:平时 8w,高峰 20w
事故现象:
- used_memory 30GB → 90GB,持续上涨不回落
- 延迟 P99:1ms → 80ms
- 部分节点触发 maxmemory-policy 淘汰,缓存命中率从 98% 掉到 76%
- 业务侧大量 RedisCommandTimeoutException
排查命令:
# 1. 看内存构成
$ redis-cli -h node1 info memory
used_memory_human:15.2G
used_memory_rss_human:16.8G
mem_fragmentation_ratio:1.10
mem_allocator:jemalloc-5.3.0
# 2. 扫描大 key(--bigkeys 采样,不阻塞)
$ redis-cli -h node1 --bigkeys
Biggest hash found 'user:online:set' has 8200000 fields
Biggest string found 'page:cache:home' has 48 MB
Biggest list found 'mq:order:retry' has 1900000 items
# 3. 慢日志
$ redis-cli slowlog get 10
1) 1) (integer) 32
2) (integer) 1719820000
3) (integer) 84000 # 84ms 单条命令
4) 1) "HGETALL"
2) "user:online:set" # 对 820w field 的 hash 做 HGETALL
发现根因:
1. user:online:set 一个 hash 存了 820w 在线用户 → 单 key 1.3GB
2. HGETALL / HKEYS 对超大 hash 全量读 → 阻塞主线程
3. 大量 key 没设 TTL,只增不减
4. mq:order:retry 用 list 当队列,积压 190w 没消费
5. 缓存 value 用 JSON 存,一个对象序列化后 4KB+,本可压到 800B
修复 1:大 key 排查与拆分
# === 精确扫描大 key(memory usage 逐 key 算)===
# --bigkeys 只采样,要精确用 SCAN + MEMORY USAGE
redis-cli -h node1 --scan --count 1000 | while read key; do
size=$(redis-cli -h node1 memory usage "$key")
echo "$size $key"
done | sort -rn | head -20
# 输出
1398123456 user:online:set
51380224 page:cache:home
48234567 mq:order:retry
...
# === 在线安全删除大 key(绝不能直接 DEL,会阻塞)===
# 错误:DEL user:online:set → 删 1.3GB,主线程卡几百 ms
# 正确 1:UNLINK(后台线程异步回收,O(1) 返回)
redis-cli -h node1 unlink user:online:set
# 正确 2:hash/set/zset 分批 SCAN 删除
redis-cli -h node1 --eval del_big_hash.lua user:online:set , 500
# del_big_hash.lua —— 每次删 500 个 field,循环到空
# local cursor = "0"
# repeat
# local r = redis.call('HSCAN', KEYS[1], cursor, 'COUNT', ARGV[1])
# cursor = r[1]
# for i = 1, #r[2], 2 do
# redis.call('HDEL', KEYS[1], r[2][i])
# end
# until cursor == "0"
# === lazy-free 配置:让删除全部走后台线程 ===
# redis.conf
lazyfree-lazy-eviction yes # 淘汰时异步释放
lazyfree-lazy-expire yes # 过期时异步释放
lazyfree-lazy-server-del yes # DEL 隐式转 UNLINK
lazyfree-lazy-user-del yes # 用户 DEL 也异步
replica-lazy-flush yes # 从库全量同步时异步清空
修复 2:大 key 拆分设计
// 问题:user:online:set 一个 hash 存全部在线用户
// 拆分思路:按 user_id 哈希分片成 N 个小 hash
public class OnlineUserStore {
private static final int SHARD_COUNT = 1024; // 拆 1024 个小 hash
private String shardKey(long userId) {
// 820w 用户 / 1024 ≈ 每片 8000 field,单片 ~1.5MB,可控
return "user:online:" + (userId % SHARD_COUNT);
}
public void markOnline(long userId, long ts) {
String key = shardKey(userId);
redis.opsForHash().put(key, String.valueOf(userId), String.valueOf(ts));
// 关键:每个分片 key 都要有 TTL,防止永久膨胀
redis.expire(key, Duration.ofHours(2));
}
public boolean isOnline(long userId) {
return redis.opsForHash().hasKey(shardKey(userId), String.valueOf(userId));
}
// 统计在线数:遍历分片(各片 HLEN 求和,不再 HGETALL)
public long onlineCount() {
long total = 0;
for (int i = 0; i < SHARD_COUNT; i++) {
Long n = redis.opsForHash().size("user:online:" + i);
total += (n == null ? 0 : n);
}
return total;
}
}
// 原则:
// 1. 单 key 控制在 10KB 以内,集合类元素数 < 5000
// 2. 大集合按 id 取模分片,把热度和体积都摊开
// 3. 永远不对集合类型做 HGETALL/SMEMBERS/LRANGE 0 -1 全量操作
// 改用 HSCAN/SSCAN/ZSCAN 游标分批
修复 3:热 key 识别与本地缓存
# === 识别热 key ===
# 方法 1:redis-cli --hotkeys(需 maxmemory-policy 为 lfu)
$ redis-cli config set maxmemory-policy allkeys-lfu
$ redis-cli --hotkeys
hot key found 'page:cache:home' freq 255
hot key found 'config:global' freq 255
# 方法 2:monitor 抽样(生产慎用,只跑几秒)
$ timeout 5 redis-cli monitor | awk '{print $4}' | sort | uniq -c | sort -rn | head
# 方法 3:proxy 层埋点统计(最推荐,无侵入)
// 热 key 解法:多级缓存,本地缓存挡住绝大部分读
public class MultiLevelCache {
// L1:Caffeine 本地缓存,容量小、TTL 短
private final Cache<String, byte[]> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(5)) // 5s 容忍少量不一致
.build();
public byte[] get(String key) {
// L1 本地
byte[] v = local.getIfPresent(key);
if (v != null) return v;
// L2 Redis
v = redis.opsForValue().get(key);
if (v != null) {
local.put(key, v);
return v;
}
// L3 回源 DB + 防击穿(单飞)
synchronized (lockFor(key)) {
v = redis.opsForValue().get(key);
if (v != null) { local.put(key, v); return v; }
v = loadFromDb(key);
// 加随机过期时间,防雪崩
redis.opsForValue().set(key, v,
Duration.ofMinutes(10 + ThreadLocalRandom.current().nextInt(5)));
local.put(key, v);
return v;
}
}
}
// 效果:热 key page:cache:home 的 QPS,Redis 侧从 12w 降到 2000
// (本地缓存命中率 98%+,5s TTL 内只回源一次)
修复 4:过期与淘汰策略
# === 根因:大量 key 没 TTL,内存只增不减 ===
# 排查没 TTL 的 key
$ redis-cli --scan | while read k; do
ttl=$(redis-cli ttl "$k")
[ "$ttl" = "-1" ] && echo "NO-TTL: $k"
done | head -50
# === maxmemory + 淘汰策略 ===
# redis.conf
maxmemory 14gb # 留 2GB 给 rss/碎片/复制缓冲
maxmemory-policy allkeys-lfu # 按访问频率淘汰最冷的
# 淘汰策略选择:
# - noeviction: 写满直接报错(默认,缓存场景不要用)
# - allkeys-lru: 全 key 按最近最少使用淘汰
# - allkeys-lfu: 全 key 按访问频率淘汰(推荐,抗偶发扫描)
# - volatile-lru: 只淘汰有 TTL 的 key(混合存储场景)
# - volatile-ttl: 只淘汰有 TTL 的,优先淘汰快过期的
# === 过期 key 主动清理参数 ===
# Redis 过期清理:惰性删除 + 定期抽样删除
# 抽样不够积极时,过期 key 占着内存不释放
hz 10 # 后台任务频率,默认 10
activeexpire yes
# 如果过期 key 堆积严重,可调高 hz(代价:CPU 略升)
# === 给所有写入兜底 TTL(业务层强制)===
# 封装统一的 set 方法,禁止裸 set 无 TTL
// 统一缓存写入入口,强制 TTL,杜绝"忘记设过期"
public class SafeRedisWriter {
private static final Duration MAX_TTL = Duration.ofDays(7);
public void set(String key, byte[] value, Duration ttl) {
if (ttl == null || ttl.isZero() || ttl.compareTo(MAX_TTL) > 0) {
// 默认或超长一律收敛到 MAX_TTL,绝不允许永久 key
ttl = MAX_TTL;
}
redis.opsForValue().set(key, value, ttl);
}
// code review 规则:业务代码禁止直接调 redisTemplate.set,
// 必须走 SafeRedisWriter,CI 用 ArchUnit 强制校验
}
修复 5:数据结构选型与编码优化
# === Redis 小集合的紧凑编码(ziplist/listpack/intset)===
# 集合元素少时用紧凑编码,内存省 5-10 倍;超阈值退化为 hashtable
# redis.conf 阈值(满足任一条件就退化为大编码)
hash-max-listpack-entries 128 # hash field 数 ≤ 128
hash-max-listpack-value 64 # hash value 长度 ≤ 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512 # 纯整数 set ≤ 512 用 intset
set-max-listpack-entries 128
# 验证编码
$ redis-cli object encoding user:online:0
"listpack" # 小 → 紧凑
$ redis-cli object encoding user:online:set
"hashtable" # 大 → 退化,内存翻几倍
# === value 压缩:JSON → 二进制/压缩 ===
原方案:对象 → JSON 字符串(4.2KB)
优化 1:JSON → Protobuf 序列化(1.1KB)
优化 2:Protobuf → 再 LZ4 压缩(680B)
单 key 从 4.2KB 降到 680B,1000w key 省 35GB
# === 用对数据类型 ===
# 场景 错误用法 正确用法 内存对比
# 存用户标签(整数集) set 存字符串 intset 省 60%
# 计数器 string 存数字串 string(共享整数池) 省 90%
# 排行榜 top N list + 业务排序 zset 算法 + 内存双赢
# 布隆过滤/去重 set 存全量 bitmap / HyperLogLog 省 99%
# 大量 bool 标记 每个 key 一个 bitmap 位运算 省 99%
修复 6:内存碎片与持久化
# === 内存碎片整理 ===
$ redis-cli info memory | grep frag
mem_fragmentation_ratio:1.46 # > 1.5 说明碎片严重
allocator_frag_ratio:1.42
# 开启主动碎片整理(jemalloc 才支持)
# redis.conf
activedefrag yes
active-defrag-ignore-bytes 100mb # 碎片 > 100MB 才整理
active-defrag-threshold-lower 10 # 碎片率 10% 启动
active-defrag-threshold-upper 100
active-defrag-cycle-min 5 # 整理占用 CPU 下限 5%
active-defrag-cycle-max 25 # 上限 25%(防止整理太猛影响业务)
# === 持久化对内存/延迟的影响 ===
# RDB fork 时 copy-on-write,写多时内存会翻倍
# AOF rewrite 同理
# 配置建议(缓存为主的集群)
save "" # 纯缓存可关 RDB 定时快照
appendonly yes
appendfsync everysec # 每秒刷盘,平衡安全与性能
aof-use-rdb-preamble yes # AOF 用 RDB 头,重写更快更小
aof-rewrite-incremental-fsync yes
# fork 优化:关闭透明大页(THP),否则 fork 后延迟抖动
$ echo never > /sys/kernel/mm/transparent_hugepage/enabled
# repl-backlog 复制积压缓冲区:别设太大,占内存
repl-backlog-size 64mb
# === client output buffer:防止慢客户端撑爆内存 ===
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
# === 监控告警(Prometheus + redis_exporter)===
groups:
- name: redis-memory
rules:
# 1. 内存使用率
- alert: RedisMemoryHigh
expr: |
redis_memory_used_bytes / redis_memory_max_bytes > 0.85
for: 5m
annotations:
summary: "{{ $labels.instance }} 内存使用率 > 85%"
# 2. 内存碎片率
- alert: RedisFragmentationHigh
expr: redis_mem_fragmentation_ratio > 1.5
for: 10m
annotations:
summary: "{{ $labels.instance }} 碎片率 > 1.5,需 activedefrag"
# 3. 淘汰速率(出现淘汰说明内存吃紧)
- alert: RedisEvictionHappening
expr: rate(redis_evicted_keys_total[5m]) > 0
for: 5m
annotations:
summary: "{{ $labels.instance }} 正在淘汰 key,缓存命中率会下降"
# 4. 命中率
- alert: RedisHitRateLow
expr: |
rate(redis_keyspace_hits_total[5m])
/ (rate(redis_keyspace_hits_total[5m])
+ rate(redis_keyspace_misses_total[5m])) < 0.9
for: 10m
annotations:
summary: "{{ $labels.instance }} 命中率 < 90%"
# 5. 阻塞客户端 / 慢命令
- alert: RedisSlowlog
expr: increase(redis_slowlog_length[5m]) > 10
annotations:
summary: "{{ $labels.instance }} 5min 新增慢命令 > 10 条"
# 6. 连接数
- alert: RedisConnectionHigh
expr: redis_connected_clients > 8000
annotations:
summary: "{{ $labels.instance }} 连接数过高,检查连接池泄漏"
优化效果
指标 优化前 优化后
=============================================================
集群 used_memory 90GB(暴涨) 28GB(稳定)
最大单 key 1.3GB < 10MB
延迟 P99 80ms 0.8ms
缓存命中率 76% 98.5%
热 key Redis QPS 12w 2000(本地缓存挡掉)
内存碎片率 1.46 1.08
无 TTL key 数量 260w+ 0(SafeRedisWriter 兜底)
淘汰速率 持续淘汰 0
成本:
- 原计划扩容到 9 主 9 从(+50% 机器),取消
- 现 6 主 6 从内存占用 28/96GB,余量充足
- 年省扩容成本约 36w
稳定性:
- 大 key 拆分后,再无单命令阻塞主线程
- UNLINK + lazy-free,删除大对象不再卡顿
- 本地缓存挡住热 key,单点流量不再打爆
- 全量操作(HGETALL/KEYS)从代码里彻底清除
避坑清单
- 禁止大 key:单 key < 10KB,集合元素 < 5000,超了按 id 取模分片
- 删大 key 用 UNLINK 不用 DEL,开 lazy-free 全异步回收
- 禁止 HGETALL/SMEMBERS/KEYS * 全量操作,改用 SCAN 系列游标
- 所有 key 必须有 TTL,封装 SafeRedisWriter 强制兜底,CI 校验
- 热 key 上多级缓存,Caffeine 本地缓存挡住绝大部分读
- 缓存击穿用单飞锁,过期时间加随机量防雪崩
- maxmemory 留 2GB 余量,淘汰策略用 allkeys-lfu
- 小集合保持 listpack 编码,别让 field 数突破阈值退化
- value 用 Protobuf + LZ4,比 JSON 省 5-6 倍内存
- 开 activedefrag 整理碎片,关 THP,监控碎片率/命中率/淘汰速率
总结
Redis 内存治理的核心认知是:Redis 是单线程的,内存问题本质上都是延迟问题。最大的认知改变来自那个 1.3GB 的 user:online:set —— 它不只是占内存,真正致命的是对它做一次 HGETALL 就会把主线程卡住 84ms,而 Redis 单线程意味着这 84ms 内所有请求全部排队,雪崩就是这么来的。所以大 key 治理的第一性原理不是"省内存",而是"消除单命令阻塞"。最被低估的是 UNLINK 和 lazy-free:很多人删大 key 直接 DEL,结果删除本身又卡一次主线程,UNLINK 把回收丢给后台线程,O(1) 返回,这是处理大对象的唯一安全姿势。最容易踩的坑是无 TTL 的 key —— 它不会报错、不会告警,只是安静地只增不减,直到某天内存撑爆,我们清出 260 万个永久 key,根治办法是封装统一写入入口、用 CI 强制校验,靠人自觉记得设过期是不可能的。最后一个反直觉的结论:治理之后我们取消了扩容计划,内存从 90GB 回落到 28GB —— 90% 的内存不是业务真的需要,而是大 key、无 TTL、JSON 冗余、编码退化堆出来的虚胖,Redis 的容量问题,绝大多数时候是治理问题,不是机器问题。
—— 别看了 · 2026