高性能二级缓存设计:Caffeine + 滑动窗口热点降级方案

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

  • 公共页缓存

    1. 容量:publicCfg.maxSize
    2. 写入过期: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);
    }
}
相关推荐
小碗羊肉1 小时前
【JavaWeb | 第十篇】Spring中的事务控制
java·后端·spring
SimonKing1 小时前
美团不做外卖做浏览器了,而且是AI浏览器:Tabbit
java·后端·程序员
Gopher_HBo1 小时前
Go语言常见并发模式
后端
_Evan_Yao1 小时前
计算机大一新生如何选择方向(前端/后端/AI/运维)?
运维·前端·人工智能·后端
skilllite作者2 小时前
SkillLite Channel 与 Gateway 配置完全指南:Webhook、环境变量与桌面助手
ide·后端·前端框架
夕除2 小时前
spring boot 4
java·spring boot·后端
starsky762382 小时前
spring boot——前后端分离
java·spring boot·后端
战南诚2 小时前
Flask中的URL ——url_for() 与 自定义动态路由过滤器
后端·python·flask
折哥的程序人生 · 物流技术专研2 小时前
《Java面试85题图解版(三)》上篇:高阶架构设计篇
java·开发语言·后端·面试·职场和发展