PostgreSQL 锁等待排查:一条 UPDATE 偶尔卡住 40 秒,问题出在事务没提交
支付回调接口有一段时间偶尔超时。日志里看起来很奇怪,同一条 UPDATE 平时几十毫秒就能执行完,慢的时候会卡到 30 秒、40 秒,最后被应用层超时切掉。
SQL 本身很普通,用业务单号把订单状态从待支付改成已支付:
UPDATE order_payment
SET status = 2,
paid_at = now(),
updated_at = now(),
version = version + 1
WHERE tenant_id = 42
AND order_no = 'P202601180001'
AND status = 1
RETURNING id, status, paid_at;
表结构做过简化,大概是这样:
CREATE TABLE order_payment (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
order_no TEXT NOT NULL,
user_id BIGINT NOT NULL,
status SMALLINT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
version INT NOT NULL DEFAULT 0,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
业务单号有唯一索引:
CREATE UNIQUE INDEX uk_order_payment_tenant_order
ON order_payment (tenant_id, order_no);
从索引和 SQL 条件看,这条更新没有明显问题。单独拿到数据库里执行,速度也很快。真正出问题的是并发场景下的行锁等待。
慢 SQL 现场看不到执行计划
这类问题一开始很容易往慢查询方向查。我先跑了一次 EXPLAIN:
EXPLAIN (ANALYZE, BUFFERS)
UPDATE order_payment
SET status = 2,
paid_at = now(),
updated_at = now(),
version = version + 1
WHERE tenant_id = 42
AND order_no = 'P202601180001'
AND status = 1
RETURNING id, status, paid_at;
正常情况下的执行计划很干净:
Update on order_payment (cost=0.42..8.45 rows=1 width=46)
(actual time=0.318..0.323 rows=1 loops=1)
Buffers: shared hit=8 dirtied=2
-> Index Scan using uk_order_payment_tenant_order on order_payment
(cost=0.42..8.45 rows=1 width=46)
(actual time=0.041..0.043 rows=1 loops=1)
Index Cond: ((tenant_id = 42) AND (order_no = 'P202601180001'::text))
Filter: (status = 1)
Buffers: shared hit=4
Planning Time: 0.214 ms
Execution Time: 0.381 ms
执行计划说明索引用上了,扫描行数也只有 1 行。这个时候继续调索引意义不大。偶发 40 秒更像锁等待,需要在问题发生时看数据库当前会话。
支付回调再次卡住时,我直接查 pg_stat_activity:
SELECT
pid,
usename,
application_name,
state,
wait_event_type,
wait_event,
now() - xact_start AS xact_age,
now() - query_start AS query_age,
left(query, 180) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND state <> 'idle'
ORDER BY query_start;
当时能看到一条正在等待的更新:
pid | state | wait_event_type | wait_event | query_age | query
------+--------+-----------------+---------------+-----------+------------------------------------------------------
9190 | active | Lock | transactionid | 00:00:37 | UPDATE order_payment SET status = 2, paid_at = now...
wait_event_type = Lock,wait_event = transactionid,这两个信息基本把方向定住了。SQL 不是在慢慢扫表,也不是在排序,而是在等另一个事务结束。
用 pg_blocking_pids 找到阻塞源
PostgreSQL 可以直接通过 pg_blocking_pids 找阻塞当前会话的 PID。为了少来回查,我一般用下面这条 SQL 把等待方和阻塞方一起列出来:
WITH waiting AS (
SELECT
pid AS waiting_pid,
unnest(pg_blocking_pids(pid)) AS blocking_pid
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
)
SELECT
w.waiting_pid,
wa.usename AS waiting_user,
wa.application_name AS waiting_app,
concat_ws(':', wa.wait_event_type, wa.wait_event) AS waiting_event,
now() - wa.query_start AS waiting_query_age,
left(wa.query, 140) AS waiting_query,
w.blocking_pid,
ba.usename AS blocking_user,
ba.application_name AS blocking_app,
ba.state AS blocking_state,
now() - ba.xact_start AS blocking_xact_age,
now() - ba.state_change AS blocking_state_age,
left(ba.query, 140) AS blocking_query
FROM waiting w
JOIN pg_stat_activity wa ON wa.pid = w.waiting_pid
JOIN pg_stat_activity ba ON ba.pid = w.blocking_pid
ORDER BY blocking_xact_age DESC;
输出里很快看到阻塞源:
waiting_pid | waiting_event | waiting_query_age | blocking_pid | blocking_state | blocking_xact_age | blocking_query
-------------+--------------------+-------------------+--------------+---------------------+-------------------+--------------------------------------------------
9190 | Lock:transactionid | 00:00:37 | 8124 | idle in transaction | 00:02:11 | SELECT id, status FROM order_payment WHERE ...
阻塞方是 idle in transaction,事务已经开了 2 分钟多。它最后执行的 SQL 是一条查询:
SELECT id, status
FROM order_payment
WHERE tenant_id = 42
AND order_no = 'P202601180001'
FOR UPDATE;
这就解释得通了。
某个事务先查了这笔订单,并且用了 FOR UPDATE 锁住这行。查询执行完后,事务没有提交,也没有回滚,连接停在 idle in transaction 状态。后续支付回调想更新同一行,只能等前面的事务结束。
这类问题在应用日志里不一定明显,因为阻塞方已经不在执行 SQL 了。它看起来“空闲”,但事务还开着,行锁还握在手里。
pg_locks 里真正要看 granted=false
为了确认锁的细节,可以继续查 pg_locks。这里把等待方和阻塞方两个 PID 放进去:
SELECT
a.pid,
a.state,
l.locktype,
l.mode,
l.granted,
l.relation::regclass AS relation_name,
l.page,
l.tuple,
l.transactionid,
now() - a.xact_start AS xact_age,
left(a.query, 120) AS query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE a.pid IN (9190, 8124)
ORDER BY
a.pid,
l.granted,
l.locktype,
l.mode;
脱敏后的结果里,关键行是这几条:
pid | state | locktype | mode | granted | relation_name | transactionid
------+---------------------+---------------+----------------+---------+---------------+---------------
8124 | idle in transaction | transactionid | ExclusiveLock | t | | 73612901
9190 | active | transactionid | ShareLock | f | | 73612901
9190 | active | relation | RowExclusiveLock | t | order_payment |
8124 | idle in transaction | relation | RowShareLock | t | order_payment |
很多人看到 RowExclusiveLock 会以为整张表被锁住了。这里真正需要看的,是 granted = false 的那一行。
等待方 9190 正在等 transactionid = 73612901 的 ShareLock。阻塞方 8124 持有这个事务 ID 的 ExclusiveLock。在 PostgreSQL 里,更新同一行时,经常会表现为等待另一个事务 ID 结束。等那个事务提交或回滚后,当前事务才能判断这行的最新版本还能不能更新。
表级的 RowExclusiveLock 和 RowShareLock 在这里不是主要矛盾。普通 UPDATE、SELECT FOR UPDATE 都会拿一些表级锁,这些锁更多是保护表结构和 DML 协调。真正让业务卡住的是同一行上的事务等待。
问题出在事务里做了外部调用
找到阻塞会话后,再回应用代码查调用链。问题在支付回调处理方法里:
@Transactional
public void handlePayCallback(PayCallback callback) {
OrderPayment payment = paymentMapper.selectForUpdate(
callback.getTenantId(),
callback.getOrderNo()
);
if (payment == null) {
throw new IllegalStateException("payment not found");
}
ChannelTrade trade = channelClient.queryTrade(callback.getChannelTradeNo());
if (!trade.isSuccess()) {
return;
}
paymentMapper.markPaid(
payment.getTenantId(),
payment.getOrderNo()
);
messageMapper.insertPaySuccessMessage(payment.getId());
}
selectForUpdate 执行后,这笔订单行就被当前事务锁住了。后面又去请求支付渠道查询交易状态。只要第三方接口慢、网络抖动,或者应用线程在这里被挂住,这个事务就会一直持有行锁。
这段代码单线程看不出问题。并发回调、补偿任务、人工重放同时打到同一笔订单时,后面的更新都会排队等这把锁。
我后来把外部调用挪到事务外,数据库事务只保留本地读写:
public void handlePayCallback(PayCallback callback) {
ChannelTrade trade = channelClient.queryTrade(callback.getChannelTradeNo());
if (!trade.isSuccess()) {
return;
}
paymentTxService.applyPaid(callback, trade);
}
@Transactional
public void applyPaid(PayCallback callback, ChannelTrade trade) {
OrderPayment payment = paymentMapper.selectForUpdate(
callback.getTenantId(),
callback.getOrderNo()
);
if (payment == null) {
throw new IllegalStateException("payment not found");
}
if (payment.getStatus() == 2) {
return;
}
paymentMapper.markPaid(
payment.getTenantId(),
payment.getOrderNo()
);
messageMapper.insertPaySuccessMessage(payment.getId(), trade.getTradeNo());
}
这版代码仍然用了 FOR UPDATE,但锁持有时间明显缩短。事务里只做本地查询、状态判断、更新和消息落库,不再夹着外部接口调用。
如果外部调用必须依赖订单数据,可以先普通查询一遍,拿到必要字段后释放连接;外部调用完成,再进入短事务做带锁校验和更新。后面的带锁校验不能省,因为外部调用期间订单状态可能已经被其他请求改掉。
给锁等待加超时保护
业务改造后,锁等待大幅减少。不过线上系统不能只靠代码自觉,关键更新最好加一个超时保护,避免连接一直挂着。
单个事务里可以这样写:
BEGIN;
SET LOCAL lock_timeout = '3s';
UPDATE order_payment
SET status = 2,
paid_at = now(),
updated_at = now(),
version = version + 1
WHERE tenant_id = 42
AND order_no = 'P202601180001'
AND status = 1
RETURNING id, status, paid_at;
COMMIT;
如果 3 秒内拿不到锁,PostgreSQL 会报错:
ERROR: canceling statement due to lock timeout
应用层捕获这个异常后,可以按业务场景选择重试、入补偿队列,或者返回“处理中”。支付回调这类场景更适合异步补偿,不适合让 HTTP 请求一直等。
还可以给应用账号设置空闲事务超时:
ALTER ROLE app_user
SET idle_in_transaction_session_timeout = '60s';
这样连接如果长时间停在 idle in transaction,数据库会主动断开它,事务随之回滚,锁也会释放。这个值不要一上来设得太激进,先观察正常业务里最长事务大概多久,再给出合理余量。
我也会定期查这类会话:
SELECT
pid,
usename,
application_name,
client_addr,
state,
now() - xact_start AS xact_age,
now() - state_change AS idle_age,
left(query, 160) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start ASC;
如果这里经常出现应用连接,基本就要回头查事务边界。大多数情况下,是 @Transactional 包得太大,或者代码里提前 return、异常处理、连接池使用方式有问题。
不要用 SKIP LOCKED 掩盖业务更新冲突
排查过程中有人提过一个方案:既然会等锁,那能不能用 SKIP LOCKED 跳过被锁住的订单。
这个写法适合任务队列,比如多个消费者抢待处理任务:
SELECT id
FROM send_job
WHERE status = 0
ORDER BY run_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;
消费者跳过被别人锁住的任务,继续拿下一批任务处理,这个语义是成立的。
支付订单更新就不一样了。某个回调明确对应一笔订单,跳过这行等于这次回调没有处理目标数据。后面即使入补偿,也会让状态流转变复杂。
支付、库存、账户余额这类强业务绑定更新,我更倾向于:
SET LOCAL lock_timeout = '3s';
拿不到锁就失败或补偿,不要悄悄跳过。任务队列可以跳,业务实体更新不要轻易跳。
如果后台管理操作希望快速失败,可以用 NOWAIT:
SELECT id, status
FROM order_payment
WHERE tenant_id = 42
AND order_no = 'P202601180001'
FOR UPDATE NOWAIT;
行已经被别的事务锁住时,这条 SQL 会立刻报错。它适合人工操作、管理后台、低频修复任务,能避免操作员页面一直转圈。
隔离级别会影响等待后的结果
这次库里使用的是 PostgreSQL 默认的 READ COMMITTED。在这个隔离级别下,如果一个事务正在更新某行,另一个事务更新同一行时会等待。等前一个事务提交后,后一个事务会重新检查 WHERE 条件。
比如等待前这行是:
status = 1
阻塞方提交后把它改成:
status = 2
等待方继续执行时,会重新判断:
WHERE status = 1
条件已经不满足,最终更新 0 行。这一点对幂等更新很有用。支付回调里可以根据 RETURNING 是否有结果判断自己有没有真正改到状态。
UPDATE order_payment
SET status = 2,
paid_at = now(),
updated_at = now()
WHERE tenant_id = 42
AND order_no = 'P202601180001'
AND status = 1
RETURNING id;
如果返回 0 行,再查一次当前状态:
SELECT id, status, paid_at
FROM order_payment
WHERE tenant_id = 42
AND order_no = 'P202601180001';
如果已经是已支付,直接按幂等成功处理。
在 REPEATABLE READ 或 SERIALIZABLE 下,并发更新同一行可能会在等待后抛出序列化相关错误。业务代码要有重试能力,尤其是账户、库存、结算这种高并发写入场景。隔离级别提高后,异常处理成本也会上来,不能只改数据库参数。
锁等待和死锁要分开处理
锁等待是一条链:A 等 B,B 迟早提交或回滚,A 就能继续。
死锁是一个环:A 等 B,B 又等 A。PostgreSQL 会检测到这个环,然后中断其中一个事务。
最常见的死锁写法是两个事务更新同一批资源,但顺序相反。
T1: UPDATE account_balance SET amount = amount - 100 WHERE account_id = 1;
T1: UPDATE account_balance SET amount = amount + 100 WHERE account_id = 2;
T2: UPDATE account_balance SET amount = amount - 50 WHERE account_id = 2;
T2: UPDATE account_balance SET amount = amount + 50 WHERE account_id = 1;
可能出现这样的错误:
ERROR: deadlock detected
DETAIL: Process 7312 waits for ShareLock on transaction 881022; blocked by process 7330.
Process 7330 waits for ShareLock on transaction 881021; blocked by process 7312.
解决这类问题,最有效的方式是固定加锁顺序。比如转账时不按转出方、转入方顺序锁,而是统一按账户 ID 从小到大锁:
long firstAccountId = Math.min(fromAccountId, toAccountId);
long secondAccountId = Math.max(fromAccountId, toAccountId);
accountMapper.selectForUpdate(firstAccountId);
accountMapper.selectForUpdate(secondAccountId);
accountMapper.decrease(fromAccountId, amount);
accountMapper.increase(toAccountId, amount);
所有事务都按同一个顺序拿锁,环路出现的概率就会低很多。死锁偶发时,应用层也要支持重试。重试前最好有退避时间,避免多个事务立刻再次撞在一起。
上线后的观察结果
改造分了几步做。
先把支付回调里的外部渠道查询挪到事务外,缩短本地事务时间。
再给关键更新加 lock_timeout,超时后进入补偿队列。
然后给应用账号配置 idle_in_transaction_session_timeout,避免空闲事务长时间握锁。
最后加了一组监控 SQL,定时采集锁等待和空闲事务。
优化前后对比大概是这样:
| 指标 | 调整前 | 调整后 |
|---|---|---|
| 支付状态更新正常耗时 | 10ms ~ 30ms | 10ms ~ 30ms |
| 偶发最大耗时 | 40s 左右 | 3s 内失败进入补偿 |
idle in transaction 应用连接 |
经常出现 | 基本清零 |
| 支付回调超时 | 偶发集中出现 | 明显下降 |
| 锁等待排查方式 | 查慢 SQL 日志 | 直接看阻塞链路 |
这次最大的收获是排查方向的切换。执行计划正常、索引命中、扫描行数很少时,偶发长耗时大概率要去看锁、事务和等待事件。
总结
PostgreSQL 里一条普通 UPDATE 偶尔卡住,常见原因是同一行正在被其他事务持有锁。EXPLAIN 只能说明 SQL 自身的访问路径,锁等待要在问题发生时查 pg_stat_activity、pg_blocking_pids 和 pg_locks。
排查时先看等待方的 wait_event_type 和 wait_event,再用 pg_blocking_pids 找阻塞 PID。如果阻塞方是 idle in transaction,基本就要回到应用代码里查事务边界。外部接口调用、复杂计算、批量循环都不要长时间放在数据库事务里。
支付、库存、账户这类更新,锁冲突无法完全避免。能做的是缩短事务、固定加锁顺序、设置合理超时、补上失败重试和补偿逻辑。这样数据库遇到冲突时不会无限等待,业务也能知道这次更新到底是成功、幂等完成,还是需要后续补偿。
浙公网安备 33010602011771号