行级锁(FOR UPDATE / FOR SHARE)
表级锁解决的是「DDL 与 DML 能不能并存」,而行级锁(Row-Level Lock)解决的是另一个更细的问题:「多个事务同时改同一批行时,谁先谁后」。一个电商系统在并发扣库存、抢券、对账时,能不能不出错,几乎完全取决于行级锁用得对不对。
本节把 PostgreSQL 行级锁的四种模式、它们背后的元组头实现、SELECT ... FOR UPDATE 的完整语法(OF / NOWAIT / SKIP LOCKED)、以及并发扣库存的三种经典方案一次讲透。
前置知识
阅读本节前,建议先了解:表级锁(ACCESS SHARE / EXCLUSIVE)、READ UNCOMMITTED / READ COMMITTED
基础概念
行级锁的四种模式
PostgreSQL 的行级锁只有四种,没有间隙锁(Gap Lock),也没有表级意向锁的独立概念(意向由 ROW SHARE / ROW EXCLUSIVE 表锁承担):
| 模式 | 英文 | 语义 | 自动加它的语句 |
|---|---|---|---|
FOR UPDATE | Update Lock | 最强行锁,锁定后该行不可被更新/删除/加任何行锁 | SELECT ... FOR UPDATE |
FOR NO KEY UPDATE | No Key Update Lock | 类似 FOR UPDATE,但不阻止外键检查 | UPDATE(未改主键/唯一键时) |
FOR SHARE | Share Lock | 共享行锁,允许多个读锁共存,阻止更新 | SELECT ... FOR SHARE |
FOR KEY SHARE | Key Share Lock | 只锁「键值」,阻止主键/唯一键被改 | SELECT ... FOR KEY SHARE、外键检查 |
记忆方式:两把「排他」(FOR UPDATE / FOR NO KEY UPDATE)+ 两把「共享」(FOR SHARE / FOR KEY SHARE),后缀 KEY 的都表示「不碰键值」。
行锁冲突矩阵(4 × 4)
| 请求 \ 已持有 | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | X | |||
| FOR SHARE | X | X | ||
| FOR NO KEY UPDATE | X | X | X | |
| FOR UPDATE | X | X | X | X |
几个关键结论:
FOR KEY SHARE之间完全兼容——多个外键检查可以同时进行。FOR KEY SHARE只与FOR UPDATE冲突——这就是「改主键会阻塞外键检查」的原因。FOR SHARE与FOR NO KEY UPDATE冲突——FOR SHARE不允许任何人改这一行。- 三把锁都与
FOR UPDATE冲突——FOR UPDATE是最强的。
行锁标记在元组头上
理解行锁的物理实现,才能理解为什么「行锁不占额外空间」以及「行锁看不到单独条目」。
PostgreSQL 的行锁不是一张独立的锁表,而是直接写在数据行的元组头(tuple header)里,具体用 xmax 字段:
┌──────────────────────────────────────────────────────┐
│ 数据页(8 KB) │
│ ┌──────────────────────────────────────────────┐ │
│ │ 行 1 元组头: xmin=100, xmax=205, infomask=... │ │ ← xmax=205 表示事务 205 锁住了它
│ │ 数据: ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ 行 2 元组头: xmin=100, xmax=0, infomask=... │ │ ← xmax=0 表示未被锁
│ │ 数据: ... │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘| 元组头字段 | 作用 |
|---|---|
xmin | 创建此行的事务 ID |
xmax | 删除/锁定此行的事务 ID(行锁就体现在这里) |
infomask 中的 HEAP_XMAX_IS_MULTI / HEAP_XMAX_LOCK_ONLY | 区分「锁定」与「删除」 |
因此:
- 行锁不占用锁表条目,这也是为什么「锁几百万行」不会耗尽内存。
- 行锁在
pg_locks里体现为locktype='transactionid',而不是一行一条记录。 locktype='tuple'的锁很少对用户暴露,只有在等待一个「正在被锁定的元组」时才短暂出现。
行锁只在事务内有效
行锁(以及所有锁)的生命周期都是到事务结束:
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 加行锁,持有中
-- ... 中间可以做任何事 ...
COMMIT; -- 行锁在此刻释放这意味着:
- 自动提交模式下,
SELECT ... FOR UPDATE几乎没有意义——语句一结束事务就结束,锁立即释放。 - 事务越长,行锁持有越久,冲突概率越高。
-- 错误!自动提交模式下,锁在语句结束时立即释放
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;行锁与表锁的关系
执行 SELECT ... FOR UPDATE 时,实际发生的是两级加锁:
1. 【表锁】在目标表上请求 ROW SHARE(不与 ROW EXCLUSIVE 冲突)
2. 【行锁】在满足条件的行的元组头 xmax 上打标记 FOR UPDATE而 UPDATE / DELETE 是:
1. 【表锁】在目标表上请求 ROW EXCLUSIVE
2. 【行锁】在目标行的元组头上打 FOR NO KEY UPDATE(或 FOR UPDATE)标记所以「写操作也会加行锁」——只是它自动帮你加了,不需要手写 FOR UPDATE。
语法与用法
[ WITH ... ]
SELECT ... [ FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
[ OF 表名 [, ...] ]
[ NOWAIT | SKIP LOCKED ] ] ...
-- 四种模式
FOR UPDATE -- 最强,阻止一切
FOR NO KEY UPDATE -- 不阻止外键的 FOR KEY SHARE
FOR SHARE -- 共享,阻止更新
FOR KEY SHARE -- 仅锁键值,允许外键检查
-- 锁定范围限定:多表 JOIN 时只锁指定表
OF 表名
-- 冲突处理:NOWAIT 拿不到锁立即报错;SKIP LOCKED 跳过被锁的行不等待
NOWAIT | SKIP LOCKED最简可运行示例(需要先 BEGIN,否则自动提交会让锁立即释放):
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; id | owner | balance
----+-------+---------
1 | alice | 1000.00
(1 row)不加 FOR UPDATE 时,同一行可以被任意并发读(只加 ACCESS SHARE 表锁,无行锁):
BEGIN;
SELECT * FROM accounts WHERE id = 1; id | owner | balance
----+-------+---------
1 | alice | 1000.00
(1 row)详细说明
四种模式逐个详解
FOR UPDATE(最强):锁定后,该行不能被其他事务 UPDATE / DELETE,也不能被加任何其他行锁。适合「读出来马上要改」的场景。
BEGIN;
-- 锁定这一行,其他事务想改它会一直等待
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;FOR NO KEY UPDATE:与 FOR UPDATE 几乎一样强,唯一区别是不阻止 FOR KEY SHARE(即不阻止外键检查)。PostgreSQL 在普通 UPDATE 时默认加的就是它——因为「改一个非键列」不应该影响外键引用的有效性。
-- 普通 UPDATE 自动加 FOR NO KEY UPDATE(未改主键/唯一键)
UPDATE accounts SET balance = balance - 100 WHERE id = 1;FOR SHARE:共享行锁。多个事务可以同时持有同一行的 FOR SHARE,但任何人都不能更新这一行。适合「读出来后要基于它做别的写操作,但不改它本身」。
BEGIN;
-- 允许其他事务也加 FOR SHARE,但阻止 UPDATE
SELECT * FROM accounts WHERE id = 1 FOR SHARE;FOR KEY SHARE(最弱):只锁「键值」(主键、唯一键)。它允许其他事务更新非键列,只阻止改键列。外键检查时自动加的就是它。
-- 显式使用(很少手写)
SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;FOR KEY SHARE 为什么存在
外键检查的逻辑是「子表插入的 parent_id,父表里必须有对应行,且这一行在我检查期间不能被删/改主键」。FOR KEY SHARE 恰好只锁「键值不可变」这一点,允许父表行的其他列被并发更新——这是 PostgreSQL 让外键检查不至于阻塞一切的关键设计。
FOR UPDATE 与 FOR NO KEY UPDATE 的区别
这是四种模式里最容易混淆的一对。差别只有一点:是否阻止外键检查。
子表 child(parent_id) REFERENCES parent(id)
▲
│ 插入子行时,外键检查会对父表行加 FOR KEY SHARE
│
父表 parent(id, name, score)
├── 事务 A 执行 FOR UPDATE → 与 FOR KEY SHARE 冲突 → 子表插入被阻塞 ❌
└── 事务 B 执行 FOR NO KEY UPDATE → 与 FOR KEY SHARE 兼容 → 子表插入正常 ✅用实验证明:
-- 准备父子表
CREATE TABLE parent (
id bigint PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE child (
id bigint PRIMARY KEY,
parent_id bigint NOT NULL REFERENCES parent(id)
);
INSERT INTO parent VALUES (1, 'p1');-- 会话 A:用 FOR UPDATE 锁父行
BEGIN;
SELECT * FROM parent WHERE id = 1 FOR UPDATE;-- 会话 B:插入子行需要对外键目标加 FOR KEY SHARE,被 A 挡住
INSERT INTO child VALUES (1, 1);-- 一直等待,直到会话 A 提交换成 FOR NO KEY UPDATE:
-- 会话 A 改用 FOR NO KEY UPDATE
BEGIN;
SELECT * FROM parent WHERE id = 1 FOR NO KEY UPDATE;-- 会话 B:这次不会被阻塞(FOR NO KEY UPDATE 与 FOR KEY SHARE 兼容)
INSERT INTO child VALUES (2, 1);INSERT 0 1结论:业务里如果没有「防止主键被改」的强需求,优先用 FOR NO KEY UPDATE 而不是 FOR UPDATE,可以显著减少与外键检查的冲突。
UPDATE / DELETE 自动加行锁
写语句会自动加行锁,且会阻塞其他事务对同一行的写操作:
-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;-- 会话 B:同一行,会一直等待,直到会话 A COMMIT 或 ROLLBACK
UPDATE accounts SET balance = balance + 50 WHERE id = 1;但「读」不受影响(因为 MVCC 让读者看到旧版本):
-- 会话 C:普通查询不会被阻塞
SELECT * FROM accounts WHERE id = 1; id | owner | balance
----+-------+---------
1 | alice | 1000.00 ← 看到的是 A 修改前的版本(MVCC 快照)
(1 row)DELETE 同理,DELETE FROM accounts WHERE id = 2; 会给该行加行锁,其他事务改这行会等待。
值得注意的是:UPDATE 也会对满足条件但被阻塞的行产生影响。在 READ COMMITTED 下,如果 WHERE 条件命中的行被别的事务改了,PostgreSQL 会重新检查 WHERE(EvalPlanQual),这可能导致「看起来命中的行最后没被更新」。
行锁在 pg_locks 中的体现
前面提到行锁写在元组头 xmax 上,但在 pg_locks 里,行锁主要体现为 locktype='transactionid'——即「等待某个事务 ID」:
-- 会话 A:持有事务 12345 的行锁(实际上它是一个事务 ID 锁)
BEGIN;
UPDATE accounts SET balance = 1 WHERE id = 1;
-- 会话 B:等待事务 12345
BEGIN;
UPDATE accounts SET balance = 2 WHERE id = 1;在会话 B 的等待期间查询:
SELECT
pid,
locktype,
mode,
granted,
transactionid,
relation::regclass AS rel,
page,
tuple
FROM pg_locks
WHERE locktype IN ('transactionid', 'tuple')
ORDER BY granted DESC; pid | locktype | mode | granted | transactionid | rel | page | tuple
-------+----------------+---------------+---------+---------------+--------+------+-------
10231 | transactionid | ExclusiveLock | t | 12345 | | |
10288 | transactionid | ShareLock | f | 12345 | | |
(2 rows)读法:
- 持有
ExclusiveLock的事务 ID 锁(pid 10231)= 它是「写者」。 - 等待
ShareLock的事务 ID 锁(pid 10288,granted=f)= 它在等写者释放。 transactionid字段就是那个被锁定的事务 ID。
locktype='tuple' 才是直接指向「某个页某个元组」的锁(输出中带 relation / page / tuple 三列),但它只在特定场景短暂出现(比如等待一个正在被锁定的元组)。
不要依赖 tuple 锁做监控
locktype='tuple' 的出现是瞬时的、不确定的。排查行锁问题应该看 locktype='transactionid',或者直接用 pg_blocking_pids()。
等待状态与 pg_blocking_pids
实际排查中,最直接的方式是 pg_stat_activity + pg_blocking_pids():
SELECT
pid,
state,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blockers,
left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0; pid | state | wait_event_type | wait_event | blockers | query
-------+--------+-----------------+---------------+----------+-------------------------
10288 | active | Lock | transactionid | {10231} | UPDATE accounts SET ba
(1 row)注意 wait_event = 'transactionid'——这就是行锁等待的标志(对比表锁等待是 relation)。
进一步查阻塞者正在做什么:
SELECT pid, state, xact_start, now() - xact_start AS age, left(query, 60) AS query
FROM pg_stat_activity
WHERE pid = 10231; pid | state | xact_start | age | query
-------+---------------------+-------------------------------+----------+------------------
10231 | idle in transaction | 2026-09-28 10:00:01+08 | 00:05:33 | UPDATE accounts
(1 row)state = 'idle in transaction' 是最危险的信号——它意味着事务开着但没在干活,锁却一直不放。
行锁等待与 lock_timeout
行锁等待会一直持续,除非:
- 持有者提交或回滚;
- 等待者设置了
lock_timeout(语句级锁等待上限); - 等待者设置了
statement_timeout(整条语句上限); - 被死锁检测中止(见 死锁检测机制)。
-- 让等待最多 2 秒,超时立即失败
BEGIN;
SET LOCAL lock_timeout = '2s';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;ERROR: canceling statement due to lock timeoutSET LOCAL 的作用域是当前事务,事务结束自动恢复——这是给单个业务操作加超时的推荐方式(详见 锁等待与超时)。
也可以用 NOWAIT 完全不等待:
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;ERROR: could not obtain lock on row in relation "accounts"行锁导致的死锁与「更新顺序」
行锁是死锁最常见的来源。经典场景是两个事务以不同顺序更新同一批行:
时刻 会话 A 会话 B
─────────────────────────────────────────────────────────
t1 BEGIN;
t2 UPDATE accounts SET ... WHERE id=1; -- 锁住行 1
t3 BEGIN;
t4 UPDATE accounts SET ... WHERE id=2; -- 锁住行 2
t5 UPDATE accounts SET ... WHERE id=2; -- 等 B 释放行 2
t6 UPDATE accounts SET ... WHERE id=1; -- 等 A 释放行 1
t7 ⛔ 死锁:A 等 B,B 等 APostgreSQL 的 deadlock_timeout(默认 1s)到期后触发检测,中止其中一个事务:
ERROR: deadlock detected
DETAIL: Process 10231 waits for ShareLock on transaction 12346; blocked by process 10288.
Process 10288 waits for ShareLock on transaction 12345; blocked by process 10231.
HINT: See server log for query details.避免方法一句话:所有事务以相同的顺序访问行(详见 避免死锁的最佳实践)。
-- 推荐:先排序再更新,保持全局一致的访问顺序
UPDATE accounts SET balance = balance - 100
WHERE id IN (SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id);并发扣库存的三种经典方案
这是一个几乎每家公司都会遇到的问题:多个请求同时买同一件商品,如何保证库存不超卖。
方案 A:悲观锁(FOR UPDATE)
BEGIN;
-- 锁住这一行,其他并发者排队
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- 应用层判断 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = 100;
COMMIT;方案 B:乐观锁(版本号 / CAS)
-- 不带任何锁,直接条件更新(受影响行数为 0 即表示冲突)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND stock > 0 AND version = 5;
-- 更简洁:直接用 stock > 0 作为条件(原子递减)
UPDATE products SET stock = stock - 1 WHERE id = 100 AND stock > 0;方案 C:SKIP LOCKED(队列化)
BEGIN;
-- 跳过已被别人锁定的行,直接拿下一件;返回 0 行 = 无可用库存
SELECT id FROM inventory
WHERE product_id = 100 AND status = 'available'
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE inventory SET status = 'reserved' WHERE id = ...;
COMMIT;三者的优缺点对照:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| A 悲观锁 | 绝对准确、逻辑直观 | 并发串行化、吞吐低 | 需要「读-判断-写」多步且不能重试 |
| B 乐观锁 | 无锁等待、吞吐高 | 冲突时需应用层重试 | 库存是一个计数单元格(stock 字段) |
C SKIP LOCKED | 多消费者零冲突 | 需数据模型支持多行 | 库存是多条独立记录(座位、券码) |
SKIP LOCKED 的完整用法见 SKIP LOCKED 与 NOWAIT。
FOR UPDATE OF 与多表 JOIN
当一个查询 JOIN 多张表时,FOR UPDATE 默认会锁所有被 JOIN 的表。用 OF 限定只锁需要的表:
-- 默认:锁 orders 和 users 两张表的相关行
SELECT o.id, u.name
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.id = 100
FOR UPDATE;-- 推荐:只锁 orders(不想锁 users 表)
SELECT o.id, u.name
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.id = 100
FOR UPDATE OF o;OF 也可以指定多张表:
SELECT o.id, i.qty
FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.id = 100
FOR UPDATE OF o, i;对视图加锁时需要小心:视图不能用 FOR UPDATE OF 限定基表名之外的名字(要写视图名),而且视图必须有可更新的基表。
用行锁实现「先读后写」的一致性
FOR UPDATE 解决的核心问题是「读出来到写回去之间,值被别人改了」。看一个没有行锁的反例:
时刻 会话 A 会话 B
─────────────────────────────────────────────────────────────
t1 SELECT balance → 1000
t2 SELECT balance → 1000
t3 UPDATE balance = 1000 - 100 = 900
t4 UPDATE balance = 1000 - 200 = 800
t5 COMMIT
t6 COMMIT
-- 期望:1000 - 100 - 200 = 700
-- 实际:800(A 的扣减被覆盖,丢失更新)加上行锁后:
时刻 会话 A 会话 B
─────────────────────────────────────────────────────────────
t1 BEGIN
t2 SELECT ... FOR UPDATE → 1000(持锁)
t3 BEGIN
t4 SELECT ... FOR UPDATE → 等待
t5 UPDATE balance = 900
t6 COMMIT → 拿到锁,读到 900
t7 UPDATE balance = 900 - 200 = 700
t8 COMMIT
-- 结果:700 ✅对应的 SQL 就是「SELECT ... FOR UPDATE → 计算 → UPDATE → COMMIT」,这里不再重复展开。
在 READ COMMITTED 下,FOR UPDATE 还会带来一个额外保证:锁等待结束后,PostgreSQL 会重新读取该行的最新版本(EvalPlanQual),而不是返回等待前的旧快照。所以会话 B 拿到锁后会读到 900 而不是 1000。
实战示例
场景:为电商系统实现安全的库存扣减,包含「防止超卖」「防止重复扣减」「避免长时间锁等待」三个要求。
第 1 步:建模并灌数据
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS orders;
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL,
stock integer NOT NULL CHECK (stock >= 0), -- 关键:负数直接被约束拦住
version integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products(id),
qty integer NOT NULL CHECK (qty > 0),
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO products (sku, name, stock) VALUES ('SKU-100', '限时秒杀商品', 100);DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 1第 2 步:方案 A —— 悲观锁(FOR UPDATE)
-- 会话 A:完整的一次下单扣减
BEGIN;
SET LOCAL lock_timeout = '3s';
-- 1) 锁定商品行
SELECT id, stock FROM products WHERE id = 1 FOR UPDATE;
-- 2) 应用层判断:stock >= qty 才继续
-- 3) 扣减库存
UPDATE products SET stock = stock - 5, updated_at = now() WHERE id = 1;
-- 4) 落订单
INSERT INTO orders (product_id, qty, status) VALUES (1, 5, 'paid');
COMMIT; id | stock
----+-------
1 | 100
(1 row)
UPDATE 1
INSERT 0 1
COMMIT同时,另一个会话会被挡住:
-- 会话 B:在 A 未提交时执行,会一直等待
BEGIN;
SET LOCAL lock_timeout = '3s';
SELECT id, stock FROM products WHERE id = 1 FOR UPDATE;-- 排队等待,直到 A COMMIT
-- 拿到锁后读到 stock = 95第 3 步:方案 B —— 乐观锁(条件更新,无锁等待)
-- 一次原子递减 + 影响行数判断
UPDATE products
SET stock = stock - 3, version = version + 1, updated_at = now()
WHERE id = 1 AND stock >= 3;UPDATE 1如果库存不足,影响行数为 0——应用层读到 UPDATE 0 就知道「库存不足」,直接返回失败,全程无需等待、无需重试。这是最推荐的方案:
-- 库存只剩 92,尝试扣 100 → 影响行数为 0
UPDATE products SET stock = stock - 100 WHERE id = 1 AND stock >= 100;UPDATE 0CHECK (stock >= 0) 约束是最后一道防线:即使逻辑写错,也只报错而不会出现负库存:
-- 错误!约束会拦住超卖
UPDATE products SET stock = -1 WHERE id = 1;ERROR: new row for relation "products" violates check constraint "products_stock_check"
DETAIL: Failing row contains (1, SKU-100, 限时秒杀商品, -1, 2, 2026-09-28 10:20:00+08).第 4 步:观察行锁等待
-- 会话 A:持锁不提交
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;-- 会话 B:等待行锁(另开一个连接执行)
BEGIN;
SET LOCAL lock_timeout = '10s';
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 一直等待在第三个会话里观察等待者与阻塞者:
SELECT
pid,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blockers,
left(query, 50) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0; pid | wait_event_type | wait_event | blockers | query
-------+-----------------+---------------+----------+--------------------------
10301 | Lock | transactionid | {10299} | UPDATE products SET stoc
(1 row)wait_event = 'transactionid' 明确表示这是行锁等待,而非表锁等待(表锁是 relation)。
第 5 步:构造行锁死锁并观察
-- 再插一个商品,制造两行
INSERT INTO products (sku, name, stock) VALUES ('SKU-200', '商品 B', 50);INSERT 0 1-- 会话 A -- 会话 B
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 锁行 1
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 锁行 2
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 等 B
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 等 A → 死锁1 秒后(deadlock_timeout 默认 1s)其中一个事务被中止:
ERROR: deadlock detected
DETAIL: Process 10299 waits for ShareLock on transaction 12350; blocked by process 10301.
Process 10301 waits for ShareLock on transaction 12349; blocked by process 10299.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,1) in relation "products"正确做法:统一顺序。 两个会话都按 id 升序加锁,就不会形成环:
-- 推荐:用固定顺序的子查询先取锁,再更新
BEGIN;
WITH target AS (
SELECT id FROM products WHERE id IN (1, 2) ORDER BY id FOR UPDATE
)
UPDATE products p
SET stock = p.stock - 1
FROM target t
WHERE p.id = t.id;
COMMIT;第 6 步:清理
DROP TABLE orders;
DROP TABLE products;注意事项
SELECT ... FOR UPDATE在自动提交模式下毫无意义,锁会在语句结束瞬间释放。
-- 错误!没有 BEGIN/COMMIT,锁不生效
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;FOR UPDATE是行锁里最强的一把,会阻塞外键检查。 能用FOR NO KEY UPDATE就不要用FOR UPDATE。
-- 不推荐:会阻塞子表插入
SELECT * FROM parent WHERE id = 1 FOR UPDATE;行锁持有到事务结束。 事务里不要做远程调用、等待用户输入、循环大查询;
FOR UPDATE也不会让SELECT读得更快,它只提供锁定语义,还会因为等待而变慢。UPDATE ... WHERE匹配很多行时会按扫描顺序加锁,不同执行计划可能产生不同加锁顺序,从而引发死锁。这是「批量更新容易死锁」的根因。
-- 危险:大批量更新时加锁顺序不确定
UPDATE accounts SET level = 'vip' WHERE score > 1000;lock_timeout与NOWAIT是两套机制:前者超时抛lock_not_available(55P03),后者一拿不到就抛错。二者都不能解决「死锁」,死锁由deadlock_timeout检测。不要用
FOR UPDATE屏蔽并发问题后就不管了。它把并发变串行,高并发下会变成排队瓶颈。视图上的
FOR UPDATE有额外限制,不能对含DISTINCT/GROUP BY/UNION的查询加行锁。
-- 错误!含聚合的查询无法加行锁
SELECT user_id, count(*) FROM orders GROUP BY user_id FOR UPDATE;ERROR: FOR UPDATE is not allowed with GROUP BY clauseFOR KEY SHARE不能阻止非键列被改,别用它来保护业务字段。pg_locks里行锁体现在transactionid,tuple锁一般不对用户暴露,不要依赖它做监控。
最佳实践
- 优先用「原子条件更新」(乐观锁)而不是
FOR UPDATE,它能避免锁等待,吞吐更高。
UPDATE products SET stock = stock - 1 WHERE id = 100 AND stock > 0;
-- 检查影响行数即可,无需重试逻辑- 必须用悲观锁时,选
FOR NO KEY UPDATE,避免阻塞外键检查。
SELECT * FROM orders WHERE id = 100 FOR NO KEY UPDATE;- 给每个带行锁的业务语句配
lock_timeout,让冲突快速失败。
BEGIN;
SET LOCAL lock_timeout = '2s';
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
COMMIT;- 批量更新前先把目标行排序,保证全局一致的加锁顺序。
WITH target AS (
SELECT id FROM accounts WHERE score > 1000 ORDER BY id FOR UPDATE
)
UPDATE accounts a SET level = 'vip' FROM target t WHERE a.id = t.id;- 用
CHECK约束兜底,把「负库存」这类业务不变量交给数据库强制。
stock integer NOT NULL CHECK (stock >= 0)保持事务短小,锁持有时间与事务时长成正比。
需要保护多行等价资源时用
SKIP LOCKED,而不是让消费者互相等待。
SELECT id FROM inventory
WHERE product_id = 100 AND status = 'available'
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;- 监控长事务与行锁等待,把
idle in transaction当告警处理:
SELECT pid, state, now() - xact_start AS age, left(query, 50) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '1 minute';- 给应用层的「读-改-写」流程加版本号,即使不用数据库锁也能检测并发冲突;同时不要在外键列上执行长事务的
FOR UPDATE,外键检查与行锁的交互很容易造成意外阻塞。
ALTER TABLE products ADD COLUMN version integer NOT NULL DEFAULT 0;
-- 更新时带上 version 条件,受影响行数为 0 即表示冲突下一节
继续学习:页级锁与死锁