缓存预热的目标很明确:在业务流量进来之前,让Redis中已经存在大部分高频访问数据。可是如果只理解成“把数据库数据提前写入Redis”,很容易出现两个问题:一是全量预热占用大量内存,导致RDB或者AOF文件快速膨胀;二是预热的数据没有过期时间或者全部同一时刻过期,事后反而引发缓存雪崩。因此预热必须和更新策略一起设计,前者决定系统冷启动时的表现,后者决定运行期间的数据一致性。

一、缓存预热的关键不是写得多,而是写得对
缓存预热并不是简单的把全部数据库记录复制到Redis。真正的预热应该解决三个问题:预热哪些数据、在什么时间点预热、以及预热数据应该设置多长的TTL。如果预热范围过大,Redis内存会被大量低频甚至从不访问的数据占满,不仅影响命中率,还可能导致有效数据被逐出;如果预热范围过小,冷启动后的请求仍然会大量回源数据库,预热效果微乎其微。
在实际项目中,热点数据的筛选通常可以通过两种方式完成。一种是根据历史访问日志离线统计TOP N的数据,比如前一天访问量最高的前5000个商品;另一种是基于业务规则直接圈定范围,例如大促页面的推荐商品、秒杀活动的SKU、首页榜单等。两种方式并非互斥,可以结合使用:先用业务规则圈定候选集,再用访问日志对候选集排序,只预热排名靠前的数据。在Redis中写入时应当设置合理的过期时间,并尽量将过期时间打散,避免同一时刻大量key过期。
预热时机主要有三类。应用启动预热通过ApplicationRunner或@PostConstruct在Spring容器启动后执行;定时预热通过调度任务周期扫描数据库或日志,动态更新缓存中的热点集合;事件驱动预热则是在运营配置变更或商品上架时主动触发。下面是一个启动预热的Java实现,使用Redis管道批量写入,减少网络RTT。
@Component
public class CacheWarmUpRunner implements ApplicationRunner {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductService productService;
@Override
public void run(ApplicationArguments args) {
List<Product> hotProducts = productService.listHotProducts(1000);
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection conn = (StringRedisConnection) connection;
for (Product product : hotProducts) {
conn.set("product:" + product.getId(), JSON.toJSONString(product));
conn.expire("product:" + product.getId(), 3600);
}
return null;
});
}
}
二、Cache Aside、Read/Write Through、Write Behind三种更新策略怎么选
缓存更新策略直接决定了数据一致性。最常用的Cache Aside模式要求读操作先查缓存,缓存未命中再查数据库并回填缓存;写操作则先更新数据库,再删除缓存,而不是更新缓存。删除而不是更新的原因在于并发写场景下,如果两个写请求同时更新缓存,可能因为顺序错乱导致缓存中保存了较旧的值。删除缓存后,下一次读请求会重新从数据库加载最新数据,虽然会带来一次缓存未命中,但能用很小的代价换取一致性。
Read/Write Through模式把缓存作为数据访问的入口,由缓存抽象层统一处理数据库读写。当读请求未命中时,缓存层自动从数据库加载;当写请求更新缓存时,缓存层同步写入数据库。这种模式对业务代码透明,但缓存层必须支持持久化写入逻辑,常见于本地缓存框架或自研缓存代理中。Write Behind Caching(异步写回)则把写操作先写入缓存,由后台任务批量异步刷新到数据库,写入性能极高,但存在Redis宕机导致数据丢失的风险,通常只用于允许短暂数据丢失的场景,比如浏览量统计、点赞数等。
对比之下,Cache Aside更适合大多数电商和互联网系统,因为实现简单、可控性强;Read/Write Through更适合需要统一缓存抽象的中台系统;Write Behind则适合高并发写多读少且对一致性要求不高的业务。下表总结了三种策略的差异。
| 策略 | 写数据库时机 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 业务代码控制,先DB后删缓存 | 最终一致 | 低 | 通用业务 |
| Read/Write Through | 缓存层同步写DB | 强一致/最终一致 | 中 | 统一缓存抽象 |
| Write Behind | 后台异步批量写DB | 弱一致 | 高 | 写多读少、允许丢失 |
下面给出Cache Aside模式下的延迟双删示例。需要注意的是,延迟时间通常要大于并发读回填旧缓存的时间,但也要避免过长导致不一致窗口扩大。
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductDao productDao;
public void updateProduct(Product product) {
productDao.update(product);
redisTemplate.delete("product:" + product.getId());
// 延迟双删,等待并发读的旧缓存被写入后再次删除
new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete("product:" + product.getId());
}).start();
}
}
三、用binlog订阅、消息队列和分布式锁解决一致性与穿透问题
即便采用Cache Aside并先删缓存,在高并发下仍然可能出现不一致。一个典型的场景是:线程A更新数据库,删除缓存;此时线程B读到旧缓存并回填;线程A的延迟双删还未执行。为避免这种窗口,除了延迟双删,更可靠的方式是订阅数据库变更日志,例如利用Canal解析MySQL binlog,将数据变更事件发送到消息队列,由独立的消费者负责删除或者更新Redis。这样缓存删除动作不再依赖业务代码,也不会漏删。
消息队列的作用还体现在异步解耦。当数据库更新后,发送缓存失效消息到MQ,消费者可以平滑地执行删除、重建缓存或一致性校验。如果消费者处理失败,消息会按照重试机制重新投递,最终保证缓存被处理。对于读多写少且一致性要求高的场景,可以在消费端使用版本号或时间戳比对,防止旧消息覆盖新缓存。
预热阶段和生产运行阶段还需要防止缓存击穿和穿透。缓存击穿通常发生在热点key过期瞬间,大量请求同时回源。可以先查询缓存,如果为空,通过分布式锁只允许一个线程去查询数据库并重建缓存,其他线程等待或返回降级数据。缓存穿透则是查询一个数据库也不存在的数据,导致每次都打到数据库,可以在缓存中存储空值并设置短过期时间。下面给出一个基于Redisson分布式锁和空值缓存的代码示例。
@Service
public class CacheQueryService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductDao productDao;
public String getProductName(Long id) {
String key = "product:" + id;
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
return "NULL".equals(value) ? null : value;
}
RLock lock = redissonClient.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return "NULL".equals(value) ? null : value;
}
Product product = productDao.findById(id);
if (product == null) {
redisTemplate.opsForValue().set(key, "NULL", 60, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(key, product.getName(), 3600, TimeUnit.SECONDS);
return product.getName();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return null;
}
}
四、预热失败兜底与监控指标让缓存体系真正闭环
预热任务本身可能失败,例如数据库查询超时、Redis连接数打满、预热过程中业务流量已经开始进入。针对这些情况,需要设计兜底方案:预热任务应当具备重试机制,重试超过阈值后要熔断,避免持续消耗资源;预热失败时系统可以自动降级为按需加载,即首次请求回源数据库并回填缓存,虽然首屏延迟会增加,但不会让服务完全不可用。
更新策略落地后,必须通过监控来验证效果。常见的指标包括缓存命中率、缓存穿透次数、缓存击穿次数、预热耗时、缓存不一致窗口时长、Redis内存使用率等。命中率一般应保持在90%以上,低于该值需要排查热点数据是否被错误逐出,或者预热覆盖范围是否不够。预热耗时可以衡量从执行预热任务到缓存就绪的时间,如果过长,需要优化批处理方式或增加并发度。
总结来说,Redis缓存预热与更新策略的落地不是简单几行代码,而是需要结合业务特点选择热点筛选方式、更新策略、一致性补偿机制和监控指标。预热解决冷启动压力,更新策略解决数据新鲜度,锁和空值缓存解决异常穿透,监控负责验证方案是否有效。这四个环节形成闭环后,缓存体系才能在流量峰值场景中稳定运行。

