ENGINEERING NOTES · 工程笔记

十几个定时任务的单机,怎么不重复执行也不漏执行

先说结论:单机部署下,@Scheduled 的正确姿势不是换框架,是给每个任务配三样东西——一把以数据库为准的执行锁、一本执行账、一条超时告警。锁管"不重复",账加补偿扫描管"不漏",告警管"坏了有人知道"。以下出自我们多租户微信小程序 SaaS(Java 21 + Spring Boot + PostgreSQL,单机部署)的实战,后端 13 个 @Scheduled 任务里,关单、对账、分账轮询都在其中,代码均为脱敏伪代码。

一、背景:两颗雷,先后炸过

第一颗:重叠执行。 @Scheduled 默认单线程调度,同一任务天然不重叠,代价是 13 个任务排队跑一条道:对账扫描某晚跑了 11 分钟,本该 19:00:00 关掉的过期订单 19:09:40 才轮上。照网上的文章把线程池扩到 8,排队消失,新坑跟着来——fixedRate 按"固定起点触发",上一轮没跑完、池里有空闲线程,下一轮照样启动。分账轮询里两个"自己"同时扫一批单子,同一笔被请求两次,靠支付侧幂等键才没出资金事故。此后定死规矩:扩池与防重锁必须同一次上线。

第二颗:静默失败。 任务抛的异常被调度器吞掉只记一条日志,下一轮照常触发——监控上它"一直在跑",实际每轮启动三秒就死。翻车现场:对账扫描依赖的中间表被迁移改名,任务连续失败 40 多小时没人发现,直到商户问"昨天的账怎么没对上"。日志里有完整异常栈,但没人半夜盯日志。结论:把"我成功过"变成一条数据,让机器替人盯着。

二、防重:执行锁以数据库为准

  • JVM 内锁(synchronized 等):零成本挡住同进程并发,但进程重启即失效;换版时新旧容器并存,两把内存锁互相看不见——只能当快门,当不了保险柜。
  • 数据库状态占位:把"这一轮归我"写成一条记录,谁抢到谁跑。锁跟着数据库走,重启、换版、多实例,规则都一样。

最终以数据库为准:重启不丢,锁记录还顺手就是执行账。不走 SELECT FOR UPDATE,原子占位即可:

// 表 task_lease: task_name 主键, owner, lease_until
int got = jdbc.update("""
    UPDATE task_lease
       SET owner = ?, lease_until = now() + interval '10 minutes'
     WHERE task_name = ?
       AND (lease_until IS NULL OR lease_until < now())
    """, instanceId, taskName);
if (got == 0) return;   // 没抢到:有人持有租约,本轮放弃
runTask(taskName);      // 业务逻辑,结束后清掉 lease_until

两个细节:租约必须带过期时间——进程被 OOM 砍掉时没人解锁,lease_until 10 分钟后自动失效,不会锁死;释放时校验 owner,只还自己的锁。

三、不漏:给每次执行记一本账

每次执行落一条 task_run:状态(RUNNING / SUCCESS / FAILED)、处理窗口起止、检查点。三个部件:

断点续跑。 关单任务的检查点记"上次处理到哪个主键、哪个时间窗",中途被打断,下一轮从检查点续跑而非跳过。前提是业务幂等:关单按订单号做状态流转(PENDING → CLOSED),重复执行不会有第二种结果——先幂等,再续跑。

补偿扫描。 机器 03:02 重启,03:00 的任务那一次就永远丢了——@Scheduled 没有"补跑错过触发"的语义。解法是每 5 分钟一次的兜底:按账本查"最近一次 SUCCESS 距今多久",超任务周期 1.5 倍就补跑,记录打 is_backfill 标记备审计。

扫描式优于触发式。 关单的正确写法不是"下单后 10 分钟定时关",而是每分钟扫"created_at 早于 now() - 10 分钟且待支付"的订单——漏一轮下一轮自动补上,天然自愈。分账轮询同理,轮询"进行中"的分账单直到终态。只有日报这类准点动作,才完全依赖账本加补偿。

四、可观测:心跳表加告警阈值

每个任务 SUCCESS 时更新心跳表(task_name, last_success_at, alert_after),独立检查任务每 10 分钟扫一遍:

SELECT task_name, now() - last_success_at AS lag
  FROM task_heartbeat
 WHERE last_success_at IS NULL
    OR now() - last_success_at > alert_after;

阈值按"周期 × 2 + 一次正常执行时长"给:分钟级任务 30 分钟,每日任务 26 小时,留缓冲防误报。命中即推告警,附最近一次 FAILED 的异常摘要。上线后,前文那起静默失败从 40 多小时缩到 30 分钟内被发现。

五、扩展的预埋:单机今天的解,多机明天的路

为什么不上 Redis 分布式锁或调度中心?答案和当初不选 K8s 一样:为一把锁多养一个有状态组件,为 13 个分钟级任务多请一个也要高可用的中间件,单机阶段不划算。数据库锁本身就是跨进程锁(PostgreSQL 原子 UPDATE),分钟级频率绰绰有余。但路要留:抢锁收口在 LockClaim 接口后,今天是 UPDATE task_lease,将来多实例换 Redis 的 SET NX PX,调用方一行不改;账本、心跳表天然多实例共享。切换成本压在"换一个实现类"上,这是单机阶段值得埋的扩展点。

六、踩坑清单

  • 默认单线程池是排队陷阱:13 个任务共一条线程,慢任务拖垮全局。给 ThreadPoolTaskScheduler 配独立线程,线程数取"峰值同时运行任务数 + 2";扩池与防重锁同次上线,先拆栏杆后过桥必翻车。
  • 异常吞噬:调度器捕获一切异常只留日志,任务"看起来还在跑"。业务里必须自己 try-catch 并写进账本,连续失败 3 次以上升级告警。
  • 时区:cron 默认取 JVM 时区,容器默认 UTC,凌晨任务差八小时才跑。容器 TZ、JVM -Duser.timezone、注解 zone 三处任选其一,写进部署检查项。
  • 别拿 fixedRate 表达"每 X 分钟":它按固定起点触发,超时后会重叠或追赶式连发;要"上轮结束后再等 X 分钟"用 fixedDelay 更稳。

三句话总结

  1. 防重以数据库为准:内存锁重启即失,带租约的原子 UPDATE 才是跨重启、跨实例都成立的"这一轮归我"。
  2. 不漏靠账本加补偿扫描:每次执行记账、断点续跑、超期补跑,任务尽量设计成天然自愈的状态扫描。
  3. 心跳表加独立阈值告警,把静默失败从商户投诉提前到半小时内的机器报警——锁、账、铃齐了,单机定时任务才算稳。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询