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

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

 

## 问题现象

 

监控系统告警 Redis 延迟:

 

- 偶发性 P99 延迟 500ms+(正常 < 5ms)

- 持续 5-15 秒,之后恢复正常

- 每天发生 3-5 次,时间不固定

- 受影响的是同一个 Redis 实例上的所有 Key,不只是某个业务

 

## 第一步:确认 Redis 自身状态

 

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

 

```bash

redis-cli -h 10.0.1.50 -p 6379 INFO memory

```

 

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

 

```bash

redis-cli -h 10.0.1.50 -p 6379 INFO stats

```

 

keyspace_hits 和 keyspace_misses 都正常。但是:

 

```bash

redis-cli -h 10.0.1.50 -p 6379 INFO commandstats

```

 

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

 

## 第二步:找到大 Key

 

```bash

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 范围分片:

 

```python

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 天然适合:

 

```bash

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

XLEN user:behavior:12345

```

 

Stream 支持自动裁剪:

 

```bash

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

```

 

### 方案三:迁移到专业存储

 

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

 

```python

# 写入时双写: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 异步删除:

 

```bash

# 先创建新的分片结构

# 迁移数据(业务低峰期执行)

redis-cli UNLINK user:behavior:20240115

```

 

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

 

## 监控防护

 

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

 

```bash

# 定期扫描,告警阈值 1000 个 field 或 10MB

redis-cli --bigkeys --i 0.1 > /dev/null

```

 

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

 

在客户端写入时加防护:

 

```python

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

- UNLINK 比 DEL 安全,但最好的方案是永远不要积累大 Key

- 时间序列数据考虑用 Stream 或专业时序数据库

- 定期跑 --bigkeys,尽早发现尽早治理

 

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

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