@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 决定"做不做得成"。
自测
SimpleKeyGenerator在零参数、单参数、多参数时分别生成什么键?@Cacheable上的 TTL 应该写在哪里?- 缓存击穿的互斥重建为什么不能和
@Cacheable写在同一方法上? SimpleAsyncTaskExecutor在并发升高时会发生什么?fixedRate与fixedDelay在上次执行超时后的行为差别是什么?@Scheduled的 cron 不写zone时按哪个时区解释?