Redis 系列 13. 缓存预热、雪崩、穿透、击穿治理


1. 四个概念先分清

缓存异常治理里有几个高频词:预热、雪崩、穿透、击穿。先放一张表。

问题 含义 典型后果
缓存预热 系统启动或活动前提前加载热点数据 避免冷启动大量打 DB
缓存雪崩 大量 key 同时失效,或 Redis 整体不可用 请求集中打到 DB
缓存穿透 请求不存在的数据,Redis 和 DB 都没有 恶意请求绕过缓存
缓存击穿 单个热点 key 失效,大量并发同时回源 热点请求打爆 DB

上述问题均发生在 cache-aside 访问链路中,也就是“请求先查缓存,缓存没有再查数据库”的链路,但触发原因不同。

预热解决的是冷启动问题。比如 MySQL 新增了 100 条活动商品记录,Redis 里还没有对应缓存。如果什么都不做,就要等真实用户请求逐步回填缓存;更稳妥的做法是在发布、活动开始或数据导入后,通过脚本、定时任务或运营任务提前把热点数据写入 Redis。

穿透是“查不存在”,击穿是“热点 key 过期”,雪崩是“大面积失效或 Redis 不可用”。

可以先用一条链路理解它们的关系:

请求进入
  -> 参数校验 / 布隆过滤器拦截不存在数据(防穿透)
  -> 查询 Redis
  -> 命中直接返回
  -> 未命中时控制回源并发(防击穿)
  -> DB 查询后回填 Redis
  -> TTL 随机、本地缓存、限流熔断兜底(防雪崩)
  -> 热点数据提前加载(预热)

2. 缓存预热

缓存预热就是在真实流量进来之前,把热点数据提前写入 Redis。

适合预热的数据:

  1. 首页配置。
  2. 活动商品。
  3. 排行榜 topN。
  4. 字典配置。
  5. 访问量确定很高的详情页。

简单写法:

@Component
public class CacheWarmUpRunner implements ApplicationRunner {

    private final ProductMapper productMapper;
    private final StringRedisTemplate redisTemplate;

    public CacheWarmUpRunner(ProductMapper productMapper, StringRedisTemplate redisTemplate) {
        this.productMapper = productMapper;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void run(ApplicationArguments args) {
        List<ProductDTO> products = productMapper.selectHotProducts();
        for (ProductDTO product : products) {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, JsonUtils.toJson(product), 30, TimeUnit.MINUTES);
        }
    }
}

如果数据量大,不建议所有应用实例启动都预热一遍。多实例重复预热会在发布瞬间制造 DB 和 Redis 压力峰值。更好的方式是用定时任务、发布流程或单独的运营任务做预热。

ApplicationRunner 适合演示,不适合承载大规模生产预热。生产上通常改成异步任务、调度任务或发布流水线步骤,并且要支持分批、限速和失败重试。

3. 缓存雪崩

缓存雪崩常见原因:

原因 说明
大量 key 同时过期 比如批量导入时统一设置 30 分钟 TTL
Redis 故障 Redis 不可用,所有请求绕到 DB
热点活动开始 冷缓存瞬间被大量请求击中

治理方式:

  1. TTL 加随机值,避免同时过期。
  2. 热点数据提前预热。
  3. Redis 做主从、Sentinel 或 Cluster。
  4. 应用侧加限流、降级、熔断。
  5. 重要接口加本地缓存兜底。

TTL 加随机值:

int baseSeconds = 1800;
int randomSeconds = ThreadLocalRandom.current().nextInt(0, 300);
redisTemplate.opsForValue().set(key, value, baseSeconds + randomSeconds, TimeUnit.SECONDS);

不要把所有 key 都设置成同一个过期时间。这个问题在活动页、商品详情页、配置缓存里特别常见。

TTL 随机化主要适用于同批写入、同批过期的 key。它不能解决 Redis 整体故障,也不能代替限流、降级和多级缓存。

Redis 做主从、Sentinel 或 Cluster 能降低单点故障概率,但不能完全避免雪崩。如果客户端连接池、网络、机房或整组 Redis 同时异常,请求仍然可能绕到 DB。

4. Redis 故障时不能无限打 DB

如果 Redis 整体不可用,应用不能无脑查数据库,否则缓存故障会扩散成数据库故障。

常见保护:

手段 说明
限流 控制进入 DB 的请求量
熔断 Redis 连续异常时快速失败或走降级
本地缓存 热点配置保留短时本地副本
降级值 非核心字段返回默认值

伪代码:

public ProductDTO getProduct(Long id) {
    try {
        ProductDTO cache = redisCache.get("product:" + id);
        if (cache != null) {
            return cache;
        }
    } catch (RedisConnectionFailureException ex) {
        ProductDTO local = localCache.getIfPresent(id);
        if (local != null) {
            return local;
        }
        rateLimiter.acquire();
    }

    return productMapper.selectById(id);
}

这里不是说 Redis 异常后永远不查 DB,而是要控制 DB 的流量入口。

限流要分层:接口级限制保护 DB 总量,用户级或 IP 级限制压制异常来源,热点 key 级限制保护单点资源。熔断和 fallback 也不能只依赖某个框架的默认行为,核心接口要明确“失败时返回什么、是否允许查 DB、查 DB 的最大并发是多少”。

本地缓存可以作为 Redis 故障时的短时兜底,但它有 stale local cache 风险。多实例本地缓存之间不会自动一致,适合配置、热点只读数据和允许短时间旧值的场景。

5. 缓存穿透

缓存穿透是请求的数据根本不存在,比如不断请求 userId=-1、userId=999999999999。

解决方式:

方案 说明
参数校验 非法 ID 直接拒绝
缓存空对象 DB 查不到时,短 TTL 缓存空值
布隆过滤器 判断 ID 是否可能存在
风控限流 对异常 IP、账号、UA 限制

缓存空对象示例:

public UserDTO getUser(Long userId) {
    String key = "user:" + userId;
    String json = redisTemplate.opsForValue().get(key);
    if ("__NULL__".equals(json)) {
        return null;
    }
    if (json != null) {
        return JsonUtils.fromJson(json, UserDTO.class);
    }

    UserDO user = userMapper.selectById(userId);
    if (user == null) {
        redisTemplate.opsForValue().set(key, "__NULL__", 60, TimeUnit.SECONDS);
        return null;
    }

    UserDTO dto = convert(user);
    redisTemplate.opsForValue().set(key, JsonUtils.toJson(dto), 10, TimeUnit.MINUTES);
    return dto;
}

空对象 TTL 要短,避免后来数据创建了,缓存里仍然长时间保留空值。

空对象也可能成为攻击放大点。如果攻击者持续构造随机 ID,即使每个空对象 TTL 很短,也会造成大量空 key,也就是 key explosion。因此参数校验必须放在最前置层,比如 gateway、controller 或 RPC 入口。

缓存穿透

布隆过滤器可以当作“可能存在 ID”的白名单:判断一定不存在就直接返回,判断可能存在再继续查 Redis 和 MySQL。

需要注意两点:

  1. 布隆过滤器存在误判,少量不存在的 ID 仍可能打到 MySQL,所以还要配合空对象短 TTL、限流和风控。
  2. 所有合法 ID 都要写入布隆过滤器;热点数据是否提前写入 Redis,则取决于是否需要预热,不能把“写入布隆过滤器”和“写入缓存值”混为一件事。

6. 缓存击穿

缓存击穿是单个热点 key 失效,大量并发同时发现 Redis 没有,于是一起查 DB。

常用解决方案:

方案 说明
互斥锁 只有一个请求回源重建缓存,其他请求等待或快速失败
逻辑过期 key 不设置物理过期,value 里存逻辑过期时间
热点永不过期 核心热点由后台任务刷新

互斥锁示例:

public ProductDTO getHotProduct(Long id) {
    String key = "product:" + id;
    ProductDTO cache = redisCache.get(key);
    if (cache != null) {
        return cache;
    }

    String lockKey = "lock:product:" + id;
    String token = UUID.randomUUID().toString();
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            ProductDTO db = productMapper.selectById(id);
            redisCache.set(key, db, 10, TimeUnit.MINUTES);
            return db;
        } finally {
            releaseLock(lockKey, token);
        }
    }

    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(50));
    return redisCache.get(key);
}

真实代码要处理等待超时、二次查询仍为空、空对象、异常重试等情况。不要让所有等待线程无限阻塞,也不要在抢锁失败后直接把 null 当成最终结果长期缓存。

互斥锁还有两个关键细节:

  1. 锁过期时间太短时,持锁线程还没回源完成,锁已经过期,其他线程会重复回源。
  2. LockSupport.parkNanos 只是短暂等待,流量很大时仍可能造成线程堆积,最好配合最大等待次数、快速失败或降级返回。

释放锁必须使用 compare-and-delete 语义,避免误删别人的锁。通常用 Lua 脚本保证“判断 token 相等”和“删除锁”两个动作原子执行:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

7. 逻辑过期

逻辑过期是把过期时间存在 value 里,Redis key 本身不设置 TTL 或设置很长 TTL。

{
  "expireAt": "2024-12-04T10:00:00",
  "data": {
    "id": 1001,
    "name": "商品A"
  }
}

读取时:

  1. 命中缓存后先返回旧值。
  2. 如果逻辑过期,尝试抢锁。
  3. 抢锁成功的线程异步重建缓存。
  4. 抢锁失败的线程继续返回旧值。

它牺牲短时间的数据新鲜度,换取热点接口的稳定性。这个短时间旧值窗口就是 stale data window。逻辑过期适合首页、活动页、排行榜,不适合价格强一致、库存强一致这类场景。

8. 一套组合拳

实际项目里不是只选一个方案。

问题 推荐组合
冷启动 预热 + 本地缓存
雪崩 TTL 随机 + 限流 + Redis 高可用
穿透 参数校验 + 布隆过滤器 + 空对象短 TTL
击穿 互斥锁或逻辑过期
Redis 故障 熔断 + 降级 + DB 限流

可以把缓存治理理解成多层防护体系:

层级 作用
L1 参数校验、风控、限流,减少无效请求进入系统
L2 本地缓存、Redis、布隆过滤器,降低 DB 访问
L3 熔断、降级、DB 限流,保证故障时系统可控

缓存的目标是减轻数据库压力,而非替代数据库。缓存异常治理的目标是让 Redis 出问题时系统可控,而不是假设 Redis 永远可用。

最后还要补监控。至少要持续看这些指标:

  1. cache hit ratio:命中率是否突然下降。
  2. DB QPS spike:缓存失效是否打穿到数据库。
  3. Redis latency:是否出现慢命令、网络抖动或连接池等待。
  4. hot key:是否存在单个 key 形成流量瓶颈。
  5. memory pressure:是否有内存逼近上限、淘汰抖动或缓存污染。

常见缓存写入模式也要分清:Cache Aside 是应用自己读写缓存;Write Through 是写请求同步写缓存和存储;Write Behind 是先写缓存再异步落库。本文主要讨论的是最常见的 Cache Aside 链路。

如果系统读流量很大,可以引入 multi-level cache,例如 Caffeine + Redis。但本地缓存会带来一致性和容量治理问题,必须控制 TTL、最大容量和失效策略,避免 bad key pollution 把无价值数据长期留在缓存里。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录