Redis 大 key 让我们整个集群卡了 10 分钟:从排查到拆分的完整复盘

一个 800MB 的 Redis Hash 让我们演习时整个集群卡了 10 分钟。本文完整复盘:DEBUG OBJECT 看大小、HSCAN 拆分大 Hash、UNLINK 异步删除、写一个每天扫的巡检脚本。附 4 行救命排查命令。

一个 800MB 的 Redis Hash 让我们整个集群在双十一前一周的演习里卡了 10 分钟,核心交易接口 P99 从 50ms 飙到 11 秒,告警群里 200+ 条告警刷屏。这不是教科书里的「不要存大 key」就能避免的——那个大 key 是我们 3 年前埋下的,平时一直没事,流量一上来才显形。

这篇文章把整个排查 + 修复过程写下来:怎么发现是它、怎么不停服迁移走、修完之后我们在所有项目里强制加的检测脚本。看完你能把这种「定时炸弹」从你的 Redis 里挖出来。

故障现场

压测开始 8 分钟后,Redis 监控显示一个奇怪的现象:

  • OPS 没涨多少(从 8w 涨到 12w,远没到性能瓶颈)
  • CPU 单核 100%,其他核闲着
  • 慢查询日志 (slowlog) 里 HGETALL / HKEYS 占了 90%
  • 所有命令 RT 都被拖累,普通 GET 都要 200ms+

第一反应是 Redis 是单线程模型,某条慢命令把整个线程占着。直接登上去看:

# 看正在执行的命令
redis-cli -h r-xxx.cache.aliyuncs.com -p 6379
> CLIENT LIST
id=1234 addr=10.1.2.3:43210 fd=8 name= age=180 idle=0 flags=N db=0 ... cmd=hgetall
id=1235 addr=10.1.2.4:43222 fd=9 name= age=178 idle=0 flags=N db=0 ... cmd=hgetall
id=1236 addr=10.1.2.5:43234 fd=10 name= age=175 idle=0 flags=N db=0 ... cmd=hgetall
# ... 几十个 HGETALL 在排队

# 看慢日志
> SLOWLOG GET 10
1) (integer) 4892
   (integer) 1731312456    # 时间戳
   (integer) 8721000       # 执行 8.7 秒
   1) "HGETALL"
   2) "user_followers:10086"
2) (integer) 4891
   (integer) 1731312445
   (integer) 8654000
   1) "HGETALL"
   2) "user_followers:10086"
# ... 同一个 key,反复被慢查

问题 key 浮出水面:user_followers:10086。这个 10086 是个内部账号,我们 3 年前的「关注/粉丝」功能用 Hash 存它的粉丝列表 —— 每个粉丝一个 field。当年这个账号 5000 粉丝,完全没问题。三年过去……

# 查这个 key 的实际大小
> DEBUG OBJECT user_followers:10086
Value at:0x7f8a... refcount:1 encoding:hashtable serializedlength:807512384 lru:5421...

# serializedlength 是序列化后字节数,这个 key 有 800MB
# encoding 是 hashtable(不是 ziplist)说明 field 数量超过 hash-max-ziplist-entries

# 看 field 数量
> HLEN user_followers:10086
(integer) 4827392
# 480 万粉丝

480 万个 field 的 Hash,任何一个 HGETALL 都要遍历 480 万次。Redis 单线程被它锁死,所有其他命令都得排队。

原理:Redis 单线程模型的隐式假设

大 key 危害的根源是 Redis 的两个特性:

  1. 核心命令处理是单线程:从 socket 读 → 解析 → 执行 → 写回 socket,整个流程一个线程串行做。
  2. 很多命令的复杂度是 O(N):HGETALL / SMEMBERS / LRANGE / KEYS / HKEYS / HVALS,N 就是元素数量。

这俩组合起来:只要有一个 O(N) 命令在大 key 上跑,整个 Redis 实例的所有客户端都会被堵住。即使你访问的是另一个完全不相关的 key,也要排队等着。

用 mermaid 画清楚阻塞过程:

第一招:别用 KEYS,用 SCAN 找出所有大 key

线上要找出所有大 key,不能用 KEYS *(它本身就是 O(N) 全量遍历)。正确做法是 SCAN + 抽样:

# 错误:KEYS * 会把整个实例锁住几分钟
redis-cli KEYS '*'   # 千万别在线上用

# 正确:SCAN 游标遍历,每次只取 1000 个 key
redis-cli --scan --pattern '*' --count 1000

# 更好:用 redis-cli 内置的 bigkey 扫描(只读 + 抽样)
redis-cli -h xxx -p 6379 --bigkeys
# 输出:
# [00.00%] Biggest hash   found so far 'user_followers:10086' with 4827392 fields
# [00.13%] Biggest string found so far 'session:abc123' with 8421 bytes
# ...
# Summary
# -------- summary -------
# Sampled 1234567 keys in the keyspace!
# Total key length in bytes is ...
# Biggest hash found 'user_followers:10086' has 4827392 fields
# Biggest list found 'queue:retry' has 12389 items
# Biggest set found 'tag:hot' has 234567 members

# memkeys 工具更准(基于 RDB 文件离线分析,不影响线上)
rdb -c memory dump.rdb --bytes 1024 -f memory.csv
# 输出每个 key 的实际内存占用,排序后看 top 100

跑完发现我们集群里有 47 个 1MB 以上的 Hash / Set,其中 6 个超过 100MB,1 个就是这个 800MB 的怪物。

第二招:在线拆分大 Hash,不停服

不能直接 DEL user_followers:10086 —— 800MB 的 key 删除本身就要几秒钟,会阻塞所有客户端。正确做法:

  1. 双写:新粉丝同时写老 key 和按 user_id 分片的新 key
  2. 后台搬:用 HSCAN 把老 key 数据逐批迁到分片 key
  3. 切流量:读路径切到新 key
  4. 渐进删除:用 UNLINK(异步删除)而不是 DEL

分片策略很简单 —— 按粉丝 user_id 模 1024 分桶:

import redis
from typing import Iterator

r = redis.Redis(host='r-xxx', port=6379, decode_responses=True)

BIG_KEY = 'user_followers:10086'
SHARD_PREFIX = 'user_followers:10086:shard'
SHARD_COUNT = 1024  # 拆 1024 个小 hash,每个约 4700 个 field

def shard_key(follower_uid: int) -> str:
    return f'{SHARD_PREFIX}:{follower_uid % SHARD_COUNT}'

# === 阶段 1: 后台搬迁(独立脚本,慢慢跑)===
def migrate(batch_size: int = 500):
    cursor = 0
    migrated = 0
    while True:
        cursor, batch = r.hscan(BIG_KEY, cursor=cursor, count=batch_size)
        if batch:
            pipe = r.pipeline(transaction=False)
            for follower_uid, value in batch.items():
                pipe.hset(shard_key(int(follower_uid)), follower_uid, value)
            pipe.execute()
            migrated += len(batch)
            print(f'migrated {migrated} fields, cursor={cursor}')
        if cursor == 0:
            break
    print(f'done, total {migrated}')

if __name__ == '__main__':
    migrate()

关键参数 count=500:每批扫 500 个 field,这样每次 HSCAN 操作只占用 Redis 主线程几毫秒,完全感知不到。整个 480 万搬完大约 25 分钟,中间业务无感。

双写代码也要短平快:

def add_follower(blogger_uid: int, follower_uid: int):
    """新关注:同时写两份 - 老 key + 新 shard key"""
    pipe = r.pipeline(transaction=False)
    if blogger_uid == 10086:  # 只对大 V 做双写
        pipe.hset(f'user_followers:{blogger_uid}', follower_uid, '1')
        pipe.hset(f'user_followers:{blogger_uid}:shard:{follower_uid % SHARD_COUNT}',
                  follower_uid, '1')
    else:
        pipe.hset(f'user_followers:{blogger_uid}', follower_uid, '1')
    pipe.execute()

def is_follower(blogger_uid: int, follower_uid: int) -> bool:
    """读:大 V 走分片 key,其他走老 key"""
    if blogger_uid == 10086:
        return r.hexists(f'user_followers:{blogger_uid}:shard:{follower_uid % SHARD_COUNT}',
                          follower_uid)
    return r.hexists(f'user_followers:{blogger_uid}', follower_uid)

这个方案的好处:写多一份(性能略降但能扛),读直接定位到分片(O(1) HEXISTS),完全避开 HGETALL。

第三招:最后的 UNLINK 不是 DEL

所有读路径切到分片 key 后,老 key 可以删了。这里有个细节:

# DEL 是同步删除,800MB 的 key 删除时主线程会阻塞 2-5 秒
DEL user_followers:10086

# UNLINK 是异步删除(Redis 4.0+),主线程只解除引用,真正回收内存在后台
UNLINK user_followers:10086

# OBJECT FREQ 看一下 key 是不是真的没人访问了再删
OBJECT FREQ user_followers:10086    # 需要 maxmemory-policy 设为 allkeys-lfu

# 配合一段 redis-cli 脚本批量 UNLINK 多个旧 key
redis-cli --scan --pattern 'user_followers:*' --count 1000 | \
  awk '{print "UNLINK "$1}' | \
  redis-cli --pipe

UNLINK 是大 key 治理的必备命令。我们后来发现项目里很多代码还在用 DEL,做了一次全局替换。

为什么 ziplist 编码会突然变成 hashtable

Hash / List / Set / ZSet 在小数据量时用紧凑的 ziplist 编码(内存占用小、缓存友好);超过阈值后转成 hashtable / linkedlist / skiplist。阈值由这几个参数控制:

# redis.conf
hash-max-ziplist-entries 128       # Hash field 数 ≤ 128 用 ziplist
hash-max-ziplist-value 64          # field 值长度 ≤ 64 字节用 ziplist
list-max-ziplist-size -2           # List 单个 ziplist 节点 ≤ 8KB
set-max-intset-entries 512         # 整数 Set 用 intset(更小)
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

有个反直觉的细节:一旦从 ziplist 升级到 hashtable,即使 field 数量后来再降到 128 以下,也不会自动降回 ziplist。所以一旦因为某次异常写入「冒头」了,就回不去了。

第四招:写一个 bigkey 周期巡检脚本

事故后我们写了一个每天扫一次的脚本,发现大 key 自动告警:

#!/usr/bin/env python3
"""每天扫一次 Redis,发现 >5MB 的 key 告警到企业微信"""
import redis
import requests
import os
from datetime import datetime

THRESHOLD_BYTES = 5 * 1024 * 1024     # 5MB
THRESHOLD_FIELDS = 10000              # 10k 个 field

r = redis.Redis(host=os.environ['REDIS_HOST'], port=6379)

def scan_bigkeys():
    alerts = []
    cursor = 0
    while True:
        cursor, keys = r.scan(cursor=cursor, count=1000)
        for key in keys:
            t = r.type(key)
            size_bytes = 0
            size_fields = 0
            if t == b'string':
                size_bytes = r.strlen(key)
            elif t == b'hash':
                size_fields = r.hlen(key)
            elif t == b'list':
                size_fields = r.llen(key)
            elif t in (b'set', b'zset'):
                size_fields = r.scard(key) if t == b'set' else r.zcard(key)
            if size_bytes > THRESHOLD_BYTES or size_fields > THRESHOLD_FIELDS:
                alerts.append({
                    'key': key.decode(),
                    'type': t.decode(),
                    'bytes': size_bytes,
                    'fields': size_fields,
                })
        if cursor == 0:
            break
    return alerts

def notify(alerts):
    if not alerts:
        return
    text = f'⚠️ Redis 大 key 巡检 {datetime.now():%Y-%m-%d}\n'
    for a in sorted(alerts, key=lambda x: x.get('bytes', 0) + x.get('fields', 0) * 100,
                    reverse=True)[:20]:
        text += f'- {a["key"]} ({a["type"]}): {a["bytes"]:,}B / {a["fields"]:,}fields\n'
    requests.post(os.environ['WEBHOOK_URL'], json={'msgtype': 'text', 'text': {'content': text}})

if __name__ == '__main__':
    alerts = scan_bigkeys()
    notify(alerts)

放到 K8s CronJob 里凌晨 3 点跑,4 个月后零生产事故。

第五招:Pipeline / Lua / 二级缓存改造

从这次事故里我们还学到几条防御性规则:

  1. 读大集合用 SCAN 系列,不用 GETALL 系列:HSCAN / SSCAN / ZSCAN 都是游标式,不会阻塞主线程
  2. 批量操作用 Pipeline:把多个命令一次发出去,网络往返从 N 次变 1 次
  3. 复杂事务用 Lua:Redis 保证 Lua 脚本原子执行,但要控制脚本本身的复杂度
  4. 热数据再加一层本地缓存:像粉丝判断这种,本地用 Caffeine 缓存 5 秒,大幅降低 Redis 压力
// Java 端用 Caffeine 做本地缓存,Redis 兜底
LoadingCache<String, Boolean> followerCache = Caffeine.newBuilder()
    .maximumSize(100_000)
    .expireAfterWrite(Duration.ofSeconds(5))   // 接受 5 秒延迟
    .build(key -> {
        String[] parts = key.split(":");
        long bloggerUid = Long.parseLong(parts[0]);
        long followerUid = Long.parseLong(parts[1]);
        return redisTemplate.opsForHash().hasKey(
            "user_followers:" + bloggerUid + ":shard:" + (followerUid % 1024),
            String.valueOf(followerUid));
    });

public boolean isFollower(long bloggerUid, long followerUid) {
    return followerCache.get(bloggerUid + ":" + followerUid);
}

事故复盘的 6 条结论

  1. Redis 单线程模型决定了:任何一个 O(N) 命令都是核弹。N 在写入时 1000,3 年后可能 1000 万,代码不变但风险已经今非昔比。
  2. "够用就好"是技术债的根源。上线时 5000 粉丝写 Hash 完全够用,但没人想过 3 年后的事情。
  3. 大 key 治理三件套:DEBUG OBJECT 看大小,HSCAN/SSCAN 流式遍历,UNLINK 异步删除
  4. 分片是大 key 治理的最稳方案。1024 个小 hash 比 1 个超大 hash 不止快 1024 倍 —— 因为单线程串行模型。
  5. 巡检比应急重要。每天扫一次,4 个月没事故 ≫ 一次事故扛半夜。
  6. 本地缓存能挽救你大半。Caffeine + Redis 的二级架构在我们厂里挽救了不止 Redis 一个组件。

当晚演习被记一次黄牌。但从结果看,这次「演习时炸"远比"大促真挂"代价小得多。再次感谢压测同学。

附:今晚就能跑的 4 行 bigkey 排查命令

# 1. 在线扫一遍大 key(只读,但会有 IO)
redis-cli -h $HOST -p $PORT --bigkeys

# 2. 单个 key 看实际占用
redis-cli -h $HOST -p $PORT DEBUG OBJECT <key>

# 3. 看最近 10 条慢日志
redis-cli -h $HOST -p $PORT SLOWLOG GET 10

# 4. 看当前连接执行的命令
redis-cli -h $HOST -p $PORT CLIENT LIST | awk -F'cmd=' '{print $2}' | sort | uniq -c | sort -rn

把这 4 行存进收藏。下次再被半夜叫起来,5 分钟之内你能知道是不是大 key 的事。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RR 隔离级别 + INSERT ON DUPLICATE KEY UPDATE:让我半夜爬起来的死锁实录

2026-5-19 10:12:31

技术教程

凌晨被叫起来排查 TIME_WAIT 堆 5 万的故事:从端口耗尽到连接池治理

2026-5-19 10:19:00

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