Skip to content

异步命令框架:从令牌认领到三模式执行 ​

看在前面 ​

库内消息队列篇讲过"消息不丢失与不重复"(A-后端内核/框架与中间件/消息队列/)。那篇回答的是"MQ 怎么保证可靠性"。

这篇是生产实践反哺。star-site 系列把异步命令做成了独立组件 star-site-components (starsite-async-command + starter),替换了 star-site-backend common 里的内置实现。 本文把实战中真正回答过的问题回写进知识库。四个问题: 为什么不用 MQ;认领怎么做才不重复; 执行器挂了命令怎么办;慢任务怎么不拖垮快任务。

组件对应源码(阅读顺序即依赖顺序):

  • starsite-async-command/src/main/java/com/xp/starsite/asynccmd/client/AsyncCommandClient.java——提交入口(业务事务内落库)
  • starsite-async-command/src/main/java/com/xp/starsite/asynccmd/store/JdbcAsyncCommandStore.java——表存储(两步令牌认领 / 悬挂回收)
  • starsite-async-command/src/main/java/com/xp/starsite/asynccmd/scheduler/AsyncCommandScheduler.java——调度器(认领 + 三模式分发)
  • starsite-async-command/src/main/java/com/xp/starsite/asynccmd/executor/ExecutionMode.java——执行模式枚举
  • 组件自带的表 DDL(async_command,建表随组件走,IF NOT EXISTS 增量安全)

一、为什么是异步命令,不是 MQ 或 @Async ​

先把三个候选放到一张表里。评价维度选了生产里真正会疼的点:

维度@Async / 线程池异步命令(DB 表)MQ(RocketMQ)
与业务事务的原子性无(方法返回即提交线程池,业务回滚它照跑)同事务(业务回滚命令一起消失)需事务消息(_half 消息 + 回查)
进程重启后任务存活丢(内存队列)存(表里 PENDING)存(broker 持久化)
失败重试自己写内置指数退避 + 死信内置(消费重试队列)
运维成本零一张表(复用业务库)一套 broker 集群
时效微秒级(线程切换)秒级(轮询周期;IMMEDIATE 模式毫秒级)毫秒级

点评:

  • @Async 的坑在"事务边界":业务方法还没提交,异步线程已经读库——读到旧数据或空数据。 这是竞态,且极难复现。
  • MQ 是重型武器。引入 broker 解决"任务可靠执行",代价是一套集群的运维、消息回查的开发、 团队的心智负担。任务量没有到削峰填谷的量级时,一张表就够。
  • 异步命令的立身之本:submit 在业务事务内调用,命令行与业务变更同一个事务提交。 业务回滚,命令消失——不存在"业务没成但任务跑了"的脏状态。

二、表结构:一张表就是一个任务队列 ​

sql
CREATE TABLE IF NOT EXISTS async_command (
  id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  cmd_type    VARCHAR(64)  NOT NULL COMMENT '命令类型=执行器路由键',
  biz_key     VARCHAR(128) DEFAULT '' COMMENT '业务键(幂等/追踪)',
  payload     TEXT         DEFAULT NULL COMMENT '命令载荷 JSON',
  status      VARCHAR(16)  NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/DEAD',
  max_retry   INT          NOT NULL DEFAULT 5,
  retry_count INT          NOT NULL DEFAULT 0,
  next_run_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '到期时间(延迟队列锚点)',
  last_error  VARCHAR(500) DEFAULT NULL,
  owner       VARCHAR(64)  DEFAULT '' COMMENT '提交方服务名',
  claim_token VARCHAR(64)  DEFAULT NULL COMMENT '调度认领令牌',
  created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_status_next (status, next_run_at),
  KEY idx_type_biz (cmd_type, biz_key)
) ENGINE=InnoDB;

设计要点逐条说:

  • 状态机四态:PENDING → RUNNING → SUCCESS / PENDING(重试) / DEAD(死信)。 状态落在行上,谁认领谁负责到底——进程崩了状态还在,恢复靠悬挂回收(见四)。
  • next_run_at 是延迟队列的锚点。"30 分钟未支付自动取消"这类需求, 提交时 runAt = now + 30min,调度器只认领 next_run_at <= NOW() 的行。 不需要单独的延迟队列组件。
  • idx_status_next (status, next_run_at) 服务认领查询:按状态过滤 + 到期排序, 认领走索引扫,不全表。
  • idx_type_biz (cmd_type, biz_key) 服务幂等检查:同类型同业务键的未完成计数。

三、两步令牌认领:多实例谁写入算谁 ​

这是全组件最核心的并发设计。多实例部署时,同一命令只能被一个实例执行。

MySQL 没有 UPDATE ... RETURNING(PostgreSQL 有),认领拆成两步:

sql
-- 第 1 步:给到期 PENDING 打上本实例唯一令牌,置 RUNNING
UPDATE async_command
SET status = 'RUNNING', claim_token = ?, updated_at = NOW()
WHERE status = 'PENDING' AND next_run_at <= NOW()
  AND id IN (SELECT id FROM (SELECT id FROM async_command
             WHERE status = 'PENDING' AND next_run_at <= NOW()
             ORDER BY id LIMIT ?) t);

-- 第 2 步:按令牌取回本轮认领的命令
SELECT * FROM async_command WHERE claim_token = ? ORDER BY id;

追问两个细节:

为什么子查询要再包一层派生表?

MySQL 的 UPDATE 不允许直接修改 IN 子查询引用的同一张表(ERROR 1093)。 包一层派生表 (SELECT id FROM (...) t) 让优化器先物化 id 集合再回写—— 这是 MySQL 的经典绕法,不是玄学。

为什么令牌是 UUID 而不是实例名?

实例名在滚动发布期间会重复(新实例顶旧实例的 Pod 名)。UUID 一次一换, 第 2 步取回的一定是本实例本轮写入的行——不存在"取到别家令牌"的串扰。

四、悬挂回收:执行器死了,命令不能陪葬 ​

实例认领命令后崩溃(OOM / kill -9),行卡在 RUNNING——没人执行也没人回写。

回收逻辑(每轮调度先跑):

sql
UPDATE async_command
SET status = 'PENDING', claim_token = NULL, updated_at = NOW()
WHERE status = 'RUNNING'
  AND updated_at < NOW() - INTERVAL ? SECOND
LIMIT ?;

为什么用库时钟不用应用时钟:updated_at 由数据库维护。列宽定义是 ON UPDATE CURRENT_TIMESTAMP,多机应用时钟有偏差时用应用时钟对比会误判。 认领的到期判定用参数时间(业务视角),悬挂判定用库时钟—— 即"DB 眼里多久没动静"。两者语义不同,刻意不统一。

阈值配置(默认 300 秒)要大于最长执行时长。反例:任务跑 6 分钟、 回收阈值 5 分钟,就会把还在跑的任务误回收成 PENDING。 另一个实例再认领,同一命令并发执行两份。这是悬挂回收唯一的设计红线。

五、退避重试与死信:失败是常态 ​

执行器抛异常不是灾难,是输入。失败处理走指数退避:

第 n 次重试等待 = min(60s × 2^n, 1h)

重试计数达到 max_retry(默认 5)后置 DEAD(死信),不再调度。 last_error 截断到 480 字符(列宽 VARCHAR(500),多字节字符留余量)。

死信不是终点。组件的管控端点提供三个人工处置动作:retry(打回重跑)、 requeue(悬挂强制打回)、kill(强制终止)。健康端点存在死信即 DOWN—— 死信代表有命令达到重试上限仍未成功,静默吞掉比告警更危险。

六、执行模式三分与线程池隔离 ​

组件支持三种执行模式,执行器声明 mode() 即可:

模式路径语义
SCHEDULED(默认)落库 → 调度认领 → 调度线程内联执行同轮串行,轻快任务
ASYNC_POOL落库 → 调度只认领分发 → 转交线程池慢任务不阻塞调度线程
IMMEDIATE落库 → 提交线程按 id 立即认领 → 直投线程池不等轮询周期,时效敏感

IMMEDIATE 的可靠性别担心降级:提交线程先落库(PENDING), 再按 id 原子认领(claimById:单行 UPDATE,认领失败说明他方已抢)。 投递前进程崩溃——命令还是 PENDING,下一轮调度兜底认领。

线程池隔离解决"慢任务占满快任务"。执行器实现 PoolIsolatedExecutor.pool() 返回自持线程池(应用 bean 注入),该类型命令跑在自己的池。 "AI 解读"再慢也占不到"消息通知"的线程。未隔离的池化命令用组件共享池 (CallerRunsPolicy 反压:队列满由提交线程自己跑,不丢任务)。

七、幂等提交的边界:执行侧才是最后防线 ​

submitIdempotent(type, bizKey, payload):同类型同业务键存在未完成命令时跳过提交。 适合"定时任务反复触发"这类同源重复。

但它是检查 + 提交两步,非原子——极端并发下两个实例同时检查通过、各落一条。 执行幂等由业务侧保证(执行器副作用可重放)。命令可能因悬挂回收被重复执行, 组件保证的是至少一次执行,不是恰好一次。

八、追问链 ​

面试官: 为什么不用 RocketMQ 事务消息?它也能保证和业务事务原子。

事务消息解决的是"本地事务和发消息的原子性",代价是_half 消息 + 回查接口开发 + broker 依赖。 我的场景是"任务执行"不是"事件通知":不需要削峰、不需要扇出、消费方就是本服务。 一张表复用业务库,事务原子性免费获得——引入 broker 是用牛刀杀鸡。

面试官: 认领为什么不用 SELECT ... FOR UPDATE?

悲观锁在认领场景有两个问题。一是锁持有时间等于执行时间——长任务把行锁攥几分钟, 其他实例全部阻塞在锁等待。二是先 SELECT 拿 id 再逐行加锁,两步之间照样竞态。 令牌法是单条 UPDATE 原子完成"筛选 + 占有",不持锁执行。

面试官: 悬挂回收阈值配小了会怎样?

命令并发执行两份。阈值必须大于最长执行时长,这是设计红线。执行侧有兜底: 完成回写是 WHERE id = ? 的状态更新,第二份回写时行已 SUCCESS,回写空过。 但副作用已经发生了两份,所以执行器副作用必须幂等。

面试官: IMMEDIATE 模式和直接调方法+@Async 有什么区别?

@Async 方法返回即触发,业务事务还没提交异步线程可能先读库(竞态); 且进程重启任务全丢。IMMEDIATE 先落库(与业务同事务)再派发, 崩溃有调度兜底——快是快在"不等轮询",不是快在"绕过持久化"。

面试官: 死信满了怎么办?

死信是"需要人看"的信号,不是"丢弃"的婉辞。处置路径有三条。 管控端点 retry(修完下游后打回重跑);kill(确认业务无需执行后终止); 不处置(健康端点持续 DOWN,监控会吼)。设计上死信必须可见—— 静默吞掉失败比失败本身危险。

九、面试官视角小结 ​

  • 事务原子性是异步命令的立身之本:submit 在业务事务内,业务回滚命令消失。 这一条直接淘汰 @Async,也是对比 MQ 事务消息的胜负手。
  • 两步令牌认领是 MySQL 下无锁占有的标准解:单条 UPDATE 原子筛选 + 打令牌, 谁写入算谁,不持锁执行。
  • 悬挂回收 + 执行幂等构成至少一次语义。组件保证命令不丢、不卡死; 副作用幂等由业务保证,两者缺一不可。
  • 三模式 + 池隔离把执行线程模型变成声明式配置。轻快任务内联、慢任务进池、 时效任务立即——同一个框架覆盖三种负载特征。
  • 选型心法:任务量级没到削峰填谷,别上 broker。一张表 + 轮询 + 令牌, 运维成本是一个数量级的差距。

参考 ​

  • 组件仓库:Mein-Augenstern/star-site-components(README 有完整配置表与迁移说明)
  • 消息队列可靠性对比:《消息不丢失与不重复》(A-后端内核/框架与中间件/消息队列/)
  • 幂等与对账的资金安全底座:《幂等与对账》(G-业务领域/金融信贷/)

2026-10 新增(star-site-components 组件化后回写;star-site-backend 已迁移替换内置实现, 迁移说明见 backend 仓库 docs/)