异步执行 · @Async 与 @Scheduled 怎么选
日期: 2026-07-29 | 作者: sunxin(+ Cursor AI) 关联代码:
ContentEventListener·VideoModerationFollowUpService·VideoModerationPollingTask·ActivityLifecycleTask·FanoutTaskExecutor·ThreadPoolConfig关联清单: 主仓内部任务清单(项目里现有 Job / Listener 登记表) 关联规则:.cursor/rules/saga-event.mdc(事件监听写法)
TL;DR
@Async+ 领域事件:有明确业务动作触发、主流程已提交、副作用可稍后做 → 用事件驱动异步(发帖后审视频、通知粉丝、记行为日志)。@ScheduledJob:没有可靠事件、或要扫全表/补漏/到期 → 用定时任务(活动到期、Saga 恢复、视频PENDING_POLL兜底)。- 不要业务里
new Thread();长等待(sleep几十秒)会占住异步工作线程,流量大时应改成「事件只记账 + Job 扫表」。 - 进程重启:正在跑的
@Async会丢;落库的挂起状态靠 Job / Outbox 重试兜底。
目录
1. 场景:为什么刚聊视频审核会碰到这个
阿里云视频审核是「提交 taskId → 过几十秒再查结果」。发帖 HTTP 里干等会超时,所以:
- 发帖成功后发
PostPublishedEvent ContentEventListener(@Async)立刻提交 + 轮询一轮(缩短违规可见窗口)VideoModerationPollingTask(@Scheduled)扫还停在PENDING_POLL的记录(重启 / 超时补漏)
活动到期则没有「刚过点就有一个可靠回调」:只能周期性扫 endTime < now → 纯 Job(ADR-028)。
两种模式经常组合:事件做「尽快」,Job 做「兜底」。
2. 对照表:什么时候用谁
| 问题 | 偏 @Async + 事件 | 偏 @Scheduled Job |
|---|---|---|
| 触发源 | 用户点了某个 API / 聚合状态变了 | 时间到了 / 扫表发现未完成 |
| 能否在写路径里「记一笔」 | 能发领域事件、或写 PENDING 行 | 只能靠时间或全表扫描 |
| 延迟要求 | 希望秒级~分钟级跟进 | 分钟级~天级可接受 |
| 重启后怎么办 | 事件进 Outbox / 状态落库,否则会丢 | Job 幂等重跑即可 |
| 典型例子 | 发帖 → 通知粉丝;点赞 → 写行为日志;发帖 → 提交视频审核 | 活动到期 COMPLETED;Saga 卡死补偿;视频 PENDING 兜底;日终画像聚合 |
刻意不用的
| 做法 | 原因 |
|---|---|
new Thread(() -> …).start() | 无池、难监控、重启无感知、和 Spring 事务/上下文脱节 |
在 @Async 里长时间 sleep 占满公共池 | 小流量可忍;视频发帖多了会堵其它 Listener → 只提交 + Job 扫,或独立线程池 |
| 为每个活动/每个帖子建一个 Job | 禁止;全站一个扫表 Job(见 ADR-028 反面) |
3. 项目里怎么落地
3.1 事件异步(主路径副作用)
ApplicationService 写库 → publishAll(事件) → AFTER_COMMIT
→ @Async Listener(ContentEventListener / ActivityEventListener / …)约定见 saga-event.mdc:@TransactionalEventListener(AFTER_COMMIT) + @Async,失败不回滚主事务。
3.2 定时任务(扫描 / 补偿)
放在 tour-mate-platform-trigger/.../job/*Task,Application 上 @EnableScheduling。
新增前必查 主仓内部任务清单,优先扩展已有 Task(AGENTS.md 摸底)。
3.3 视频审核(组合示例)
| 组件 | 角色 |
|---|---|
ContentEventListener.onPostPublished | @Async:尽快 submitAndAwait |
VideoModerationFollowUpService | 提交 / 查结果 / 软下架(共用) |
VideoModerationPollingTask | @Scheduled:扫 PENDING_POLL 兜底 |
3.4 特殊:独立线程池
关注扩散 fan-out 用专用 fanoutExecutor(ADR-014),避免大 V 发帖打满公共 @Async 池。只有「长时间、大批量、会挤兑别人」才值得独立池。
4. 线程从哪来(不是手搓 Thread)
| 机制 | 项目入口 | 说明 |
|---|---|---|
@EnableAsync | ThreadPoolConfig | 开启异步代理;另有 ThreadPoolExecutor Bean(配置见 ThreadPoolConfigProperties) |
@Async 方法 | 各 Listener / 部分 Saga | 提交到异步执行器,不是业务里 new Thread |
fanoutExecutor | FanoutExecutorConfig | 写扩散专用池 |
@Scheduled | Application + *Task | Spring 调度线程周期性调方法 |
重启含义:
@Async正在 sleep / 跑到一半 → 中断- 库里有
PENDING_POLL/saga_state/event_record→ Job 或重试任务接着做
所以「能落库的挂起状态 + Job 幂等扫」比「只靠内存里的线程」可靠。
5. 教学收获
- 先问触发源:有业务事件 → 优先事件;只有时间/全表 → Job。
- 尽快 + 兜底 很常见:事件缩短窗口,Job 保证最终一致(视频审核、Outbox 重试同构)。
- 异步线程是稀缺资源:短副作用 OK;长轮询优先 Job 或独立池,别默认
sleep占公共池。 - 登记:新增
@Scheduled/ Listener 必须写进 主仓内部任务清单,方便下次摸底。
附录
- 现有 Job / Listener 全表:主仓内部任务清单
- 事件生命周期事故:事件生命周期与 Outbox 事故复盘
- 活动到期为何纯 Job:ADR-028
- 视频审核闭环:ADR-021