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 = Lockwait_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 = 73612901ShareLock。阻塞方 8124 持有这个事务 ID 的 ExclusiveLock。在 PostgreSQL 里,更新同一行时,经常会表现为等待另一个事务 ID 结束。等那个事务提交或回滚后,当前事务才能判断这行的最新版本还能不能更新。

表级的 RowExclusiveLockRowShareLock 在这里不是主要矛盾。普通 UPDATESELECT 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 READSERIALIZABLE 下,并发更新同一行可能会在等待后抛出序列化相关错误。业务代码要有重试能力,尤其是账户、库存、结算这种高并发写入场景。隔离级别提高后,异常处理成本也会上来,不能只改数据库参数。

锁等待和死锁要分开处理

锁等待是一条链: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_activitypg_blocking_pidspg_locks

排查时先看等待方的 wait_event_typewait_event,再用 pg_blocking_pids 找阻塞 PID。如果阻塞方是 idle in transaction,基本就要回到应用代码里查事务边界。外部接口调用、复杂计算、批量循环都不要长时间放在数据库事务里。

支付、库存、账户这类更新,锁冲突无法完全避免。能做的是缩短事务、固定加锁顺序、设置合理超时、补上失败重试和补偿逻辑。这样数据库遇到冲突时不会无限等待,业务也能知道这次更新到底是成功、幂等完成,还是需要后续补偿。

posted @ 2026-05-10 10:16  小雨青年  阅读(19)  评论(0)    收藏  举报