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没按预期锁定行,让其他请求跳过已锁定的记录。
问题分析与解决方案
问题根源
NOW()的事务级精度问题:PostgreSQL里NOW()返回的是事务开始时间,不是语句执行的实时时间。如果多个并发事务几乎同时启动,它们的NOW()值完全相同。- 事务间隙的竞态条件:当第一个事务选中某篇文章并更新
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
相关产品推荐
相关产品推荐

