摘要: 本文结合一个社区 Feed 流项目的真实代码,深入讲解如何以"滑动窗口热键探测"为核心,通过分级阈值定义热度、动态 TTL 延长缓存、多缓存层协同、SingleFlight 防击穿、反向索引准实时更新等机制,实现热门 Feed 页缓存的高效智能化管理。方案无需重构现有架构,配置灵活、运维成本低,可快速落地并持续优化。
一、背景:高流量 Feed 场景的缓存之痛
在社区类平台中,Feed 流首页往往是流量最大的接口------QPS 可达数千甚至上万。传统缓存策略(固定 TTL、无差别缓存)在面对热点流量时,暴露了三个核心痛点:
- 热点页面频繁失效:热门内容被大量用户集中访问,但因固定 TTL 到期,大量请求同时回源数据库和 Redis,造成负载飙升;
- 热点衰减后资源浪费:当热点冷却后,缓存仍保留着该页面的长 TTL,无法自动降级;
- 缓存雪崩风险:大量缓存同时失效的瞬间,回源洪峰可能直接压垮后端数据库。
针对这些问题,我们设计了一套以 "热度驱动缓存生命周期" 为核心的缓存治理方案。
二、核心设计思想
本方案的核心理念是 "热度驱动缓存生命周期",通过以下闭环实现智能缓存管理:
滑动窗口探测热点 → 分级阈值定义热度 → 动态 TTL 延长缓存 → 多缓存层协同
↕
稳定性机制兜底
(SingleFlight / 双删 / 随机抖动)
四个关键设计思路:
- 热点识别轻量化:采用 JVM 内存滑动窗口计数,O(1) 写入开销,无需网络通信;
- 缓存 TTL 与热度强绑定:热度越高,缓存停留时间越长,最大化命中率收益;
- 分离公共维度与用户维度:公共页面骨架走缓存,点赞/收藏等个性化状态实时查询,避免缓存碎片化;
- 与现有架构协同:不重构核心业务逻辑,在现有三级缓存架构上扩展。
三、核心技术实现
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 足够 |
核心优势
- 轻量高效:热度探测基于内存数组与原子操作,无复杂计算与网络开销,不影响核心链路性能;
- 自适应热点:滑动窗口实现热度自动衰减,无需人工干预,适配热点"瞬时集中、快速衰减"的特征;
- 稳定性强:SingleFlight + 随机抖动 + 双删 + 反向索引四位一体,全方位防范回源洪峰与缓存雪崩;
- 落地成本低:基于现有三级缓存架构扩展,不重构核心业务逻辑,兼容现有回源与缓存机制。
实际效果
- L2 Caffeine 本地缓存命中率:80%+
- L1 + L2 综合命中率:95%+
- 数据库回源压力降低:20 倍以上
- 响应时间:毫秒级(L2 命中时为纳秒级)
六、总结
本文介绍了一套轻量级的热键探测与缓存治理方案。核心是 "热度驱动缓存生命周期"------用滑动窗口以极低开销识别热点,用分级 TTL 扩展让热门数据留得更久,用三级缓存逐级削峰,用 SingleFlight 和反向索引保障一致性与稳定性。
这套方案的核心代码已在生产项目中落地验证,代码开源、配置灵活,适用于高流量 Feed 场景以及任何需要缓存智能治理的读密集型应用。
参考
- 京东 HotKey 探测框架 --- 分布式热点探测的工业级实现
- Caffeine --- Java 生态最快的本地缓存库,基于 W-TinyLFU 淘汰算法