写在前面
项目里的定时任务越来越多: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 默认的单线程调度器意味着同一实例内任务天然串行——多实例时靠锁表互斥,单实例时靠调度器串行,两层防御各自独立,谁也不依赖谁。
六、小结
三点体会:
- 中间件选型先问"它依赖什么"。 任务依赖数据库,锁就放数据库——依赖对齐之后,故障域是同一个,心智负担是零。Redis 锁很好,但它在这个场景里是一份独立的运维责任。
- 租约是分布式锁的氧气。 任何"记得 finally 释放"的设计都会在进程崩溃面前破产,带过期时间的锁才有自动恢复能力;ownerId 校验则守住释放环节的最后防线。
- SKIPPED 也是一次成功运行。 多实例系统的监控里,"什么都没做"必须和"没被调度"区分开——记录它,否则你会在假问题上烧掉整个晚上。
现在四个定时任务在两张实例上安静地各跑各的,sys_job_lock 表里常年只有一行数据。它安静,是因为每一次调度周期,另一台实例都看到了它并转身离开——好的锁就是这样的,你永远不会注意到它存在。
默认评论
Halo系统提供的评论