跳转到内容

SKIP LOCKED — 让 Postgres 当任务队列用

是什么

SELECT ... FOR UPDATE SKIP LOCKED 是 Postgres 9.5(2016 年 1 月)加的一个只多了两个单词的 SQL 语法。它让一条查询遇到已经被别人锁住的行就直接跳过,而不是排队等。

日常类比:超市自助结账,前面机器有人在用。原本你只能排队等他付完。SKIP LOCKED 等于说”这台被占了?我去下一台”——你永远不卡在别人后面

BEGIN;
SELECT id FROM jobs
WHERE state = 'pending'
ORDER BY priority
LIMIT 1
FOR UPDATE SKIP LOCKED; -- 关键:跳过被锁的行
-- 处理任务 ...
UPDATE jobs SET state = 'done' WHERE id = $1;
COMMIT; -- COMMIT 才释放锁

10 个 worker 并发跑这段代码,每个都拿到不同的任务,零冲突、零等待、零客户端协调。

为什么重要

不理解 SKIP LOCKED,下面这些事都没法解释:

  • 为什么 Solid Queue / River / pg_queue 这一波”Postgres 当队列”的库 2020 年后才爆发——核心特性 9.5 才有
  • 为什么 ADR-3 选 Postgres 做后端队列而不是引入 Redis / RabbitMQ——少一个组件少一份运维
  • 为什么 MySQL 用户做并发作业队列一直很难——MySQL 8.0(2018)才补上同名特性
  • 为什么”用数据库做队列”从被嘲笑(2010s)变成主流推荐(2020s)

核心要点

FOR UPDATE 之后能跟三种修饰:

  1. 默认(什么都不加):行被别人锁住 → 阻塞等待,可能死等几秒
  2. NOWAIT:行被别人锁住 → 立即报错,应用层接住重试
  3. SKIP LOCKED:行被别人锁住 → 跳过这一行,继续往下找

执行顺序拆开看:

WHERE → ORDER BY → 加锁 → 跳过被别人锁的 → LIMIT 计数

注意:被跳过的行不计入 LIMITLIMIT 1 SKIP LOCKED 在 9 个 worker 都拿到任务时还能给第 10 个找到第 10 行,不会”该返回的没返回”。

实践案例

案例 1:最小作业队列

CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
payload JSONB,
state TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON jobs (state) WHERE state = 'pending'; -- 部分索引

worker 循环里:

BEGIN;
SELECT id, payload FROM jobs
WHERE state = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 业务处理
UPDATE jobs SET state = 'done' WHERE id = $1;
COMMIT;

为什么是部分索引pending 任务只占总量的极小比例(健康系统下 done 是 99%+),部分索引比全表索引小一两个数量级。

案例 2:批量取任务

SELECT id FROM jobs
WHERE state = 'pending'
ORDER BY priority DESC, id
LIMIT 50
FOR UPDATE SKIP LOCKED;

一个 worker 一次拿 50 条,吞吐量直接拉满。瓶颈从”加锁次数”变成”业务处理速度”。

案例 3:抢库存

UPDATE inventory
SET stock = stock - 1
WHERE id = (
SELECT id FROM inventory
WHERE sku = 'X' AND stock > 0
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id;

10 个并发请求抢同一个 SKU,每个请求锁不同行,没有热点行抢锁,吞吐量比”锁同一行”高一个数量级(前提是同 SKU 有多行库存记录)。

踩过的坑

  1. 忘 COMMIT 任务永远 stuck:worker 进程崩溃但事务没结束(连接还活着)→ 行锁不释放 → 这条任务永远没人能拿。解决:用短事务 + 连接 idle timeout + 监控 long-running tx。

  2. 没索引就是灾难WHERE state = 'pending' 不走索引时,每次 SELECT FOR UPDATE 都要全表扫 + 给所有候选行加锁,并发 worker 互相阻塞退化到 O(N²)。

  3. Repeatable Read 隔离级别有惊喜:RR 下事务看的是一致快照,但 SKIP LOCKED 看的是当前可见的行。可能跳过你以为还在那的行,或锁到一行后发现已被别人 UPDATE 走了。作业队列默认用 READ COMMITTED 就够

  4. autovacuum bloat:每次 UPDATE state 产生一行死元组。高吞吐队列表死元组堆积快,autovacuum 跟不上 → 表膨胀到原来 5 倍。配 autovacuum_vacuum_scale_factor=0.05 给 jobs 表单独调。

  5. ORDER BY 在 SKIP LOCKED 前:先排序再锁,所以 ORDER BY 会让你”按优先级试着拿”,被跳过后继续按排序找下一个。优先级高的任务会被先尝试,但不保证”高优先级总比低优先级先被某个 worker 拿到”——某个 worker 在锁高优先级任务时,另一个 worker 可能已经从低优先级里捞了一个。

适用 vs 不适用

适用

  • 中低吞吐作业队列(<10k 任务/秒)—— Postgres 单机够用,省一个 Redis/MQ 组件
  • 抢库存 / 抢订单 / 抢配额 —— 多行可分配的场景
  • 已经在用 Postgres 的小团队 —— 把队列也放进事务里,免去跨系统一致性

不适用

  • 极高吞吐(>50k/s)—— 走 Redis Streams / Kafka / NATS
  • 强顺序保证 —— SKIP LOCKED 不保证 FIFO,需要严格顺序用单 worker + 默认锁
  • 任务延迟敏感(毫秒级)—— Postgres 事务开销 + 索引扫描有 ms 级抖动

历史小故事(可跳过)

  • 2014:PostgreSQL 邮件列表上 Thomas Munro 提交 patch 实现 SKIP LOCKED,参考 Oracle 同名特性
  • 2016-01-07:PostgreSQL 9.5 发布,正式进入主线
  • 2018:MySQL 8.0 跟进,从此两大主流开源 RDBMS 都有
  • 2020 后:Rails Solid Queue、Go 的 River、Ruby 的 GoodJob 等”Postgres-backed queue”框架爆发,全部以 SKIP LOCKED 为命脉
  • 2024:37signals 把 Sidekiq(基于 Redis)替换成 Solid Queue(基于 Postgres)写进 Rails 8 默认栈

学到什么

  1. 两个单词改变一个生态:SKIP LOCKED 让 Postgres 从”勉强能当队列”变成”推荐方案”,新组件的引入门槛被一句 SQL 削平
  2. 基础设施特性 vs 应用层重试:以前要在客户端做的”锁冲突重试”被下沉到 SQL 引擎,少一层错误处理就少一类 bug
  3. 数据库做队列的真正成本是运维:少一个 Redis 实例 = 少一份监控 / 备份 / 故障恢复,这是 ADR-3 选 Postgres 的真正理由,性能反而是次要的
  4. 看版本号:当一个流行做法忽然在某年大量出现,往往是底层某个小特性那一年才正式可用——SKIP LOCKED 之于队列正是如此

延伸阅读

关联

  • stonebraker-2010-sqlnosql —— Stonebraker 论”何时需要 NoSQL”,SKIP LOCKED 是 SQL 派”不要急着换”的论据
  • sqlite-2022 —— SQLite 没有 SKIP LOCKED(单写),所以 SQLite-backed queue 走完全不同的路线(WAL + 单线程写)
  • aries-1992 —— ARIES 给所有现代行级锁数据库提供恢复理论基础,SKIP LOCKED 跑在它的上面
  • rest-fielding-2000 —— REST 强调无状态客户端;SKIP LOCKED 把”谁拿到任务”的状态留在数据库,客户端无须协调

反向链接

(暂无反向链接)