我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Redis 从 0 到 1(二十二):一个 500MB 的 Hash 拖垮实例——bigkey 的 Go 渐进删除
- 一、现象:一条 DEL,实例阻塞 3 秒
- 二、bigkey 的 4 类成因(收藏点 1)
- 三、找出 bigkey(收藏点 2)
- 四、Go 实操:渐进删除(收藏点 3)
- 五、治理 Checklist(收藏点 4)
- 六、排查步骤:怀疑有 bigkey
- 七、总结
Redis 从 0 到 1(二十二):一个 500MB 的 Hash 拖垮实例——bigkey 的 Go 渐进删除
上一篇《Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器》我们学会了抓慢命令,链接:https://blog.csdn.net/2501_92769340/article/details/167399878 。
本篇处理慢命令最常见的元凶:bigkey——怎么找、怎么安全删、怎么预防。
一、现象:一条 DEL,实例阻塞 3 秒
运维同学清理一批历史数据,脚本里对几百个 key 执行 DEL。其中一个是 500MB 的 Hash(某活动的用户报名表)。DEL 是同步释放内存:Redis 主线程遍历整个 Hash 结构逐个释放——3 秒里实例不响应任何请求,上游全部超时。更糟的是这样的 key 往往不止一个。
bigkey 的危害不止 DEL:
| 操作 | bigkey 下的表现 |
|---|---|
| HGETALL / SMEMBERS | 全量返回,网络 + 序列化 + 客户端内存三杀 |
| DEL / HDEL 大批量 | 同步释放,阻塞主线程 |
| 主从复制 | 全量同步时大 key 拖慢传输,backlog 被冲掉(第 8 篇来源 2) |
| AOF 重写 / RDB | 处理大 value 耗时翻倍 |
二、bigkey 的 4 类成因(收藏点 1)
| 成因 | 典型现场 | 预防 |
|---|---|---|
| 无上限集合 | 消息列表、日志队列只增不删 | LTrim / 定期归档 / Stream MAXLEN(第 17、18 篇) |
| 单个 value 过大 | 把整个 JSON 数组(100 万条订单明细)塞一个 String | 拆 key 分页存(list:1001:page:1) |
| 基数失控的 Hash | Hash 存"全部商品 id → 信息",商品涨到百万级 | 按前缀拆 Hash(分桶) |
| 批量导入未分批 | 一次性导入 500 万 key 的单 value 很大 | 分批 + 每批验大小 |
三、找出 bigkey(收藏点 2)
# 方法 1:采样统计(生产常用,低侵入)redis-cli--bigkeys# 每种类型最大的 key,采样估算redis-cli--memkeys# 直接按内存排序(Redis 6.2+,更准确)# 方法 2:精确测量单个 keyMEMORY USAGE key:xxx[SAMPLES5]# 含内部结构开销的真实字节数MEMORY USAGE key:xxx SAMPLES50# 大 Hash/Set 增加采样数更准OBJECT ENCODING key:xxx# 看编码(hashtable 通常意味着大)# 方法 3:离线全量分析(最准,从库或 RDB 上做)rdb-cmemory dump.rdb>mem.csv# 工具:github.com/sripathikrishnan/redis-rdb-tools判多大算 bigkey:经验阈值——String > 100KB、集合类 > 5000 个元素或 > 10MB,就该警惕(结合实例内存看,8GB 实例和 256GB 实例标准不同)。
四、Go 实操:渐进删除(收藏点 3)
大 String 用 UNLINK(异步释放,立即返回);大集合类用 SCAN 家族分批删:
packagemainimport("context""fmt""time""github.com/redis/go-redis/v9""redis-0to1/redisx")// DeleteBigHash 渐进删除大 Hash:HSCAN 分批 HDEL,每批之间主动让出主线程funcDeleteBigHash(ctx context.Context,rdb*redis.Client,keystring,batchint64)error{varcursoruint64for{// 1. 扫一批 fieldfields,next,err:=rdb.HScan(ctx,key,cursor,"*",batch).Result()iferr!=nil{returnfmt.Errorf("HSCAN 失败: %w",err)}// 2. 分批删这批 field(Pipeline 打包)iflen(fields)>0{pipe:=rdb.Pipeline()fori:=0;i<len(fields);i+=2{// HScan 返回 field,value 交替pipe.HDel(ctx,key,fields[i])}if_,err:=pipe.Exec(ctx);err!=nil{returnfmt.Errorf("HDel 批次失败: %w",err)}}cursor=nextifcursor==0{break}// 3. 主动让出主线程:每批删完歇 10ms,实例继续服务time.Sleep(10*time.Millisecond)}returnnil}// DeleteBigSet / DeleteBigZSet 同理:SSCAN+SREM / ZSCAN+ZREM,模板相同funcmain(){rdb,err:=redisx.NewClient(redisx.Config{Addr:"127.0.0.1:6379",Password:"redis123",PoolSize:10})iferr!=nil{panic(err)}ctx:=context.Background()key:="activity:signup"// 先量一下有多大usage,_:=rdb.MemoryUsage(ctx,key,0).Result()fmt.Printf("%s 占用 %d 字节\n",key,usage)// String 类型的大 key:UNLINK 一步搞定(后台线程异步释放)t,_:=rdb.Type(ctx,key).Result()ift=="string"{rdb.Unlink(ctx,key)}elseift=="hash"{DeleteBigHash(ctx,rdb,key,500)}fmt.Println("删除完成,实例无感")}要点:
- UNLINK 优先:Redis 4+ 的 UNLINK 把释放动作交给后台线程,主线程只做摘除,大 String 场景一步到位;
- SCAN 家族分批:HSCAN/SSCAN/ZSCAN 每批只碰一小部分内存,配 HDEL 精确删;
- 批间 sleep 让出:删 500 个 field 约 1ms,sleep 10ms 是让单线程喘口气服务别的请求;
- 删完验证:
EXISTS+MEMORY USAGE,并回看 slowlog 确认无新慢命令。
五、治理 Checklist(收藏点 4)
- 上线前:
redis-cli --bigkeys进巡检脚本,每周跑 - 应用侧:所有"可能无限增长"的集合都设上限(LTrim / MAXLEN / 定期归档)
- 删除侧:禁止 DEL 大 key,String 用 UNLINK、集合用渐进删
- 配置侧:
lazyfree-lazy-user-del yes——把 DEL 全局改成 UNLINK 语义,防手滑 - 监控侧:slowlog 采集器(第 21 篇)+ MEMORY USAGE 抽查,双保险
六、排查步骤:怀疑有 bigkey
- slowlog 找耗时异常的全量读命令(HGETALL/SMEMBERS),拿到 key;
MEMORY USAGE key确认大小,OBJECT ENCODING看编码;OBJECT REFCOUNT+ 从库DEBUG OBJECT(看 serializedlength)交叉验证;- 按第三节模板渐进删除,删完复核 slowlog。
七、总结
- bigkey 的罪不在"大",在"对它做的操作都是 O(N) 且单线程执行";
- 找:–bigkeys 采样 + MEMORY USAGE 精确;
- 删:UNLINK(String)/ SCAN 家族分批删(集合类),批间让出主线程;
- 防:集合设上限、DEL 改 UNLINK 语义、巡检常态化。
下一篇预告:bigkey 是"大"的问题,下一篇是"热"的问题——下一篇《一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现》,单槽热点的解法是二级缓存。