由 John Doe 十一月 9, 2026
在 PostgreSQL 中编写一个基础存储过程很容易。真正挑战在于:如何让它在生产流量下存活,而不出现死锁、竞态条件或停机。
目录
作者:Oz
以下是来自实战前线的 3 个真实世界案例研究,涵盖用于维持高并发系统存活的精确架构与解决方案。

案例研究 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。它完全跳过锁定的行并抓取下一个可用的,这非常适合高吞吐量作业队列。