定时任务多实例打架:用一条 INSERT 语句做租约锁

SailTrack
2026-07-06
点 赞
0
热 度
6
评 论
0
  1. 首页
  2. 后端开发
  3. 定时任务多实例打架:用一条 INSERT 语句做租约锁

写在前面

项目里的定时任务越来越多:Agent 超时收口、AI 供应商健康检查、插件幂等记录清理、IM outbox 补投递……这些任务有一个共同的沉默假设——“同一时刻只有我在跑”。

单实例部署时这个假设天然成立。但只要扩到两个实例,@Scheduled 就会在每台机器上各触发一次:同一个超时任务被扫两遍,取消操作重复执行,通知发两份。本文记录我用一张 10 行以内的锁表 + 一条 upsert 语句解决这个问题的方式,不引入任何新中间件。

一、需求清单:锁到底要满足什么

把需求摆出来,才知道为什么最后选了这个方案:

互斥:同一时刻,同一个任务名,只有一个实例在执行。
防死锁:持锁实例崩了,锁必须能自动释放,否则任务永远不再执行。
误删保护:A 实例执行超时锁被释放后,B 抢到锁;A 恢复后跑完想释放锁——不能把 B 的锁放掉。
可观测:谁持有锁、持了多久、上次任务跑成什么样,要看得见。

Redis 的 SET NX EX 能满足前三条,但我的系统里这批定时任务本身依赖数据库可用性——数据库挂了任务本来就没法跑。为一个"数据库可用才有意义"的锁引入第二个存储,等于给鸡蛋买两份篮子。所以锁放在数据库里,和任务数据同生共死,事务语义还免费。

二、锁表与那条核心语句

CREATE TABLE sys_job_lock (
    lock_name    VARCHAR(64) PRIMARY KEY,
    owner_id     VARCHAR(64),
    locked_at    DATETIME,
    locked_until DATETIME
);

抢锁的核心是一条 upsert:

INSERT INTO sys_job_lock (lock_name, owner_id, locked_at, locked_until)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
    owner_id     = IF(locked_until <= ?, ?, owner_id),
    locked_at    = IF(locked_until <= ?, ?, locked_at),
    locked_until = IF(locked_until <= ?, ?, locked_until)

参数依次是:锁名、本次抢锁者的 ownerId、当前时间、租约到期时间(now + 5 分钟),以及四个用于比较的"当前时间"。

逻辑展开是这样:

  • 锁不存在(首次执行)→ INSERT 成功,抢锁成功;
  • 锁存在且已过期(locked_until <= now)→ IF 条件成立,owner_id 等字段被覆盖成新抢锁者,抢锁成功;
  • 锁存在且未过期 → IF 条件不成立,UPDATE 等于没改,MySQL 对"值没有变化的行"返回影响行数 0。

于是 Java 侧的判断薄到透明:

Boolean acquired = transactionTemplate.execute(status -> {
    timeoutRepository.createTaskTimeoutJobRun(jobRunId, triggerType, ...);
    if (!timeoutRepository.tryAcquireJobLock(LOCK_NAME, ownerId, startedAt, lockUntil)) {
        timeoutRepository.finishTaskTimeoutJobRun(jobRunId, "SKIPPED", ...);
        return false;
    }
    return true;
});

tryAcquireJobLock 返回的就是 jdbcTemplate.update(...) 的影响行数是否大于 0。没有 SELECT 后再 UPDATE 的竞态窗口,没有 Lua 脚本,互斥由数据库对这一行的原子写保证。 这条语句是我整个项目里性价比最高的 SQL。

三、租约:不依赖"记得释放"的自动释放

抢到锁的实例不是无限期持有,而是租 5 分钟:

LocalDateTime lockUntil = startedAt.plusMinutes(5);

两种释放路径并存:

主动释放:任务跑完,finally 里调 releaseJobLock,但带了 ownerId 条件——只有"自己还是持有者"时才删:

private void releaseLockQuietly(String ownerId) {
    try {
        transactionTemplate.executeWithoutResult(status ->
            timeoutRepository.releaseJobLock(LOCK_NAME, ownerId, LocalDateTime.now()));
    } catch (RuntimeException e) {
        log.warn("Agent人工确认超时扫描锁释放失败,ownerId={}", ownerId, e);
    }
}

被动过期:实例在任务执行中崩溃,来不及释放——没关系,锁 5 分钟后自动过期,下一个调度周期自然有人能抢到。

两个细节是这个设计的灵魂。释放必须校验 ownerId,否则 A 实例卡顿超过租约、锁被 B 拿走,A 醒来后一句 DELETE 会把 B 的锁连根拔掉——两个实例从此并行跑同一个任务,锁形同虚设。这就是"误删保护"。释放失败只 warn 不抛,因为租约兜底已经存在,释放失败的最坏结果是"锁多留 5 分钟",而不是任务异常——让兜底机制承担它该承担的部分,代码才有资格写得这么短。

租约时长怎么定?大于任务 P99 执行时间、小于调度间隔,取中间值。我的超时扫描 P99 在秒级、间隔 60 秒,5 分钟的租约两头都留足了余量。

四、任务流水:比锁更重要的部分

锁解决"不打架",流水解决"说不清"。每次运行(无论成功、失败还是 SKIPPED)都写一条 job_run 记录:

finishJobRun(jobRunId, "SUCCESS", stats.scannedCount, stats.cancelledCount, 0L, null);
// 失败分支
finishJobRun(jobRunId, "FAILED", ..., errorMessage);
// 抢锁失败分支
finishTaskTimeoutJobRun(jobRunId, "SKIPPED", 0L, 0L, 1L, "已有实例正在执行AI任务超时扫描");

运行流水回答三个在运维中最常被问到的问题:

“任务到底跑没跑?” — 看 job_run 有没有记录、什么状态。
“为什么扫出来 0 条?” — 看 SKIPPED 记录:是被别的实例抢了先,还是真的没有过期任务。
“这段时间扫了多少、取消了多少?” — scanned/cancelled 计数在,趋势可查。

尤其是 SKIPPED 这个状态。多实例部署后,抢锁失败是正常现象——但如果不记录,监控看到的就是"一半的调度周期没有产出",你会花一晚上排查一个不存在的问题。把正常现象显式记录下来, abnormal 才有机会显形。

五、兼容单实例:配置开关兜底

当前部署是单实例,锁暂时用不上,但代码照常工作(每次抢锁都成功,成本是一条 upsert)。同时留了开关:

@Scheduled(fixedDelayString = "${workforce.agent.timeout-scan.fixed-delay-ms:60000}")
public void scheduledTimeoutScan() {
    if (!scheduledEnabled) return;
    ...
}

fixedDelay 和开关都走配置。@Scheduled 默认的单线程调度器意味着同一实例内任务天然串行——多实例时靠锁表互斥,单实例时靠调度器串行,两层防御各自独立,谁也不依赖谁。

六、小结

三点体会:

  1. 中间件选型先问"它依赖什么"。 任务依赖数据库,锁就放数据库——依赖对齐之后,故障域是同一个,心智负担是零。Redis 锁很好,但它在这个场景里是一份独立的运维责任。
  2. 租约是分布式锁的氧气。 任何"记得 finally 释放"的设计都会在进程崩溃面前破产,带过期时间的锁才有自动恢复能力;ownerId 校验则守住释放环节的最后防线。
  3. SKIPPED 也是一次成功运行。 多实例系统的监控里,"什么都没做"必须和"没被调度"区分开——记录它,否则你会在假问题上烧掉整个晚上。

现在四个定时任务在两张实例上安静地各跑各的,sys_job_lock 表里常年只有一行数据。它安静,是因为每一次调度周期,另一台实例都看到了它并转身离开——好的锁就是这样的,你永远不会注意到它存在。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数