Administrator
发布于 2020-08-21 / 296 阅读
3

Redis 缓存预热与热点 key 发现方案

大促前的准备:缓存预热到底该怎么做

八月中旬,运营定在 9 月 9 号做一场大促。我被分到的任务是"保证缓存不崩",具体要回答两个问题:活动开始时缓存是空的怎么办?某个商品突然爆了怎么办?

我们的缓存现状:Redis 6.0,一主两从 + 哨兵,单机 12GB。商品详情页的缓存是「被动」的——用户访问时才查库写缓存,TTL 30 分钟加随机偏移。平时没问题,大促一开始就完了。

空缓存的代价:算一下就知道有多可怕

去年双十二我们没做预热,活动开始第一分钟的数据:

时间QPS缓存命中率MySQL CPU详情页 TP99
T+0s18003%96%2400ms
T+30s320041%88%1100ms
T+2min410078%52%380ms
T+10min450096%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-policyallkeys-lfuvolatile-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 CPU96%11%
详情页 TP992400ms43ms
Redis 最高 CPU100%(单实例)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 发现方案》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考