🔥 多节点抢任务?SELECT FOR UPDATE SKIP LOCKED 才是真正的无锁调度神器
告别锁等待、死锁与竞争,让分布式任务调度如丝般顺滑
在分布式系统中,任务调度 是一个永恒的话题。无论是定时任务、延迟队列,还是批量处理,我们总会遇到一个核心问题:多个节点同时抢任务,如何保证不冲突、不重复、不阻塞?
传统的解决方案往往在"锁"上做文章:数据库悲观锁、乐观锁、分布式锁......但它们都有各自的短板。而今天我们要介绍的 SELECT FOR UPDATE SKIP LOCKED,正在悄然成为分布式任务调度的"银弹"。它究竟是何方神圣?为什么能吊打传统方案?本文将带你彻底搞懂它。
一、分布式调度中的痛点
1.1 多节点同时拉取任务导致冲突
想象一个典型的任务表:
| id | task_name | status | execute_time | node_id |
|---|---|---|---|---|
| 1 | 发送邮件 | 0 | 2025-01-01 10:00 | NULL |
| 2 | 生成报表 | 0 | 2025-01-01 10:05 | NULL |
我们启动了三个调度器节点(Node A、B、C),它们都在同一时刻扫描这张表,拉取"待执行"的任务。问题来了:如何保证每个任务只被一个节点拉走?
如果没有任何保护机制,三个节点可能同时读到任务1,然后各自执行,导致任务被重复处理------这在大多数业务场景下是不可接受的。
1.2 传统解决方案的局限性
悲观锁(SELECT FOR UPDATE)
sql
BEGIN;
SELECT * FROM task WHERE status = 0 AND execute_time <= NOW() LIMIT 10 FOR UPDATE;
-- 拿到任务,更新状态
COMMIT;
问题 :当一个事务锁住一批任务后,其他节点必须阻塞等待,直到锁释放。这会严重降低系统的吞吐量,甚至引发雪崩。
乐观锁(版本号或 CAS)
sql
UPDATE task SET status = 1, version = version + 1
WHERE id = ? AND status = 0 AND version = ?;
问题:高并发下,大量更新失败,导致大量重试。而且乐观锁只能解决单行更新,无法原子性地"抢"一批任务。
分布式锁(Redis / Zookeeper)
java
// 伪代码
if (redis.setnx("lock:task_poll", "1", 30)) {
// 抢到锁,拉取任务
List<Task> tasks = fetchTasks();
releaseLock();
}
问题:
- 引入额外组件,增加复杂度
- 锁超时、网络分区等问题可能导致任务被多个节点同时执行
- 性能开销较大(加锁、解锁的 RTT)
有没有一种方法,既能保证任务不被重复拉取,又不需要等待,还能原生支持数据库事务?答案是 SELECT ... FOR UPDATE SKIP LOCKED。
二、SKIP LOCKED 是什么?
2.1 语法示例
SKIP LOCKED 是 SELECT ... FOR UPDATE 的一个可选修饰符,它的作用非常直接:当一行被其他事务锁定时,跳过它,而不是等待。
下面是一个典型的用法(以 PostgreSQL 为例):
sql
SELECT * FROM task
WHERE status = 0 AND execute_time <= NOW()
ORDER BY execute_time
LIMIT 10
FOR UPDATE SKIP LOCKED;
这条语句的含义:
- 找出所有状态为待执行且已到执行时间的任务
- 按执行时间排序
- 尝试给它们加锁(
FOR UPDATE) - 如果某行已经被其他事务锁定,跳过它,继续寻找下一行
- 最终返回最多 10 条成功加锁的任务
2.2 工作原理:跳过已被锁定的行
下图展示了 SKIP LOCKED 在多节点竞争下的行为:
渲染错误: Mermaid 渲染失败: Parse error on line 18: ...) Note over A,B,C: 三个节点无阻塞地获得了各自的任务 ---------------------^ Expecting 'TXT', got ','
关键点:
- 每个节点执行相同的查询,但数据库保证同一行不会被两个节点同时锁定
- 锁定的行对当前事务可见,对其他事务不可见(直到提交或回滚)
- 没有等待,没有死锁,没有轮询
从锁的视角看,SKIP LOCKED 相当于一个非阻塞的尝试加锁操作。
三、各数据库对 SKIP LOCKED 的支持情况
虽然 SKIP LOCKED 是一个非常有用的特性,但并不是所有数据库都原生支持。下表总结了主流数据库的支持情况:
| 数据库 | 最低版本要求 | 语法 | 特性说明 |
|---|---|---|---|
| PostgreSQL | 9.5+ | FOR UPDATE SKIP LOCKED |
支持最完善,可与 FOR SHARE、FOR NO KEY UPDATE 等组合 |
| MySQL | 8.0.1+ | FOR UPDATE SKIP LOCKED |
仅 InnoDB 引擎支持,有一些限制(见下文) |
| Oracle | 12c+ | FOR UPDATE SKIP LOCKED |
企业级支持,可与 FETCH FIRST 分页结合 |
| SQL Server | 不支持 | 无 | 可使用 WITH (UPDLOCK, READPAST) 模拟类似行为 |
| SQLite | 不支持 | 无 | 无行级锁机制 |
3.1 PostgreSQL:最成熟的选择
PostgreSQL 自 9.5 版本引入 SKIP LOCKED,并且支持多种锁模式(FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE),可灵活应对不同场景。它没有任何额外的限制,可以在任何索引或非索引列上使用。
3.2 MySQL 8.0+:可用但有坑
MySQL 8.0.1 开始支持 SKIP LOCKED,但必须使用 InnoDB 引擎,并且有一些限制:
- 查询条件必须使用唯一索引 或主键 进行等值或范围查询,不能使用非索引列作为过滤条件(否则会退化为表锁或间隙锁,导致
SKIP LOCKED失效) - 不能与
NOWAIT同时使用 - 在可重复读(RR)隔离级别下,如果查询条件涉及间隙锁,可能会锁住更多的行,导致
SKIP LOCKED无法跳过
示例(正确用法):
sql
-- 假设 id 是主键,status 有普通索引
SELECT * FROM task WHERE status = 0 AND id > 100 ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;
-- 可以工作,但必须保证 where 条件能走唯一索引或主键
错误用法:
sql
-- 假设 status 列没有索引
SELECT * FROM task WHERE status = 0 ORDER BY execute_time LIMIT 10 FOR UPDATE SKIP LOCKED;
-- 会导致全表扫描 + 间隙锁,SKIP LOCKED 不生效
3.3 Oracle 12c+:企业级支持
Oracle 从 12c 开始支持 SKIP LOCKED,常与 FETCH FIRST 分页结合使用:
sql
SELECT * FROM task
WHERE status = 0 AND execute_time <= SYSTIMESTAMP
ORDER BY execute_time
FETCH FIRST 10 ROWS ONLY
FOR UPDATE SKIP LOCKED;
3.4 SQL Server:替代方案
SQL Server 没有 SKIP LOCKED,但可以通过表提示 READPAST + UPDLOCK 实现类似效果:
sql
SELECT TOP 10 * FROM task WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE status = 0 AND execute_time <= GETDATE()
ORDER BY execute_time;
UPDLOCK:对读取的行加更新锁,防止其他事务修改READPAST:跳过已被锁定的行ROWLOCK:建议使用行级锁(不是必须,但有助于提高并发)
3.5 SQLite:无能为力
SQLite 的锁机制是数据库级别的,不支持行级锁,因此无法实现 SKIP LOCKED。
四、为什么它是分布式调度的"银弹"?
SKIP LOCKED 之所以能成为分布式调度的利器,是因为它完美解决了传统方案的三个核心痛点:
4.1 无等待、无阻塞
在传统 SELECT FOR UPDATE 中,如果一行被锁定,后续会话必须等待,直到锁释放。在高并发场景下,这会造成大量线程阻塞,系统吞吐量急剧下降。
SKIP LOCKED 则完全不同:它遇到被锁定的行就"绕道而行",直接跳过,继续寻找可用的行。这样一来,多个节点可以同时执行查询,各自取走属于自己的任务,谁也不等谁。
下图对比了传统锁定与 SKIP LOCKED 的区别:
FOR UPDATE SKIP LOCKED
锁定T1,T2
尝试锁定T1
T1已被锁,跳过
锁定T3,T4
尝试锁定T1-T4
全部跳过,锁定T5
节点A
数据库
节点B
节点C
传统 FOR UPDATE
锁定T1,T2
尝试锁定T1
T1已被锁,等待
等待直到A释放
节点A
数据库
节点B
阻塞
4.2 无竞争、高并发
由于没有阻塞,多个节点可以同时运行拉取任务,数据库能处理的并发量大大提升。理论上,随着节点数量的增加,系统整体处理能力可以线性扩展------只要数据库的 CPU 和 IO 跟得上。
4.3 天然支持批量与事务
SKIP LOCKED 在一个语句内原子性地锁定多行,并返回这些行。开发者可以在一个事务中完成"查询 + 加锁 + 更新状态"的操作,保证一致性。这比乐观锁需要多次尝试要简单得多。
4.4 无需外部组件
直接利用数据库的能力,不引入 Redis、ZooKeeper 等额外组件,降低系统复杂性和运维成本。
五、适用场景与注意事项
5.1 适用场景
- 任务队列/延迟队列:多个 worker 从任务表中拉取待执行的任务,处理完成后更新状态。
- 批处理系统:如数据清洗、报表生成等,将大批任务拆分后并发执行。
- 消息重试机制:将失败的消息存入数据库,由多个消费者重试。
- 分布式爬虫:多个爬虫节点从任务池中领取 URL 进行抓取。
5.2 任务表结构设计建议
为了充分发挥 SKIP LOCKED 的威力,任务表的设计至关重要。以下是一个推荐的表结构(以 MySQL 为例):
sql
CREATE TABLE `task` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`task_type` VARCHAR(64) NOT NULL COMMENT '任务类型',
`payload` JSON DEFAULT NULL COMMENT '任务参数',
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '状态:0-待执行 1-执行中 2-成功 3-失败 4-待重试',
`priority` TINYINT NOT NULL DEFAULT '5' COMMENT '优先级 1-10',
`execute_time` DATETIME NOT NULL COMMENT '计划执行时间',
`retry_times` TINYINT NOT NULL DEFAULT '0' COMMENT '已重试次数',
`max_retries` TINYINT NOT NULL DEFAULT '3' COMMENT '最大重试次数',
`node_id` VARCHAR(64) DEFAULT NULL COMMENT '当前执行节点ID',
`version` INT NOT NULL DEFAULT '0' COMMENT '乐观锁版本(可选)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_status_exec_time` (`status`, `execute_time`),
KEY `idx_node_id` (`node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点:
- 主键自增:方便按 ID 排序,避免热点行。
- 复合索引 :
(status, execute_time)是查询的核心,必须加索引。 - node_id:记录当前执行节点,便于故障恢复(如节点宕机后,其他节点可接手)。
- execute_time:支持延迟执行和重试延迟。
- JSON 字段:灵活存储任务参数,避免频繁改表。
5.3 索引优化与锁粒度控制
使用 SKIP LOCKED 时,查询必须尽可能高效,否则可能锁住大量行,导致 SKIP LOCKED 失效。以下是一些优化建议:
1. 索引必须覆盖查询条件
假设我们常用的查询是:
sql
SELECT * FROM task
WHERE status IN (0, 4)
AND execute_time <= NOW()
ORDER BY priority DESC, execute_time ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
那么应该建立复合索引:
sql
ALTER TABLE task ADD INDEX idx_status_exec_time (status, execute_time);
如果还涉及 priority 排序,可以尝试建立 (status, priority, execute_time) 的索引,但需要根据实际查询分析。
2. 避免全表扫描
如果查询条件无法使用索引,数据库可能选择全表扫描,并锁住大量行(甚至整张表),导致 SKIP LOCKED 无法正常工作。务必通过 EXPLAIN 确认查询使用了合适的索引。
3. 控制事务长度
锁定行之后,事务应尽快提交或回滚,避免长时间持有锁。如果任务处理时间较长,建议采用异步处理模式:在事务内只做"领取任务"和"更新状态为执行中",然后提交事务,再异步执行任务。任务执行完成后,再开启一个新事务更新最终状态。
4. 使用合适的锁模式
PostgreSQL 支持多种锁模式,如果只是要"标记"任务被领取,不一定需要 FOR UPDATE,可以使用 FOR NO KEY UPDATE(较弱锁,允许其他事务修改非关键字段),减少锁冲突。
六、实战示例:Spring Boot 中集成 SKIP LOCKED
下面我们通过一个 Spring Boot 示例,展示如何用 SKIP LOCKED 实现分布式任务拉取。
6.1 依赖与配置
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
yaml
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/scheduler?useSSL=false&serverTimezone=UTC
username: root
password: 123456
hikari:
maximum-pool-size: 20
6.2 Repository 层
java
@Repository
public class TaskRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Task> pollTasks(int limit) {
String sql = """
SELECT id, task_type, payload, status, execute_time, retry_times, max_retries
FROM task
WHERE status IN (0, 4)
AND execute_time <= NOW()
ORDER BY priority DESC, execute_time ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
""";
return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(Task.class), limit);
}
@Transactional
public void claimTasks(List<Long> taskIds, String nodeId) {
if (taskIds.isEmpty()) return;
String sql = "UPDATE task SET status = 1, node_id = ? WHERE id IN (" +
taskIds.stream().map(String::valueOf).collect(Collectors.joining(",")) +
") AND status IN (0,4)";
jdbcTemplate.update(sql, nodeId);
}
public void markSuccess(Long taskId) {
jdbcTemplate.update("UPDATE task SET status = 2 WHERE id = ?", taskId);
}
public void markFailed(Long taskId, int retryTimes, int maxRetries) {
if (retryTimes >= maxRetries) {
jdbcTemplate.update("UPDATE task SET status = 3 WHERE id = ?", taskId);
} else {
// 下次重试时间 = 当前时间 + 指数退避
LocalDateTime nextRetry = LocalDateTime.now().plusSeconds(1 << retryTimes);
jdbcTemplate.update("UPDATE task SET status = 4, retry_times = ?, execute_time = ? WHERE id = ?",
retryTimes + 1, nextRetry, taskId);
}
}
}
6.3 调度器服务
java
@Service
public class TaskScheduler {
@Autowired
private TaskRepository taskRepository;
@Value("${scheduler.node-id}")
private String nodeId;
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void start() {
executor.scheduleWithFixedDelay(this::pollLoop, 0, 1, TimeUnit.SECONDS);
}
private void pollLoop() {
int batchSize = 10;
List<Task> tasks = taskRepository.pollTasks(batchSize);
if (!tasks.isEmpty()) {
// 批量更新为执行中(在事务内)
List<Long> ids = tasks.stream().map(Task::getId).collect(Collectors.toList());
taskRepository.claimTasks(ids, nodeId);
// 异步执行任务(避免持有事务)
tasks.forEach(this::executeAsync);
}
}
private void executeAsync(Task task) {
CompletableFuture.runAsync(() -> {
try {
// 模拟任务执行
System.out.println("Executing task " + task.getId());
Thread.sleep(1000);
taskRepository.markSuccess(task.getId());
} catch (Exception e) {
taskRepository.markFailed(task.getId(), task.getRetryTimes(), task.getMaxRetries());
}
});
}
}
6.4 启动多个实例测试
通过设置不同的 scheduler.node-id(如 node1、node2),启动多个应用实例,观察数据库中的任务是否被平均分配、无重复处理。
七、总结
SELECT FOR UPDATE SKIP LOCKED 是分布式任务调度领域的一颗明珠,它以极简的方式解决了多节点竞争下的锁等待、死锁和性能问题。通过本文的介绍,你应该已经掌握了:
- 分布式调度的核心痛点
SKIP LOCKED的原理与语法- 各主流数据库的支持情况与差异
- 如何设计任务表及优化索引
- 在 Spring Boot 中集成
SKIP LOCKED的完整示例
当然,没有银弹。SKIP LOCKED 也有其适用范围和限制(如 MySQL 的索引要求、事务长度的控制等)。但在大多数场景下,它比传统方案更简单、更高效。
下一篇文章,我们将深入比较 PostgreSQL 与 MySQL 在 SKIP LOCKED 上的实现差异,并探讨如何根据业务场景选择最合适的数据库。敬请期待!
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,也欢迎在评论区留言交流你的实战经验!