Skip to content
DAILY QUOTE

“ ”

本地缓存限流

这个项目做了什么

这个项目用JUC工具类做了一个本地缓存防击穿演示。

前端可以一次性发起很多请求,后端统计这些请求里有多少命中缓存、多少真正回源、多少被限流拦截。最后通过图表把结果展示出来。

核心目的不是做一个完整缓存框架,而是把ConcurrentHashMapSemaphoreAtomicInteger这些JUC工具放到一个具体场景里理解。

核心流程

text
请求进入/api/cache/get/{key}
->先查ConcurrentHashMap本地缓存
->命中缓存就直接返回
->没命中就尝试获取Semaphore许可
->抢到许可的线程执行慢查询
->查询结果写入本地缓存
->没抢到许可的请求直接返回系统繁忙
->前端请求/api/cache/stats展示统计数据

我觉得难的地方

难点是理解为什么不能让所有线程都去查数据库。

如果热点Key刚好不存在,很多请求会同时进来。没有保护的话,这些请求会全部绕过缓存去查数据库,形成缓存击穿。

这个项目用Semaphore控制同一时间只有一个线程能回源,其他线程快速返回系统繁忙。这样虽然有些请求被拦截了,但数据库不会被瞬间打爆。

用到的技术

  • ConcurrentHashMap:保存JVM本地缓存
  • Semaphore:控制回源线程数量
  • AtomicInteger:统计命中、回源、拦截次数
  • Callable:把真实查询逻辑传入缓存组件
  • Vue3+ECharts:展示并发请求结果

关键实现

Controller只负责接收请求和返回统计数据:

java
@GetMapping("/get/{key}")
public Map<String, Object> getData(@PathVariable("key") String key) {
    long start = System.currentTimeMillis();
    String result = cacheManage.get(key, () -> {
        TimeUnit.SECONDS.sleep(2);
        return "Real_Database" + key;
    });
    long duration = System.currentTimeMillis() - start;

    Map<String, Object> response = new HashMap<>();
    response.put("result", result);
    response.put("duration", duration);
    response.put("timestamp", System.currentTimeMillis());
    return response;
}

缓存防击穿核心:

java
String value = cache.get(key);
if (value != null) {
    hitCount.incrementAndGet();
    return value;
}

if (backOriginSemaphore.tryAcquire()) {
    try {
        value = cache.get(key);
        if (value != null) {
            hitCount.incrementAndGet();
            return value;
        }
        missCount.incrementAndGet();
        value = loader.call();
        if (value != null) {
            cache.put(key, value);
        }
        return value;
    } finally {
        backOriginSemaphore.release();
    }
}

blockedCount.incrementAndGet();
return "系统繁忙";

统计指标

text
hitCount:命中本地缓存的次数
missCount:真正回源查询的次数
blockedCount:被Semaphore拦截的次数
hitRate:命中率

测试效果:

当前版本的限制

  • 缓存没有过期时间,数据会一直留在JVM内存里
  • 现在所有Key共用一个Semaphore,不同Key之间会互相影响
  • 被拦截的请求直接返回系统繁忙,没有等待重试
  • 本地缓存只适合单机演示,多实例部署时缓存数据不共享

可以继续优化

  • 给缓存加过期时间
  • 不同Key使用不同的Semaphore
  • 被拦截请求可以短暂等待,再尝试读取缓存
  • 增加最大缓存数量,避免本地内存无限增长