Redis 大 Key 导致延迟飙升的排查与治理

Redis 大 Key 导致延迟飙升的排查与治理

线上 Redis 偶发性抖动,每次持续几秒到十几秒,业务侧表现为接口 P99 延迟突然飙高。排查下来,根因是一个 Hash 结构的大 Key——单个 Key 存了 200 多万个 field,每次 HSCAN 都把 Redis 主线程卡住。这篇文章记录完整的排查和治理过程。

一、问题现象

监控系统告警 Redis 延迟:

  • 偶发性 P99 延迟 500ms+(正常 < 5ms)
  • 持续 5-15 秒,之后恢复正常
  • 每天发生 3-5 次,时间不固定
  • 受影响的是同一个 Redis 实例上的所有 Key,不只是某个业务

二、先确认 Redis 自身状态

先排除 Redis 本身的问题。连上 Redis 执行:

redis-cli -h 10.0.1.50 -p 6379 INFO memory

used_memory_human: 12.8Gpeak: 14.2G,没到 maxmemory 限制。

redis-cli -h 10.0.1.50 -p 6379 INFO stats

keyspace_hitskeyspace_misses 都正常。但是:

redis-cli -h 10.0.1.50 -p 6379 INFO commandstats

发现 cmdstat_hscanusec_per_call 高达 85000(85ms),其他命令都在微秒级。问题锁定在 HSCAN 操作。

三、找到大 Key

redis-cli -h 10.0.1.50 -p 6379 --bigkeys

输出:

[00.00%] Biggest hash found so far 'user:behavior:20240115' with 2187432 fields

一个 Hash Key 里存了 218 万个 field。每个 field 是用户行为记录,结构类似:

HSET user:behavior:20240115 uid:12345 {"action":"click","ts":1700000000,"page":"/home"}

问题是这个 Hash 没有过期时间,而且每天都在往里追加新数据。

四、为什么大 Key 危险

Redis 是单线程模型。当执行 HSCAN user:behavior:20240115 时:

  • Redis 主线程被阻塞直到 scan 完成
  • 这期间其他所有命令(GET、SET、LPUSH 等)都在排队
  • 所以表现为同一个实例上所有 Key 的延迟都飙高

更隐蔽的问题是内存碎片化。Redis 4.0+ 虽然有 active defrag,但大 Key 的频繁增删会导致 jemalloc 的 extent 碎片严重,实际内存占用比看到的 used_memory 高 30-50%。

五、治理方案

方案一:拆分 Hash(推荐)

把一个大 Hash 拆成多个小 Hash,按时间或 ID 范围分片:

def get_shard_key(date: str, user_id: int) -> str:
    shard = user_id % 100
    return f"user:behavior:{date}:shard:{shard:03d}"

# 写入
redis.hset(get_shard_key("20240115", 12345), f"uid:{12345}", data)
redis.expire(get_shard_key("20240115", 12345), 86400 * 7)

# 读取
data = redis.hget(get_shard_key("20240115", 12345), f"uid:{12345}")

每个分片最多 2 万个 field,HSCAN 耗时从 85ms 降到 < 1ms。

方案二:改用 Stream

如果数据是时间序列,Redis Stream 天然适合:

XADD user:behavior:12345 * action click ts 1700000000 page /home
XLEN user:behavior:12345

Stream 支持自动裁剪:

XADD user:behavior:12345 MAXLEN ~ 10000 * action click ts 1700000000

方案三:迁移到专业存储

如果数据量太大(几十 G 级别),Redis 就不是合适的选择了。可以把历史数据迁到 ClickHouse 或 TiKV:

# 写入时双写:Redis(热数据)+ ClickHouse(全量)
async def record_behavior(user_id, data):
    # 热数据进 Redis
    await redis.hset(f"user:behavior:hot:{user_id}", data["action"], json.dumps(data))
    await redis.expire(f"user:behavior:hot:{user_id}", 3600)

    # 全量进 ClickHouse
    await clickhouse.insert("user_behavior", data)

六、存量清理

线上已经有这个大 Key,不能直接 DEL(会阻塞 Redis 数秒)。用 UNLINK 异步删除:

# 先创建新的分片结构
# 迁移数据(业务低峰期执行)
redis-cli UNLINK user:behavior:20240115

UNLINK 在 Redis 4.0+ 可用,它在后台线程中释放内存,不阻塞主线程。

七、监控防护

治理完成后加上防护,防止再次出现大 Key:

# 定期扫描,告警阈值 1000 个 field 或 10MB
redis-cli --bigkeys --i 0.1 > /dev/null

或者用 Prometheus 的 redis_exporter,配置 key-size-limit 告警规则。

在客户端写入时加防护:

MAX_HASH_FIELDS = 10000

def safe_hset(key, field, value):
    size = redis.hlen(key)
    if size >= MAX_HASH_FIELDS:
        # 自动拆分到新 shard
        new_key = f"{key}:overflow:{uuid4().hex[:8]}"
        redis.hset(new_key, field, value)
        redis.expire(new_key, 86400)
        log.warning(f"Hash {key} exceeded limit, split to {new_key}")
    else:
        redis.hset(key, field, value)

八、总结

Redis 大 Key 问题的核心教训:

  • 单个 Key 的数据量要控制在合理范围(Hash < 5000 fields,List < 10000 elements)
  • 监控要覆盖 key 大小,不只是内存和 QPS
  • UNLINKDEL 安全,但最好的方案是永远不要积累大 Key
  • 时间序列数据考虑用 Stream 或专业时序数据库
  • 定期跑 --bigkeys,尽早发现尽早治理

这次治理后,Redis 延迟 P99 稳定在 2ms 以内,再没出现过抖动。

posted @ 2026-06-17 21:52  fitch_liu  阅读(41)  评论(0)    收藏  举报