缓存预热一、什么时候做缓存预热核心目的避免缓存冷启动大量请求打到 DB造成缓存雪崩1. 触发时机项目上线 / 发布重启后最常见 服务刚启动Redis 里没有热点数据第一个请求过来直接查 DB。如果流量大瞬间大量请求击穿缓存打数据库。缓存过期集中失效前比如大量 key 设置相同过期时间快要到期前提前刷新。数据发生大批量变更后批量导入、数据迁移、大版本数据修正旧缓存数据不准提前加载新数据。流量峰值到来之前秒杀、大促活动活动开始前把商品、库存等热点数据提前刷入缓存。关于上述情况 2. 缓存过期集中失效前TTL随机能否代替缓存预热解决这个问题1、区分两个场景场景 A缓存集中过期雪崩所有热点 key 设置了一样的过期时间到点瞬间大批量 key 失效大量请求同时打到 DB。✅ 方案TTL 随机偏移expireTime base random(0,300) 作用打散过期时间避免同一时刻集体失效。 但是随机打散只是错峰失效key 依旧会逐个过期单个 key 过期的瞬间依然会有少量请求穿透到 DB如果这个 key 是超级热点哪怕只有一瞬间失效依然会打崩数据库。场景 B缓存预热解决的问题两种预热场景项目刚启动Redis 是空的缓存里压根没有数据不是过期了是根本不存在。随机 TTL 在这里完全没用因为 key 都还没写入 Redis。热点 key 快要过期主动提前刷新预热不等 key 过期后台主动更新缓存保证缓存里永远有数据。这就是上面的第二种预热触发时机。为什么就算加了随机 TTL依然需要「过期前主动预热」举个例子 热点商品 key基础过期时间 1 小时随机 ±5 分钟。不加预热key 到时间失效 → 第一个访问的请求查 DB 写回缓存。 如果这个商品 QPS 极高哪怕只是一瞬间缓存为空并发请求瞬间击穿 DB缓存击穿。随机 TTL 只能保证不会一堆 key 同时发生这件事但单个热点 key 击穿问题依旧存在。加主动预热后台定时任务在 key 到期前比如 55 分钟的时候主动查询 DB更新 Redis缓存永远不会失效。用户请求永远走缓存不会触发 DB 查询。 总结随机 TTL解决批量 key 同时过期雪崩过期前预热解决热点 key 失效瞬间的缓存击穿两个方案可以搭配使用不是二选一。两种预热方式对比预热类型触发时机适用场景依赖上线预热项目发布 / 重启后服务启动时Redis 为空提前批量加载热点数据启动任务、热点 key 清单过期主动预热热点 key 过期前后台刷新超高 QPS 热点数据防止 key 失效瞬间击穿定时任务 / 消息触发补充工程上的选型建议普通热点TTL 随机偏移就够用不用主动预热单个 key 过期少量请求查 DB压力可控。超高 QPS 核心热点秒杀商品、首页配置过期前主动预热避免单点缓存击穿。上线场景随机 TTL 完全无能为力必须做上线预热否则刚发布那一波流量直接打穿 DB。2. 不适合预热的场景低频冷门数据没必要预热浪费 Redis 内存数据实时性极高、频繁变更预热很快失效意义不大二、缓存预热有哪些方案从简单到生产方案 1项目启动时预热应用启动自动加载SpringBoot 启动完成后通过ApplicationRunner / CommandLineRunner主动查询 DBput 到 Redis。Component public class CachePreHeat implements ApplicationRunner { Autowired private RedisTemplateString, Object redisTemplate; Autowired private GoodsMapper goodsMapper; Override public void run(ApplicationArguments args) throws Exception { // 查询热点商品列表 ListGoods hotGoodsList goodsMapper.selectHotGoods(); for (Goods goods : hotGoodsList) { redisTemplate.opsForValue().set(goods: goods.getId(), goods); } } }✅ 优点简单 ❌ 缺点服务实例多的时候多个实例同时预热重复查 DB启动预热如果数据量很大会拖慢服务启动时间服务扩容新增实例还要重新预热适合热点数据量不大的场景。扩展一、ApplicationRunner 原理ApplicationRunner是 SpringBoot 提供的启动回调接口当 SpringBoot 应用上下文ApplicationContext完全初始化完成、Bean 全部创建注入完毕之后在应用正式对外提供服务之前自动执行run()方法。和它同类的还有CommandLineRunner时机几乎一样区别只在于入参解析方式ApplicationRunner入参是封装好的ApplicationArguments方便解析--keyvalue这种命名参数CommandLineRunner入参是原始字符串数组String[] args就是 main 方法的原始参数ApplicationRunner 接口源码public interface ApplicationRunner { void run(ApplicationArguments args) throws Exception; }只有一个抽象方法run(ApplicationArguments args)方法说明方法签名void run(ApplicationArguments args) throws Exception;参数ApplicationArguments args封装了启动命令行参数。 可以区分选项参数--namexxx→args.getOptionValues(name)非选项参数普通参数→args.getNonOptionArgs()执行时序加载配置、扫描包实例化所有 BeanCachePreHeat加了Component会被 Spring 扫描实例化自动注入redisTemplate、goodsMapper刷新 ApplicationContext容器初始化完成SpringBoot 自动收集所有实现了 ApplicationRunner / CommandLineRunner 的 Bean依次调用它们的run()方法执行完所有 Runner 之后才启动 Web 容器Tomcat/Undertow开始接收外部 HTTP 请求 ✅ 这一点非常适合启动缓存预热 预热代码执行的时候所有 Bean 已经就绪Web 服务还没监听端口外部请求进不来。预热完成后才开始接收流量天然避免请求进来但是缓存还没加载完成击穿数据库。二、怎么保证自动运行两个前提条件类上加Component或者配置成 Bean交给 Spring 容器管理。 如果不加 ComponentSpring 找不到这个 Bean不会自动执行。实现ApplicationRunner接口重写run(ApplicationArguments args)方法。SpringBoot 底层机制 在SpringApplication.run()内部容器刷新完成后会调用callRunners()。 源码简化逻辑private void callRunners(ApplicationContext context, ApplicationArguments args) { // 从容器拿到所有 ApplicationRunner ListApplicationRunner runners new ArrayList(context.getBeansOfType(ApplicationRunner.class).values()); // 排序然后循环执行 run 方法 for (ApplicationRunner runner : runners) { runner.run(args); } }Spring 会自动去容器查找所有ApplicationRunner类型的 Bean挨个执行不需要手动 new 对象或者调用 run。多个 Runner 执行顺序如果项目里有多个 ApplicationRunner / CommandLineRunner可以用Order(n)指定顺序数字越小越先执行Component Order(1) // 最先执行 public class CachePreHeat implements ApplicationRunner {三、这段预热代码的优缺点 坑点Component public class CachePreHeat implements ApplicationRunner { Autowired private RedisTemplateString, Object redisTemplate; Autowired private GoodsMapper goodsMapper; Override public void run(ApplicationArguments args) throws Exception { // 查询热点商品列表 ListGoods hotGoodsList goodsMapper.selectHotGoods(); for (Goods goods : hotGoodsList) { redisTemplate.opsForValue().set(goods: goods.getId(), goods); } } }✅ 优点时机完美容器就绪还没开放 Web 端口预热完成后才接收流量不会出现预热过程中用户请求进来击穿 DB。简单代码量少开箱即用。⚠️存在的问题阻塞应用启动run()方法是同步串行执行。如果热点数据量很大几万条DB 查询 循环写 Redis 很慢 → SpringBoot 启动卡住直到预热全部完成Tomcat 才会启动。后果发布的时候健康检查一直失败 解决方案预热改成异步Override public void run(ApplicationArguments args) throws Exception { // 异步预热不阻塞主线程启动 new Thread(() - { try { ListGoods hotGoodsList goodsMapper.selectHotGoods(); for (Goods goods : hotGoodsList) { redisTemplate.opsForValue().set(goods: goods.getId(), goods); } } catch (Exception e) { log.error(启动缓存预热失败, e); } }).start(); }但是异步预热会引入新问题Web 端口已经打开预热还没完成早期流量依然会击穿 DB。生产方案健康检查等待预热标记或者分批次灰度发布。一次性全量加载DB 压力突增启动一次性 select 大量热点数据瞬间压数据库。优化分页分批查询分批写入 Redis。没有超时、失败重试机制预热过程中 Redis 挂了 / DB 超时直接抛异常会影响应用启动同步模式下。需要 try-catch日志埋点告警。集群多实例重复预热如果部署 10 个 Pod每个 Pod 启动都会执行一遍预热10 次重复查 DB、重复写 Redis。优化用分布式锁Redis lock保证只有其中一个实例执行预热。没有设置过期时间代码set没有 expirekey 永久存在 Redis会造成 Redis 内存持续膨胀。// 增加过期时间 随机偏移 redisTemplate.opsForValue().set(goods: goods.getId(), goods, 1, TimeUnit.HOURS);四、ApplicationRunner VS PostConstruct很多人会搞混经常对比PostConstructBean实例化完成依赖注入结束之后立刻执行。 此时整个 Spring 容器还没完全刷新Web 容器还没启动。❌ 问题如果这个 Bean 依赖其他还没初始化完成的组件容易报错而且是单个 Bean 生命周期的回调不是全局启动完成回调。ApplicationRunner整个 ApplicationContext 容器全部刷新完毕之后执行。✅ 推荐用来做项目级启动预热任务。区分PostConstruct这个 Bean 自己造好了就跑ApplicationRunner所有 Bean 全部造好了容器就绪之后才跑。总结ApplicationRunner 是 SpringBoot 的启动回调接口。当 Spring 容器所有 Bean 加载完成在 Web 容器启动接收请求之前Spring 会自动查找容器中所有实现该接口的 Bean调用 run 方法。因此我们可以在 run 方法编写缓存预热逻辑在服务对外提供流量前加载热点数据到 Redis。缺点是同步预热会阻塞启动集群环境多实例会重复预热生产环境要做异步、分布式锁、分批加载、异常捕获。方案 2独立预热 Job定时任务 / 调度平台用 XXL-Job / Elastic-Job / Airflow 单独写预热任务独立于业务应用。大促前手动触发一次也可以定时周期性刷新热点缓存。✅ 优点不占用业务服务资源不会拖慢业务启动控制并发分批加载保护 DB可以手动触发运维可控。 ✅生产最推荐。方案 3消息驱动预热数据变更时触发数据库数据变更binlog canal捕获变更事件更新缓存。 适合数据会持续变动需要持续维护热点缓存。方案 4流量回放 / 访问日志预热抓取线上访问日志把高频访问 key 提取出来提前加载缓存。适合不知道哪些是热点 key 的场景。