SpringBoot本地缓存+热点key自动探测+动态TTL延长,解决高并发信息流分页场景
整体架构
这是一个可配置、高性能、自动识别热点、自动延长缓存时间的本地缓存系统:
配置类:统一缓存大小,过期时间,热点阈值
缓存构建类:创建本地caffeine本地缓存
热点探测器:滑动窗口统计访问热度,分级,动态延长TTL
作用:让热门内容缓存更久、冷门内容自动过期,大幅减轻数据库 / 服务压力。
实现详解
CacheProperties(配置绑定类)
当本地 Caffeine 缓存里的缓存键值对数量 ,超过 maxSize=1000 条时,Caffeine 自动按照自己内置淘汰算法,删掉一部分缓存,控制总数不超限。
这里讲一下caffeine的逐出策略
-LRU :只淘汰最久没访问的,容易被冷门一次性数据污染缓存
-
LFU :只淘汰访问次数最少的,老旧冷门容易占坑
-
Window TinyLFU(Caffeine 默认)
结合 访问频率 + 最近访问时间,兼顾:
经常访问的热点 key 尽量保留 偶尔访问的一次性冷数据优先被清 避免缓存污染、命中率极高 代码采用容量限制 + 写入固定过期时间 双重控制
时间到了:不管满不满都过期
数量超了:按 Caffeine 算法主动踢掉冷门
`package com.tongji.cache.config;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
/**
- 缓存相关配置项。
- 配置前缀:{@code cache},用于绑定 {@code application.yml} 中的缓存参数。
*/ @Component @ConfigurationProperties(prefix = "cache") @Data public class CacheProperties { // 进程内缓存(本地二级缓存)配置。 private L2 l2 = new L2();
java
// 热点 Key 识别与扩展策略配置。
private Hotkey hotkey = new Hotkey();
@Data
public static class L2 {
// 公共信息流缓存配置。
private PublicCfg publicCfg = new PublicCfg();
// 个人信息流缓存配置。
private MineCfg mineCfg = new MineCfg();
// 知文详情缓存配置
private DetailCfg detailCfg = new DetailCfg();
}
@Data
public static class PublicCfg {
// TTL(秒):写入后在本地缓存中保留的时长。
private int ttlSeconds = 15;
// 最大条目数:超过后按 Caffeine 策略逐出。
private long maxSize = 1000;
}
@Data
public static class MineCfg {
// TTL(秒):写入后在本地缓存中保留的时长。
private int ttlSeconds = 10;
// 最大条目数:超过后按 Caffeine 策略逐出。
private long maxSize = 1000;
}
@Data
public static class DetailCfg {
// TTL(秒):写入后在本地缓存中保留的时长。
private int ttlSeconds = 30;
// 最大条目数:超过后按 Caffeine 策略逐出。
private long maxSize = 5000;
}
@Data
public static class Hotkey {
// 热点统计窗口长度(秒)。
private int windowSeconds = 60;
// 统计窗口切片大小(秒),用于按段累计访问次数。
private int segmentSeconds = 10;
// 低热度阈值:窗口内访问次数达到该值视为低热。
private int levelLow = 50;
// 中热度阈值:窗口内访问次数达到该值视为中热。
private int levelMedium = 200;
// 高热度阈值:窗口内访问次数达到该值视为高热。
private int levelHigh = 500;
// 低热度额外延长 TTL(秒)。
private int extendLowSeconds = 20;
//中热度额外延长 TTL(秒)。
private int extendMediumSeconds = 60;
// 高热度额外延长 TTL(秒)。
private int extendHighSeconds = 120;
}
}
CacheConfig(缓存构建类)
使用 Caffeine 创建两个本地缓存 Bean,分别给:
公共信息流(广场 / 推荐) 个人信息流(我的发布) 两个 Bean feedPublicCache
-
公共页缓存
- 容量:publicCfg.maxSize
- 写入过期:publicCfg.ttlSeconds feedMineCache
个人页缓存
markdown
1. 容量:mineCfg.maxSize
1. 写入过期:mineCfg.ttlSeconds
@Bean("feedPublicCache) 把方法返回的caffeine对象交给Spring管理
FeedPageResponse 一页公共信息的流的返回结果,Key一般是分页参数
是一个专门用来放一页信息流的封装结果类
是java16的新Record类型,专门用了存数据,不存逻辑,自动生成各种方法(get,set)
List items记录一页中所有内容,比如一页返回 10 条帖子,这里就是 10 个 FeedItem。
java
public record FeedPageResponse(
List<FeedItemResponse> items,
int page,
int size,
boolean hasMore
) {}
Caffeine.newBuilder() 链式调用
java
@Bean("feedPublicCache")
public Cache<String, FeedPageResponse> feedPublicCache(CacheProperties props) {
return Caffeine.newBuilder()
.maximumSize(props.getL2().getPublicCfg().getMaxSize())
.expireAfterWrite(Duration.ofSeconds(props.getL2().getPublicCfg().getTtlSeconds()))
.build();
}
过期设置,写入后多久过期'
java
package com.tongji.cache.config;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.tongji.knowpost.api.dto.FeedPageResponse;
import com.tongji.knowpost.api.dto.KnowPostDetailResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Caffeine 本地缓存配置。
*
* <p>用于在应用进程内缓存分页结果,降低数据库与下游服务压力。</p>
*/
@Configuration
public class CacheConfig {
/**
* 公共信息流(广场/推荐)分页缓存。
*
* <p>键通常由分页游标、页大小、过滤条件等组合而成;值为一页的 {@link FeedPageResponse}。</p>
*/
@Bean("feedPublicCache")
public Cache<String, FeedPageResponse> feedPublicCache(CacheProperties props) {
return Caffeine.newBuilder()
.maximumSize(props.getL2().getPublicCfg().getMaxSize())
.expireAfterWrite(Duration.ofSeconds(props.getL2().getPublicCfg().getTtlSeconds()))
.build();
}
/**
* 我的信息流(个人主页/我的发布等)分页缓存。
*
* <p>键通常包含用户标识与分页参数;TTL 与容量由配置项控制。</p>
*/
@Bean("feedMineCache")
public Cache<String, FeedPageResponse> feedMineCache(CacheProperties props) {
return Caffeine.newBuilder()
.maximumSize(props.getL2().getMineCfg().getMaxSize())
.expireAfterWrite(Duration.ofSeconds(props.getL2().getMineCfg().getTtlSeconds()))
.build();
}
/**
* 知文详情本地缓存。
*
* <p>键为 knowpost:detail:{id}:v{version},值为 {@link KnowPostDetailResponse}。</p>
*/
@Bean("knowPostDetailCache")
public Cache<String, KnowPostDetailResponse> knowPostDetailCache(CacheProperties props) {
return Caffeine.newBuilder()
.maximumSize(props.getL2().getDetailCfg().getMaxSize())
.expireAfterWrite(Duration.ofSeconds(props.getL2().getDetailCfg().getTtlSeconds()))
.build();
}
}
HotKeyDetector(热点 Key 探测器 ------ 核心)
滑动窗口 + 热度分级 + 动态 TTL 延长
java
/** 缓存配置(包含窗口/分段参数、等级阈值、扩展秒数) */
private final CacheProperties properties;
/** 每个 key 的滑窗分段计数数组,长度为 segments */
private final Map<String, int[]> counters = new ConcurrentHashMap<>();
/** 当前活跃分段索引(原子维护) */
private final AtomicInteger current = new AtomicInteger(0);
/** 滑窗分段数量:windowSeconds / segmentSeconds */
private final int segments;
new ConcurrentHash 高并发安全Hash
使用不同的Hashmap会出错,数据丢失
多线程读写会脏数据,死循环
为什么不用Hashtable? 加锁太重,并发差,性能低
ConcurrentHashMap 优点:
线程安全 高并发无锁 读多写多都极快 适合热点统计这种超高 QPS 场景 这个到底存什么
窗口 60s 切片 10s 分成 6 段 key1: [ 12, 5, 3, 0, 0, 0 ] → 总热度 20 key2: [ 100, 80, 60, 40, 0, 0 ] → 总热度 280 → 高热 key3: [ 0, 0, 0, 0, 0, 0 ] → 无热度
我后续会专门出一期高并发Hash精讲
AtomicInteger AtomicInteger = 线程安全的 int 计数器
它能保证:多线程同时修改同一个数字,不会算错、不会乱套。
原子操作整数类
"原子" = 不可分割、一步完成
java
package com.tongji.cache.hotkey;
import com.tongji.cache.config.CacheProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 热键探测器(滑动时间窗口计数 + 热度分级 + TTL 动态扩展)。
* <p>
* 设计说明:
* - 采用固定分段滑动窗口:窗口长度 windowSeconds,分段长度 segmentSeconds,段数 segments=window/segment;
* - 每个 key 维护长度为 segments 的数组 counters[key],current 指向当前活跃段;
* - 周期性 rotate 将 current 前移并清零新段,实现近窗口热度的自然衰减;
* - 根据总热度 h=Σ段计数,映射到 NONE/LOW/MEDIUM/HIGH 的热度等级;
* - 提供 ttlForPublic/ttlForMine:在基准 TTL 上叠加等级扩展秒数,保护热点请求。
* <p>
* 并发语义:
* - 使用 ConcurrentHashMap 存储计数数组,AtomicInteger 维护段游标;
* - 计数递增为无锁数组操作,rotate 仅清零新段,避免大范围写冲突;
* - 统计为近似滑窗,保证在高并发下的稳定与低开销。
*/
@Component
public class HotKeyDetector {
public enum Level { NONE, LOW, MEDIUM, HIGH }
/** 缓存配置(包含窗口/分段参数、等级阈值、扩展秒数) */
private final CacheProperties properties;
/** 每个 key 的滑窗分段计数数组,长度为 segments */
private final Map<String, int[]> counters = new ConcurrentHashMap<>();
/** 当前活跃分段索引(原子维护) */
private final AtomicInteger current = new AtomicInteger(0);
/** 滑窗分段数量:windowSeconds / segmentSeconds */
private final int segments;
/**
* 初始化探测器:根据配置计算分段数量。
* @param properties 缓存配置(hotkey)
*/
public HotKeyDetector(CacheProperties properties) {
//表示当前对象的properties
this.properties = properties;
int segSeconds = properties.getHotkey().getSegmentSeconds();
int winSeconds = properties.getHotkey().getWindowSeconds();
this.segments = Math.max(1, winSeconds / Math.max(1, segSeconds));
}
/**
* 记录一次访问,将计数累加到当前分段。
* @param key 缓存键
*/
public void record(String key) {
int[] arr = counters.computeIfAbsent(key, k -> new int[segments]);
arr[current.get()]++;
}
/**
* 计算近窗口总热度(各分段求和)。
* @param key 缓存键
* @return 热度值
*/
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;
}
/**
* 计算热度评级:根据总热度与阈值映射到等级。
* 阈值来源:properties.hotkey.levelLow/Medium/High。
* @param key 缓存键
* @return 热度等级
*/
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:基准 TTL + 等级扩展秒数。
* @param baseTtlSeconds 基准 TTL 秒数
* @param key 缓存键
* @return 动态 TTL 秒数
*/
public int ttlForPublic(int baseTtlSeconds, String key) {
Level l = level(key);
return baseTtlSeconds + extendSeconds(l);
}
/**
* 计算"我的发布"页面的动态 TTL:基准 TTL + 等级扩展秒数。
* @param baseTtlSeconds 基准 TTL 秒数
* @param key 缓存键
* @return 动态 TTL 秒数
*/
public int ttlForMine(int baseTtlSeconds, String key) {
Level l = level(key);
return baseTtlSeconds + extendSeconds(l);
}
/**
* 根据热度等级返回扩展秒数。
* @param l 热度等级
* @return 扩展秒数
*/
private int extendSeconds(Level l) {
return switch (l) {
case HIGH -> properties.getHotkey().getExtendHighSeconds();
case MEDIUM -> properties.getHotkey().getExtendMediumSeconds();
case LOW -> properties.getHotkey().getExtendLowSeconds();
default -> 0;
};
}
/**
* 定时轮转当前分段,清零新分段以实现滑动窗口统计。
* 触发频率由配置 `cache.hotkey.segment-seconds` 指定(单位秒)。
*/
@Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
public void rotate() {
//找到即将被循环使用的 "最老段"
int next = (current.get() + 1) % segments;
//所有 record(key) 都会往这个新分段计数。
current.set(next);
for (int[] arr : counters.values()) {
arr[next] = 0;
}
}
/**
* 重置指定 key 的滑窗计数(全部清零)。
* 用于手动降级或在配置变更后清理历史热度。
* @param key 缓存键
*/
public void reset(String key) {
int[] arr = counters.get(key);
if (arr != null) Arrays.fill(arr, 0);
}
}