基于数据库字段实现可续期分布式锁:从任务抢占到心跳续约

基于数据库字段实现可续期分布式锁:从任务抢占到心跳续约

前言

在多实例部署的后台系统中,经常会遇到这样一类问题:多个服务节点都会定时扫描数据库中的任务表,找出已经到期的任务并执行。

如果没有互斥控制,就可能出现:

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_ownerlock_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 抢锁,并通过后台心跳机制为长任务续期。该方案适合低频调度、任务级互斥和已有任务表的后台系统,优势是实现简单、排查方便、无需额外中间件。

相关推荐
用户8356290780511 小时前
Python 操作 PowerPoint 页眉与页脚指南
后端·python
苍何1 小时前
从 0-1 跑通 AI 产品出海,没那么难
后端
掘金一周1 小时前
想换一辆电车,JYM有什么推荐 | 沸点周刊 5.21
前端·人工智能·后端
_院长大人_2 小时前
Java Excel导出:如何实现自定义表头与字段顺序的完全控制
java·开发语言·后端·excel
武子康3 小时前
Java-03 深入浅出 MyBatis 增删改查与映射配置详解
java·后端
百度Geek说3 小时前
网盘存量代码迁移实战:我们如何用三层架构管住 AI 的输出
后端
Maiko Star5 小时前
* SpringBoot整合LangChain4j
java·spring boot·后端·langchain4j
明月_清风5 小时前
Go语言空接口与类型断言完全指南:从"万能容器"到"类型还原"
后端·go