4、数据库乐观锁:基于版本号的冲突检测与重试机制
乐观锁,说白了就是「先干活,再检查」。跟悲观锁那种「先锁门,再做事」的思路完全相反。我刚开始接触分布式调度时,总觉得乐观锁不靠谱——万一冲突了怎么办?后来踩过几次坑才明白,在大多数业务场景下,冲突其实是小概率事件,乐观锁反而能跑出更好的性能。
4.1 乐观锁的核心思想
乐观锁假设数据在大部分时间内不会被并发修改。它不阻塞任何操作,只在提交更新时检查数据是否被他人动过。如果发现冲突,就放弃本次操作,让调用方决定怎么处理。
你想想看,调度任务的状态变更其实很典型:一个任务从「待执行」变成「执行中」,正常情况下只有一台机器会去抢这个变更。其他机器要么没抢到,要么根本不会来抢。这种情况下用悲观锁,反而白白浪费了数据库连接资源。
核心原则:乐观锁适用于读多写少、冲突概率低的场景。调度任务的状态变更恰好符合这个特征。
4.2 基于版本号的实现方案
最常见的乐观锁实现方式,就是在数据库表里加一个 version 字段。每次更新时,把版本号作为条件带上,同时把版本号加一。如果更新影响的行数为 0,说明版本号已经被别人改了,冲突发生了。
我记得第一次在调度系统里用这个方案时,表结构大概长这样:
CREATE TABLE task_instance (
id BIGINT PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_task_id (task_id)
);
更新状态的 SQL 也很直接:
UPDATE task_instance
SET status = 1,
version = version + 1,
updated_at = NOW()
WHERE task_id = 'TASK-001'
AND version = 5;
如果这条 SQL 返回的受影响行数为 1,说明更新成功。如果返回 0,说明 version 已经不是 5 了——有人抢先一步改了状态。
小技巧:我个人习惯把 version 字段初始值设为 0,每次更新加 1。这样版本号天然就是单调递增的,排查问题时一眼就能看出数据被改了多少次。
4.3 重试机制的设计
冲突检测出来之后怎么办?直接抛异常让上层处理?还是默默重试?这取决于业务容忍度。
调度任务的状态变更,通常可以接受短暂的重试延迟。我一般会设计一个带退避的重试策略:
- 立即重试:第一次冲突后,马上重新读取最新版本号,再试一次。
- 固定间隔重试:如果连续失败,每次重试间隔 100ms。
- 指数退避:重试间隔按 2 的指数增长,比如 100ms、200ms、400ms……直到上限。
- 最大重试次数:超过 3 次或 5 次后,放弃并记录告警。
我曾经在一个高并发的调度场景里,把重试次数设成了 10 次。结果某次突发流量下,所有实例都在疯狂重试,数据库连接池直接被打满。嗯,后来我学乖了——重试次数别超过 3 次,剩下的交给业务补偿。
4.4 版本号 vs 时间戳
除了版本号,也有人用时间戳做乐观锁。比如用 updated_at 字段作为检测条件:
UPDATE task_instance
SET status = 1,
updated_at = NOW()
WHERE task_id = 'TASK-001'
AND updated_at = '2024-01-15 10:00:00';
但我不推荐这种做法。原因很简单:时间戳的精度问题。如果两个更新发生在同一毫秒内,时间戳可能完全一样,导致冲突检测失效。版本号是严格递增的整数,不存在这个问题。
| 对比维度 | 版本号 | 时间戳 |
|---|---|---|
| 精度 | 严格递增,无歧义 | 受限于毫秒/微秒精度 |
| 实现复杂度 | 低,加一个字段即可 | 低,复用已有字段 |
| 并发安全性 | 高 | 中等,存在时间窗口 |
| 可读性 | 版本号直观表示修改次数 | 时间戳表示最后修改时间 |
避坑指南:我曾经在一个项目里看到有人用 updated_at 做乐观锁,但数据库的时间精度只到秒。结果同一秒内两个请求都通过了检测,数据被覆盖了。从那以后,我坚持用版本号,绝不偷懒。
4.5 乐观锁在调度任务中的典型流程
下面这张图展示了多实例调度时,乐观锁的完整工作流程:
流程其实不复杂。核心就三步:读版本号 → 带版本号更新 → 检查影响行数。如果冲突了,走重试分支;重试次数用完了,就告警。
4.6 乐观锁的适用边界
乐观锁不是万能的。我见过不少团队盲目上乐观锁,结果被坑得很惨。这里列几个需要警惕的场景:
- 高冲突场景:如果多个实例同时争抢同一个任务,乐观锁会导致大量重试,反而比悲观锁更慢。
- 长事务场景:乐观锁的检测点在最后一步,如果业务逻辑执行时间很长,持有旧版本号的时间窗口就很大,冲突概率飙升。
- 批量操作:一次更新多条记录时,乐观锁的版本号管理会变得复杂。每条记录都要单独检测,代码量翻倍。
我的建议:调度任务的状态变更,用乐观锁完全够用。但如果是账户扣款、库存扣减这类高冲突场景,还是老老实实用悲观锁或者分布式锁吧。
4.7 代码示例:带重试的乐观锁
最后给一段伪代码,展示实际项目中怎么用。我个人习惯把重试逻辑封装成一个工具方法,业务代码里只关心核心逻辑:
public boolean tryAcquireTask(String taskId, int maxRetries) {
int retries = 0;
while (retries < maxRetries) {
// 1. 读取当前版本号
TaskInstance task = taskMapper.selectByTaskId(taskId);
if (task == null) {
return false;
}
int currentVersion = task.getVersion();
// 2. 执行本地业务逻辑(比如检查任务是否可执行)
if (!canExecute(task)) {
return false;
}
// 3. 带版本号更新
int affected = taskMapper.updateStatusWithVersion(
taskId,
ExecStatus.RUNNING,
currentVersion
);
// 4. 检查是否成功
if (affected > 0) {
return true; // 更新成功
}
// 5. 冲突了,等待后重试
retries++;
if (retries < maxRetries) {
sleep(100 << retries); // 指数退避
}
}
// 6. 重试耗尽,记录告警
alertService.sendAlert("任务抢占失败: " + taskId);
return false;
}
这段代码里,sleep(100 << retries) 就是指数退避。第一次等 200ms,第二次 400ms,第三次 800ms。既不会把数据库打爆,也不会让调用方等太久。
额外提醒:重试时的 sleep 最好用 Thread.sleep 配合 System.nanoTime 做精确延时。别用 TimeUnit.SECONDS.sleep 这种粗粒度方法,调度场景下 100ms 的差距可能就是成败的关键。