马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
Redis 大 Key 治理实战:从内存倾斜到平滑拆分
一、具体的问题
一个 3 节点 Cluster 集群,总内存 96GB,最近两周开始频繁报警:某个分片 used_memory 达到 92%,另外两个分片只有 41% 左右。SLOWLOG 里每隔一段时间就冒出一条执行 11 秒的 DEL 命令,伴随主从复制抖动和 P99 延迟飙到 320ms。开发同学排查半天没找到"慢 SQL",因为这不是 SQL——是业务把 2300 万个购物车明细塞进了一个 hash key 里,这个 key 独占 4.2GB 内存。
这正是 Redis 运维里最常见、也最容易被忽视的病灶:大 Key(bigkey)。它不像连接打满、内存溢出那样直接宕机,而是慢刀子割肉:内存倾斜、命令阻塞、主从抖动、过期风暴,四件事互相纠缠。本文把一次完整的治理过程拆开讲清楚,所有命令都可以照做。
二、核心原理
大 Key 是个相对概念,通常指满足以下任一条件的 key:string 超过 10KB,或集合类型(hash/list/set/zset)元素数超过 5000、总序列化体积超过 1MB。它的危害来自 Redis 的执行模型:
- 单线程命令执行。Redis 处理命令是单线程的,删除一个 2300 万 field 的 hash,DEL 是 O(N) 操作,期间整个实例不响应任何命令。11 秒的阻塞,主从心跳超时、哨兵主观下线、应用池耗尽,全是连锁反应。
- 内存倾斜。Cluster 按 16384 个 slot 分片,slot 由 key 的 CRC16 决定。一个 key 再大也只能落在一个 slot 上,分片就失去了意义——你买了一台 96GB 的集群,实际只敢用一个 32GB 的分片。
- 过期与淘汰风暴。主动过期策略下,大量同时过期的 key 会同步删除;从 4.0 开始才可以用 UNLINK 把释放动作交给后台线程(bio),主线程只做引用摘除,几乎瞬时返回。
- 迁移失控。Cluster resharding 时一个 4.2GB 的 key 要整体搬移,migrate 命令超时、源和目标不一致,都是大 key 引出的次生灾害。
治理思路是两层:应急层用 UNLINK + lazyfree + 渐进式删除止血;根治层改数据结构,把大 hash 拆成小 key,让 slot 分布重新发挥作用。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先摸清内存全貌和倾斜程度,不要上来就删:
- # 每个分片各执行一次,记录 used_memory_human、mem_fragmentation_ratio
- redis-cli -h 10.0.0.11 -p 6379 INFO memory | grep -E "used_memory_human|mem_fragmentation_ratio"
- # 采样扫描各类型的 top key(-i 0.01 表示每次扫描睡 10ms,降低影响)
- redis-cli -h 10.0.0.11 -p 6379 --bigkeys -i 0.01
复制代码
--bigkeys 的输出按 string/hash/list/set/zset 分别给出最大 key 和平均大小。注意它是从左到右遍历全库的采样统计,hash 只量 field 数不量字节,精确体积要靠下面的 MEMORY USAGE。
步骤 1:定位头号大户
- # 精确测量单个 key 的内存占用(SAMPLES 0 表示统计全部元素,不加则默认采样 5 个)
- redis-cli -h 10.0.0.11 -p 6379 MEMORY USAGE cart:user_10086 SAMPLES 0
- # 返回 4523917832 —— 约 4.2GB,就是它
- redis-cli -h 10.0.0.11 -p 6379 TYPE cart:user_10086 # hash
- redis-cli -h 10.0.0.11 -p 6379 HLEN cart:user_10086 # 23000000 个 field
- # 用 SLOWLOG 和 commandstats 量化阻塞证据
- redis-cli -h 10.0.0.11 -p 6379 SLOWLOG GET 10
- redis-cli -h 10.0.0.11 -p 6379 INFO commandstats | grep -E "cmdstat_del|cmdstat_unlink"
复制代码
步骤 2:开启 lazyfree,先给防线装上保险丝
在 redis.conf 里加三行并热生效,之后所有"意外的大删除"都不会再阻塞主线程:
- lazyfree-lazy-expire yes # 过期 key 异步释放
- lazyfree-lazy-evict yes # 内存淘汰异步释放
- lazyfree-lazy-server-del yes # 隐式删除(如 RENAME 覆盖)异步释放
- # 热生效(不重启):
- redis-cli -h 10.0.0.11 -p 6379 CONFIG SET lazyfree-lazy-expire yes
- redis-cli -h 10.0.0.11 -p 6379 CONFIG SET lazyfree-lazy-evict yes
- redis-cli -h 10.0.0.11 -p 6379 CONFIG SET lazyfree-lazy-server-del yes
复制代码
验证方式:UNLINK 一个大 key 后立刻执行 INFO memory | grep lazyfree_pending_objects,能看到 pending 数量在涨、几秒后归零,主线程全程无感。
步骤 3:应急清理——分批渐进删除,而不是一把梭
对确定要废弃的大 hash,用 HSCAN 分批删除 field,控制每批 500 个:
- #!/bin/bash
- KEY="cart:user_10086"
- CURSOR=0
- while :; do
- RESP=$(redis-cli -h 10.0.0.11 -p 6379 HSCAN $KEY $CURSOR COUNT 500)
- CURSOR=$(echo "$RESP" | sed -n 2p)
- KEYS=$(echo "$RESP" | tail -n +4 | paste -sd " " -)
- [ -n "$KEYS" ] && redis-cli -h 10.0.0.11 -p 6379 HDEL $KEY $KEYS
- [ "$CURSOR" = "0" ] && break
- sleep 0.05
- done
- redis-cli -h 10.0.0.11 -p 6379 UNLINK $KEY
复制代码
各类型的对应写法:set 用 SSCAN+SREM,zset 用 ZSCAN+ZREM,list 没有 SCAN,用 LTRIM key 0 499 反复截头。全程监控 SLOWLOG,确认没有超过 10ms 的命令。
步骤 4:根治——hash 分桶拆分
购物车按 user_id 取模拆成固定 64 个桶,单个 key 的 field 上限从 2300 万降到 40 万以内(再配合按商品维度清理,日常不超过几千):
- # 拆分迁移脚本(Python + redis-py,双写期使用)
- import redis
- r = redis.StrictRedis(host='10.0.0.11', port=6379)
- BUCKETS = 64
- src = 'cart:user_10086'
- cursor = 0
- while True:
- cursor, items = r.hscan(src, cursor, count=500)
- pipe = r.pipeline()
- for uid, sku in items.items():
- idx = int(uid) % BUCKETS
- pipe.hset(f'cart:user_{uid}:b{idx}', sku, 1)
- pipe.execute()
- if cursor == 0:
- break
复制代码
应用侧同步改造:写路径 cart:user_<uid>:b<int(uid)%64>;读路径先 HGETALL 各桶再聚合。迁移期间老 key 只读、新 key 双写,验证数据一致后 UNLINK 老 key。拆完之后 key 天然散落在不同 slot,Cluster 的分片能力才真正用起来。
步骤 5:离线全量分析,摸清其余大 key
在线采样有盲区,用 RDB 文件离线分析(rdb-tools):
- rdb -c memory /data/dump.rdb --bytes 10240 -f bigkeys.csv
- # 按 type 聚合排序,找出所有 >10MB 的 key
- python3 -c "
- import csv
- rows = [r for r in csv.reader(open('bigkeys.csv')) if r[0] != 'database']
- big = sorted(rows, key=lambda x: int(x[3]), reverse=True)[:50]
- for r in big: print(r[2], r[3], r[5])
- "
复制代码
步骤 6:防线固化
把大 key 监控做成定时任务,每小时跑一次 --bigkeys,与基线快照 diff,环比增长超过 20% 告警;应用上线评审时按阈值把关(string < 10KB、集合元素 < 5000、单 key < 1MB)。
四、实操检查清单
- [ ] 每个分片执行 INFO memory,记录 used_memory 与 mem_fragmentation_ratio 基线
- [ ] --bigkeys -i 0.01 采样扫描,集群模式下每个 master 都要单独跑
- [ ] 可疑 key 用 MEMORY USAGE key SAMPLES 0 精确计量,TYPE+HLEN/LLEN/SCARD/ZCARD 确认规模
- [ ] SLOWLOG GET 10 与 INFO commandstats 中 del/unlink 用时,作为阻塞证据留存
- [ ] CONFIG SET 三项 lazyfree-lazy-* 全部开启,并用 lazyfree_pending_objects 验证生效
- [ ] 清理一律 UNLINK 不用 DEL;单条命令禁止操作 >10 万元素
- [ ] 分批删除脚本带 sleep 限速,全程盯 SLOWLOG 不出现 >10ms 命令
- [ ] 拆分方案先在测试库演练:桶数、取模函数、双写开关、回滚步骤
- [ ] 迁移完成后老 key UNLINK,并抽查 1000 条数据比对一致性
- [ ] 定时任务每小时 --bigkeys 比对基线,环比 +20% 告警
- [ ] 应用评审卡点:string < 10KB、集合 < 5000 元素、单 key < 1MB
- [ ] 每月跑一次 RDB 离线分析(rdb -c memory),覆盖在线采样的盲区
几个容易踩的坑
- 生产环境执行 KEYS *。全库遍历 + O(N) 返回,等于自己制造一次全局阻塞。找 key 一律用 SCAN(增量游标、可限速),或者直接走离线 RDB 分析。
- 以为 --bigkeys 是精确值。它是遍历时的采样统计,hash 只统计 field 数不统计字节;精确体积必须 MEMORY USAGE SAMPLES 0 或离线分析,两者结合才不会漏。
- 用 DEL 清大 key。10 万 field 以下感知不明显,百万级就是秒级阻塞。养成肌肉记忆:删除用 UNLINK,配上 lazyfree 配置。
- 拆分后双写不一致。先写新读旧、灰度切流、最后删旧,任何一步跳过都可能出现"部分订单丢失"的诡异工单。拆分脚本要支持幂等重跑(HSET 天然幂等,DELETE 型操作要小心)。
- 只治理不设防。治理完不加上线评审卡点和定时扫描,半年后大 key 会换个业务名卷土重来。防线固化比一次清理更重要。
治理前后对比
| 指标 | 治理前 | 治理后 | | 最大单 key 体积 | 4.2GB(2300 万 field) | 12MB(< 40 万 field,日常 < 5000) | | DEL/UNLINK 最大阻塞 | 11 秒 | 0ms(UNLINK + lazyfree 异步) | | 分片内存利用率 | 92% / 41% / 38%(严重倾斜) | 68% / 63% / 65%(均衡) | | P99 命令延迟 | 320ms | 4ms | | 主从复制抖动 | 每周 2~3 次 | 0 次 | | 大 key 告警 | 事后人工发现 | 每小时扫描 + 环比告警 |
这次治理总共花了一周:止血半天(lazyfree + UNLINK 应急),拆分迁移三天(双写 + 灰度),防线固化两天。事后最大的体会是:Redis 的问题从来不在"删不删得掉",而在结构设计阶段就该想清楚这个 key 会长多大。 |