Redis缓存穿透怎么解决?

文章导读
缓存穿透指的是查询一个「数据库里本来也不存在」的 key,缓存永远不命中,请求全部落到存储层。解决方向只有两条:一是把不存在的 key 挡住(参数校验、空值缓存、布隆过滤器),二是把异常流量挡住(限流、鉴权、封禁)。但在改代码之前,先确认当前现象确实是穿透——被叫成「穿透」的故障里,相当一部分其实是 key 被集中淘汰、缓存整体失效,或者存储层自己变慢。
📋 目录
  1. 一 Redis缓存穿透先定位在哪一层
  2. 二 Redis缓存穿透:先看这四个位置
  3. 三 Redis缓存穿透:按可能性从高到低排除
  4. 四 Redis缓存穿透:改完怎么确认真的解决了
A A

缓存穿透指的是查询一个「数据库里本来也不存在」的 key,缓存永远不命中,请求全部落到存储层。解决方向只有两条:一是把不存在的 key 挡住(参数校验、空值缓存、布隆过滤器),二是把异常流量挡住(限流、鉴权、封禁)。但在改代码之前,先确认当前现象确实是穿透——被叫成「穿透」的故障里,相当一部分其实是 key 被集中淘汰、缓存整体失效,或者存储层自己变慢。

先判断「miss 在涨、回源也在涨、Redis 自身不忙」这三件事是否同时成立,成立才按穿透处理。常用组合是参数格式校验挡掉明显非法 id,空值缓存用较短 TTL 兜住重复查询,布隆过滤器挡随机 key。空值缓存会带来最长一个 TTL 的脏读窗口,布隆过滤器不支持删除、重建时要评估误判放行,两者都需要留开关,改完观察覆盖一个完整业务高峰再判定稳定。

Redis缓存穿透先定位在哪一层

按请求链路分四层看,每层有各自的检查位置和判断依据,不要一上来就改业务代码。

  • 接入层:看网关或 Nginx 访问日志里同一路径的请求量、来源 IP、UA 是否高度集中。判断依据是「同一时刻大量请求打向同一批接口」,常见于爬虫遍历或脚本扫描。
  • 应用层:看缓存客户端日志或埋点里未命中的 key 有没有规律。连续递增的 id、随机 uuid、固定前缀加无意义后缀,分别对应遍历、伪造、key 拼装错误三种不同的原因。
  • Redis 层:看 info stats 里 keyspace_misses 的增长速度,以及 info commandstats 里 get 类命令的调用量。判断依据是「miss 上涨但 Redis CPU 和延迟正常」,说明 Redis 没被打满,压力在往下游走。
  • 存储层:看数据库慢查询、活跃连接数、QPS 曲线是否与 miss 同步上涨。判断依据是「涨幅几乎同步」,基本可以确定回源量被放大。

如果看到的信号是 evicted_keys 持续增长,那是淘汰导致的不命中,属于容量和淘汰策略问题,不是穿透;如果 Redis 命中率正常而数据库慢查询上涨,先查存储层自身,别急着加空值缓存。

Redis缓存穿透:先看这四个位置

以下命令把 host、port 换成实际地址,在能连到 Redis 的机器上执行;生产环境建议先只读排查,不要直接改动数据。

# 命中与淘汰、过期,注意比较两次采样的差值,不要只看瞬时值
redis-cli -h host -p 6379 info stats | egrep 'keyspace_hits|keyspace_misses|evicted_keys|expired_keys'

# 连接数、阻塞客户端,判断是流量问题还是连接泄漏
redis-cli -h host -p 6379 info clients | egrep 'connected_clients|blocked_clients'

# 慢命令,确认 Redis 本身是否被大 key 或复杂命令拖慢
redis-cli -h host -p 6379 slowlog get 10

# 每秒命令数实时观察,看 get 的调用量是否异常放大
redis-cli -h host -p 6379 --stat

四个位置的具体落点:

  1. 日志:应用侧检索缓存未命中日志,按 key 前缀聚合;网关侧检索访问日志,按 path 和来源 IP 聚合。不要只搜「error」,穿透通常不报错。
  2. 命令输出:就是上面四条命令,重点是两次 info stats 的差值,以及 slowlog 里是否有批量取数。需要抓具体 key 时可以用 redis-cli monitor 短时间抽样,但它会明显加重 Redis 负担,只在低峰、限定时长使用。
  3. 配置项:redis-cli config get maxmemory、config get maxmemory-policy、config get timeout;应用侧则看缓存客户端的连接池上限、命令超时、序列化方式。很多「不命中」是序列化不一致或 key 前缀写错导致的。
  4. 面板字段:监控面板上先看命中率(keyspace_hits 与 keyspace_misses 的比值)、evicted_keys、connected_clients、命令延迟分位、数据库 QPS 与慢查询数。命中率和数据库 QPS 两条曲线是否反相关,是判断穿透最直观的依据。所用监控系统的具体字段名需要以你环境里的采集配置为准。

Redis缓存穿透:按可能性从高到低排除

下面按常见程度排序,每条都配一个能确认或直接排除它的动作。

1. 查询不存在的 id(遍历或伪造请求)

确认动作:从应用日志里抽样几个未命中的 key,拿对应的 id 去数据库执行一次 select ... where id=?,如果返回 0 行且这类 id 在短时间内大量出现,就是这个原因。处理:先加参数格式校验(范围、长度、字符集),再加短 TTL 的空值缓存,必要时前置布隆过滤器。

# 通用接入骨架,字段名和 Redis 客户端按实际项目替换
NULL_TTL = 60      # 秒,按业务能接受的脏读窗口调整

def get_user(uid):
    if not isinstance(uid, int) or not (1 <= uid <= 2**31 - 1):
        return None                      # 明显非法 id 直接挡掉,不查缓存也不查库
    key = "user:%d" % uid
    v = redis.get(key)
    if v is not None:
        return None if v == "__NULL__" else json.loads(v)
    row = db.query_one("select id,name from user where id=%s", uid)
    if row is None:
        redis.set(key, "__NULL__", ex=NULL_TTL)   # 空结果也占位,防止反复回源
        return None
    redis.set(key, json.dumps(row), ex=3600)
    return row

2. 空值根本没有缓存,或缓存 TTL 设置过短

确认动作:对同一个不存在的 id 连续请求两次,看第二次是否仍然回源(在数据库侧观察 SQL 计数或开 general log 短时抽样)。处理:补上空值缓存,或把不存在的 id 单独走一条更短 TTL 的路径。

Redis缓存穿透怎么解决?

3. key 拼装不一致或序列化方式不统一

确认动作:用 redis-cli keys 'user:*' | head 抽样(生产环境避免用 keys 全量扫描,改用 scan)对比实际 key 形态,和代码里拼出来的 key 是否完全一致。处理:统一收敛到一个 key 生成函数,禁止各处手拼。

4. maxmemory 触顶后 key 被淘汰

确认动作:观察 evicted_keys 是否在故障时段持续增长。增长即说明是被淘汰,不是穿透。处理:扩容、调整淘汰策略、或用本地缓存分担一部分读;这属于容量问题,不要用空值缓存去掩盖。

5. 存储层自身慢

确认动作:数据库慢查询数上涨但 Redis 命中率稳定,直接排除穿透。处理:回到数据库侧排查索引和连接池。

Redis缓存穿透:改完怎么确认真的解决了

验证要同时看 Redis 侧和数据库侧,只看其中一边容易被误导。

# 间隔一段时间各执行一次,比较差值的增速是否回落
redis-cli -h host -p 6379 info stats | egrep 'keyspace_hits|keyspace_misses|evicted_keys'
redis-cli -h host -p 6379 info commandstats | egrep 'cmdstat_get|cmdstat_set'

同时在监控面板上传入两个字段对比:缓存命中率曲线、数据库的查询量与慢查询数曲线。判断标准是「同一批异常 key 不再产生回源」,而不是「整体 QPS 看起来降了」。观察时长建议至少覆盖一个完整业务高峰时段;如果业务有明显的每日周期,通常要看到两次高峰都平稳,再下「已解决」的判断。

回滚办法要提前准备好,不要等出事再想:空值缓存和布隆过滤器都要有配置开关(例如配置中心里的 cache.null_value.enabled、cache.bloom.enabled),关闭后逻辑退化为「直接查库」,可以先扛住功能可用;布隆过滤器还需要一个「放行模式」,即在过滤器重建期间全部放行,避免误判把正常数据挡掉。改动前先备份原有配置项,回滚时按备份恢复并重启对应服务实例。

最容易踩的坑

空值缓存的 TTL 和数据写入没有联动:数据后来真的被创建了,但 __NULL__ 占位还在,接口在 TTL 到期前一直返回「不存在」。所以写路径上必须做删除或覆盖,通常是写入成功后 del 对应 key,而不是等它自然过期。布隆过滤器的坑类似——它不支持删除,删除后重建期间如果不放行,新写入的数据会被误判为不存在,表现为「数据明明有,查一次空一次」。这两处都要在改完之后单独用「先查空、再写入、再查一次」的流程手工验证一遍。