适用场景

这篇文章适用于线上 Redis 作为缓存、会话、计数器或排行榜组件时,出现接口响应时间突然升高、Redis CPU 飙升、慢查询增多、网络出口流量变大等问题的排查。

典型环境包括:

  • 单机 Redis 或 Redis Sentinel 架构;
  • Redis Cluster 分片集群;
  • 业务使用 Python、Java、Go 等客户端访问 Redis;
  • 缓存中存在 hash、list、set、zset、string 等多种数据结构;
  • 应用侧偶发出现超时,但 Redis 进程没有直接宕机。

本文重点不是解释 Redis 基础命令,而是给出一个可以在线上执行的排查顺序:先确认延迟,再定位慢命令,再识别大 key 和热 key,最后给出拆分、限流、过期时间和客户端改造方案。

现象描述

一次常见故障可能是这样的:

  • 应用接口 P95、P99 延迟从几十毫秒升到几百毫秒甚至数秒;
  • Redis 所在机器 CPU 使用率较高,单核接近打满;
  • 应用日志里出现 Read timed outConnection resetCommand timed out
  • Redis 慢日志里出现 HGETALLLRANGE 0 -1SMEMBERSZRANGEGET 等命令;
  • 监控中 Redis instantaneous_ops_per_sec 不一定明显变高,但网络入/出流量明显上升;
  • 删除某些缓存 key 或重启应用后短暂恢复,过一段时间又复现。

这类问题经常不是 Redis “不能用”,而是少数 key 的访问方式破坏了 Redis 单线程执行模型的稳定性。

可能原因

Redis 大 key 和热 key 经常同时出现,但它们不是一个概念。

大 key 指单个 key 占用的数据量过大,常见形式如下:

  • string value 很大,例如一个 key 存了几 MB 的 JSON;
  • hash field 数量很多,例如一个用户画像 hash 存了几十万个 field;
  • list、set、zset 元素数量过多;
  • 一个 key 的序列化对象持续追加,长期没有拆分或清理。

热 key 指某个 key 被高频访问,常见形式如下:

  • 首页配置、热门商品、全站开关等 key 被所有请求读取;
  • 某个活动、秒杀、热点新闻导致单个 key 访问量突然升高;
  • 缓存击穿后大量请求同时访问同一个 key;
  • 业务代码循环内重复访问同一个 key。

大 key 的直接问题是单次命令执行时间长、网络传输大、内存回收慢;热 key 的直接问题是访问集中,导致单个 Redis 节点负载不均。二者叠加时,问题会非常明显:一个很大的 key 又被频繁读取,Redis 主线程会被连续占用,其他正常请求也会排队。

排查思路总览

建议按照下面的顺序排查:

  1. 确认 Redis 延迟是否真实升高;
  2. 查看慢日志,确认是否存在危险命令;
  3. 查看 Redis 实时状态,判断 CPU、连接、内存、网络是否异常;
  4. 使用安全方式扫描大 key;
  5. 从应用日志、监控或 Redis 命令统计中识别热 key;
  6. 对命中的 key 做结构分析;
  7. 制定拆分、缓存副本、限流、过期和代码改造方案。

不要一上来就在生产环境执行 KEYS *HGETALLSMEMBERS 这类全量命令。它们可能让本来已经变慢的 Redis 更慢。

第一步:确认 Redis 延迟

先在 Redis 机器或同网段机器上执行延迟测试:

redis-cli -h 127.0.0.1 -p 6379 --latency

输出示例:

min: 0, max: 153, avg: 3.21 (1280 samples)

关键字段:

  • min:最小延迟;
  • max:采样期间最大延迟;
  • avg:平均延迟。

如果 max 偶尔冲到几十毫秒甚至几百毫秒,就要继续看慢命令和系统资源。

也可以执行:

redis-cli -h 127.0.0.1 -p 6379 --latency-history

这个命令会分段输出延迟,更容易观察问题是否持续存在。

第二步:查看慢日志

查看慢日志配置:

redis-cli CONFIG GET slowlog-log-slower-than
redis-cli CONFIG GET slowlog-max-len

slowlog-log-slower-than 的单位是微秒。比如 10000 表示超过 10ms 的命令会进入慢日志。

查看最近慢日志:

redis-cli SLOWLOG GET 20

示例输出中需要重点关注命令部分:

1) 1) (integer) 128
   2) (integer) 1783439201
   3) (integer) 62435
   4) 1) "HGETALL"
      2) "user:profile:all"
   5) "10.0.12.33:53128"
   6) "app-api"

关键字段:

  • 第 3 个字段是执行耗时,单位微秒;
  • 命令是 HGETALL user:profile:all
  • 客户端地址是 10.0.12.33:53128
  • 客户端名称是 app-api

如果慢日志里反复出现下面这些命令,要特别警惕:

HGETALL large_hash_key
LRANGE large_list_key 0 -1
SMEMBERS large_set_key
ZRANGE large_zset_key 0 -1 WITHSCORES
GET huge_json_string
DEL very_large_key

这些命令不是绝对不能用,而是不能在大集合或大 value 上高频使用。

第三步:查看 Redis 实时状态

执行:

redis-cli INFO stats
redis-cli INFO commandstats
redis-cli INFO memory
redis-cli INFO clients

重点看这些字段:

instantaneous_ops_per_sec:4200
instantaneous_input_kbps:512.34
instantaneous_output_kbps:18342.80
rejected_connections:0
expired_keys:123456
evicted_keys:0
used_memory_human:6.20G
mem_fragmentation_ratio:1.48
connected_clients:380
blocked_clients:0

判断方法:

  • instantaneous_output_kbps 很高,说明 Redis 正在向客户端返回大量数据,常见于读取大 key;
  • used_memory_human 持续上涨,说明缓存没有及时淘汰或 key 设计不合理;
  • connected_clients 异常升高,可能是应用连接池配置不合理;
  • evicted_keys 增长,说明内存已经触发淘汰策略;
  • blocked_clients 大于 0,要检查是否使用了阻塞命令。

查看命令统计:

redis-cli INFO commandstats | sort

输出示例:

cmdstat_hgetall:calls=8231,usec=91234567,usec_per_call=11084.21,rejected_calls=0,failed_calls=0
cmdstat_get:calls=982312,usec=3182011,usec_per_call=3.23,rejected_calls=0,failed_calls=0
cmdstat_zrange:calls=13822,usec=42301822,usec_per_call=3060.47,rejected_calls=0,failed_calls=0

关键字段:

  • calls:命令调用次数;
  • usec:累计耗时;
  • usec_per_call:平均每次命令耗时。

如果某个命令 calls 不算高,但 usec_per_call 很高,通常说明它操作的数据量大。

第四步:安全扫描大 key

生产环境不要用 KEYS *。可以使用 Redis 自带的 --bigkeys

redis-cli -h 127.0.0.1 -p 6379 --bigkeys

它会基于 SCAN 增量扫描,风险比 KEYS 小很多。输出示例:

Biggest string found '"cache:home:payload"' has 7340032 bytes
Biggest hash   found '"user:profile:all"' has 820000 fields
Biggest list   found '"queue:retry:failed"' has 430000 items
Biggest zset   found '"rank:daily:20260708"' has 280000 members

如果担心扫描对线上造成压力,可以降低扫描频率:

redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1

-i 0.1 表示每 100 次 SCAN 休眠 0.1 秒。扫描会变慢,但对线上更友好。

对于 Redis 4.0 及以上版本,可以用 MEMORY USAGE 查看单个 key 的估算内存:

redis-cli MEMORY USAGE cache:home:payload
redis-cli MEMORY USAGE user:profile:all

再结合类型和长度查看:

redis-cli TYPE user:profile:all
redis-cli HLEN user:profile:all
redis-cli STRLEN cache:home:payload
redis-cli LLEN queue:retry:failed
redis-cli ZCARD rank:daily:20260708

这些命令只返回长度或估算值,通常比拉取完整数据安全。

第五步:识别热 key

热 key 的定位方式和架构有关。

如果是 Redis 4.0 以上,可以使用 LFU 相关信息辅助判断,但前提是内存淘汰策略启用了 LFU,例如 allkeys-lfuvolatile-lfu。查看配置:

redis-cli CONFIG GET maxmemory-policy

如果策略支持 LFU,可以查看对象频率:

redis-cli OBJECT FREQ cache:home:payload

返回值越高,说明近期访问越频繁。注意这个值是近似统计,不是精确 QPS。

如果没有 LFU,可以从应用侧入手:

grep "redis timeout" /var/log/app/app.log | awk '{print $1,$2,$NF}' | sort | uniq -c | sort -nr | head

如果应用日志里记录了 key,可以统计高频 key:

grep "redis key=" /var/log/app/app.log \
  | sed -n 's/.*key=\([^ ]*\).*/\1/p' \
  | sort | uniq -c | sort -nr | head -20

也可以临时观察 Redis 正在执行的命令:

redis-cli MONITOR

MONITOR 会输出所有命令,本身有性能开销。线上只建议短时间使用,例如 10 到 30 秒,并尽量在低峰期执行:

timeout 10 redis-cli MONITOR | awk '{print $NF}' | sort | uniq -c | sort -nr | head

如果系统没有 timeout 命令,可以手动中断,或在测试环境复现。

Redis Cluster 可以结合节点维度监控判断热点分片:

redis-cli -c -h redis-cluster.example.com -p 6379 CLUSTER NODES
redis-cli -c -h redis-cluster.example.com -p 6379 INFO stats

如果只有某个节点 CPU、网络、延迟明显高于其他节点,通常说明热点 key 落在该节点。

定位示例:HGETALL 大 hash 拖慢接口

假设慢日志里反复出现:

HGETALL user:profile:all

先确认 key 类型和长度:

redis-cli TYPE user:profile:all
redis-cli HLEN user:profile:all
redis-cli MEMORY USAGE user:profile:all

输出:

hash
820000
168435120

这说明 user:profile:all 是一个 hash,有 82 万个 field,估算占用约 160MB。此时继续执行 HGETALL 会把所有 field 和 value 一次性返回给客户端,Redis 要编码响应,网络要传输大量数据,客户端还要反序列化,整个链路都会慢。

再看应用代码,可能有类似逻辑:

profiles = redis_client.hgetall("user:profile:all")
return profiles.get(str(user_id))

这段代码为了拿一个用户资料,却每次拉取全量 hash。正确方式应该改成按 field 读取:

profile = redis_client.hget("user:profile:all", str(user_id))
return profile

如果单个 hash 本身仍然过大,可以按用户 ID 取模拆分:

bucket = user_id % 128
key = f"user:profile:{bucket}"
profile = redis_client.hget(key, str(user_id))

这样可以把一个超大 hash 拆成 128 个较小 hash,降低单 key 内存和迁移风险。

定位示例:热门配置 key 被全站请求打爆

假设监控显示 Redis QPS 不高,但某个节点 CPU 接近 100%,应用日志统计发现 config:global 出现频率非常高:

grep "config:global" /var/log/app/app.log | wc -l

如果这是全站每个请求都读取的配置,就不应该每次都打 Redis。可以在应用进程内增加短 TTL 本地缓存:

import time

_local_cache = {}

def get_global_config(redis_client):
    now = time.time()
    item = _local_cache.get("config:global")
    if item and item["expire_at"] > now:
        return item["value"]

    value = redis_client.get("config:global")
    _local_cache["config:global"] = {
        "value": value,
        "expire_at": now + 5,
    }
    return value

这段代码把 Redis 读取压到每个应用进程最多 5 秒一次。配置变化允许几秒延迟时,这是非常有效的降压方式。

如果配置必须更实时,可以使用版本号 key:

config:global:version
config:global:data

应用只高频读取小的版本号,版本变化后再读取完整配置。

修复方案

1. 禁止全量读取大集合

把这些命令从核心链路中移除:

HGETALL
SMEMBERS
LRANGE key 0 -1
ZRANGE key 0 -1

替代方式:

HGET key field
HMGET key field1 field2 field3
SSCAN key cursor COUNT 100
HSCAN key cursor COUNT 100
ZRANGE key 0 99 WITHSCORES

核心原则是分页、按需读取、限制返回数量。

2. 拆分大 key

常用拆分方式:

  • 按业务 ID 取模:user:profile:{bucket}
  • 按时间拆分:rank:daily:20260708rank:daily:20260709
  • 按租户拆分:tenant:{tenant_id}:config
  • 按冷热拆分:近期数据放 Redis,历史数据落数据库或对象存储。

示例:

def profile_key(user_id: int) -> str:
    return f"user:profile:{user_id % 128}"

拆分后要注意数据迁移过程。建议先双写新旧 key,再灰度读新 key,确认稳定后清理旧 key。

3. 删除大 key 使用异步方式

直接 DEL large_key 可能阻塞 Redis 主线程。Redis 4.0 以后可以优先使用:

redis-cli UNLINK user:profile:all

UNLINK 会把释放内存的动作交给后台线程,降低阻塞风险。

如果版本较老,只能 DEL,建议在低峰期分批清理,或者通过业务脚本逐步删除集合元素。

4. 热 key 增加本地缓存或副本 key

对允许短暂不一致的数据,可以增加应用本地缓存:

local_ttl_seconds = 3

对读多写少的热点数据,可以构造多个副本 key:

hot:config:0
hot:config:1
hot:config:2
hot:config:3

客户端读取时随机选择一个:

import random

key = f"hot:config:{random.randint(0, 3)}"
value = redis_client.get(key)

更新时同时更新所有副本。这样可以在 Redis Cluster 中把读流量打散到不同槽位和节点,但要接受更新逻辑更复杂的问题。

5. 防止缓存击穿

热点 key 过期瞬间,可能大量请求同时打到数据库并回写 Redis。可以使用互斥锁:

def get_with_mutex(redis_client, key, build_func, ttl=300):
    value = redis_client.get(key)
    if value is not None:
        return value

    lock_key = f"lock:{key}"
    got_lock = redis_client.set(lock_key, "1", nx=True, ex=10)
    if got_lock:
        try:
            value = build_func()
            redis_client.set(key, value, ex=ttl)
            return value
        finally:
            redis_client.delete(lock_key)

    time.sleep(0.05)
    return redis_client.get(key)

生产环境中还要考虑锁误删、重试次数、降级返回旧值等细节。

6. 给 key 设置合理过期时间

检查没有过期时间的业务缓存:

redis-cli TTL cache:home:payload

返回值含义:

  • -1:key 存在,但没有过期时间;
  • -2:key 不存在;
  • 大于 0:剩余过期秒数。

对缓存类数据,尽量设置 TTL,并增加随机抖动,避免大量 key 同时过期:

import random

ttl = 3600 + random.randint(0, 300)
redis_client.set(cache_key, value, ex=ttl)

预防措施

建议把下面几项加入上线规范:

  • 禁止在核心链路使用 KEYSHGETALLSMEMBERS、无边界 LRANGE、无边界 ZRANGE
  • 代码评审时检查 Redis 命令是否有分页和数量限制;
  • 对可能膨胀的 key 设计拆分规则;
  • 所有缓存 key 默认设置 TTL,除非明确是持久状态;
  • 监控 Redis 慢日志数量、单节点 CPU、网络输出、内存使用、连接数;
  • 定期在低峰期执行 --bigkeys -i 0.1,保存扫描结果做趋势对比;
  • 对首页配置、热门活动、排行榜等热点 key 设计本地缓存或副本 key;
  • 删除大 key 优先使用 UNLINK
  • Redis Cluster 要关注单节点负载差异,不能只看集群总 QPS。

应急处理清单

线上已经出现明显超时时,可以按下面顺序处理:

  1. SLOWLOG GET 20 找到最可疑命令;
  2. TYPEHLENLLENSCARDZCARDSTRLEN 确认 key 规模;
  3. 临时下线或限流调用大 key 的接口;
  4. 如果是热点配置 key,快速增加应用本地缓存;
  5. 如果是大 key 删除,优先使用 UNLINK
  6. 如果是缓存击穿,临时延长 TTL 或增加互斥构建;
  7. 故障恢复后做数据结构拆分和代码改造。

总结

Redis 大 key 和热 key 问题的本质,是少数 key 让 Redis 的单线程执行、网络传输和客户端反序列化都变重。排查时不要只看 Redis 是否存活,也不要只看总 QPS。更有效的路径是:用延迟命令确认问题,用慢日志找到危险命令,用 INFO commandstats 看命令成本,用 --bigkeys 和长度命令确认 key 规模,再结合应用日志判断是否存在热点访问。

修复时优先改访问方式:全量读取改成按需读取,大集合改成分片存储,热点读取加本地缓存或副本 key,大 key 删除使用 UNLINK。只要在设计阶段控制 key 的规模和访问频率,Redis 就能稳定地承担高并发缓存压力。