Skip to content
Go back

缓存预热策略——启动时就把热数据装进Redis

缓存预热策略:启动时就把热数据装进 Redis

一句话结论(30s)

缓存预热的本质是解决冷启动时「Redis 空转、100% 请求回源打挂数据库」的问题:因为重启后缓存为空、首批流量承担全部回源压力,所以在接外部流量前用启动事件 + Pipeline 批量预加载热数据,并用分布式锁防多节点重复、readinessProbe 控流量接入。

核心原理(2min)

用 Spring 的 ContextRefreshedEvent 在容器初始化完成时预热(用 getParent()!=null 过滤父子容器双触发);Redis Pipeline 把 N 次网络往返合并成 1 次(每批 500-1000 条、命令非原子、不与 MULTI/EXEC 混用)。多节点部署用 SET NX 分布式锁保证只预热一次:拿到锁后二次检查缓存是否已有数据、释放用 Lua 保证 get+del 原子。预热期间 readinessProbe 指向 /health/ready,warmedUp=false 时 Pod 不就绪不接流量,预热完成自动切换。

底层深入(5-10min)

一个线上事故的复盘

凌晨 3 点发布新版本,重启了 5 个服务节点。启动后 Redis 里空空如也——所有缓存数据因为重启丢失。早上 8 点用户涌入,每个请求都是 cache miss → 数据库瞬间被打到 100% CPU → 整个系统雪崩。

根因:只做了”缓存”,没做”缓存预热”。Redis 在启动后是空的,第一批请求承担了全部回源压力。

想一想:为什么冷启动会打挂数据库? 因为重启后 Redis 是空的,缓存命中率从 95% 瞬间掉到 0%,100% 请求回源打到数据库。平时数据库只承受 5% 的请求压力,冷启动瞬间要承受约 20 倍的流量,自然被打到 100% CPU。所以预热的本质是”在流量进来之前,先把缓存填满”,让 Redis 从一开始就是热的。

缓存预热解决什么问题?

正常运行期间:用户请求 → Redis(命中率 95%) → 数据库(只有 5% 请求打到)。数据库压力很轻。

服务重启后(冷启动):Redis 为空 → 100% 请求穿透到数据库 → 数据库被”冷启动峰值”打挂。

缓存预热的目标:在服务接收外部流量之前,把热点数据提前加载到 Redis 中,让 Redis 从”空”变成”热”。

方案一:ApplicationListener 监听启动完成

Spring 提供了 ContextRefreshedEvent 事件——当 ApplicationContext 初始化完成(所有 Bean 加载完毕)时触发。利用这个时机做预热:

@Component
public class CacheWarmer implements ApplicationListener<ContextRefreshedEvent> {

    @Autowired
    private ProductService productService;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 注意:Spring 的父子容器会导致两次触发,只处理 root context
        if (event.getApplicationContext().getParent() != null) {
            return;
        }

        log.info("开始缓存预热...");
        long start = System.currentTimeMillis();

        // 1. 预热商品信息(热点数据)
        List<Product> hotProducts = productService.findHotProducts(1000);
        for (Product product : hotProducts) {
            redisTemplate.opsForValue()
                .set("cache:product:" + product.getId(), product, 30, TimeUnit.MINUTES);
        }

        // 2. 预热字典数据(全局常量)
        List<Dict> dicts = dictService.findAll();
        redisTemplate.opsForValue()
            .set("cache:dict:all", dicts, 24, TimeUnit.HOURS);

        long elapsed = System.currentTimeMillis() - start;
        log.info("缓存预热完成,加载 {} 条商品,{} 条字典,耗时 {}ms",
            hotProducts.size(), dicts.size(), elapsed);
    }
}

注意:ContextRefreshedEvent 在父容器和子容器各触发一次(Spring MVC 有父子容器)。用 event.getApplicationContext().getParent() != null 过滤掉父容器事件,避免预热代码执行两次。

方案二:Redis Pipeline —— 1000 次网络往返变 1 次

上面方案一的代码有个性能问题:for 循环里每条 redisTemplate.opsForValue().set() 都是一次独立的网络往返。预热 1000 个 key → 1000 次网络 IO → 耗时可长达数秒。

Redis Pipeline 把多条命令打包发送,一次网络往返执行所有命令:

@Component
public class PipelineCacheWarmer implements ApplicationListener<ContextRefreshedEvent> {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (event.getApplicationContext().getParent() != null) return;

        List<Product> hotProducts = productService.findHotProducts(1000);

        // Pipeline:批量发送,一次网络 IO
        stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (Product product : hotProducts) {
                String key = "cache:product:" + product.getId();
                String value = JSON.toJSONString(product);
                connection.setEx(
                    key.getBytes(),
                    1800,  // TTL 30 分钟
                    value.getBytes()
                );
            }
            return null;
        });
    }
}

Pipeline 的注意事项

  1. 不要积累太多命令——建议每批 500-1000 条。过多命令占用 Redis 内存缓冲区,可能 OOM。
  2. Pipeline 中的命令不保证原子性——如果中间某条命令失败,前面的命令不会回滚。
  3. 不要和事务(MULTI/EXEC)混用——行为不确定。

想一想:为什么 Pipeline 能把 1000 次网络往返变 1 次? 因为逐条 set 是”发一条、等响应、再发下一条”,每条都要付一次网络 RTT;Pipeline 把 1000 条命令打包成一批一次性发送,只付一次 RTT,服务端批量执行后一起返回。预热这种”命令之间无依赖、无顺序要求”的批量写入,正是 Pipeline 的最佳场景。

方案三:分布式锁防止多节点重复预热

生产环境通常有 3-5 个服务节点。每个节点启动时都执行预热 → 同一份数据被写入 Redis 3-5 次 → 浪费资源。

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
    if (event.getApplicationContext().getParent() != null) return;

    String lockKey = "cache:warmup:lock";
    String lockValue = hostName + ":" + Thread.currentThread().getId();
    // 尝试获取分布式锁,超时 5 秒
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 再次检查缓存是否已有数据(可能其他节点已经预热完成)
            Long keyCount = redisTemplate.countExistingKeys(
                Collections.singletonList("cache:product:*"));
            if (keyCount > 0) {
                log.info("缓存已预热,跳过");
                return;
            }
            doWarmUp();
        } finally {
            // 释放锁(用 Lua 保证原子性)
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                            "return redis.call('del', KEYS[1]) else return 0 end";
            redisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(lockKey), lockValue
            );
        }
    } else {
        log.info("其他节点正在预热中,本节点跳过");
    }
}

这里有两个关键设计:

  1. 二次检查缓存:拿到锁后先查 Redis 是否已有数据——避免”A 节点预热完成释放锁,B 节点拿到锁又预热一遍”。
  2. Lua 释放锁get + delete 两步必须原子——否则 A 的锁过期被删,B 拿到锁,A 的 delete 把 B 的锁删了。

想一想:为什么释放锁要用 Lua 保证 get + del 原子? 因为如果先 get 判断”锁还是我的”、再 del 删除,这两步之间锁可能已经过期、被别的节点抢走了,此时 del 就会误删别人的锁。用 Lua 脚本把”判断 value 是否等于自己 + 删除”打包成一条原子命令执行,就杜绝了这个竞态窗口——这正是分布式锁”加锁用 SET NX、解锁用 Lua”的标准套路。

预热什么数据?

不是所有缓存都需要预热。预热的数据应该满足三个条件:

条件说明反例
访问频率高系统核心热数据用户个人设置(低频访问)
数据量可控预热 100 万条 key → 启动慢 + Redis 内存爆全量商品(上百万 SKU)
变化频率低预热期间数据不变或变化不大实时股价(预热完已过时)

典型预热数据

预热期间的外部流量处理

预热可能需要 10-30 秒。期间如果有用户请求进来:

private volatile boolean warmedUp = false;  // 预热标记

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
    // ... 预热逻辑 ...
    warmedUp = true;
}

// 在层(如 Nginx / API Gateway)设置健康检查
// 接口 /health/ready → 只有 warmedUp == true 时才返回 200
// Kubernetes readinessProbe 用这个接口控制流量接入

让 Kubernetes 的 readinessProbe 指向 /health/ready:预热期间 Pod 状态为”未就绪”,不接收流量。预热完成后自动切换为”就绪”。

总结

缓存预热是”缓存-穿透-击穿-雪崩”防御体系的最后一道防线。核心三件套:

  1. 启动时预加载ContextRefreshedEvent + Pipeline 批量写入
  2. 分布式锁SET NX 保证多节点只预热一次
  3. 就绪检查:预热完成前不接流量(readinessProbe)

缓存设计的最高境界不是”缓存命中率 99%“,而是 “冷启动也能正常服务”。如果你的系统每次重启后都需要”暖机”几分钟才能正常工作——那不是分布式系统,那是预热系统。

章末提问

Q1:缓存预热解决什么问题?不预热会怎样? 结论先行:解决”冷启动时 Redis 空转、100% 请求回源打挂数据库”的问题;不预热会让首批流量承担全部回源压力,可能雪崩。 因为重启后缓存为空,命中率从 95% 掉到 0%,所有请求都穿透到数据库,数据库要承受平时约 20 倍的压力。预热的本质就是在接外部流量之前,把热数据提前装进 Redis,让系统”一出生就是热的”。

Q2:为什么 Pipeline 能提速?有什么注意事项? 结论先行:它把 N 次网络往返合并成 1 次,省掉 N-1 次 RTT;注意命令非原子、不宜过多、别和事务混用。 因为逐条 set 每条都要付一次网络 RTT,Pipeline 打包一次发送只付一次。但 Pipeline 里的命令不保证原子性(中途失败不回滚),命令太多会占满 Redis 缓冲区甚至 OOM(建议每批 500-1000 条),也不能和 MULTI/EXEC 事务混用。

Q3:多节点部署为什么需要分布式锁?怎么防重复预热? 结论先行:因为每个节点启动都会触发预热,需要 SET NX 锁保证只有一个节点真正预热;配合”二次检查 + Lua 释放”防重复和误删。 因为 3-5 个节点同时启动会重复写入同一份数据、浪费资源;用 setIfAbsent 抢锁,拿到锁的节点预热,并在预热前二次检查”缓存是否已有数据”避免 A 预热完 B 又预热一遍。释放锁用 Lua 保证 get+del 原子,防止锁过期后被误删。


Share this post on:

Previous Post
Copy on Write原理:fork背后的内存优化魔法
Next Post
异步化改造邮件发送——从同步阻塞2秒到异步立即返回