滑动窗口热键探测与三级缓存设计

摘要: 本文结合一个社区 Feed 流项目的真实代码,深入讲解如何以"滑动窗口热键探测"为核心,通过分级阈值定义热度、动态 TTL 延长缓存、多缓存层协同、SingleFlight 防击穿、反向索引准实时更新等机制,实现热门 Feed 页缓存的高效智能化管理。方案无需重构现有架构,配置灵活、运维成本低,可快速落地并持续优化。


一、背景:高流量 Feed 场景的缓存之痛

在社区类平台中,Feed 流首页往往是流量最大的接口------QPS 可达数千甚至上万。传统缓存策略(固定 TTL、无差别缓存)在面对热点流量时,暴露了三个核心痛点:

  1. 热点页面频繁失效:热门内容被大量用户集中访问,但因固定 TTL 到期,大量请求同时回源数据库和 Redis,造成负载飙升;
  2. 热点衰减后资源浪费:当热点冷却后,缓存仍保留着该页面的长 TTL,无法自动降级;
  3. 缓存雪崩风险:大量缓存同时失效的瞬间,回源洪峰可能直接压垮后端数据库。

针对这些问题,我们设计了一套以 "热度驱动缓存生命周期" 为核心的缓存治理方案。


二、核心设计思想

本方案的核心理念是 "热度驱动缓存生命周期",通过以下闭环实现智能缓存管理:

复制代码
滑动窗口探测热点 → 分级阈值定义热度 → 动态 TTL 延长缓存 → 多缓存层协同
                          ↕
                    稳定性机制兜底
             (SingleFlight / 双删 / 随机抖动)

四个关键设计思路:

  1. 热点识别轻量化:采用 JVM 内存滑动窗口计数,O(1) 写入开销,无需网络通信;
  2. 缓存 TTL 与热度强绑定:热度越高,缓存停留时间越长,最大化命中率收益;
  3. 分离公共维度与用户维度:公共页面骨架走缓存,点赞/收藏等个性化状态实时查询,避免缓存碎片化;
  4. 与现有架构协同:不重构核心业务逻辑,在现有三级缓存架构上扩展。

三、核心技术实现

3.1 热度探测:滑动窗口计数模型

热度探测是整个方案的基础。核心数据结构是一组 分段计数数组(Segment Array) ,每个被追踪的 key 维护一个 int[],数组的每个元素对应滑动窗口内的一个时间段。

参数设计
参数 默认值 说明
windowSeconds 60 滑动窗口总时长(秒),即统计最近 60 秒的访问量
segmentSeconds 10 每个时间切片的时长(秒),窗口被划分为 6 段

核心原则:分段数量保持在 6~12 个,既保证统计细腻度,又避免数组过长带来的求和开销。

核心代码实现
java 复制代码
@Component
public class HotKeyDetector {
    public enum Level { NONE, LOW, MEDIUM, HIGH }

    private final CacheProperties properties;
    // 每个 key 的滑窗分段计数数组
    private final Map<String, int[]> counters = new ConcurrentHashMap<>();
    // 当前活跃分段索引
    private final AtomicInteger current = new AtomicInteger(0);
    // 滑窗分段数量
    private final int segments;

    public HotKeyDetector(CacheProperties properties) {
        this.properties = properties;
        int segSeconds = properties.getHotkey().getSegmentSeconds();
        int winSeconds = properties.getHotkey().getWindowSeconds();
        this.segments = Math.max(1, winSeconds / Math.max(1, segSeconds));
    }

    // O(1) 记录访问
    public void record(String key) {
        int[] arr = counters.computeIfAbsent(key, k -> new int[segments]);
        arr[current.get()]++;
    }

    // O(segments) 求和计算热度
    public int heat(String key) {
        int[] arr = counters.get(key);
        if (arr == null) return 0;
        int sum = 0;
        for (int v : arr) sum += v;
        return sum;
    }

    // 定时轮转:每 segmentSeconds 执行一次
    @Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
    public void rotate() {
        int next = (current.get() + 1) % segments;
        current.set(next);
        for (int[] arr : counters.values()) {
            arr[next] = 0;  // 清零新段,旧段自然移出窗口
        }
    }
}

为什么不用 Redis Sorted Set? 热点检测是每次请求都会触发 的操作。如果在 Redis 中做热度统计,每次 record() 都需要一次网络 IO,高频访问本身就是性能问题。而 JVM 内存的 int[] + ConcurrentHashMap,写入是纳秒级的数组自增,没有任何网络开销。

热度自动衰减

滑动窗口的轮转机制天然实现了热度的自动降级------旧时间切片的计数随着轮转被清零,不再计入求和结果,无需额外编写降级逻辑。热点上得快,冷下来也快

3.2 热度分级与 TTL 动态扩展

热度值被映射为四个等级,每个等级对应不同的 TTL 扩展秒数:

java 复制代码
public Level level(String key) {
    int h = heat(key);
    if (h >= properties.getHotkey().getLevelHigh())   return Level.HIGH;
    if (h >= properties.getHotkey().getLevelMedium()) return Level.MEDIUM;
    if (h >= properties.getHotkey().getLevelLow())    return Level.LOW;
    return Level.NONE;
}
等级 阈值 扩展 TTL 示例(基准 60s + 扩展)
NONE < 50 0 秒 60 秒
LOW ≥ 50 20 秒 80 秒
MEDIUM ≥ 200 60 秒 120 秒
HIGH ≥ 500 120 秒 180 秒

扩展 TTL 不仅作用于页面缓存,还作用于 Feed 条目片段缓存,确保热点内容在 Feed 流中也不会轻易过期:

java 复制代码
private void recordHotKeyAndExtendTtl(long id, String detailPageKey) {
    String hotKeyId = "knowpost:" + id;
    hotKey.record(hotKeyId);
    
    int baseTtl = 60;
    int target = hotKey.ttlForPublic(baseTtl, hotKeyId);
    
    // 延长详情页缓存
    Long detailTtl = redis.getExpire(detailPageKey);
    if (detailTtl < target) {
        redis.expire(detailPageKey, Duration.ofSeconds(target));
    }
    
    // 延长 Feed 流内容片段缓存
    String itemKey = "feed:item:" + id;
    Long itemTtl = redis.getExpire(itemKey);
    if (itemTtl < target) {
        redis.expire(itemKey, Duration.ofSeconds(target));
    }
}

3.3 多缓存层协同架构

方案基于 L0/L1/L2 三级缓存 构建读路径,各层分工明确:

层级 存储介质 内容 访问速度 TTL
L0(碎片缓存) Redis 单条 Feed 详情、条目元数据 0.5~2ms 60~89s(带抖动)
L1(页面骨架缓存) Redis 页面 ID 列表、hasMore 标记 0.5~2ms 60~89s(带抖动)
L2(本地完整页缓存) Caffeine(JVM 内存) 已组装的完整 Feed 页 纳秒级 15s
读路径流程
复制代码
请求进入 → L2 Caffeine 命中? → 是 → 叠加用户状态 → 返回
                ↓ 否
         L1 Redis 骨架命中?   → 是 → 从 L0 组装完整页 → 写入 L2 → 返回
                ↓ 否
         SingleFlight 回源 MySQL → 回填 L0/L1/L2 → 返回

关键设计: 每次返回前调用 enrich() 方法,从计数服务的 Redis 位图中实时查询当前用户的 liked/faved 状态。这部分是用户维度的,不入共享缓存,避免用户 A 的点赞状态污染用户 B 的视图。

缓存 TTL 协同规则
  • L0、L1、L2 均使用"基础 TTL + 热度扩展 TTL + 随机抖动"的配置;
  • L0 碎片缓存的 TTL 与对应 L1 页面骨架的 TTL 保持一致;
  • L2 本地缓存的 TTL 不超过 L1 缓存的 TTL,避免本地缓存长期有效但 Redis 缓存已失效导致数据不一致。

3.4 SingleFlight:缓存击穿的终极防护

当缓存全部失效,N 个并发请求同时打穿到数据库时,SingleFlight 机制 确保只允许一个请求执行数据库查询,其余请求等待并复用结果。

java 复制代码
// 以页面 ID 列表的 Redis Key 作为"航班号"
Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
synchronized (lock) {
    // Double Check:等待锁期间,前一个线程可能已回填缓存
    FeedPageResponse again = assembleFromCache(idsKey, ...);
    if (again != null) {
        feedPublicCache.put(localPageKey, again);
        singleFlight.remove(idsKey);
        return again;
    }
    
    // 真正回源数据库
    List<KnowPostFeedRow> rows = mapper.listFeedPublic(safeSize + 1, offset);
    // ... 回填各级缓存 ...
    singleFlight.remove(idsKey);
    return respForCache;
}

为什么用 JVM 本地锁而不是 Redis 分布式锁? SingleFlight 的目的是"合并同一 JVM 实例内的重复请求",而非跨实例互斥。JVM 本地锁没有网络 IO、没有锁竞争的开销。配合 Nginx 按用户 ID 哈希路由,同一个页面的请求尽量落到同一个实例上,合并效果更佳。

3.5 事件驱动双删与反向索引

缓存双删

当内容更新时(如发布、修改、置顶),执行"更新前删除 + 更新后删除"的双删策略:

java 复制代码
private void invalidateCache(long id) {
    String pageKey = "knowpost:detail:" + id + ":v" + DETAIL_LAYOUT_VER;
    redis.delete(pageKey);           // 第一次删除
    knowPostDetailCache.invalidate(pageKey);
    // ... 执行数据库更新 ...
    redis.delete(pageKey);           // 第二次删除
    knowPostDetailCache.invalidate(pageKey);
}

为什么删两次?时间线说明:

  • T1:线程 A 第一次删除缓存
  • T2:线程 B 读请求进入,缓存未命中,从数据库读到旧数据并回填缓存
  • T3:线程 A 更新数据库为新数据
  • T4:线程 A 第二次删除缓存 → 清除 T2 产生的脏缓存
  • T5:线程 C 读请求 → 缓存未命中 → 从数据库读到新数据 → 写入缓存 ✅
反向索引:计数变更的准实时更新

点赞/收藏数变了怎么办?不能每次都双删整页缓存------代价太大。我们设计了反向索引机制:

复制代码
┌──────────────────────────────────────────┐
│  反向索引 Redis Set                       │
│  feed:public:index:{知文ID}:{hourSlot}    │
│  ┌────────────────────────────────────┐   │
│  │  feed:public:10:1:v1              │   │
│  │  feed:public:10:2:v1              │   │
│  │  feed:public:20:1:v1              │   │
│  └────────────────────────────────────┘   │
│  Set 成员 = 包含该知文的页面 Key          │
└──────────────────────────────────────────┘

CounterEvent 触发时,FeedCacheInvalidationListener 通过反向索引定位所有受影响的页面,原地更新计数并保留原 TTL

java 复制代码
@EventListener
public void onCounterChanged(CounterEvent event) {
    // ... 通过反向索引找到受影响的页面 ...
    for (String key : keys) {
        // 更新本地 Caffeine 缓存(保留 liked/faved)
        FeedPageResponse updatedLocal = adjustPageCounts(local, eid, metric, delta, true);
        feedPublicCache.put(key, updatedLocal);
        
        // 更新 Redis 页面 JSON(不携带用户态标志,保留 TTL)
        writePageJsonKeepingTtl(key, updated);
    }
}

3.6 随机抖动防雪崩

所有缓存的 TTL 均加入随机抖动,避免大量 key 在同一时刻集中失效:

java 复制代码
int baseTtl = 60;
int jitter = ThreadLocalRandom.current().nextInt(30);  // 0~29 秒随机
Duration frTtl = Duration.ofSeconds(baseTtl + jitter);

即使大量页面处于同一热度等级,失效时间也会分散,避免"集中失效→集体回源"的缓存雪崩。


四、关键配置与调参指南

方案提供多维度可配置参数,支持根据业务场景精细调整:

yaml 复制代码
cache:
  hotkey:
    window-seconds: 60          # 滑动窗口时长
    segment-seconds: 10         # 时间切片粒度
    level-low: 50               # 低热度阈值
    level-medium: 200           # 中热度阈值
    level-high: 500             # 高热度阈值
    extend-low-seconds: 20      # 低热度扩展 TTL
    extend-medium-seconds: 60   # 中热度扩展 TTL
    extend-high-seconds: 120    # 高热度扩展 TTL
  l2:
    public-cfg:
      ttl-seconds: 15           # L2 公共 Feed TTL
      max-size: 1000            # L2 最大条目数
    detail-cfg:
      ttl-seconds: 30           # 详情页 L2 TTL
      max-size: 5000            # 详情页 L2 最大条目数

调参原则:

  • 阈值应根据业务 QPS 分布调整------QPS 高的场景适当提高阈值;
  • 扩展 TTL 不应超过 window-seconds 的 2 倍,控制数据陈旧风险;
  • segment-seconds 不宜过小(避免轮转过快导致计数不稳定),也不宜过大(降低热度响应灵敏性)。

五、方案权衡与核心优势

设计权衡

选择 方案 权衡点
热度探测 本地滑动窗口 vs 分布式同步 本地探测零网络开销、落地成本低;分布式需解决一致性与通信成本
统计模型 滑动窗口 vs 指数衰减 滑动窗口逻辑简单、上快下也快;指数衰减需要调衰减系数
一致性 最终一致性 vs 强一致性 Feed 流场景接受秒级不一致,双删 + 短 TTL 足够

核心优势

  1. 轻量高效:热度探测基于内存数组与原子操作,无复杂计算与网络开销,不影响核心链路性能;
  2. 自适应热点:滑动窗口实现热度自动衰减,无需人工干预,适配热点"瞬时集中、快速衰减"的特征;
  3. 稳定性强:SingleFlight + 随机抖动 + 双删 + 反向索引四位一体,全方位防范回源洪峰与缓存雪崩;
  4. 落地成本低:基于现有三级缓存架构扩展,不重构核心业务逻辑,兼容现有回源与缓存机制。

实际效果

  • L2 Caffeine 本地缓存命中率:80%+
  • L1 + L2 综合命中率:95%+
  • 数据库回源压力降低:20 倍以上
  • 响应时间:毫秒级(L2 命中时为纳秒级)

六、总结

本文介绍了一套轻量级的热键探测与缓存治理方案。核心是 "热度驱动缓存生命周期"------用滑动窗口以极低开销识别热点,用分级 TTL 扩展让热门数据留得更久,用三级缓存逐级削峰,用 SingleFlight 和反向索引保障一致性与稳定性。

这套方案的核心代码已在生产项目中落地验证,代码开源、配置灵活,适用于高流量 Feed 场景以及任何需要缓存智能治理的读密集型应用。


参考

  • 京东 HotKey 探测框架 --- 分布式热点探测的工业级实现
  • Caffeine --- Java 生态最快的本地缓存库,基于 W-TinyLFU 淘汰算法
相关推荐
好家伙VCC1 小时前
区块链双向支付通道实战:从签名到结算
java·后端·区块链·asp.net
ss2731 小时前
【入门OJ题解】分苹果问题(Python/Java/C 实现)
java·c语言·python
weikecms2 小时前
美团霸王餐报名API接口
java·开发语言
真实的菜2 小时前
【无标题】Redis 从入门到精通(七):缓存设计与最佳实践 —— 穿透、击穿、雪崩与一致性终极指南
数据库·redis·缓存
李白的天不白2 小时前
配置mysql密码
java
念何架构之路2 小时前
存储技术Redis
数据库·redis·缓存
何中应2 小时前
Nexus如何上传JAR包
java·maven·jar
我登哥MVP2 小时前
Spring Boot 从“会用”到“精通”:参数解析原理
java·spring boot·后端·spring·servlet·maven·intellij-idea
Wenzar_2 小时前
VITS+Whisper微调:低延迟TTS实战
java·人工智能·whisper