首页数据通常呈现明显的热点特征,访问量集中在少量商品、榜单或推荐位上。这些数据如果每次都由用户请求触发缓存写入,缓存过期后的第一批请求会集中打到数据库。预加载的作用就是把这些高频读取的数据提前放进Redis,让缓存命中率在业务高峰前就处于较高水平。

一、为什么首页热点数据不能等请求来了再加载
首页接口的访问模型和普通详情页不同。详情页的key天然按ID分散,某个key过期影响范围有限;首页聚合数据则经常是几十个固定key被高频读取,比如首页推荐位、热销榜、活动配置。一旦这些key同时失效或Redis重启,恢复期间会有大量请求直接落到数据库。即使数据库单条索引查询不慢,并发量上来后连接池也会迅速被占满。
缓存击穿和缓存雪崩在首页场景会被放大。单个热点key过期后,如果没有保护,多个线程可能同时执行回源查询。假设回源接口耗时50毫秒,在1000 QPS下,这50毫秒内可能积压几十个相同查询。更严重的是,如果多个首页key的TTL设置成相同值,它们会在同一时间点过期,瞬时压力更大。
下面是一段没有预加载的常见写法。首次访问时发现缓存不存在,就查数据库再写入Redis,但在并发场景下无法阻止重复回源。
public HomeData getHomeData(String biz) {
String key = "home:data:" + biz;
String cache = redis.opsForValue().get(key);
if (cache != null) {
return JSON.parseObject(cache, HomeData.class);
}
HomeData data = homeMapper.selectByBiz(biz);
redis.opsForValue().set(key, JSON.toJSONString(data), Duration.ofMinutes(10));
return data;
}
这段代码在缓存未命中时没有互斥,高并发下会有多个请求同时执行selectByBiz。虽然最终缓存会写入,但数据库已经承受了不必要的压力。
二、预加载的触发时机与热点数据筛选
预加载首先需要明确什么时候触发。最简单的做法是应用启动完成后执行一次预热,把配置好的热点key全部加载到Redis。这种方式的优点是实现直接,缺点是Redis重启或手动清缓存后,需要等到下次应用发布或重启才能恢复。因此通常会在启动预热之外再增加定时刷新,让缓存即使被清理也能在较短时间内重新建立。
触发时机可以从三个维度设计:启动时预热、定时周期刷新、数据变更时更新。启动预热适合首页固定配置类数据,比如导航、类目、运营位;定时刷新适合榜单和推荐这种会周期性变化的数据;数据变更更新更适合后台修改配置后主动同步,能减少缓存脏数据窗口。
热点数据筛选同样不能忽视。首页模块很多,如果把所有key都塞进Redis,内存占用会明显上升。可以根据最近7天的访问日志统计key的请求次数,也可以直接从推荐系统、榜单服务或运营后台导出当天需要保证低延迟的热点集合。筛选时建议给每个热点设置一个优先级,优先预热访问量最大的前50到200个key,而不是全量预热。
下面是一个基于Spring Boot的启动预热和定时刷新示例。这里使用@PostConstruct执行启动加载,@Scheduled周期刷新,实际项目中可以配合配置中心读取热点列表。
@Service
public class HomePageCacheWarmer {
private final StringRedisTemplate redis;
private final HomePageService homePageService;
public HomePageCacheWarmer(StringRedisTemplate redis, HomePageService homePageService) {
this.redis = redis;
this.homePageService = homePageService;
}
@PostConstruct
public void warmUpOnStart() {
List<String> hotBizList = homePageService.loadHotBizList();
for (String biz : hotBizList) {
refreshOne(biz);
}
}
@Scheduled(fixedDelay = 120000, initialDelay = 60000)
public void warmUpBySchedule() {
List<String> hotBizList = homePageService.loadHotBizList();
for (String biz : hotBizList) {
refreshOne(biz);
}
}
private void refreshOne(String biz) {
String key = "home:hot:" + biz;
HomeData data = homePageService.loadFromDb(biz);
redis.opsForValue().set(key, JSON.toJSONString(data), Duration.ofMinutes(30));
}
}
这段代码的问题在于多实例部署时每个实例都会执行同样的预热任务。如果实例数量不多,重复查询还能接受;如果实例数量较多,就等同于一批定时任务同时回源,需要引入分布式锁来限制。
三、并发控制与缓存更新策略
多实例预热最常见的问题是重复回源。比如十个服务实例在同一时间启动,每个实例都在@PostConstruct里拉取热点数据,数据库会收到十次相同的批量查询。对于大查询来说这是不必要的放大。解决办法是使用Redis本身提供的SET NX EX命令做分布式锁,只有获取锁成功的实例才执行预热,其他实例等待一段时间后直接读取缓存。
缓存更新策略比预热本身更影响一致性。对于首页热点数据,通常采用旁路缓存模式:读请求先查缓存,未命中则回源并写缓存;写请求先更新数据库,再删除或覆盖缓存。预加载可以看作一种主动写缓存操作,它的难点在于如果数据库内容已经变化,而预加载还使用旧数据覆盖,就会人为延长脏数据时间。因此预加载前最好有版本号或更新时间校验。
另一种思路是设置逻辑过期时间。物理TTL到了之后Redis就删除key,逻辑过期则把过期时间写在value里,key仍然保留。读取时发现逻辑过期,先返回旧值,同时异步去回源重建。这样可以避免缓存过期瞬间的集中回源,也能让首页接口保持稳定。
获取分布式锁进行预热的代码示例如下。这里使用Redis的setIfAbsent实现锁,并设置过期时间防止实例宕机后锁永不释放。
public boolean tryWarmUp(String lockKey) {
String lockValue = UUID.randomUUID().toString();
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(locked)) {
try {
List<String> hotBizList = homePageService.loadHotBizList();
for (String biz : hotBizList) {
refreshOne(biz);
}
} finally {
String current = redis.opsForValue().get(lockKey);
if (lockValue.equals(current)) {
redis.delete(lockKey);
}
}
return true;
}
return false;
}
四、一个可落地的首页热点预加载方案
把前面的内容串起来,完整的预加载方案可以分为三层。第一层是热点清单维护,可以由离线任务统计访问日志生成,也可以由运营平台手工维护,最终落到配置中心或Redis集合中。第二层是预热执行器,负责在启动、定时和收到变更消息时执行预热,并用分布式锁控制并发。第三层是读取兜底,首页接口读取Redis时如果发现key缺失,仍然走回源逻辑,但需要加互斥锁,保证极端情况下不会击穿数据库。
读取链路可以使用逻辑过期加异步重建。下面给出一段读取热点数据的封装方法。它在缓存存在但逻辑过期时,先返回旧数据,同时尝试获取重建锁。如果获取成功,就通过线程池异步加载新数据并覆盖缓存。
public HomeData getHotData(String biz) {
String key = "home:hot:" + biz;
String json = redis.opsForValue().get(key);
if (json == null) {
return loadAndCache(biz, key);
}
HotCacheWrapper wrapper = JSON.parseObject(json, HotCacheWrapper.class);
if (wrapper.getExpireAt() > System.currentTimeMillis()) {
return wrapper.getData();
}
// 逻辑过期,先返回旧值,异步重建
String lockKey = "lock:hot:" + biz;
boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(20));
if (locked) {
executor.execute(() -> {
try {
loadAndCache(biz, key);
} finally {
redis.delete(lockKey);
}
});
}
return wrapper.getData();
}
private HomeData loadAndCache(String biz, String key) {
HomeData data = homePageService.loadFromDb(biz);
HotCacheWrapper wrapper = new HotCacheWrapper();
wrapper.setData(data);
wrapper.setExpireAt(System.currentTimeMillis() + Duration.ofMinutes(30).toMillis());
redis.opsForValue().set(key, JSON.toJSONString(wrapper));
return data;
}
这段代码中的HotCacheWrapper用expireAt字段记录逻辑过期时间,物理key可以设置得长一些或不设置过期时间。这样即使后台任务没有及时刷新,首页请求也不会因为缓存过期而直接回源。
最后还需要考虑Redis的内存容量和监控。首页热点预加载通常只占Redis总数据的一小部分,但要避免把不必要的大对象塞进去。可以设置maxmemory和合适的淘汰策略,例如allkeys-lru或volatile-lru。监控上重点关注缓存命中率、预热耗时、回源次数和Redis内存使用。尤其是预热耗时,如果某天预热从几百毫秒变成几秒,通常说明数据库查询变慢或热点数据量膨胀,需要及时调整查询SQL或热点集合大小。