大促前的准备:缓存预热到底该怎么做
八月中旬,运营定在 9 月 9 号做一场大促。我被分到的任务是"保证缓存不崩",具体要回答两个问题:活动开始时缓存是空的怎么办?某个商品突然爆了怎么办?
我们的缓存现状:Redis 6.0,一主两从 + 哨兵,单机 12GB。商品详情页的缓存是「被动」的——用户访问时才查库写缓存,TTL 30 分钟加随机偏移。平时没问题,大促一开始就完了。
空缓存的代价:算一下就知道有多可怕
去年双十二我们没做预热,活动开始第一分钟的数据:
| 时间 | QPS | 缓存命中率 | MySQL CPU | 详情页 TP99 |
|---|---|---|---|---|
| T+0s | 1800 | 3% | 96% | 2400ms |
| T+30s | 3200 | 41% | 88% | 1100ms |
| T+2min | 4100 | 78% | 52% | 380ms |
| T+10min | 4500 | 96% | 18% | 95ms |
头 30 秒缓存命中率只有 3%,MySQL 直接被打到 96%。更要命的是缓存击穿的放大效应:同一个热门商品 key 过期,瞬间几百个请求同时发现缓存没有,全部去查库、全部去写缓存。MySQL 上是一模一样的 SQL 执行几百遍。
所以预热这件事的核心不是"提前填数据",而是让数据库绝对不要在高并发下承担冷启动流量。
预热时机:别在应用启动时做
我第一种做法是加个 ApplicationRunner,服务启动时把热门商品灌进 Redis:
@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
List<Long> hotItemIds = itemMapper.selectHotItemIds(5000);
hotItemIds.forEach(id -> {
ItemVO vo = itemService.loadFromDb(id);
redisTemplate.opsForValue().set("item:detail:" + id, vo, 30, TimeUnit.MINUTES);
});
}
}
这个方案有三个问题,很快就暴露了:
- 启动变慢:5000 个商品逐个查,单次 3ms,串行跑就是 15 秒。K8s 的 readiness probe 超时是 10 秒,Pod 直接被标记为不健康,滚动发布卡住。
- 每台机器都在灌:我们 8 个实例,同一份数据写 8 遍。用
SETNX能挡住大部分,但启动时还是有竞争。 - 大促前重启一次就得重来:临时改个配置发布,预热又要等 15 秒。
最终改成了独立的预热任务 + 分布式锁 + 并行加载。预热不跟应用启动绑定,而是一个可以手动触发、也可以定时触发的接口:
@Service
public class CacheWarmService {
@Autowired private StringRedisTemplate redis;
@Autowired private RedissonClient redisson;
@Autowired private ItemMapper itemMapper;
private static final String WARM_LOCK = "lock:cache:warm:item";
public int warmHotItems(int limit) {
RLock lock = redisson.getLock(WARM_LOCK);
if (!lock.tryLock()) {
log.info("已有实例在执行预热,跳过");
return 0;
}
try {
List<ItemPO> items = itemMapper.selectHotItems(limit);
// 并行加载,20 个线程
items.parallelStream().forEach(item -> {
ItemVO vo = convert(item);
// SET NX,已有缓存的不覆盖,避免把刚更新的数据刷成旧的
redis.opsForValue().setIfAbsent(
"item:detail:" + item.getId(),
JSON.toJSONString(vo),
30 + ThreadLocalRandom.current().nextInt(10),
TimeUnit.MINUTES);
});
log.info("预热完成, count={}", items.size());
return items.size();
} finally {
lock.unlock();
}
}
}
几个决策点:
- 用
setIfAbsent而不是set,避免预热把运行中的新数据覆盖成旧快照。 - TTL 加随机偏移(30~40 分钟),防止同一批 key 同时过期造成缓存雪崩。这个之前吃过亏:一批 key 在同一秒过期,Redis 瞬间被打穿。
- 加分布式锁,8 个实例只有一个在干活。
- 定时预热放在活动开始前 20 分钟,用 Spring 的
@Scheduled或者 Linux cron 调一次接口。我们用的是 cron + curl,比改代码灵活。
预热 5000 个商品,20 线程并行,耗时从 15 秒降到 2.1 秒。
热点 key 怎么发现
预热解决的是"活动开始时缓存是空的",但还有另一个问题:某个商品突然爆了,不在预热名单里。去年双十二有个商品因为被大 V 转发,QPS 从平时的 20 涨到 11000,单 key 打爆了 Redis 的一个连接。
Redis 是单线程处理命令的,单 key 的 QPS 上限就是单核的处理能力。我们实测单 key 的 GET 能到 8 万 QPS,但 value 是 20KB 的商品详情 JSON,只能到 9000 QPS——瓶颈在网络传输和序列化。而且这个 key 只落在某一个 Redis 实例上,那个实例 CPU 100%,其他实例很闲,加机器没用。
方法一:客户端统计(我们最终用的)
在 Redis 客户端做一个轻量的访问计数,本地统计、定时上报。用 Guava 的 LongAdder 数组,避免并发竞争:
public class HotKeyDetector {
private static final int SLOTS = 1024;
private final AtomicLongArray counters = new AtomicLongArray(SLOTS);
private final ConcurrentHashMap<Integer, LongAdder> slotAdders = new ConcurrentHashMap<>();
public void record(String key) {
int slot = Math.abs(key.hashCode()) % SLOTS;
slotAdders.computeIfAbsent(slot, k -> new LongAdder()).increment();
}
/** 每 10 秒调用一次,返回超过阈值的 key 候选 */
public List<String> drainAndReport(long threshold) {
List<String> hot = new ArrayList<>();
for (int i = 0; i < SLOTS; i++) {
LongAdder adder = slotAdders.get(i);
if (adder == null) continue;
long count = adder.sumThenReset();
if (count > threshold) {
hot.add("slot-" + i + ":" + count);
}
}
return hot;
}
}
这个只能定位到 slot(因为 1024 个槽会 hash 冲突),精度不高。我们后来改成了用 LRU 的 LinkedHashMap 缓存 top 200 的精确 key,每次访问都更新,10 秒上报一次。额外开销实测:单次访问增加 0.3 微秒,对接口耗时(平均 12ms)影响可以忽略。
方法二:redis-cli --hotkeys(只能用于排查,不能常态化)
$ redis-cli --hotkeys
# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Hot key 'item:detail:10086' found so far with counter 41203 avg object size 20480
[03.12%] Hot key 'item:stock:20031' found so far with counter 8821 avg object size 16
-------- summary -------
Sampled 312843 keys in the keyspace!
hot key found with counter: 41203 keyname: item:detail:10086
这个命令的原理是扫描全库,对每个 key 读 OBJECT FREQ(需要 maxmemory-policy 是 allkeys-lfu 或 volatile-lfu,LFU 才会记录访问频率)。扫全库非常慢,我们 300 万 key 跑一次要 40 多秒,而且会消耗 CPU。只适合出问题时临时用一次,绝对不能做成定时任务。
方法三:monitor(强烈不建议在线上用)
$ redis-cli monitor | head -10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20
这个我只在测试环境用过。MONITOR 会把 Redis 处理的每一条命令都打回来,官方自己都说了它会让吞吐量下降 50% 以上。线上开这个等于自杀。我第一次用的时候不知道,在从库上开了 3 秒,结果从库主从同步延迟一下涨到 12 秒。
方法四:代理层统计(最理想但要改架构)
如果用了 Redis 代理(Twemproxy、Codis、或者云厂商的 Proxy),在代理层做统计是最干净的——对业务代码零侵入,而且能覆盖所有客户端。我们的架构没有代理层,就没走这条路。这是我在技术方案里写下的"下一步规划",但大促前没有时间改了。
热点的应对:本地缓存兜底
发现热点之后怎么办?我们的方案是本地缓存(Caffeine)+ 短 TTL。热点 key 在应用本地也存一份,大部分请求根本不出 JVM。
@Configuration
public class CacheConfig {
@Bean
public Cache<String, ItemVO> localItemCache() {
return Caffeine.newBuilder()
.maximumSize(500) // 只放真正热的
.expireAfterWrite(3, TimeUnit.SECONDS) // 3 秒,容忍短暂不一致
.recordStats()
.build();
}
}
读逻辑改成三级:本地缓存 → Redis → DB。
public ItemVO getItem(Long itemId) {
String key = "item:detail:" + itemId;
// 1. 本地缓存
ItemVO local = localCache.getIfPresent(key);
if (local != null) {
return local;
}
// 2. Redis
String json = redis.opsForValue().get(key);
if (json != null) {
ItemVO vo = JSON.parseObject(json, ItemVO.class);
// 热点判断:命中频率高的才往本地放
if (hotKeyDetector.recordAndCheck(key)) {
localCache.put(key, vo);
}
return vo;
}
// 3. DB,加分布式锁防击穿
RLock lock = redisson.getLock("lock:item:" + itemId);
try {
if (lock.tryLock(2, TimeUnit.SECONDS)) {
// 双重检查
json = redis.opsForValue().get(key);
if (json != null) return JSON.parseObject(json, ItemVO.class);
ItemVO vo = loadFromDb(itemId);
redis.opsForValue().set(key, JSON.toJSONString(vo),
30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.MINUTES);
return vo;
}
Thread.sleep(50);
return getItem(itemId); // 没抢到锁,递归重试(有次数限制)
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
本地缓存的 TTL 设 3 秒是个权衡:太长会导致商品改价之后用户看到旧价格,太短则命中率低。我们商品详情页对一致性的要求没那么高,3 秒完全可以接受。
最终效果和踩的坑
9 月 9 号大促当天的数据:
| 指标 | 去年双十二 | 今年 9.9 |
|---|---|---|
| 活动首分钟缓存命中率 | 3% | 99.2% |
| 活动首分钟 MySQL CPU | 96% | 11% |
| 详情页 TP99 | 2400ms | 43ms |
| Redis 最高 CPU | 100%(单实例) | 34% |
踩的一个坑值得记一下:本地缓存用了 Caffeine 的 maximumSize(500),但我们的商品详情 JSON 平均 20KB,500 条就是 10MB。8 个实例加起来 80MB,直接进老年代,触发了两次 Full GC。后来把 value 精简了一下(去掉了不用展示的字段,20KB → 6KB),并且把 maximumSize 改成基于权重的:
.maximumWeight(4096 * 1024) // 总权重 4MB
.weigher((String k, ItemVO v) -> k.length() + estimateSize(v))
留个问题
关于《Redis 缓存预热与热点 key 发现方案》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。