在涉及资金、库存等关键数据的业务中,多个事务同时尝试修改同一条记录是常见现象。如果两个事务都基于旧数据做出判断,后提交的事务可能覆盖前一个事务的修改,造成业务上的错误。PostgreSQL的SELECT ... FOR UPDATE通过显式申请行级锁,让读取操作具备排他性,从而在事务提交之前阻止其他事务修改同一行,这就是典型的悲观锁思路。

SELECT FOR UPDATE 的锁机制与语法
FOR UPDATE是SELECT语句的锁定子句,语法为SELECT ... FROM ... WHERE ... FOR UPDATE;。执行后,查询返回的行会被加上行级排他锁,与其他更新操作需要获取的锁类型一致。这意味着如果另一个事务已经对这些行加了锁,当前事务会进入等待队列,直到持有锁的事务提交或回滚。锁的释放与事务绑定,而不是语句结束,因此必须显式地使用BEGIN、COMMIT或ROLLBACK来管理事务边界。
当连接查询涉及多张表时,可以使用FOR UPDATE OF table_name来只锁定指定表中的行,避免不必要地锁定关联表。例如SELECT * FROM orders o JOIN customers c ON o.customer_id=c.id WHERE o.status='pending' FOR UPDATE OF o;只会锁定orders表的行,customers表的数据仍可以被其他事务修改。这种粒度控制在复杂的业务查询中能有效减少锁冲突。
与普通的SELECT不同,FOR UPDATE不会使用单纯的一致性快照,而是读取每个锁定行的最新已提交版本。如果目标行自当前事务开始后被其他事务更新过,PostgreSQL会通过EvalPlanQual机制重新检查该行是否仍满足查询条件,并返回更新后的数据。这一行为确保了读取到的内容与即将锁定的实际版本一致,避免了基于陈旧快照做判断的风险。
不同事务隔离级别下的表现
在READ COMMITTED隔离级别下,每个SQL语句都会看到最新的已提交快照。SELECT ... FOR UPDATE会重新获取最新版本并尝试加锁。如果在等待锁期间,目标行被其他事务更新并提交,那么当前事务会被唤醒并重新评估查询条件;如果该行不再满足WHERE条件,则不会返回该行。例如,事务A执行SELECT * FROM products WHERE id=1 AND quantity>0 FOR UPDATE;,在它等待期间,事务B将quantity改为0并提交,那么事务A锁定到该行后会重新检查quantity,发现不再大于0,结果可能是空集。
在REPEATABLE READ和SERIALIZABLE隔离级别下,事务的快照在第一次查询时固定。FOR UPDATE仍然会读取最新已提交版本以完成加锁,但这可能引发序列化异常。具体来说,如果事务开始时看到的是行版本A,但加锁时行已被事务B更新为版本B,在REPEATABLE READ下,PostgreSQL会返回一个错误提示“could not serialize access due to concurrent update”,要求事务回滚重试。这是因为该级别承诺快照隔离,无法接受返回已更新后的数据。
需要特别注意的是,FOR UPDATE只能锁定已经存在的行,不能阻止其他事务插入符合条件的新行。如果业务依赖范围条件进行锁定,例如WHERE amount > 100,有可能出现幻读:另一个事务在查询后插入了amount为200的新行,而当前事务无法察觉。要解决这种情况,通常需要配合唯一约束或使用SERIALIZABLE隔离级别,由数据库的谓词锁机制来处理幻读。
锁等待与死锁处理
默认情况下,如果FOR UPDATE请求的行已被其他事务锁定,当前会话会一直等待,直到锁被释放。这在某些场景下可能造成数据库连接被长时间占用,甚至拖垮整个连接池。可以通过设置lock_timeout参数来控制等待时间,例如SET lock_timeout = '5s';,超过指定时间后语句会报错并回滚当前语句。也可以使用NOWAIT选项,让语句在无法立即获得锁时直接抛出错误,不给等待机会。
对于任务队列类的场景,SKIP LOCKED是一个非常实用的选项。它会让查询跳过已经被其他事务锁定的行,只返回未被锁定的记录。例如多个Worker进程从同一张表中领取任务时,可以使用以下语句:
SELECT id, task FROM task_queue ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1;
这样每个Worker都能高效地获取到一条未被处理的任务,而不会因为锁等待相互阻塞。这个机制在PostgreSQL 9.5及以上版本可用。
死锁是悲观锁使用中需要重点防范的问题。当两个事务各自持有部分资源并等待对方释放锁时,就会形成循环等待。PostgreSQL的自动死锁检测器会在检测到死锁后中止其中一个事务,使其回滚并释放锁,另一个事务可以继续执行。虽然在数据库层面不会永久卡死,但应用程序仍需要处理好异常并重试。为了避免死锁,应尽量保证事务内的加锁顺序一致,缩短事务时间,避免在持有锁的情况下进行外部交互或长时间计算。
一个常见的死锁示例是:事务A锁定id=1然后尝试锁定id=2;事务B同时锁定id=2然后尝试锁定id=1。如果两者几乎同时执行,就可能触发死锁。解决方法是约定所有事务都按id升序加锁,即先锁1再锁2,这样就不会出现交叉等待。
典型应用:库存扣减的悲观锁实现
库存扣减是悲观锁的经典应用场景。如果不加锁,两个请求同时读取库存为10,各自判断可以扣减并更新为9,最终可能只扣减了一次,造成超卖。使用悲观锁可以保证整个“读取-判断-写入”过程在数据库层面串行化。
下面是一个用SQL实现的安全扣减过程:
BEGIN; SELECT quantity FROM products WHERE id = 1 FOR UPDATE; -- 假设在业务代码中判断 quantity 是否大于 0 -- 如果足够则执行扣减 UPDATE products SET quantity = quantity - 1 WHERE id = 1; COMMIT;
需要注意的是,FOR UPDATE锁定的范围和执行计划有关。如果查询使用了索引,只会锁定匹配的索引行和对应的数据行;如果执行了全表扫描,则可能锁定扫描过程中遇到的所有行,导致不必要的锁竞争。因此务必为WHERE条件创建合适的索引,避免大范围锁定。另外,事务中一旦持有锁,应尽快提交或回滚,避免包含用户交互、RPC调用等耗时操作,否则锁会被长时间占用,其他事务全部排队等待。
对于简单扣减,其实也可以直接使用条件更新UPDATE products SET quantity = quantity - 1 WHERE id = 1 AND quantity > 0;,这样利用行锁的原子性也能避免超卖。但FOR UPDATE的价值在于需要在读取和写入之间做复杂的业务判断,例如根据用户等级决定折扣、更新多个关联表等场景,此时显式锁定整个流程更清晰安全。
与乐观锁的权衡
乐观锁通常通过版本号或时间戳实现。读取数据时不加锁,更新时在WHERE条件中带上版本号,例如UPDATE products SET quantity = quantity - 1, version = version + 1 WHERE id = 1 AND version = ?。如果影响行数为0,说明数据已被其他事务修改,事务回滚并重试。这种方式没有数据库锁等待,连接占用时间短,但在冲突频繁时会导致大量重试,成功率下降。
悲观锁则是在冲突真正发生之前就通过锁来避免竞争,适用于写冲突概率较高的场景。比如秒杀活动中的同一件商品、热点账户余额变更等。如果业务中每个请求都命中同一行或多行,使用乐观锁可能产生大量无效重试,反而拖慢整体吞吐。而悲观锁虽然增加了锁等待,但能够保证每次扣减都成功,不需要额外的重试逻辑。
实际选型时,可以结合两者的优点。例如在低峰期使用乐观锁,高峰期切换为悲观锁;或者先通过Redis等缓存预扣库存,再落到数据库时使用FOR UPDATE做最终校验。关键是评估冲突概率、事务时长、连接池大小以及数据库的锁管理能力,而不是盲目追求某一种方案。
总的来说,SELECT ... FOR UPDATE是PostgreSQL中实现悲观锁最直接的手段。它把并发冲突的解决提前到读取阶段,通过行级排他锁保证事务的串行化执行。正确理解其在不同隔离级别下的行为、掌握NOWAIT和SKIP LOCKED等选项的用法,并合理控制事务范围和加锁顺序,才能在高并发环境中既保证数据一致性,又保持系统可扩展性。
PostgreSQL悲观锁SELECT FOR UPDATE行级锁修改时间:2026-09-20 16:02:39