PostgreSQL 存储过程:3 个真实应用场景

由 John Doe 十一月 9, 2026

在 PostgreSQL 中编写一个基础存储过程很容易。真正挑战在于:如何让它在生产流量下存活,而不出现死锁、竞态条件或停机。

目录

作者:Oz

以下是来自实战前线的 3 个真实世界案例研究,涵盖用于维持高并发系统存活的精确架构与解决方案。

image

案例研究 1:一次简单的转账如何锁死系统(死锁解决与行级锁)

简单性的幻觉

让我们从软件工程中最具欺骗性的任务开始:在两个账户之间转账。业务需求很简单:从账户 A 扣除 100 美元,加到账户 B。

当开发者第一次发现 PostgreSQL 存储过程时,他们很兴奋:可以直接在数据库内部用 COMMIT 和 ROLLBACK 管理事务。他们很快写出类似这样的代码:

天真做法(切勿在生产环境使用):

CREATE OR REPLACE PROCEDURE transfer_funds_naive(
    p_sender_id INT,
    p_receiver_id INT,
    p_amount DECIMAL
)
LANGUAGE plpgsql
AS $$
BEGIN
    -- 第 1 步:从发送方扣除
    UPDATE accounts 
    SET balance = balance - p_amount 
    WHERE id = p_sender_id;

-- 第 2 步:加到接收方
    UPDATE accounts 
    SET balance = balance + p_amount 
    WHERE id = p_receiver_id;
    COMMIT;
END;
$$;

在只有一个测试人员的预发环境中,这运行完美。但在生产环境、高流量事件期间,数据库开始抛出 40P01(deadlock_detected)异常,API 调用开始像苍蝇一样掉落。

刚刚发生了什么?

死锁剖析

要理解崩溃,我们必须查看两个并发事务的精确时间线。想象 Alice(ID: 10)正在给 Bob(ID: 20)转账,而就在同一毫秒,Bob 也在给 Alice 转账。

以下是 PostgreSQL 锁管理器处理过程:

时间线 事务 1(Alice 转 Bob) 事务 2(Bob 转 Alice) 系统状态
t0 UPDATE accounts… WHERE id = 10; UPDATE accounts… WHERE id = 20; T1 锁定行 10。T2 锁定行 20。
t1 UPDATE accounts… WHERE id = 20; UPDATE accounts… WHERE id = 10; T1 等待 20。T2 等待 10。
t2 (等待中……) (等待中……) 死锁

在 t2,两个事务都在等待对方持有的资源。它们会无限等待,直到 PostgreSQL 的死锁检测器醒来(由 deadlock_timeout 参数定义)。数据库会强制杀死其中一个事务(牺牲者)并回滚它,以解除另一个的阻塞。

你的应用收到一个丑陋的 500 Internal Server Error。

解决方案:确定性锁顺序

高并发数据库设计的黄金法则是:始终以确定性顺序获取锁。如果你系统中的每个事务都以完全相同的顺序锁定行(例如,从最低 ID 到最高 ID),死锁在数学上就不可能发生。一个事务只会排在另一个后面,等待轮到自己,然后成功。

要在我们的过程中实现这一点,我们不能盲目执行 UPDATE 语句。我们需要 upfront 使用 SELECT … FOR UPDATE 显式锁定行,而且必须按顺序进行。

生产级做法:

CREATE OR REPLACE PROCEDURE transfer_funds_robust(
    p_sender_id INT,
    p_receiver_id INT,
    p_amount DECIMAL
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_locked_id INT;
    v_sender_balance DECIMAL;
BEGIN
    -- 1. 确定性锁定阶段
    -- 我们对 ID 排序,确保总是先锁定较小的 ID。
    -- FOR UPDATE 子句获取 RowExclusiveLock。
    FOR v_locked_id IN 
        SELECT id 
        FROM accounts 
        WHERE id IN (p_sender_id, p_receiver_id) 
        ORDER BY id ASC 
        FOR UPDATE
    LOOP
        -- 我们只是迭代以安全地获取锁。
        -- 此循环内暂时不需要任何操作。
    END LOOP;

-- 2. 业务逻辑与验证阶段
    -- 现在我们拥有锁,可以安全地读取余额。
    SELECT balance INTO v_sender_balance 
    FROM accounts 
    WHERE id = p_sender_id;

    IF v_sender_balance < p_amount THEN
        -- 异常/回滚时锁会自动释放
        RAISE EXCEPTION '余额不足。账户:%,可用:%,请求:%', 
                        p_sender_id, v_sender_balance, p_amount
              USING ERRCODE = 'P0001'; -- 自定义错误码
    END IF;

    -- 3. 执行阶段
    UPDATE accounts 
    SET balance = balance - p_amount 
    WHERE id = p_sender_id;

    UPDATE accounts 
    SET balance = balance + p_amount 
    WHERE id = p_receiver_id;

    -- 4. 提交并释放锁
    COMMIT;
    
EXCEPTION
    WHEN OTHERS THEN
        -- 如果发生任何错误,回滚事务以防止部分更新。
        ROLLBACK;
        -- 重新抛出错误,让应用层知道失败
        RAISE;
END;
$$;

为什么这种架构有效

  • 消除死锁:通过使用 ORDER BY id ASC,如果 Alice(10)转给 Bob(20),Bob(20)转给 Alice(10),两个事务都会先尝试锁定 ID 10。竞争失败者只是排队等待。
  • 防止竞态条件:FOR UPDATE 锁确保我们在验证阶段读取的余额(v_sender_balance)在执行 UPDATE 之前不会被另一个事务更改。
  • 优雅的错误处理:我们使用显式错误码(ERRCODE)。这允许后端应用捕获特定的 P0001 错误,向用户显示干净的“余额不足”消息,而不是通用的数据库崩溃日志。

在高风险环境中,存储过程不仅仅是将逻辑移到数据库;它是在裸机层面控制流量流向。

案例研究 2:部分成功是否可能?批处理中的保存点与子事务

全有或全无的陷阱

批处理是企业系统的现实。想象你有一个夜间 cron 作业,处理 50,000 个待处理的订阅续费或批量付款。

历史上,通过单个大规模查询或传统的 PostgreSQL 函数(CREATE FUNCTION)执行这意味着在原子事务的 rigid 规则下运行:全有或全无。如果第 49,999 条记录抛出 unique_violation 或 check_violation,整个事务回滚。数小时的数据库 I/O 被浪费,49,998 个成功的续费被撤销。

在 PostgreSQL 11 引入存储过程之前,开发者不得不把所有 50,000 条记录拉到应用层(Node.js、Python、Java),迭代它们,并通过网络执行 50,000 个单独事务。这导致巨大的网络延迟、连接池耗尽和应用内存膨胀。

有了存储过程,我们终于可以在数据库内部原生处理批处理,保留部分成功,同时优雅地处理单个失败。

子事务与批量提交的力量

在 PostgreSQL 存储过程中,你可以原生发出 COMMIT 和 ROLLBACK 命令。更重要的是,当你在 BEGIN … END 块内放置 EXCEPTION 块时,PostgreSQL 会自动创建一个子事务(实际上是一个隐式 SAVEPOINT)。

如果该块内的语句失败,PostgreSQL 只回滚该特定子事务。主循环继续执行。

然而,有一个陷阱:在内存中累积数千个锁和子事务最终会降低性能或使数据库崩溃。真正的高级做法是将子事务与批量提交结合——分块提交数据。

生产级批处理器:

CREATE OR REPLACE PROCEDURE process_bulk_payouts(p_batch_size INT DEFAULT 1000)
LANGUAGE plpgsql
AS $$
DECLARE
    v_record RECORD;
    v_processed_count INT := 0;
BEGIN
    -- 遍历所有待处理付款
    FOR v_record IN 
        SELECT id, user_id, amount 
        FROM payouts 
        WHERE status = 'PENDING'
        -- 注意:如果在循环内提交,请不要在这里使用 FOR UPDATE,
        -- 因为 COMMIT 会释放所有锁并关闭标准游标。
    LOOP
        BEGIN -- 隐式子事务开始
            
            -- 1. 业务逻辑
            UPDATE accounts 
            SET balance = balance + v_record.amount 
            WHERE id = v_record.user_id;
            
            UPDATE payouts 
            SET status = 'COMPLETED', processed_at = NOW() 
            WHERE id = v_record.id;
            
            v_processed_count := v_processed_count + 1;

            -- 2. 批量提交策略
            -- 每 N 条记录提交一次,以释放锁并防止内存膨胀
            IF v_processed_count % p_batch_size = 0 THEN
                COMMIT;
                RAISE NOTICE '已提交 % 条记录的批次', p_batch_size;
            END IF;
            
        EXCEPTION
            WHEN OTHERS THEN
                -- 3. 失败处理
                -- 此特定 v_record 的子事务自动回滚。
                -- 我们记录失败并让循环继续。
                INSERT INTO payout_errors (payout_id, error_message, created_at) 
                VALUES (v_record.id, SQLERRM, NOW());
                
                UPDATE payouts 
                SET status = 'FAILED' 
                WHERE id = v_record.id;
                
                -- 我们必须立即提交错误日志,否则未来的
                -- 回滚可能会抹掉我们的错误跟踪。
                COMMIT; 
        END;
    END LOOP;

    -- 对未达到批次大小限制的剩余记录进行最终提交
    COMMIT;
    RAISE NOTICE '批量付款处理完成。';
END;
$$;

为什么这种架构有效

  • 网络效率:通过将循环移入数据库内部,你消除了后端应用与数据库服务器之间的 50,000 次网络往返。
  • 韧性:EXCEPTION 块充当盾牌。如果用户 A 的账户有约束违反(例如账户冻结),它会优雅失败,记录到 payout_errors,用户 B 的付款继续处理而不中断。
  • 内存与锁管理:每 1,000 条记录(p_batch_size)调用 COMMIT 至关重要。它会刷新 WAL(预写日志),释放行锁,并释放内存。没有这一点,一个长时间运行的过程会锁定数千行数小时,阻塞应用的其他部分。

关于游标的重要警告:当你在 FOR 循环内执行 COMMIT 时,PostgreSQL 在现代版本中会隐式保持游标打开,但它会释放你在 SELECT 语句中可能获取的任何 FOR UPDATE 锁。因此,设计你的批量查询以增量方式处理数据(比如按状态过滤),而不是依赖跨提交持有的锁。

案例研究 3:票务销售中的竞态条件(防止重复预订)

“先检查后行动”反模式

卖一张票、预订一间酒店房间,或预订航班上的特定座位,引入了计算机科学中最臭名昭著的并发 bug 之一:竞态条件。具体来说,是“检查时间到使用时间”(TOCTOU)缺陷。

想象一个 API 端点,允许用户预订特定演唱会座位。初级开发者通常会这样写逻辑:

天真做法(重复预订灾难):

CREATE OR REPLACE PROCEDURE book_seat_naive(
    p_user_id INT,
    p_seat_id INT
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_status VARCHAR;
BEGIN
    -- 第 1 步:检查可用性(检查)
    SELECT status INTO v_status 
    FROM seats 
    WHERE id = p_seat_id;

    -- 第 2 步:进行预订(行动)
    IF v_status = 'AVAILABLE' THEN
        -- 模拟一些业务逻辑延迟(例如检查用户余额)
        PERFORM pg_sleep(0.1); 
        UPDATE seats 
        SET status = 'BOOKED', user_id = p_user_id 
        WHERE id = p_seat_id;
        INSERT INTO tickets (user_id, seat_id, created_at) 
        VALUES (p_user_id, p_seat_id, NOW());
        
        COMMIT;
    ELSE
        RAISE EXCEPTION '座位已被占用!';
    END IF;
END;
$$;

如果你在本地测试,它运行完美。但当 Taylor Swift 演唱会门票开售,数千粉丝在同一毫秒点击“购买”按钮时,你最终会有两个人持有 12A 座位的有效票。

竞态条件剖析

因为 PostgreSQL 中的标准 SELECT 语句不会互相阻塞,多个事务可以在任何一个有机会更新之前读取完全相同的状态。

时间线 事务 1(Alice) 事务 2(Bob) 系统状态
t0 SELECT status FROM seats (ID:12) SELECT status FROM seats (ID:12) 两者都读到 ‘AVAILABLE’。
t1 IF v_status = ‘AVAILABLE’ (True) IF v_status = ‘AVAILABLE’ (True) 两者都通过验证检查。
t2 UPDATE seats SET status=‘BOOKED’ (等待 T1 提交……) Alice 得到座位。
t3 COMMIT; UPDATE seats SET status=‘BOOKED’ Bob 覆盖 Alice 的预订。

最终,数据库记录座位被 Bob 预订,但 Alice 和 Bob 都收到了 API 的成功响应,而且都被扣款了。

解决方案:使用 NOWAIT 的悲观锁

要修复这个问题,我们必须使用悲观锁将检查和行动阶段绑定在一起。当 Alice 检查座位时,她必须在该行上放置排他锁,这样 Bob 在她完成之前甚至无法读取它。

在 PostgreSQL 中,我们使用 SELECT … FOR UPDATE 实现这一点。然而,如果我们只使用 FOR UPDATE,Bob 的事务会挂起并等待 Alice 完成。在高流量票务系统中,挂起的连接会立即耗尽你的数据库连接池。

我们需要 Bob 的请求立即失败,这样 UI 可以告诉他“其他人正在查看这个座位”。我们通过附加 NOWAIT 来实现这一点。

生产级预订过程:

CREATE OR REPLACE PROCEDURE book_seat_robust(
    p_user_id INT,
    p_seat_id INT
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_status VARCHAR;
BEGIN
    -- 1. 原子检查并锁定阶段
    -- NOWAIT 确保如果另一个事务持有锁,
    -- 此查询立即抛出 "could not obtain lock" 错误 (55P03)
    SELECT status INTO v_status 
    FROM seats 
    WHERE id = p_seat_id 
    FOR UPDATE NOWAIT;

    -- 2. 验证阶段
    IF v_status != 'AVAILABLE' THEN
        -- 座位被我们锁定,但过去已经被预订了
        RAISE EXCEPTION '座位 % 不再可用。', p_seat_id
              USING ERRCODE = 'P0002';
    END IF;

    -- 3. 执行阶段
    UPDATE seats 
    SET status = 'BOOKED', user_id = p_user_id 
    WHERE id = p_seat_id;

    INSERT INTO tickets (user_id, seat_id, created_at) 
    VALUES (p_user_id, p_seat_id, NOW());

    -- 4. 提交并释放行锁
    COMMIT;
    
EXCEPTION
    WHEN lock_not_available THEN -- 捕获特定的 NOWAIT 异常 (55P03)
        ROLLBACK;
        RAISE EXCEPTION '座位当前正被另一位用户预订。请重试。'
              USING ERRCODE = 'P0003';
    WHEN OTHERS THEN
        ROLLBACK;
        RAISE;
END;
$$;

为什么这种架构有效

  • 绝对一致性:通过在 SELECT 阶段获取 FOR UPDATE 锁,我们保证行的状态在我们检查它和更新它之间不会改变。
  • 快速失败机制:NOWAIT 子句是系统稳定性的救星。不是创建大量阻塞事务队列来消耗 CPU 和连接池,次级请求立即命中 lock_not_available 异常。
  • 清晰的 API 响应:因为我们捕获特定的锁异常并将其转换为自定义错误码(P0003),后端应用可以优雅地处理拒绝,并通知前端显示“座位已锁定,请选择其他”的 UI 提示。

专业提示:如果你正在构建一个队列系统,工作者是领取任务而不是用户预订特定座位,请研究 SELECT ... FOR UPDATE SKIP LOCKED 而不是 NOWAIT。它完全跳过锁定的行并抓取下一个可用的,这非常适合高吞吐量作业队列。

参考

PostgreSQL Stored Procedures: 3 Real-World Scenarios