Redis 大 Key 阻塞排查:一个 200MB 的 Hash 拖垮了整个集群

引言

周三下午 2 点,运营群反馈"用户行为分析看板打不开了"。运维查 Redis 监控:CPU 正常、内存正常、连接数正常,但命令延迟 P99 从 2ms 飙到 800ms——看起来一切正常却慢得离谱。接下来 10 分钟,越来越多的接口开始超时,告警从 3 条涨到 47 条,最终整个 Redis 集群不可用。

排查发现:一个名叫 user:behavior:all 的 Hash Key,存了全量用户行为日志,200MB。更致命的是,运维在试图 DEL 这个 Key 时,Redis 主线程被阻塞了 3 秒——3 秒内所有命令排队等待,集群雪崩。

根因出奇简单:业务把用户行为日志全塞进一个 Hash,没设过期时间,日积月累从 2KB 长到 200MB。Redis 是单线程的——一个慢命令阻塞主线程,整个实例都卡住。这篇文章从发现到修复完整复盘,附大 Key 扫描脚本和预防机制。


一、为什么一个大 Key 能拖垮整个集群

1.1 Redis 单线程模型

Redis 主线程(单线程处理所有命令)
  │
  ├─ 命令1: GET user:1001        0.01ms
  ├─ 命令2: SET token:abc ...     0.02ms
  ├─ 命令3: DEL user:behavior:all  3000ms  ← 阻塞 3 秒!
  ├─ 命令4: GET order:2001        ← 等了 3 秒才开始执行
  ├─ 命令5: HGET user:2002 ...    ← 等了 3 秒
  └─ ... 后面所有命令都延迟 3 秒

Redis 的单线程模型意味着:所有命令在主线程串行执行。一个命令耗时 3 秒,后面所有命令都要等 3 秒——不只是这个 Key 的请求慢,整个实例的所有 Key 的请求都慢

1.2 大 Key 的危害链

危害机制表现
DEL 阻塞DEL 是同步操作,大 Key 删除时主线程阻塞删一个 200MB Hash 阻塞 3s
网络阻塞大 Value 传输占满网卡带宽HGETALL 返回 200MB,网络 100ms+
内存抖动大 Key 删除后内存碎片率飙升used_memory_rss 不降,碎片率 > 2.0
Cluster 迁移卡死集群扩缩容时 slot 迁移搬大 Keymigrate 命令超时,slot 迁移卡住
AOF 重写卡顿大 Key 写入 AOF 时 fsync 慢AOF 重写期间延迟毛刺
雪崩放大阻塞 → 客户端超时重试 → 更大并发 → 更堵超时从 800ms → 5s → 30s

1.3 什么样的 Key 算"大 Key"

数据类型大 Key 阈值说明
String> 10KB单个 Value 超过 10KB
Hash/List/Set/ZSet> 5MB 或 > 10000 个 field体积或元素数量任一超标
任何类型> 10MB绝对值警戒线
Stream> 10MB 或 > 10000 条消费者读取卡顿

200MB 的 Hash 属于极端大 Key——每个 field 存一条用户行为日志,约 50 万个 field,DEL 时主线程要遍历 50 万个 entry 逐个释放内存。


二、排查过程

2.1 第一步:确认是不是大 Key 引起的

# 方法1:redis-cli --bigkeys(采样扫描,对生产安全)
redis-cli -h <redis-host> -p <redis-port> -a <password> --bigkeys

# 输出示例:
# Sampling 100000 keys from keyspace...
# 
# Biggest string found 'user:behavior:all' has 209715200 bytes (200MB)
# Biggest hash   found 'user:behavior:all' has 524288 field
# 
# Warning: 'user:behavior:all' is unusually large for a hash

# 方法2:memory usage(精确查单个 Key 内存占用)
redis-cli -h <redis-host> -p <redis-port> -a <password>
> MEMORY USAGE user:behavior:all
(integer) 209715200    # 200MB

# 方法3:查元素数量
> HLEN user:behavior:all
(integer) 524288       # 52 万个 field

# 方法4:查类型和 TTL
> TYPE user:behavior:all
hash
> TTL user:behavior:all
(integer) -1           # 没设过期时间!永久存在

2.2 --bigkeys 原理与局限

--bigkeys 的工作原理:
  ① 从所有 Key 中随机采样 N 个(默认按 --sample-keys 配置)
  ② 对每个采样的 Key 执行 TYPE + SIZE 操作(STRLEN/LLEN/HLEN/SCARD/ZCARD)
  ③ 统计每种类型最大的 Key

局限:
  - 只采样,不全量扫描——如果大 Key 不在采样范围里就发现不了
  - 只看元素数量,不看内存体积——一个 field 存 1MB 的 Hash 只有 1 个 field 也不算"大"
  - 不报 TTL——不会告诉你这个大 Key 有没有过期时间

生产建议--bigkeys 做日常巡检(采样快速),全量扫描用脚本(见第五章)做定期深度检查。

2.3 第二步:确认 DEL 阻塞

# 用 INFO stats 的 total_system_time 观察主线程阻塞
> INFO stats
# ...
# latest_fork_usec:1234        # 最近一次 fork 耗时(微秒)
# ...
# 如果 latest_fork_usec 或延迟指标异常,说明主线程被阻塞过

# 更直接的方法:slowlog
> SLOWLOG GET 10
1) 1) (integer) 14              # 日志 ID
   2) (integer) 1694501234      # 时间戳
   3) (integer) 3120000         # 执行耗时(微秒)= 3.12 秒!
   4) 1) "DEL"                  # 命令
      2) "user:behavior:all"    # Key 名
   # ...
# slowlog 里有一条 DEL 耗时 3.12 秒——实锤了

2.4 第三步:定位根因

# 抽样看这个 Hash 里存了什么
> HSCAN user:behavior:all 0 COUNT 10
1) "0"
2) 1) "user:10001:20250901"
   2) "{\"action\":\"click\",\"page\":\"home\",\"ts\":1725148800}"
   3) "user:10001:20250902"
   4) "{\"action\":\"view\",\"page\":\"product\",\"ts\":1725235200}"
   ...

# 结论:用户行为日志全塞进一个 Hash
# Key = userId:日期,Value = JSON 行为记录
# 没设过期时间,日志天天累积,长到 200MB

2.5 事故时间线

时间事件影响
14:00用户行为 Hash 增长到 200MB内存正常(集群有 16GB)
14:05运维发现延迟告警,尝试 DEL 删除主线程阻塞 3s
14:05:03阻塞期间所有命令排队P99 从 2ms → 3s
14:06客户端超时重试,连接数翻倍雪崩开始
14:10超时从 3s → 30s47 条告警
14:15用 UNLINK 替代 DEL 异步删除主线程不再阻塞
14:18集群恢复P99 回到 2ms

三、修复方案

3.1 紧急止血:UNLINK 替代 DEL

# ❌ 危险:DEL 是同步删除,大 Key 阻塞主线程
DEL user:behavior:all

# ✅ 安全:UNLINK 是异步删除,主线程立即返回
UNLINK user:behavior:all
# 返回 (integer) 1,主线程几乎无阻塞
# 实际删除在后台线程(bio 后台异步删除线程)执行
删除方式机制阻塞主线程适用
DEL同步释放内存小 Key
UNLINK异步释放内存(Redis 4.0+)大 Key
SCAN + 逐个删除分批删除 field每批短暂阻塞超大 Key(GB 级)
lazyfree-lazy-expire过期删除走异步配置项全局生效

3.2 超大 Key 分批删除脚本

#!/bin/bash
# safe-delete-bigkey.sh
# 安全删除大 Hash Key:逐 field 删除,避免主线程阻塞
# 用法: ./safe-delete-bigkey.sh <redis-host> <port> <password> <key-name>

REDIS_HOST=$1
REDIS_PORT=$2
REDIS_PASS=$3
KEY_NAME=$4
BATCH_SIZE=100              # 每批删除 100 个 field
SLEEP_MS=50                  # 每批之间 sleep 50ms 给主线程喘息

echo "[INFO] 开始安全删除大 Key: $KEY_NAME"

while true; do
    # 用 HSCAN 取一批 field(不阻塞主线程)
    FIELDS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS \
             --no-auth-warning HSCAN $KEY_NAME 0 COUNT $BATCH_SIZE)
    
    # 提取 field 名(HSCAN 返回格式:cursor field1 value1 field2 value2 ...)
    CURSOR=$(echo "$FIELDS" | head -1)
    
    if [ "$CURSOR" == "0" ] && [ $(echo "$FIELDS" | wc -l) -le 1 ]; then
        echo "[INFO] Key 已空,执行最终 UNLINK"
        redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS --no-auth-warning UNLINK $KEY_NAME
        break
    fi
    
    # 逐个 HDEL 删除 field
    FIELD_LIST=$(echo "$FIELDS" | tail -n +2 | awk 'NR%2==1')
    for field in $FIELD_LIST; do
        redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS --no-auth-warning HDEL $KEY_NAME "$field" > /dev/null
    done
    
    echo "[INFO] 已删除 $BATCH_SIZE 个 field,cursor=$CURSOR"
    sleep 0.05    # 50ms
done

echo "[INFO] 删除完成"

3.3 根因修复:大 Key 拆分(Hash 按时间分片)

/**
 * 大 Key 拆分:用户行为日志按天分片
 * 
 * 拆分前:user:behavior:all(一个 200MB Hash,52 万 field)
 * 拆分后:user:behavior:20250901(每天一个 Hash,~2000 field,~800KB)
 *         user:behavior:20250902
 *         user:behavior:20250903
 *         ...
 */
@Service
@RequiredArgsConstructor
public class UserBehaviorService {

    private final StringRedisTemplate redis;

    private static final int EXPIRE_DAYS = 30;     // 每天数据保留 30 天

    /**
     * 记录用户行为:写入当天的分片 Hash
     */
    public void recordBehavior(Long userId, String date, BehaviorLog log) {
        String key = "user:behavior:" + date;       // user:behavior:20250912
        String field = userId + ":" + System.currentTimeMillis();
        
        redis.opsForHash().put(key, field, JsonUtils.toJson(log));
        
        // 设过期时间(只在 Key 第一次创建时设)
        redis.expire(key, Duration.ofDays(EXPIRE_DAYS));
    }

    /**
     * 查询某天用户行为
     */
    public List<BehaviorLog> queryByDate(String date) {
        String key = "user:behavior:" + date;
        List<Object> values = redis.opsForHash().values(key);
        return values.stream()
                .map(v -> JsonUtils.fromJson((String) v, BehaviorLog.class))
                .toList();
    }

    /**
     * 查询跨天范围(多 Key 聚合,每个 Key 都小)
     */
    public List<BehaviorLog> queryRange(String startDate, String endDate) {
        List<BehaviorLog> result = new ArrayList<>();
        LocalDate start = LocalDate.parse(startDate);
        LocalDate end = LocalDate.parse(endDate);
        
        for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
            String key = "user:behavior:" + date.toString().replace("-", "");
            // 每个 Key 只有几百 KB,HGETALL 不会阻塞
            Map<Object, Object> entries = redis.opsForHash().entries(key);
            entries.values().forEach(v -> 
                result.add(JsonUtils.fromJson((String) v, BehaviorLog.class))
            );
        }
        return result;
    }
}

3.4 拆分前后对比

维度拆分前拆分后
Key 数量1 个30 个(按天分片)
单 Key 体积200MB~800KB
单 Key field 数52 万~2000
HGETALL 延迟800ms(网络+序列化)< 5ms
DEL 阻塞3s< 1ms
过期清理需手动TTL 自动过期
Cluster 迁移卡死无影响

四、预防机制

4.1 全局配置:异步删除

# redis.conf
lazyfree-lazy-eviction yes       # 内存淘汰时异步删除
lazyfree-lazy-expire yes         # Key 过期时异步删除
lazyfree-lazy-server-del yes     # 服务端内部删除(如 RENAME 覆盖旧 Key)异步
lazyfree-lazy-user-del yes       # 用户执行 DEL 时也走异步(Redis 7.0+)
replica-lazy-flush yes           # 主从同步时全量同步异步删除

这五条配置是预防大 Key 阻塞的第一道防线——即使出现大 Key,删除/过期/淘汰都不会阻塞主线程。

4.2 客户端规范:代码层防大 Key

规范说明
单 Value 限制String 类型 ≤ 10KB,超过用 Hash 拆分
单 Hash field 限制≤ 10000 个 field,超过按时间/用户分片
必设 TTL所有 Key 必须设过期时间,禁止无 TTL 的 Key
禁止大结果集HGETALL/LRANGE 0 -1/SMEMBERS 禁用在大集合上,用 HSCAN/LRANGE 分页
写入前检查SET/HSET 前检查 Value 大小,超限拒绝写入

4.3 监控告警

# 告警规则:单 Key 内存超过阈值
# 每日扫描所有 Key,发现 > 10MB 的 Key 告警

# 方式1:MEMORY USAGE 逐个检查(精确但慢)
redis-cli -h <host> -p <port> --scan --pattern '*' | while read key; do
    size=$(redis-cli -h <host> -p <port> MEMORY USAGE "$key" 2>/dev/null)
    if [ "$size" -gt 10485760 ]; then    # > 10MB
        echo "[WARN] Big key: $key = $size bytes"
    fi
done
# Prometheus + Grafana 告警
# redis_exporter 指标
- alert: RedisBigKeyDetected
  expr: redis_key_size_bytes > 10485760    # 单 Key > 10MB
  for: 5m
  annotations:
    summary: "Redis 大 Key 检测: {{ $labels.key }} = {{ $value }} bytes"
    action: "检查该 Key 是否需要拆分或设置 TTL"

- alert: RedisSlowCommand
  expr: redis_slowlog_last_id > redis_slowlog_last_id_offset  # slowlog 有新条目
  for: 1m
  annotations:
    summary: "Redis slowlog 新增慢命令"

五、大 Key 扫描脚本

5.1 全量扫描脚本

#!/bin/bash
# scan-bigkeys.sh
# 全量扫描 Redis 大 Key(不遗漏,不阻塞)
# 用法: ./scan-bigkeys.sh <redis-host> <port> <password> [size-threshold-bytes]

REDIS_HOST=${1:-127.0.0.1}
REDIS_PORT=${2:-6379}
REDIS_PASS=${3:-}
THRESHOLD=${4:-10485760}     # 默认 10MB
BATCH=1000                   # SCAN 每批数量

CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS --no-auth-warning --raw"

echo "=========================================="
echo " Redis 大 Key 全量扫描"
echo " 阈值: $(numfmt --to=iec $THRESHOLD)"
echo " 开始: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="

printf "%-60s %-10s %-10s %-10s\n" "KEY" "TYPE" "SIZE" "TTL"
printf "%-60s %-10s %-10s %-10s\n" "---" "---" "---" "---"

CURSOR=0
while true; do
    # SCAN 不阻塞主线程
    RESULT=$($CLI SCAN $CURSOR COUNT $BATCH)
    CURSOR=$(echo "$RESULT" | head -1)
    KEYS=$(echo "$RESULT" | tail -n +2)
    
    for key in $KEYS; do
        [ -z "$key" ] && continue
        
        TYPE=$($CLI TYPE "$key")
        
        # 根据类型查大小
        case "$TYPE" in
            string) SIZE=$($CLI STRLEN "$key") ;;
            hash)   SIZE=$($CLI HLEN "$key") ;;
            list)   SIZE=$($CLI LLEN "$key") ;;
            set)    SIZE=$($CLI SCARD "$key") ;;
            zset)   SIZE=$($CLI ZCARD "$key") ;;
            stream) SIZE=$($CLI XLEN "$key") ;;
            none)   SIZE=0 ;;
            *)      SIZE=0 ;;
        esac
        
        # 查内存占用(比元素数更精确)
        MEM_BYTES=$($CLI MEMORY USAGE "$key" 2>/dev/null)
        [ -z "$MEM_BYTES" ] && MEM_BYTES=0
        
        # 查 TTL
        TTL=$($CLI TTL "$key")
        
        # 超过阈值才打印
        if [ "$MEM_BYTES" -gt "$THRESHOLD" ]; then
            MEM_HUMAN=$(numfmt --to=iec "$MEM_BYTES")
            printf "%-60s %-10s %-10s %-10s\n" "$key" "$TYPE" "$MEM_HUMAN" "${TTL}s"
        fi
    done
    
    # SCAN 到 0 表示扫描完成
    [ "$CURSOR" == "0" ] && break
done

echo "=========================================="
echo " 扫描完成: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="

5.2 扫描输出示例

==========================================
 Redis 大 Key 全量扫描
 阈值: 10MiB
 开始: 2025-09-12 14:30:00
==========================================
KEY                                                          TYPE       SIZE       TTL
---                                                          ---        ---        ---
user:behavior:all                                            hash       200MiB     -1s
order:detail:cache:202509                                   hash       52MiB      -1s
session:active:users                                         set        18MiB      3600s
log:access:20250912                                         list       12MiB      -1s
==========================================
 扫描完成: 2025-09-12 14:32:15
==========================================

# 4 个大 Key,其中 2 个 TTL=-1(无过期),最高优先级处理

5.3 定时扫描 Cron

# crontab -e
# 每天凌晨 3 点扫描大 Key,结果发钉钉告警
0 3 * * * /opt/scripts/scan-bigkeys.sh redis-prod 6379 "$REDIS_PASS" 10485760 | \
    grep -E "^user|^order|^log" | \
    while read line; do
        curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=$DING_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[Redis大Key告警] $line\"}}"
    done

六、常见问题

6.1 UNLINK 和 DEL 有什么区别?

DEL 是同步删除——主线程在删除完成前不处理其他命令。大 Key 的内存释放涉及遍历所有元素、逐个释放,200MB 的 Hash 可能阻塞 3 秒。UNLINK(Redis 4.0+)是异步删除——主线程只做"从键空间摘除 Key"这一步(O(1)),实际的内存释放在后台线程(bio 后台线程)异步执行。主线程几乎无阻塞。但 UNLINK 不是万能的——后台线程释放内存也要时间,如果同时有多个大 Key UNLINK,后台线程也会拥塞。

6.2 lazyfree 配置开了是不是就不用担心大 Key 了?

不是。lazyfree 解决的是"删除时不阻塞主线程",但大 Key 的其他危害依然存在:网络传输阻塞(HGETALL 返回 200MB)、Cluster 迁移卡死、AOF 重写卡顿、内存碎片化。lazyfree 是安全网不是免死金牌——大 Key 本身就是设计缺陷,应该从源头拆分,而不是靠 lazyfree 兜底。

6.3 大 Key 是怎么产生的?

四种典型来源:① 日志累积型——用户行为/访问日志全塞一个 Key,没设 TTL(本文案例);② 缓存堆积型——把 DB 查询结果全量缓存,不分页不裁剪;③ 配置型——把省市区树形数据全塞一个 Hash,全国几千条不拆分;④ 数据结构误用——用 List 存活跃用户列表,用户增长到百万级不换数据结构。预防的核心是代码 review 时检查"这个 Key 会增长吗?有上限吗?有 TTL 吗?"

6.4 Cluster 模式下大 Key 迁移怎么办?

Cluster 的 slot 迁移用 MIGRATE 命令搬运 Key——大 Key 迁移时 MIGRATE 会阻塞源节点和目标节点(同步传输整个 Value)。200MB 的 Key 迁移可能阻塞 5~10 秒。解法:① 迁移前先拆分大 Key(按 field 分到多个小 Key);② Redis 7.0+ 的 MIGRATE 支持 COPY + REPLACE 异步模式(部分场景缓解);③ 扩缩容时避开高峰期。生产纪律:扩容前先跑大 Key 扫描,大 Key 不迁移只拆分

6.5 HSCAN 会阻塞主线程吗?

不会。HSCAN(以及 SCAN/SSCAN/ZSCAN)是增量式遍历——每次只取 cursor 指定位置的一批元素,O(1)~O(N) 取决于 COUNT 参数。但要注意:① HSCAN 的 COUNT 只是建议值不是硬限——可能返回比 COUNT 多的元素;② 如果 Hash 在 SCAN 过程中被修改(field 增删),可能出现重复或遗漏(弱一致性)。HSCAN 是扫描大 Hash 的安全方式,但不能保证遍历完整性

6.6 如何在代码层防止写入大 Key?

在 SET/HSET 前做大小检查——用 AOP 或 RedisTemplate 拦截器,对写入的 Value 序列化后检查字节数,超限抛异常拒绝写入。更有效的做法是在设计阶段就拆分:用户行为按天分 Key,订单缓存按订单号分 Key,省市区树按层级分 Key。设计阶段的拆分比运行时的拦截更治本——运行时拦截只能兜底,不能解决已有的大 Key。


七、总结

大 Key 治理速查卡

┌────────────┬──────────────────────────────────────────┐
│ 阶段        │ 关键动作                                  │
├────────────┼──────────────────────────────────────────┤
│ 发现        │ --bigkeys 日常巡检(采样)                │
│            │ scan-bigkeys.sh 定期全量扫描              │
│            │ MEMORY USAGE 精确查体积                  │
│            │ SLOWLOG 确认是否阻塞过主线程              │
├────────────┼──────────────────────────────────────────┤
│ 止血        │ UNLINK 替代 DEL(异步删除)               │
│            │ 超大 Key 用 HSCAN+HDEL 分批删             │
│            │ 开 lazyfree-* 全局异步删除                │
├────────────┼──────────────────────────────────────────┤
│ 修复        │ 大 Key 拆分(按时间/用户/ID 分片)        │
│            │ 所有 Key 必设 TTL                         │
│            │ 禁止大结果集操作(HGETALL 0 -1)           │
├────────────┼──────────────────────────────────────────┤
│ 预防        │ 代码规范:单 Value ≤10KB / Hash ≤10000 field│
│            │ 定时扫描 Cron + 钉钉告警                 │
│            │ 扩容前先查大 Key                          │
└────────────┴──────────────────────────────────────────┘

关键数据

指标事故时修复后
大 Key 体积200MB< 1MB
DEL 阻塞3s< 1ms(UNLINK)
P99 延迟800ms → 30s2ms
无 TTL Key全部设 TTL
告警数47 条0

一句话

Redis 是单线程——一个 200MB 的 Hash 用 DEL 删除阻塞主线程 3 秒,3 秒内所有命令排队,集群雪崩。根因不是 Redis 性能不行,是业务把用户行为日志全塞进一个 Hash 没设过期时间。修复的紧急动作是 UNLINK 替代 DEL(异步删除不阻塞主线程),根治方案是按时间分片拆分大 Key + 全局必设 TTL。但大 Key 的本质是设计缺陷——单 Value > 10KB / Hash > 10000 field 就是设计有问题,不是运行时该靠 lazyfree 兜底的。治理三步走:--bigkeys 巡检发现、UNLINK+分批删除止血、按维度拆分+TTL 根治,加定时扫描 Cron + 钉钉告警做最后一道防线。

给团队的建议

建议
紧急大 Key 用 UNLINK 删除,禁用 DEL 删大 Key
配置开启 lazyfree-* 五项全局异步删除
拆分日志型数据按时间分 Key,缓存型数据按 ID 分 Key
TTL所有 Key 必设过期时间,Code Review 检查
扫描每日 Cron 全量扫描 > 10MB 的 Key + 钉钉告警
扩容扩容前先跑大 Key 扫描,大 Key 先拆分再迁移
规范禁止 HGETALL/LRANGE 0 -1/SMEMBERS 在大集合上

互动话题:你们 Redis 里有没有定时扫描大 Key?有没有踩过 DEL 大 Key 导致阻塞的坑?评论区聊聊。


参考资料


标题:Redis 大 Key 阻塞排查:一个 200MB 的 Hash 拖垮了整个集群
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/17/1789223612536.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消