微服务架构下 任务调度 的设计与权衡
上周把订单服务里的几个定时任务从单体拆出来,什么超时关闭、账单生成、状态同步,全挪到微服务里。一开始我以为就是把 @Scheduled 复制过去,再给每个任务加个分布式锁就完事了。结果测试环境没复现,生产一上来就两个实例,账单任务被重复执行了三次,下游对账直接炸了。
下面是我后来实测比较稳的设计,不一定适合所有人,但如果你是那种小团队、微服务刚拆完、又不想为了几个定时任务先搭一套重型平台的情况,可以参考下。
从 @Scheduled 拆出来那一刻,坑就开始了
单体里最常见的是这样:
@Scheduled(cron = "0 5 1 * * ?")
public void syncBill() {
// 拉数据、算账单、写表
}
单实例没毛病,服务扩到 3 台以后,这玩意儿就变成了三份任务同时跑。你以为只是“偶尔重复执行一下”,实际上只要任务里有任何 insert、update、写文件、调第三方接口,生产就会给你表演什么叫重复数据。
所以我当时先试了最简单的 Redis 锁:
if (!redis.tryLock("syncBill", 30)) {
return;
}
try {
// 执行业务
} finally {
redis.unlock("syncBill");
}
看着能用,但生产里很脆。锁 30 秒,任务跑 50 秒,锁过期了另一台实例拿到锁继续跑;任务失败后锁提前释放,下一轮又拿锁;或者实例拿到锁以后宕机,锁释放依赖过期时间,这段时间任务就卡着。Redis 分布式锁能解决一部分并发,但不等于调度系统。
我更建议先把调度状态放进数据库
后来我把调度状态从“内存 + 锁”改成“数据库租约”。思路很简单:任务什么时候该跑、现在谁在跑、什么时候租约过期,都写在库里。调度器只负责捞到期的任务,并且用 CAS 抢租约。
建一张简单表就够:
CREATE TABLE job_schedule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_name VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
next_fire_time DATETIME NOT NULL,
last_fire_time DATETIME NULL,
lease_owner VARCHAR(128) NULL,
lease_expire DATETIME NULL,
retry_count INT DEFAULT 0,
status VARCHAR(16) DEFAULT 'IDLE'
);
调度器每几秒扫一次:
SELECT * FROM job_schedule
WHERE next_fire_time <= NOW()
AND (lease_owner IS NULL OR lease_expire <= NOW())
ORDER BY next_fire_time
LIMIT 50
FOR UPDATE SKIP LOCKED;
MySQL 8 可以用 FOR UPDATE SKIP LOCKED,没这功能就用普通查询 + CAS 更新:
UPDATE job_schedule
SET lease_owner = '10.10.20.31:9000',
lease_expire = NOW() + INTERVAL 30 SECOND,
status = 'RUNNING',
last_fire_time = next_fire_time
WHERE id = ?
AND (lease_owner IS NULL OR lease_expire <= NOW());
如果 updated == 1,说明这台实例抢到了任务,可以执行;如果 updated == 0,直接跳过,别硬跑。这样哪怕多台实例同时扫表,也只有一个能拿到租约。
任务执行成功以后,再根据 cron 算下一次时间,把状态置回 IDLE。失败就递增 retry_count,超过最大重试次数进告警。别迷信什么 exactly once,分布式环境里先做到 at most once 或 at least once 已经要命了。
配置可以长这样
scheduler:
enabled: true
scan-interval: 3
lease-seconds: 30
batch-size: 50
worker-pool-size: 8
max-retry: 5
retry-backoff: 1,2,5,10,30
几个坑得提前记住:
- 别用每台机器本地时间当唯一判断依据。能统一用数据库时间就用数据库时间,不然一台机器时钟飘了,任务要么不跑,要么狂跑。
- 租约时间一定要大于任务平均耗时。你以为 30 秒够了,实际遇到一次慢 SQL、一次外部接口超时,就直接翻车。
- 任务执行一定要异步化。调度线程只负责派发,别在调度线程里跑长任务,不然一个慢任务能把整个扫描周期拖死。
next_fire_time要能看出来是不是卡住了。漏跑、重复跑、不跑,最后基本都体现在这几个字段上。
任务本身还是要做幂等
调度器只能保证“尽量别重复触发”,真正扛住重复的还是业务幂等。这个真的没法绕。
订单关闭可以这样写:
UPDATE order_info
SET status = 'CLOSED'
WHERE order_no = ?
AND status = 'WAIT_PAY';
有 order_no + status 条件兜着,重复执行也只会更新成功一次。
账单生成就更直接,给 bill_no、user_id + period、source_id 这种唯一键加索引,插入冲突就当成功,别抛异常把任务搞得红彤彤一片:
INSERT INTO user_bill (bill_no, user_id, period, amount)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = id;
数据同步类任务最好有水位线:
SELECT updated_at, offset_key
FROM job_watermark
WHERE job_name = 'sync_user';
每次只拉 updated_at > watermark 的数据,任务结束后推进水位。这样就算中间失败重试,也不会全量重刷。
XXL-JOB / ElasticJob / Airflow 要不要用
如果你任务数量开始变多,需要管理后台、失败告警、执行日志、路由策略、分片、权限,那直接上 XXL-JOB 其实挺省心。
xxl:
job:
admin:
addresses: http://10.10.20.20:8080/xxl-job-admin
accessToken: default_token
executor:
appname: order-service
ip:
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
logretentiondays: 7
我之前也试过 ElasticJob,分片挺顺,但依赖 ZooKeeper。为了几个任务养一套 ZK,小团队会有点麻,ZK 一抽风,调度也跟着懵。Airflow 更适合数据 pipeline,跨天依赖、补数、复杂 DAG 这些场景。如果只是“每天凌晨 1 点生成账单”,上 Airflow 我总觉得用挖掘机切菜。
Temporal 我也认真看过,重试、补偿、长流程确实强,但业务代码侵入感比较明显,架构复杂度也真实。适合审批流、外部回调、长事务、需要状态恢复的流程。纯定时任务别一上来就整重型平台,先把任务拆稳。
我的取舍大概是这样:
- 5 个以内核心定时任务,团队小,不想多运维组件:数据库租约 + CAS + 幂等。
- 几十个任务,需要运营后台和统一告警:XXL-JOB。
- 需要分片、弹性、ZK 已有现成集群:ElasticJob 也可以。
- 数据任务、DAG 依赖、补数很重:Airflow 或类似批处理平台。
- 业务流程长,要重试、补偿、人工介入、状态恢复:Temporal。
分片、依赖和失败隔离别漏
有些任务单机跑不动,比如几百万用户积分重算,一个实例硬扛肯定超时。这种就要分片。调度时把任务拆成 shardIndex 和 shardTotal,worker 自己处理:
long idMod = userId % shardTotal;
if (idMod != shardIndex) {
return;
}
任务之间如果有依赖,我一开始很想做成 DAG,后来发现很多场景没必要。A 跑完写一个事件或更新一张状态表,B 检查 A 的水位后再跑,比在调度器里搞复杂依赖简单多了。真到复杂工作流阶段,再考虑专门的工作流引擎。
失败隔离也很关键。不同任务别共用一个无界线程池,最好每个任务有独立线程池、队列长度、超时时间和熔断策略。最怕的就是一个慢同步任务把线程全占了,订单超时关闭也跟着卡住。
最后说下权衡
微服务架构下的任务调度,核心不是“cron 表达式怎么写”,而是多个实例同时活着的时候,怎么尽量少重复、少漏掉、别把业务搞脏。代码写起来可能也就几十行,生产里一炸全是血泪。
小项目我更推荐先做轻一点:一张任务表、一个轮询线程、一段 CAS 抢租约、几套幂等逻辑。等任务数量、告警需求、权限管理上来了,再迁移到 XXL-JOB 这种现成方案。别一开始就被“架构先进性”推着走,先把任务跑稳。
生产里一定要盯几个指标:next_fire_time 有没有持续前进、lease_expire 有没有异常、任务平均耗时、重试次数、业务唯一键冲突数。如果日志里只有一句“任务开始执行”,那基本等于没监控。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。