面试必备之Caffeine使用不当导致的线上问题

299 阅读5分钟

《异步加载也翻车?Caffeine缓存把ForkJoinPool搞崩溃的奇幻漂流》

各位缓存探险家们,今天我们要聊一个比刺激的故事——caffine异步加载高枕无忧吗?Too young too simple!当Caffeine遇上ForkJoinPool,就像把大象塞进冰箱,结局往往出人意料...

案发现场:异步加载的"甜蜜陷阱"

先看这位"受害者"的代码:

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
   ..refreshAfterWrite(1, TimeUnit.MINUTES)  // 写入1分钟后触发异步刷新
    .buildAsync(key -> {
        // 模拟耗时IO操作
        try { Thread.sleep(100); }   // 实际是redis 查询等IO操作
        catch (InterruptedException e) { /* 忽略 */ }
        return db.getProduct(key);
    }); // 默认使用ForkJoinPool.commonPool()

// 业务代码
public CompletableFuture<Product> getProduct(String id) {
    return cache.get(id);
}

看起来不存在同步阻塞的问题,但是...当并发量上来时:线上大量如下报错

java.util.concurrent.RejectedExecutionException: 
    Thread limit exceeded replacing blocked worker

上述报错解释:

  1. 工作线程全部被阻塞
  2. 补偿机制尝试创建新线程但达到上限
  3. 系统彻底"死锁"——没有线程能执行任务了

原因分析: 根本原因是caffine没有配置线程池,使用了默认线程池(ForkJoinPool),导致无法创建线程,死锁

原理深潜:ForkJoinPool的"七宗罪"

1. 公共池的"共享经济"模式

默认的ForkJoinPool.commonPool()是个共享资源:

  • 被整个JVM中所有并行流、CompletableFuture等共享
  • 默认线程数 = CPU核心数 - 1(比如8核机器只有7个线程)

这就像用一家奶茶店的外卖小哥服务全城所有奶茶店,高峰期不爆单才怪!

2. 工作窃取算法的"死锁"陷阱

ForkJoinPool使用工作窃取(work-stealing)算法:

  • 每个线程有自己的工作队列
  • 空闲线程可以"偷"其他线程队列中的任务

但当所有线程都在等待缓存加载完成时:

// 伪代码展示问题
mainThread: 提交任务A到ForkJoinPool → 等待A完成
ForkJoinWorkerThread-1: 执行A → 需要加载key1 → 提交子任务B
ForkJoinWorkerThread-1: 等待B完成 → 尝试窃取其他任务...
// 但所有线程都在等待,形成死锁!

3. 线程饥饿的"饥饿游戏"

当缓存加载任务比可用线程多时:

  • 新任务无法分配到线程
  • 已分配线程被阻塞在IO上
  • 系统吞吐量断崖式下跌

犯罪证据:线程转储分析

查看线程dump会发现大量类似堆栈:

"ForkJoinPool.commonPool-worker-1" #32 daemon prio=5 os_prio=0 tid=0x00007f8a3c0c5000 nid=0x5a1e waiting on condition [0x00007f8a243f6000]
   java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    - parking to wait for  <0x00000006c0f0b2c8> (a java.util.concurrent.CompletableFuture)
    at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:3316)
    at java.util.concurrent.CompletableFuture.waitingGet(CompletableFuture.java:1729)
    at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:1895)
    at com.github.benmanes.caffeine.cache.LocalCache.lambda$newAsyncLoadingCache$1(LocalCache.java:143)

解决方案:从"共享经济"到"专车服务"

方案1:自定义专用线程池

// 创建专用线程池
ExecutorService loaderExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() * 2, // 通常2N~4N
    new ThreadFactoryBuilder()
        .setNameFormat("cache-loader-%d")
        .setDaemon(true)
        .build());

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .executor(loaderExecutor)  // 关键配置!
    .buildAsync(key -> db.getProduct(key));

优点

  • 隔离缓存加载和业务逻辑
  • 避免与系统其他组件争抢线程资源
  • 可监控线程池状态

监控小技巧

// 监控线程池状态
ThreadPoolExecutor executor = (ThreadPoolExecutor)loaderExecutor;
logger.info("CacheLoader pool status: {}/{} active/total", 
    executor.getActiveCount(), 
    executor.getMaximumPoolSize());

方案2:弹性线程池配置

ExecutorService loaderExecutor = new ThreadPoolExecutor(
    10, // 核心线程数
    100, // 最大线程数
    60, TimeUnit.SECONDS, // 空闲超时
    new LinkedBlockingQueue<>(1000), // 缓冲队列
    new ThreadFactoryBuilder()
        .setNameFormat("cache-loader-%d")
        .build(),
    new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略

关键参数解析

  • 队列容量:建议设置合理上限避免OOM
  • 拒绝策略:推荐选择以下之一:
    • CallerRunsPolicy:让调用线程直接执行(简单但可能影响调用方)
    • 自定义策略:如返回兜底值或降级结果

方案3:响应式编程改造

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .executor(task -> Schedulers.boundedElastic().schedule(task)) // Reactor线程池
    .buildAsync(key -> 
        Mono.fromCallable(() -> db.getProduct(key))
            .subscribeOn(Schedulers.parallel())
            .toFuture());

适用场景

  • 已使用Reactor/WebFlux的项目
  • 需要更精细的线程控制时

深度优化:缓存加载的"降级艺术"

1. 批量加载优化

// 批量加载接口
List<Product> batchGetProducts(List<String> ids);

// 批量加载装饰器
AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .executor(loaderExecutor)
    .buildAsync(new AsyncCacheLoader<String, Product>() {
        @Override
        public CompletableFuture<Product> asyncLoad(String key, Executor executor) {
            return CompletableFuture.supplyAsync(() -> db.getProduct(key), executor);
        }

        @Override
        public CompletableFuture<Map<String, Product>> asyncLoadAll(
            Iterable<? extends String> keys, Executor executor) {
            List<String> idList = StreamSupport.stream(keys.spliterator(), false)
                .collect(Collectors.toList());
            return CompletableFuture.supplyAsync(
                () -> db.batchGetProducts(idList).stream()
                    .collect(Collectors.toMap(Product::getId, p -> p)), 
                executor);
        }
    });

2. 超时与熔断

// 使用Resilience4j添加熔断
CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("cacheLoader");

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .executor(loaderExecutor)
    .buildAsync(key -> CircuitBreaker.decorateSupplier(
        circuitBreaker, 
        () -> db.getProduct(key)).get());

3. 监控与告警

// Micrometer监控
registry.gauge("cache.loader.queue.size", 
    loaderExecutor, 
    exec -> ((ThreadPoolExecutor)exec).getQueue().size());

registry.gauge("cache.loader.active.threads",
    loaderExecutor,
    exec -> ((ThreadPoolExecutor)exec).getActiveCount());

知识点扩展:线程池设计的"黄金分割"

  1. IO密集型 vs CPU密集型

IO密集型(如缓存加载):线程数 ≈ N*(1 + WT/ST)

WT: 等待时间

ST: 服务时间

N: CPU核心数

CPU密集型:线程数 ≈ N+1

  1. 队列选择策略

SynchronousQueue:直接传递,适合快速任务

LinkedBlockingQueue:无界队列,小心OOM

ArrayBlockingQueue:有界队列,需要合理设置大小

  1. 拒绝策略对比

策略行为适用场景

AbortPolicy直接抛出异常需要快速失败的场景

CallerRunsPolicy调用者线程执行不想丢弃任务时

DiscardPolicy静默丢弃可容忍丢失的场景

DiscardOldestPolicy丢弃队列最老任务时效性重要的场景

终极配置建议

// 推荐生产环境配置
ExecutorService cacheLoaderExecutor = new ThreadPoolExecutor(
    Runtime.getRuntime().availableProcessors(), // 核心线程数
    Runtime.getRuntime().availableProcessors() * 4, // 最大线程数
    60, TimeUnit.SECONDS, // 空闲超时
    new LinkedBlockingQueue<>(1000), // 有界队列
    new ThreadFactoryBuilder()
        .setNameFormat("cache-loader-%d")
        .setUncaughtExceptionHandler((t, e) -> 
            logger.error("Thread {} failed", t.getName(), e))
        .build(),
    (r, executor) -> {
        logger.warn("Cache loader queue full, rejecting task");
        throw new RejectedExecutionException("Cache loader queue overflow");
    });

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(30, TimeUnit.MINUTES)
    .refreshAfterWrite(15, TimeUnit.MINUTES)
    .executor(cacheLoaderExecutor)
    .recordStats() // 开启统计
    .buildAsync(key -> db.getProduct(key));

总结:异步缓存加载的生存法则

  1. 隔离原则:缓存加载要用专用线程池
  2. 容量规划:根据业务特点设置合理线程数
  3. 防御编程:队列、熔断、降级一个不能少
  4. 监控告警:线程池状态要实时可见

记住:异步不是银弹,用错地方照样让你"异步"转"同步","并发"变"并罚"!你的缓存加载策略,是时候升级到2.0版本了。

本文事故现场还原度99%,剩下1%留给读者自行想象。如果你的系统还没崩,可能只是时候未到...(手动狗头)