2414 字
约 8 分钟
13
S06-J06 · 缓存、异步与调度:三个注解背后的线程与失效边界

@Cacheable 加了但 QPS 没降、@Async 加了但接口还是同步耗时、@Scheduled 加了但任务偶尔整点丢一次——这三个注解的共同点是:它们都不是"功能",而是给某个基础设施打的标记。缓存标记最终落到 CacheManager,异步标记落到 TaskExecutor,调度标记落到 TaskScheduler。标记生效与否,取决于那个基础设施是否存在、是否被你换成了能用的实现。

下面按"键怎么来 → 什么时候失效 → 线程从哪来 → 上下文怎么过去"的顺序拆。


1. @Cacheable 的键:默认生成器与它的坑

@Cacheable(cacheNames = "user", key = "#id")
public User byId(Long id) { ... }

不写 key 时用默认 SimpleKeyGenerator:

参数个数 生成的键 注意
0 SimpleKey.EMPTY 无参方法全部共用一个键
1 参数对象本身 参数为 null 时退化成 SimpleKey.EMPTY,与无参方法共键
2+ SimpleKey(params...) 依赖每个参数的 equals/hashCode

两个高发问题:

  • 键里塞了可变对象。SimpleKey 的 hashCode 在创建时算好并缓存,但如果键是自定义对象、且其 hashCode 依赖可变字段,写入后字段一变,get 查不到——表现为"永不命中、缓存无限增长"。
  • key 与 keyGenerator 同时存在。两者都写时 key 优先,keyGenerator 被忽略,不报错。跨团队协作时经常因此以为换了生成器。

需要统一前缀或版本号时,自己实现 KeyGenerator 并注册:

@Bean
public KeyGenerator versionedKeyGenerator() {
    return (target, method, params) ->
        target.getClass().getSimpleName() + ":" + method.getName() + ":" + Arrays.deepToString(params);
}

Arrays.deepToString 只求可读性;更稳的是对参数做稳定序列化,避免 toString 里的时间戳与代理对象地址导致键漂移。


2. TTL 到底写在哪

位置 写法 生效范围 陷阱
注解 @Cacheable 没有 TTL 属性 —— 注解本身不带过期
缓存实现 Redis 配置类里 RedisCacheConfiguration.entryTtl(...) 该 CacheManager 下所有缓存 全场统一,无法按 key 区分
缓存级 多 CacheManager 或 RedisCacheConfiguration 按名定制 指定 cacheNames 配置分散,易漏
本地缓存 Caffeine expireAfterWrite 单实例 各实例过期时刻不同步
业务兜底 值里带时间戳,读取时校验 精确控制 多一次判断,键不能只存裸值

结论:TTL 属于 Cache 实现,不属于注解;想按业务分 TTL,就让 cacheNames 对应不同的 RedisCacheConfiguration。


3. 穿透、击穿、雪崩:三种问题三个代码位置

问题 特征 应对 代码位置
穿透 查不存在的数据,每次都打到库 空值缓存(短 TTL)+ 参数校验 + 布隆过滤器 @Cacheable 返回 Optional 或在 service 里缓存哨兵值
击穿 单个热点键过期瞬间并发回源 互斥重建(本地锁或 Redis SETNX) 缓存未命中分支,不在注解层
雪崩 大量键同时过期或缓存整体不可用 TTL 加随机抖动 + 多级缓存 + 降级 配置层与 fallback
public User byIdWithMutex(Long id) {
    if (redisTemplate.hasKey(key(id))) return get(key(id));
    String lock = "lock:user:" + id;
    boolean ok = Boolean.TRUE.equals(
        redisTemplate.opsForValue().setIfAbsent(lock, "1", Duration.ofSeconds(3)));
    try {
        if (!ok) { Thread.sleep(50); return byIdWithMutex(id); }  // 生产用自旋 + 次数上限
        User db = repo.selectById(id);
        redisTemplate.opsForValue().set(key(id), db, ttlWithJitter());
        return db;
    } finally {
        if (ok) redisTemplate.delete(lock);
    }
}

两者不能叠在同一方法上:注解在进入方法体前就返回,锁分支永远不执行。


4. 本地 + Redis:一致性窗口在哪

方案 一致性窗口 失效广播 适用
只本地(Caffeine) 各实例独立,最长一个 TTL 无 只读字典、配置
只 Redis 单点一致 天然共享 大多数业务缓存
本地 + Redis,读穿透 本地 TTL 内可能旧 无 能容忍秒级旧值
本地 + Redis + 广播失效 广播到达延迟 Redis Pub/Sub 或消息队列 要求秒内一致
// 失效广播:删除 Redis 后 publish,各实例清本地
public void evict(Long id) {
    redisTemplate.delete("user:" + id);
    redisTemplate.convertAndSend("cache:evict", "user:" + id);
}

Pub/Sub 不保证送达(订阅端断线期间消息丢失),需要"广播 + 本地 TTL 上限"双保险。

双写顺序只有两种:先删缓存会留出并发回填旧值的窗口;先更库后删缓存,最差读到一次旧值。两者都得配短 TTL 与提交后失效。


5. @Async:默认执行器是个陷阱

Spring Boot 不配执行器时,@Async 用 SimpleAsyncTaskExecutor——每次调用新建线程,无池化、无队列上限,并发一涨线程数就线性上涨。

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean("bizExecutor")
    public ThreadPoolTaskExecutor bizExecutor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(32);
        ex.setQueueCapacity(200);
        ex.setThreadNamePrefix("biz-");
        ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        ex.setTaskDecorator(new MdcTaskDecorator());
        ex.initialize();
        return ex;
    }
}
参数 作用 设错的后果
corePoolSize 常驻线程 过小 → 队列堆积
queueCapacity 排队上限 过大 → 延迟没人发现;过小 → 直接拒绝
maxPoolSize 峰值线程 只有队列满才扩容,队列大时形同虚设
CallerRunsPolicy 拒绝时由调用线程执行 保住任务,但会拖慢请求线程
taskDecorator 包装任务,可传上下文 不设则 MDC/租户/SecurityContext 全丢

异常被吞的根因只有一条:void 返回值时异常抛给 AsyncUncaughtExceptionHandler,默认实现只打日志;返回 Future/CompletableFuture 时异常封装在结果里,不 get 就永远看不见。

@Async("bizExecutor")
public CompletableFuture<Void> push(Long id) {
    // 异常进入 future,调用方必须 join/get 才能感知
    return CompletableFuture.runAsync(() -> doPush(id), executor);
}

上下文传递用 TaskDecorator:

public class MdcTaskDecorator implements TaskDecorator {
    public Runnable decorate(Runnable r) {
        var ctx = MDC.getCopyOfContextMap();      // 租户同理:TenantHolder.get()
        return () -> {
            try { MDC.setContextMap(ctx); r.run(); }
            finally { MDC.clear(); }
        };
    }
}

@Async 同样受代理约束:同类自调用不生效,private/final 方法不生效,与 @Transactional 叠加时事务在新线程里重新开始(详见事务那篇)。


6. @Scheduled:线程模型与重复执行

默认 TaskScheduler 只有一个线程。两个 @Scheduled 方法,一个跑 40 秒,另一个整分钟的任务就会被推迟——不是丢,是排队。

@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
        s.setPoolSize(4);
        s.setThreadNamePrefix("sched-");
        s.setErrorHandler(t -> log.error("scheduled failed", t));
        s.initialize();
        registrar.setTaskScheduler(s);
    }
}
表达式 语义 上次执行超时后的行为
fixedRate = 5000 按固定频率触发,从上一次开始时刻计 会补偿式连跑,可能叠压
fixedDelay = 5000 上一次结束后等 5 秒 天然串行,不会叠压
cron = "0 */5 * * * *" 按日历时刻 错过就跳过,不补

多实例部署时,每个实例都会执行——重复跑是默认行为,不是 bug。处理方式两种:

方式 实现 代价
分布式锁 Redis SETNX + 续期,或数据库行锁 锁失效窗口、续期失败
选主 Zookeeper/etcd/ShedLock 之类 引入额外组件
单实例部署 调度实例与业务实例分离 该实例成为单点
@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Shanghai")
public void daily() { ... }   // 不写 zone 时用 JVM 默认时区,容器里常是 UTC

// 动态注册/停用:任务来自库时用它替代注解
ScheduledFuture<?> f = taskScheduler.schedule(task,
    new CronTrigger("0 0 3 * * *", ZoneId.of("Asia/Shanghai")));
f.cancel(false);

cron 的时区必须显式写。容器基础镜像默认 UTC 时,0 0 3 实际是北京时间 11 点。


排错

现象 根因 处理
缓存永不命中 键含可变对象、toString 不稳定 固定键结构,或改 KeyGenerator
@Async 后线程数暴涨 用了默认 SimpleAsyncTaskExecutor 显式定义 ThreadPoolTaskExecutor
异步任务失败无日志 返回 void,异常进了默认处理器 返回 Future 或自定义 AsyncUncaughtExceptionHandler
定时任务互相拖延 默认单线程调度器 setPoolSize 或分离调度器
集群里任务跑多次 每个实例各自调度 分布式锁或选主

边界

场景 该用的方案 理由
强一致读 直接查库或加短 TTL + 版本号 缓存天生有窗口
任务需要重试与审计 消息队列 / 任务表 @Async 不持久化,进程挂了任务消失
秒级精确定时 独立调度服务(如 XXL-JOB/ElasticJob) 应用内调度随实例重启漂移
缓存不可用时的读 降级到库 + 限流 否则缓存故障会放大成库故障

一句话:注解决定"要不要做",CacheManager/TaskExecutor/TaskScheduler 决定"做不做得成"。


自测

  1. SimpleKeyGenerator 在零参数、单参数、多参数时分别生成什么键?
  2. @Cacheable 上的 TTL 应该写在哪里?
  3. 缓存击穿的互斥重建为什么不能和 @Cacheable 写在同一方法上?
  4. SimpleAsyncTaskExecutor 在并发升高时会发生什么?
  5. fixedRate 与 fixedDelay 在上次执行超时后的行为差别是什么?
  6. @Scheduled 的 cron 不写 zone 时按哪个时区解释?
S06-J06 · 缓存、异步与调度:三个注解背后的线程与失效边界
https://www.clxhxhhr.top/posts/6045/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。