Appearance
异步命令框架:从令牌认领到三模式执行
看在前面
库内消息队列篇讲过"消息不丢失与不重复"(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/)