Redis 内存从 30G 暴涨到 90G:大 key 热 key 与过期策略治理实录

Redis 6 主 6 从,凌晨内存从 30GB 暴涨到 90GB,P99 从 1ms 飙到 80ms,触发淘汰业务大面积超时。两周治理:大 key 扫描拆分 + UNLINK 异步删 + 热 key 多级缓存 + 强制 TTL + listpack 编码 + LZ4 压缩 + activedefrag。内存稳定回落 28GB,P99 回到 1ms,取消扩容省 36w。

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)从代码里彻底清除

避坑清单

  1. 禁止大 key:单 key < 10KB,集合元素 < 5000,超了按 id 取模分片
  2. 删大 key 用 UNLINK 不用 DEL,开 lazy-free 全异步回收
  3. 禁止 HGETALL/SMEMBERS/KEYS * 全量操作,改用 SCAN 系列游标
  4. 所有 key 必须有 TTL,封装 SafeRedisWriter 强制兜底,CI 校验
  5. 热 key 上多级缓存,Caffeine 本地缓存挡住绝大部分读
  6. 缓存击穿用单飞锁,过期时间加随机量防雪崩
  7. maxmemory 留 2GB 余量,淘汰策略用 allkeys-lfu
  8. 小集合保持 listpack 编码,别让 field 数突破阈值退化
  9. value 用 Protobuf + LZ4,比 JSON 省 5-6 倍内存
  10. 开 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

K8s 集群利用率 18% 治理:requests 调准 + HPA + 弹性伸缩实战

2026-5-20 10:55:06

技术教程

JVM 老年代每天 Full GC 上百次:从 CMS 到 G1 再到 ZGC 调优实录

2026-5-20 12:06:46

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索