Skip to content

异步执行 · @Async 与 @Scheduled 怎么选 ​

日期: 2026-07-29 | 作者: sunxin(+ Cursor AI) 关联代码: ContentEventListener · VideoModerationFollowUpService · VideoModerationPollingTask · ActivityLifecycleTask · FanoutTaskExecutor · ThreadPoolConfig 关联清单: 主仓内部任务清单(项目里现有 Job / Listener 登记表) 关联规则: .cursor/rules/saga-event.mdc(事件监听写法)

TL;DR ​

  • @Async + 领域事件:有明确业务动作触发、主流程已提交、副作用可稍后做 → 用事件驱动异步(发帖后审视频、通知粉丝、记行为日志)。
  • @Scheduled Job:没有可靠事件、或要扫全表/补漏/到期 → 用定时任务(活动到期、Saga 恢复、视频 PENDING_POLL 兜底)。
  • 不要业务里 new Thread();长等待(sleep 几十秒)会占住异步工作线程,流量大时应改成「事件只记账 + Job 扫表」。
  • 进程重启:正在跑的 @Async 会丢;落库的挂起状态靠 Job / Outbox 重试兜底。

目录 ​


1. 场景:为什么刚聊视频审核会碰到这个 ​

阿里云视频审核是「提交 taskId → 过几十秒再查结果」。发帖 HTTP 里干等会超时,所以:

  1. 发帖成功后发 PostPublishedEvent
  2. ContentEventListener(@Async)立刻提交 + 轮询一轮(缩短违规可见窗口)
  3. 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) ​

机制项目入口说明
@EnableAsyncThreadPoolConfig开启异步代理;另有 ThreadPoolExecutor Bean(配置见 ThreadPoolConfigProperties)
@Async 方法各 Listener / 部分 Saga提交到异步执行器,不是业务里 new Thread
fanoutExecutorFanoutExecutorConfig写扩散专用池
@ScheduledApplication + *TaskSpring 调度线程周期性调方法

重启含义:

  • @Async 正在 sleep / 跑到一半 → 中断
  • 库里有 PENDING_POLL / saga_state / event_record → Job 或重试任务接着做

所以「能落库的挂起状态 + Job 幂等扫」比「只靠内存里的线程」可靠。


5. 教学收获 ​

  1. 先问触发源:有业务事件 → 优先事件;只有时间/全表 → Job。
  2. 尽快 + 兜底 很常见:事件缩短窗口,Job 保证最终一致(视频审核、Outbox 重试同构)。
  3. 异步线程是稀缺资源:短副作用 OK;长轮询优先 Job 或独立池,别默认 sleep 占公共池。
  4. 登记:新增 @Scheduled / Listener 必须写进 主仓内部任务清单,方便下次摸底。

附录 ​

Powered by VitePress