第2章 缓存穿透的根本原因分析

缓存穿透是指查询一个一定不存在的数据,由于缓存层无法命中,每次请求都直接穿透到后端数据库,导致数据库压力骤增,严重时引发系统故障。要有效防御缓存穿透,必须从根本原因入手。本章系统分析缓存穿透的三种典型成因:缓存未命中(常规场景)、恶意攻击(大规模无效键)以及数据确实不存在(业务逻辑所致)。

核心认知:缓存穿透的本质不是“缓存被穿透了”,而是“查询路径上根本没有可缓存的数据”,导致缓存层形同虚设。

2.1 三种根本原因对比

原因分类 键是否可能存在于数据库 触发场景 对数据库的影响 典型判别特征
常规缓存未命中 是,但缓存过期或未预热 第一次查询、缓存淘汰后重查 单次冲击,可承受 查询率低且键分布均匀
恶意攻击 否(大量随机或不存在的键) 爬虫、DDoS、刷接口 QPS激增,直接击穿 请求量高,键无规律
数据不存在 否(业务逻辑保证不存在) 查询已删除的记录、无效ID 持续小流量穿透 键集合固定且有限

2.2 原因一:常规缓存未命中

这是最自然的场景:数据真实存在于数据库但缓存尚未加载,或缓存过期被淘汰。正常情况下这类请求数量有限(如第一次访问、缓存重建期),数据库能正常处理。如果系统预热不足或缓存策略过于激进(TTL过短),可能演变为小规模穿透。

2.3 原因二:恶意攻击

攻击者构造大量不存在且不重复的键(如自增ID缺失区间、随机字符串),由于缓存中永远查不到,每一条请求都会打到数据库。以典型Redis+MySQL架构为例,攻击代码可能类似:

# 攻击者持续请求不存在的用户ID
for i in range(1000000, 2000000):   # 假设实际ID范围为1~500000
    requests.get(f"https://api.example.com/user/{i}")

当并发攻击量达到数万QPS时,数据库连接池立即耗尽,CPU飙升,正常业务全部阻塞。这是缓存穿透最危险的形式。

2.4 原因三:数据确实不存在

某些业务场景下,数据被删除或根本不存在(如查询已注销账号、过期订单),但业务逻辑允许查询。因为缓存中没有(或从未写入过),每次查询都走数据库。如果此类查询频率不高则可接受,但一旦成为高频接口(如大量用户查询已删除好友),就会形成持续的穿透压力。

容易忽视的陷阱:很多团队只关注恶意攻击,却忽略了业务数据删除后产生的大量穿透查询。对删除操作的键应在缓存中设置一个空值标记(如“null”或“N/A”),并设较短TTL,避免直接穿透。

2.5 缓存穿透全链路流程图

下图完整展示了请求从进入系统到穿透数据库的三种分支路径:

缓存穿透根本原因流程图 外部请求(Query) 缓存键查找 缓存未命中 ❌ 常规未命中 (缓存过期/未预热) 恶意攻击 (大量随机无效键) 数据不存在 (业务删除/无效ID) 直接穿透 → 数据库查询 (每次请求都执行SQL) 说明:三种原因最终都导致同一种结果——后端数据库承受了本应由缓存屏蔽的查询压力。

2.6 从原因到应对思路

理解根本原因后,应对策略可精准匹配:

  • 常规未命中 → 缓存预热 + 合理TTL + 布隆过滤器降低无效查询。
  • 恶意攻击 → 参数校验 + 限流降级 + 布隆过滤器拦截非法键(推荐使用Redis布隆过滤器模块或Redisson客户端)。
  • 数据不存在 → 缓存空值(即使数据库返回空结果也写入缓存,TTL设置较短如60秒,避免持续穿透)。

实践关键:无论哪种原因,布隆过滤器都是最通用的防御手段——它能在缓存层前置判断键是否存在,从源头拦截99%以上的无效请求。具体实现将在后续章节详细讲解。

2.7 代码级验证:模拟缓存穿透

以下Python伪代码清晰展示了穿透产生的原因:

def query_user(user_id: int):
    # 1. 查缓存
    cached = redis_client.get(f"user:{user_id}")
    if cached:
        return cached

    # 2. 缓存未命中,查数据库
    user = db.execute("SELECT * FROM users WHERE id = %s", user_id).fetchone()

    if user:
        # 真实数据:回写缓存
        redis_client.setex(f"user:{user_id}", 3600, user)
        return user
    else:
        # 数据不存在:什么也不缓存 → 下次请求依然穿透!
        return None

# 攻击者调用:query_user(-1) 或 query_user(999999999)
# 每次都会执行SQL,数据库压力无限放大