基于数据库字段实现可续期分布式锁:从任务抢占到心跳续约
前言
在多实例部署的后台系统中,经常会遇到这样一类问题:多个服务节点都会定时扫描数据库中的任务表,找出已经到期的任务并执行。
如果没有互斥控制,就可能出现:
bash
实例 A 扫到 task-1 到期
实例 B 也扫到 task-1 到期
实例 C 也扫到 task-1 到期
结果:同一个任务被多个实例重复执行。
如果任务只是简单查询,影响可能不大。但如果任务会下载文件、解析内容、调用外部服务、写数据库、写索引或重建缓存,重复执行就会带来数据覆盖、资源浪费和状态错乱。
这类场景需要一把分布式锁:
bash
同一条任务,同一时间,只允许一个实例执行。
如果系统本身已经有任务表,并且调度频率不高,比如每 10 秒扫描一次,那么可以不额外引入 Redis 或 ZooKeeper,而是在任务表中增加两个字段,用数据库实现一把轻量的、可过期、可续期的分布式锁。
技术原理
1. 锁字段设计
在任务表中增加两个字段:
bash
ALTER TABLE schedule_task
ADD COLUMN lock_owner VARCHAR(128),
ADD COLUMN lock_until TIMESTAMP;
字段含义:
| 字段 | 含义 |
|---|---|
lock_owner |
当前锁持有者的唯一 token |
lock_until |
锁过期时间 |
没有实例持锁时:
bash
lock_owner = null
lock_until = null
某个实例抢到锁后:
bash
lock_owner = instance-a-token
lock_until = 当前时间 + 锁 TTL
lock_owner 表示"谁持有锁",lock_until 表示"这把锁最多持有到什么时候"。如果持锁实例宕机,没有机会释放锁,等 lock_until 过期后,其他实例仍然可以重新抢锁。
2. 抢锁:基于 UPDATE 的 CAS
抢锁的核心 SQL:
bash
UPDATE schedule_task
SET lock_owner = :lockToken,
lock_until = :lockUntil
WHERE id = :taskId
AND (lock_until IS NULL OR lock_until < :now);
判断方式:
bash
影响行数 > 0:抢锁成功
影响行数 = 0:抢锁失败
这就是一个数据库版 CAS:
bash
Compare:WHERE lock_until IS NULL OR lock_until < now
Set:SET lock_owner = lockToken, lock_until = lockUntil
Java 示例:
bash
public TaskLockLease tryAcquire(String taskId, Date now) {
TaskLockLease lease = new TaskLockLease(taskId, nextLockToken());
int updated = taskMapper.updateLock(
taskId,
lease.lockToken(),
computeLockUntil(),
now
);
return updated > 0 ? lease : null;
}
TaskLockLease 是锁凭证:
bash
public record TaskLockLease(String taskId, String lockToken) {
}
后续续期、释放锁、写回状态,都必须带着这个 lockToken。
3. MySQL 和 PostgreSQL 为什么能保证互斥
以 MySQL InnoDB 为例,如果 WHERE id = ? 命中主键,UPDATE 会对这条记录加排他行锁。
多个实例并发更新同一行时,大致过程是:
bash
实例 A 先拿到这行的排他锁
实例 B 等待
A 更新 lock_owner 和 lock_until
A 提交事务,释放行锁
B 被唤醒,重新判断 WHERE 条件
发现 lock_until 已经是未来时间
B 更新 0 行
PostgreSQL 也是类似,UPDATE 会对被更新的行加行级锁。后来的事务会等待前一个事务结束,再重新判断条件。
所以这类数据库锁成立有几个前提:
- MySQL 使用 InnoDB。
WHERE id = ?命中主键或唯一索引。- 所有实例连接同一个数据库主库。
- 抢锁后必须检查
UPDATE影响行数。
注意,数据库内部的行锁只保护这次 UPDATE 的原子性。真正的业务锁状态,是由 lock_owner 和 lock_until 字段持续表达的。
4. 为什么 lock_owner 要用唯一 token
lock_owner 不建议只存机器名,最好存每次抢锁生成的唯一 token。
原因是防止旧任务误释放新任务的锁。
考虑这个场景:
bash
T0:实例 A 抢到锁,lock_owner = A-token-1
T1:实例 A 卡顿,锁过期
T2:实例 B 抢到锁,lock_owner = B-token-1
T3:实例 A 恢复,进入 finally,尝试释放锁
如果释放锁只按任务 ID 清空字段,A 会把 B 的锁释放掉。
正确做法是释放时也校验 token:
bash
UPDATE schedule_task
SET lock_owner = NULL,
lock_until = NULL
WHERE id = :taskId
AND lock_owner = :lockToken;
只有数据库里的 lock_owner 仍然等于自己的 lockToken,才允许释放。
5. 长任务为什么需要续期
如果任务很短,只抢锁和释放锁就够了。
但很多后台任务是长任务,比如:
- 下载远程文件。
- 解析大文本。
- 调用外部模型或第三方接口。
- 写入数据库。
- 重建索引或向量数据。
如果任务执行时间超过锁 TTL,其他实例就可能重新抢到这条任务,导致同一个任务被并发执行。
因此需要续期机制:
bash
只要任务还在执行,并且锁仍然属于自己,就周期性延长 lock_until。
续期 SQL:
bash
UPDATE schedule_task
SET lock_until = :lockUntil
WHERE id = :taskId
AND lock_owner = :lockToken;
Java 示例:
bash
public boolean renew(TaskLockLease lease) {
if (lease == null) {
return false;
}
int updated = taskMapper.renewLock(
lease.taskId(),
lease.lockToken(),
computeLockUntil()
);
return updated > 0;
}
续期成功,说明锁仍然属于自己。续期失败,通常说明:
- 锁过期后已经被其他实例抢走。
- 任务记录被删除。
lock_owner被外部逻辑修改。
6. 续期分两层:启动前确认 + 后台心跳
一个容易忽略的点是:任务不是抢到锁后立刻执行。
通常流程是:
bash
TaskLockLease lease = lockManager.tryAcquire(task.getId(), now);
workerExecutor.execute(() -> taskProcessor.process(lease));
这里存在时间差:
bash
T0:扫描线程抢到锁
T1:任务提交到线程池
T2:任务真正开始执行
如果线程池很忙,任务可能在队列里等一段时间。因此任务真正开始时,需要先同步续期一次:
bash
if (!lockManager.renew(lease)) {
return;
}
这一步不是为了"多续一次",而是为了确认:
bash
这把锁现在还属于我吗?
确认通过后,再启动后台心跳:
bash
ScheduledFuture<?> future = heartbeatExecutor.scheduleWithFixedDelay(
() -> doHeartbeat(heartbeat),
intervalMillis,
intervalMillis,
TimeUnit.MILLISECONDS
);
注意,第一个 intervalMillis 是初始延迟。也就是说,startHeartbeat 不会立刻续期,而是等一个心跳间隔后才第一次执行。
如果默认锁 TTL 是 900 秒,心跳间隔是 60 秒,时间线就是:
bash
T0:扫描线程抢锁成功
lock_until = T0 + 900 秒
T1:任务真正开始
立即 renew 一次
lock_until = T1 + 900 秒
T1 + 60 秒:第一次后台心跳
lock_until = T1 + 60 秒 + 900 秒
T1 + 120 秒:第二次后台心跳
lock_until = T1 + 120 秒 + 900 秒
任务结束:
close heartbeat
release lock
7. 心跳如何停止
后台心跳不能无限续期。任务结束时必须关闭心跳,并释放锁:
bash
try {
processTask();
} finally {
heartbeat.close();
lockManager.release(lease);
}
heartbeat.close() 会取消周期任务:
bash
public void close() {
ScheduledFuture<?> scheduledFuture = future;
if (scheduledFuture != null) {
scheduledFuture.cancel(false);
}
}
cancel(false) 的含义是:
bash
取消后续调度;
如果当前这次心跳正在执行,不强行中断。
如果某次心跳续期失败,也要标记锁已丢失,并取消后续心跳:
bash
private void markLost() {
if (lost.compareAndSet(false, true)) {
ScheduledFuture<?> scheduledFuture = future;
if (scheduledFuture != null) {
scheduledFuture.cancel(false);
}
}
}
所以正常生命周期是:
bash
抢锁成功
-> 任务开始时同步续期
-> 启动后台心跳
-> 任务执行中周期续期
-> 任务结束关闭心跳
-> 释放锁
8. 状态写回也要校验锁持有者
锁不仅用于开始执行任务,也要保护任务状态写回。
假设:
bash
实例 A 执行很久,锁过期
实例 B 抢到锁并开始执行
实例 A 后来完成了任务,准备写 success
如果 A 还能无条件写任务状态,就可能覆盖 B 的执行结果。
所以状态写回也要带上锁条件:
bash
UPDATE schedule_task
SET last_run_time = :lastRunTime,
next_run_time = :nextRunTime,
last_status = :status,
last_error = :error
WHERE id = :taskId
AND lock_owner = :lockToken;
只有当前任务仍然持有锁,才允许写调度主状态。
9. 为什么还需要业务资源锁
调度锁保护的是:
bash
同一条任务同一时间只被一个调度器执行。
但某些业务资源可能还有其他入口会触发处理,比如人工重试、手动刷新、异步补偿任务。
因此,在真正处理业务资源前,还可以增加资源级别的互斥。例如:
bash
UPDATE biz_resource
SET status = 'running'
WHERE id = :resourceId
AND status <> 'running';
这层锁保护的是:
bash
同一个业务资源不能同时被多个流程重建。
两层锁职责不同:
bash
任务锁:防止同一条调度任务重复执行。
资源锁:防止同一份业务数据被并发重建。
总结
基于数据库字段实现可续期分布式锁,核心是三条 SQL:
bash
-- 抢锁
UPDATE schedule_task
SET lock_owner = :lockToken,
lock_until = :lockUntil
WHERE id = :taskId
AND (lock_until IS NULL OR lock_until < :now);
-- 续期
UPDATE schedule_task
SET lock_until = :lockUntil
WHERE id = :taskId
AND lock_owner = :lockToken;
-- 释放
UPDATE schedule_task
SET lock_owner = NULL,
lock_until = NULL
WHERE id = :taskId
AND lock_owner = :lockToken;
优点:
- 实现简单,不需要额外中间件。
- 锁状态可直接从数据库排查。
- 依赖
lock_until自动过期,实例崩溃后可恢复。 - 依赖
lockToken防止误释放、误续期、误写状态。 - 适合低频调度、任务级互斥、已有任务表的场景。
缺点:
- 不适合高频抢锁和强竞争场景。
- 依赖数据库主库,锁竞争会给数据库带来写压力。
- 如果业务线程永久卡死且心跳线程仍正常,锁可能持续被续。
- 多实例机器时间不一致时,基于应用时间计算
lock_until可能有误差。
优化方向:
- 使用数据库时间,例如
CURRENT_TIMESTAMP,避免应用机器时间漂移。 - 增加单任务最大执行时长和最大续约总时长。
- 给远程 IO、外部接口、模型调用设置超时。
- 增加卡住任务监控和告警。
- 对高竞争场景,可以考虑 Redis、ZooKeeper 或专业调度框架。
拓展用法:
- 定时同步任务抢占。
- 数据重建任务抢占。
- 批处理任务分片执行。
- 异步补偿任务防重复。
- 低频后台任务的跨实例互斥。
这套方案的关键不是让数据库长时间持有行锁,而是利用数据库单行 UPDATE 的原子性完成抢锁,再用 lock_owner + lock_until 表达一段可过期、可续期的业务租约。
摘要
本文介绍了一种基于数据库字段实现可续期分布式锁的方案:通过 lock_owner 标识锁持有者,通过 lock_until 表示锁过期时间,利用 UPDATE ... WHERE 的原子性完成 CAS 抢锁,并通过后台心跳机制为长任务续期。该方案适合低频调度、任务级互斥和已有任务表的后台系统,优势是实现简单、排查方便、无需额外中间件。