【任务调度:数据库锁 + 线程池实战】3、 从 SELECT 到 UPDATE:深入理解 SKIP LOCKED 的锁机制与隔离级别

🔒 从 SELECT 到 UPDATE:深入理解 SKIP LOCKED 的锁机制与隔离级别

源码级剖析 SKIP LOCKED 是如何"跳过锁定"的,以及在不同隔离级别下的行为差异

在前两篇文章中,我们认识了 SELECT FOR UPDATE SKIP LOCKED 的强大之处,也对比了 MySQL 和 PostgreSQL 的实现差异。但很多读者可能仍然困惑:SKIP LOCKED 究竟是如何实现"跳过"的?它和 NOWAIT 有什么区别?在不同隔离级别下表现一样吗?会不会有死锁风险?

本文将从最基础的锁类型讲起,一步步深入 SKIP LOCKED 的内部机制,并通过大量示例和 mermaid 图,让你彻底掌握它的工作原理和潜在陷阱。


一、行锁、表锁、间隙锁的区别

在深入 SKIP LOCKED 之前,我们必须先厘清数据库中几种常见的锁类型。它们就像不同粒度的"门禁",决定了事务对数据的访问权限。

1.1 锁的粒度层次

锁粒度
表锁
页锁
行锁
记录锁

Record Lock
间隙锁

Gap Lock
Next-Key Lock

  • 表锁 :锁定整张表,开销小,并发度低。如 MySQL 的 LOCK TABLES
  • 页锁:锁定一页(通常 4KB~16KB),介于表锁和行锁之间,SQL Server 等数据库使用。
  • 行锁:锁定单行记录,并发度高,是 InnoDB、PostgreSQL 等数据库的核心锁机制。

行锁又可以细分为:

  • 记录锁(Record Lock):锁定索引上的某一行。
  • 间隙锁(Gap Lock):锁定索引记录之间的间隙,防止幻读。
  • Next-Key Lock:记录锁 + 间隙锁的组合,MySQL 在可重复读隔离级别下默认使用。

1.2 行锁的加锁方式

在 MySQL InnoDB 中,行锁是基于索引实现的。如果查询没有使用索引,则会升级为表锁。在 PostgreSQL 中,行锁存储在堆表的行头上,通过元组头部的标志位实现,不依赖索引。

示例: 两个事务对同一行加锁

sql 复制代码
-- 事务 A
BEGIN;
SELECT * FROM task WHERE id = 1 FOR UPDATE;

-- 事务 B
BEGIN;
SELECT * FROM task WHERE id = 1 FOR UPDATE; -- 等待 A 提交

如果事务 B 使用 SKIP LOCKED,则不会等待,直接返回空。


二、SKIP LOCKED 是如何实现"跳过"的?

2.1 锁的等待队列

在传统 SELECT FOR UPDATE 中,当一个事务试图锁定已被其他事务锁定的行时,它会被放入该行的锁等待队列,进入睡眠状态,直到锁被释放。
事务B 数据库 事务A 事务B 数据库 事务A 事务B 休眠 SELECT ... FOR UPDATE (锁定 id=1) 返回行 SELECT ... FOR UPDATE (尝试锁定 id=1) 检测到 id=1 已被 A 锁定 将 B 放入等待队列,阻塞 COMMIT 唤醒,锁定 id=1 返回行

2.2 SKIP LOCKED 的"跳过"机制

SKIP LOCKED 改变了这一行为:当数据库发现某行已被锁定时,不会将当前事务加入等待队列,而是立即跳过该行,继续扫描下一行,直到找到足够数量的未锁定行。
事务B 数据库 事务A 事务B 数据库 事务A SELECT ... FOR UPDATE (锁定 id=1) 返回行 SELECT ... FOR UPDATE SKIP LOCKED (扫描 id=1,2,3...) 检查 id=1,发现被 A 锁定 → 跳过 检查 id=2,未锁定 → 锁定并加入结果集 检查 id=3,未锁定 → 锁定并加入结果集 返回 id=2, id=3

实现原理(以 PostgreSQL 为例):

  • PostgreSQL 的行锁信息存储在堆表元组(tuple)的 t_infomask 标志位中。当一行被 FOR UPDATE 锁定时,会设置 HEAP_XMAX_KEYSHR_LOCK 或类似标志。
  • 扫描过程中,heapam.c 中的 heap_lock_tuple 函数会检查元组的锁状态。如果发现已被其他事务锁定,且当前事务请求的是冲突锁模式,则根据 SKIP LOCKED 选项决定是否跳过。
  • 跳过操作实际上就是忽略该元组,继续调用 heap_getnext 获取下一条元组。

MySQL InnoDB 的实现稍有不同:

  • InnoDB 的行锁信息存储在聚集索引的记录上,通过 lock_rec_t 结构管理。
  • 在加锁阶段,InnoDB 会遍历扫描到的记录,对每条记录尝试加锁。如果记录上已有锁且冲突,则根据 SKIP LOCKED 决定是否跳过。跳过意味着不返回该行,也不加锁,继续处理下一条。

2.3 SKIP LOCKED 与 MVCC 的交互

SKIP LOCKED 是在当前读 (锁定读)阶段发生的,与 MVCC 快照读无关。即使使用了 SKIP LOCKED,事务依然能看到自己之前修改的数据,但不会看到其他事务未提交的数据。


三、与 NOWAIT 的对比

NOWAIT 是另一个常见的锁定读选项,它与 SKIP LOCKED 经常被混淆。我们来看看它们的区别。

3.1 NOWAIT 的行为

SELECT FOR UPDATE NOWAIT 的含义是:尝试加锁,如果某行已被锁定,则立即报错返回,而不是等待

sql 复制代码
-- 事务 A
BEGIN;
SELECT * FROM task WHERE id = 1 FOR UPDATE;

-- 事务 B
BEGIN;
SELECT * FROM task WHERE id = 1 FOR UPDATE NOWAIT;
-- 立即返回错误:ERROR: could not obtain lock on row in relation "task"

3.2 核心差异对比

特性 SKIP LOCKED NOWAIT
遇到锁定行 跳过该行,继续寻找其他行 立即报错,终止语句
返回结果 可能返回部分行(少于请求数) 要么全部成功,要么全部失败
适用场景 批量抢占任务,容忍部分行不可用 必须锁定特定行,否则放弃
事务影响 语句可能成功,但返回行数不足 语句失败,回滚整个事务(取决于错误处理)

下面的流程图直观展示了两者的区别:
渲染错误: Mermaid 渲染失败: Parse error on line 11: ... error[抛出锁等待错误] --> end([语句失败]) skip -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'end'

3.3 实际使用建议

  • 在任务调度中,我们通常希望尽可能多地领取任务,即使部分任务被其他节点抢走也没关系,因此 SKIP LOCKED 是更自然的选择。
  • 如果需要确保事务成功时必须锁定所有目标行(例如,必须同时锁定订单和订单明细),则应使用 NOWAIT 或默认等待,并在失败时重试整个事务。

四、在不同隔离级别下的行为差异

隔离级别会影响锁的持有时间和锁的范围,进而影响 SKIP LOCKED 的行为。我们以 MySQL 和 PostgreSQL 为例,分别分析在 RC 和 RR 下的表现。

4.1 读已提交(RC)

在 RC 隔离级别下,事务只能看到已提交的数据,且锁定读只锁定实际扫描到的行,不会加间隙锁。

PostgreSQL

  • RC 是默认隔离级别。SKIP LOCKED 正常工作,只锁定返回的行,且不会锁定间隙。
  • 示例:事务 A 锁定 id=1,事务 B 执行 SELECT ... FOR UPDATE SKIP LOCKED 会跳过 id=1,锁定其他行。

MySQL

  • RC 下,InnoDB 只使用记录锁,没有间隙锁。因此 SKIP LOCKED 可以很好地工作,即使查询条件使用非唯一索引,也不会锁住间隙,跳过的行为符合预期。
  • 但需要注意:如果查询条件不走索引,InnoDB 会锁住所有扫描到的行(全表扫描),这可能导致大量行被锁定,SKIP LOCKED 仍然会跳过这些锁,但性能会很差。

4.2 可重复读(RR)

RR 隔离级别需要防止幻读,因此数据库会使用间隙锁或 Next-Key Lock。

PostgreSQL

  • RR 下,SKIP LOCKED 依然正常工作。PostgreSQL 的 RR 通过快照实现,锁定读仍然只锁定实际行,但会使用更严格的锁模式(如 FOR UPDATE 会阻止其他事务并发更新)。
  • 间隙锁不是 PostgreSQL 的实现方式,因此 SKIP LOCKED 的行为与 RC 下基本一致。

MySQL

  • RR 下,InnoDB 默认使用 Next-Key Lock,即不仅锁定行,还锁定间隙。此时如果查询条件涉及范围(如 status=0status 是非唯一索引),InnoDB 会在索引上加上 Next-Key Lock,锁定一个范围。
  • 当使用 SKIP LOCKED 时,它只能作用于行锁,不能作用于间隙锁。因此,如果查询条件导致间隙锁,SKIP LOCKED 无法跳过被间隙锁锁定的"范围",可能导致整个语句等待或锁定过多行。

示例(MySQL RR):

sql 复制代码
-- 假设 status 是普通索引
-- 事务 A
BEGIN;
SELECT * FROM task WHERE status = 0 ORDER BY id LIMIT 10 FOR UPDATE;
-- 这会在 status=0 对应的索引记录上加 Next-Key Lock

-- 事务 B
BEGIN;
SELECT * FROM task WHERE status = 0 ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;
-- InnoDB 会尝试获取锁,发现 status=0 的某些索引记录上有 Next-Key Lock(间隙锁)
-- 但 SKIP LOCKED 不能跳过间隙锁,导致事务 B 可能需要等待,或者产生意想不到的结果

结论

  • 在 MySQL 中使用 SKIP LOCKED,强烈建议将隔离级别设为 RC,以避免间隙锁干扰。
  • PostgreSQL 则无此限制,任何隔离级别下 SKIP LOCKED 都能可靠工作。

五、可能出现的坑

再好的工具,用不好也会出问题。下面列出使用 SKIP LOCKED 时常见的陷阱及解决方案。

5.1 锁升级

现象:在某些情况下,行锁可能会升级为更粗粒度的锁(如表锁或页锁),导致并发度骤降。

  • MySQL :如果查询没有使用索引,InnoDB 会锁住所有扫描到的行(实际是锁住聚簇索引的全部记录),相当于表锁。此时 SKIP LOCKED 虽然仍然能跳过已被锁定的行,但加锁范围过大,性能急剧下降。
  • PostgreSQL:没有锁升级的概念,但大范围的锁定会消耗大量内存(每个锁占约 100 字节)。

解决方案 :始终确保 SKIP LOCKED 的查询使用合适的索引,且 where 条件能有效过滤数据。定期使用 EXPLAIN 检查执行计划。

5.2 死锁风险

SKIP LOCKED 可以减少锁等待,但并不能完全避免死锁。死锁的发生通常是因为两个事务以不同顺序锁定资源。

示例

sql 复制代码
-- 事务 A
BEGIN;
SELECT * FROM task WHERE id = 1 FOR UPDATE SKIP LOCKED; -- 锁定 id=1
SELECT * FROM task WHERE id = 2 FOR UPDATE SKIP LOCKED; -- 尝试锁定 id=2

-- 事务 B
BEGIN;
SELECT * FROM task WHERE id = 2 FOR UPDATE SKIP LOCKED; -- 锁定 id=2
SELECT * FROM task WHERE id = 1 FOR UPDATE SKIP LOCKED; -- 尝试锁定 id=1

如果 A 锁定 id=1 后,B 锁定了 id=2,然后 A 请求 id=2 被 B 锁,B 请求 id=1 被 A 锁,就形成了死锁。数据库会检测到并回滚其中一个事务。

如何降低死锁风险

  • 尽量以固定顺序锁定资源(例如按 id 升序)。
  • 保持事务简短,减少锁持有时间。
  • 使用 SKIP LOCKED 时,如果某行被跳过,不会锁定它,这反而可能打破循环等待?实际上,SKIP LOCKED 不会强制锁定特定行,所以如果 A 想要 id=2 但被 B 锁,它会跳过 id=2(如果还有别的行可选),而不是等待,这可以避免一部分死锁。但若事务必须锁定特定行(如更新固定主键),仍可能死锁。

5.3 事务超时

事务超时通常由两个原因引起:

  1. 锁等待超时 :事务等待其他事务释放锁的时间超过了 lock_timeout 设置。
  2. 事务空闲超时:事务开启后长时间不提交,导致连接被断开。

与 SKIP LOCKED 的关系

  • 由于 SKIP LOCKED 不会等待,所以它几乎不会触发锁等待超时。但当事务成功锁定一批任务后,如果处理任务耗时很长,事务一直不提交,就会导致空闲超时或被其他节点当作死任务回收。
  • 在分布式调度中,我们通常采用两阶段提交:在事务内仅领取任务(更新状态为"执行中"),然后提交事务,再异步执行任务。这既避免了长事务,也释放了锁。

5.4 数据一致性问题

使用 SKIP LOCKED 时,事务可能只锁定部分行,但业务逻辑需要原子性地处理一批任务。例如,一个业务要求同时处理 10 个任务,如果只锁定了 8 个,剩下 2 个被跳过,可能导致部分成功。

解决方案

  • 在应用层处理:循环尝试,直到锁定足够数量的任务。
  • 使用 SKIP LOCKED 配合 LIMIT 时,设置一个较大的限制值,然后检查返回数量,如果不足则稍后重试。

六、总结

通过本文的深入剖析,我们了解到:

  • 行锁、间隙锁等不同粒度的锁机制是理解 SKIP LOCKED 的基础。
  • SKIP LOCKED 通过跳过已被锁定的行,实现了非阻塞的并发控制,其内部实现依赖于数据库的锁管理模块。
  • NOWAITSKIP LOCKED 虽然都用于避免等待,但行为截然不同:前者报错,后者跳过。
  • 隔离级别对 SKIP LOCKED 的影响因数据库而异:PostgreSQL 在任何隔离级别下都能正常工作,而 MySQL 在 RR 下可能因间隙锁导致问题,建议使用 RC。
  • 实际使用中要注意锁升级、死锁和事务超时等问题,通过合理的事务设计和索引优化来规避。

理解这些底层原理,不仅能帮你更好地使用 SKIP LOCKED,还能在遇到问题时快速定位和解决。在下一篇文章中,我们将进入实战,用线程池和 SKIP LOCKED 构建一个完整的分布式调度框架,敬请期待!


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,也欢迎在评论区留言交流你的实战经验!

相关推荐
编程小白gogogo2 小时前
苍穹外卖图片不显示解决教程
java·spring boot
舟舟亢亢2 小时前
算法总结——二叉树【hot100】(上)
java·开发语言·算法
百锦再2 小时前
Java中的char、String、StringBuilder与StringBuffer 深度详解
java·开发语言·python·struts·kafka·tomcat·maven
努力努力再努力wz3 小时前
【Linux网络系列】:TCP 的秩序与策略:揭秘传输层如何从不可靠的网络中构建绝对可靠的通信信道
java·linux·开发语言·数据结构·c++·python·算法
yy.y--4 小时前
Java数组逆序读写文件实战
java·开发语言
BD_Marathon5 小时前
IDEA创建多级包时显示在同一行怎么办
java·ide·intellij-idea
亓才孓5 小时前
【Exception】CONDITIONS EVALUATION REPORT条件评估报告
java·开发语言·mybatis
硅基动力AI5 小时前
如何判断一个关键词值不值得做?
java·前端·数据库
重生之后端学习6 小时前
78. 子集
java·数据结构·算法·职场和发展·深度优先