You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PostgreSQL中FOR UPDATE SKIP LOCKED未跳过锁定行问题

问题描述

我需要构建一个文章队列,每篇文章包含last_seen_at字段。用户访问页面时,服务器按last_seen_at升序选取首篇返回给浏览器,同时将该文章的last_seen_at更新为当前时间,使其移至队尾。

我用FOR UPDATE SKIP LOCKED实现这个逻辑,但在高并发毫秒级请求场景下,会出现同一文章返回给不同用户的问题。

执行的SQL语句如下:

SELECT * INTO rec
FROM articles
ORDER BY last_seen_at ASC NULLS FIRST
LIMIT 1
FOR UPDATE SKIP LOCKED;

UPDATE articles SET last_seen_at = NOW() WHERE id = rec.id;

我写了bash+psql脚本复现问题:模拟10个客户端并行从20条记录的表中请求文章,预期每个客户端获取唯一文章,但实际约5-10%的概率出现重复返回同一文章的情况(比如Run3中Article16被返回两次)。我搞不懂为什么FOR UPDATE SKIP LOCKED没按预期锁定行,让其他请求跳过已锁定的记录。

问题分析与解决方案

问题根源

  1. NOW()的事务级精度问题:PostgreSQL里NOW()返回的是事务开始时间,不是语句执行的实时时间。如果多个并发事务几乎同时启动,它们的NOW()值完全相同。
  2. 事务间隙的竞态条件:当第一个事务选中某篇文章并更新last_seen_at为事务开始时间,但还没提交时,后续并发事务执行SELECT时,看到的还是更新前的last_seen_at状态——因为PostgreSQL默认隔离级别是READ COMMITTED,只有事务提交后,其他事务才能看到更新结果。这就导致多个并发事务会同时认为同一篇文章是排序最靠前的,而FOR UPDATE SKIP LOCKED的锁是在SELECT执行时才加的,若多个事务的SELECT几乎同时触发,可能会在锁生效前都选中同一行。

解决办法

1. 用clock_timestamp()替代NOW()

clock_timestamp()返回语句执行的实时时间,精度到微秒,能保证并发更新的last_seen_at值存在差异,避免排序时出现大量相同值导致的重复选中。修改后的UPDATE语句:

UPDATE articles SET last_seen_at = clock_timestamp() WHERE id = rec.id;

2. 合并SELECT与UPDATE为原子操作(最优解)

把选行、锁定、更新合并成一个语句,彻底消除两个语句之间的时间窗口,从根本上避免竞态:

WITH selected AS (
    SELECT id
    FROM articles
    ORDER BY last_seen_at ASC NULLS FIRST
    LIMIT 1
    FOR UPDATE SKIP LOCKED
)
UPDATE articles
SET last_seen_at = clock_timestamp()
FROM selected
WHERE articles.id = selected.id
RETURNING articles.*;

这个写法在同一个原子操作里完成所有逻辑,不会给并发事务留下可乘之机。

3. 给last_seen_at加索引

为last_seen_at创建索引,让排序和选行的速度更快,减少并发事务的时间重叠窗口,降低冲突概率:

CREATE INDEX idx_articles_last_seen_at ON articles(last_seen_at ASC NULLS FIRST);

内容的提问来源于stack exchange,提问作者kmitov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 18:52:47