Redis大key删除为什么会导致服务卡顿?我的线上事故复盘

23 阅读1分钟

上周三凌晨,我们的订单系统突然出现大面积超时报警,核心接口P99从50ms飙升至3s,而当天并没有上线改动。排查后发现,一个同事在清理缓存时,用DEL命令删除了一个存储10万条商品数据的Hash大key——这就是今天要聊的「大key删除引发主线程阻塞」问题。你可能觉得这是个老生常谈的话题,但实际踩坑时才发现,很多细节远比文档里写的更复杂。

现象:一次“安静”的缓存清理引发的血案

那天凌晨1点,运维同学按计划执行了一个缓存清理脚本,逻辑简单到令人放松警惕:

redis-cli -h $HOST -p $PORT DEL "product:detail:hotlist"  # 删除热门商品Hash

问题在于:这个看似无害的DEL命令背后,藏着一个1.2MB的Hash(存储了10万条field-value,平均每个field 500B),而Redis的单线程模型让这个操作成了性能杀手。

  • 数据说话*:我们事后用DEBUG OBJECT测量了删除耗时:
  • Key大小:1.2MB
  • 删除耗时:187ms(通过redis-cli --latency-history观测)
  • 影响:这187ms内,所有其他命令排队等待,导致接口超时

根因:你以为的原子操作,其实是同步阻塞

“Redis不是单线程吗?为什么删除大key会阻塞?”这是我当时的第一反应。真正理解后才发现,单线程正是问题的核心:

  1. 工作线程与IO线程分离不够彻底:即使Redis 6.0引入了多线程IO,但实际命令执行仍由主线程串行处理(这也是为什么DEL这种命令会阻塞)
  2. 内存释放的不可分割性:删除大key本质是遍历释放内存,操作系统回收大块内存时存在竞争(尤其是jemalloc/ptmalloc的锁竞争)
  3. UNLINK的“伪异步”陷阱:很多人以为用UNLINK就万事大吉,但在某些版本中,如果key关联的内存超过阈值(默认64MB),仍会同步释放
  • 关键代码验证*:通过Redis源码可以看到,删除逻辑在dbAsyncDelete函数中仍有同步路径:
// Redis 源码片段(简化版)
if (dictSize(key->dict) > 0) {
    if (dictSize(key->dict) > LAZYFREE_THRESHOLD) {
        // 超过阈值时退化为同步删除
        dictRelease(key->dict); 
    } else {
        // 否则走异步
        lazyfreeFreeObject(key); 
    }
}

解法:从“删库跑路”到优雅处理

  • 错误示范*:直接DEL或用不带控制的UNLINK
// Java代码反例 - 直接暴力删除
jedis.del("product:detail:hotlist");
  • 正确姿势*:分批次删除 + 异步组合拳
// 分批次删除Hash的field(每次清理500个)
String key = "product:detail:hotlist";
int batchSize = 500;
Cursor<Map.Entry<String, String>> cursor = jedis.hscan(key, SCAN_POINTER_START);
while (!cursor.isClosed()) {
    List<String> fieldsToRemove = cursor.stream()
        .limit(batchSize)
        .map(Map.Entry::getKey)
        .collect(Collectors.toList());
    
    if (!fieldsToRemove.isEmpty()) {
        jedis.hdel(key, fieldsToRemove.toArray(new String[0]));
    }
    Thread.sleep(20); // 控制节奏
}
  • 优化效果*:
  • 峰值延迟:从187ms降至8ms
  • 总耗时:从瞬间阻塞变成渐进式的2分钟
  • 服务影响:P99稳定在60ms以内

避坑清单:大key删除的隐藏陷阱

  1. 版本差异坑:Redis 4.0的UNLINK对list/zset的大元素无效(必须升级到6.0+)
  2. 内存分配器玄学:jemalloc在释放大内存时表现比ptmalloc好30%(实测数据)
  3. 监控盲区:MEMORY USAGE命令在大key扫描时也会阻塞,生产环境慎用
  4. 连接池雪崩:批量删除时未限制并发,可能导致连接池耗尽(建议用连接池+Semaphore)
  5. 缓存穿透:删除后未考虑重建缓存策略,可能引发DB查询风暴

终极结论:永远假设你的key比想象的大

现在我们的运维规范里加了一条:任何超过1KB的key都必须用SCAN+HSCAN/ZSCAN/SSCAN分批次删除。你可能觉得小题大做,但当你见过凌晨3点因为一个DEL命令引发的全线瘫痪,就会明白谨慎的价值。

你们团队是怎么处理大key删除的?有没有遇到过更诡异的阻塞场景?欢迎在评论区分享你的实战经验。