介绍
两个柜台销售手工工作坊的名额。顾客期待的是确认获得一个名额,而不是一个看起来没有负数的计数器。你将先完成正常团体报名,再在可丢弃的练习工作坊中重现一个名额产生两份确认的情况。最后,你将保护该名额,同时让另一工作坊继续接待顾客。
你需要掌握基本终端导航,以及 PostgreSQL SELECT、WHERE、INSERT 和 UPDATE。这里会讲解事务、连接身份、行锁、RETURNING 和恢复方法。PostgreSQL 和合成数据已准备好。Workshop Desk 是 shell;Desk A 和 Desk B 是同一台机器上连接同一个数据库的独立原生 psql 连接。保持三个标签页都打开。正文中的实际标签和运行时标签为英文。作者估计的时长包含阅读和普通错误处理,并非初学者计时研究。
只添加指定的试验报名,并更新其工作坊的 places_left。竞态练习明确允许删除你自己的试验报名。保留原始报名、工作坊 ID、名称、容量、备注、表定义和准备好的工具。不要改变服务器设置或创建表。本练习使用单个数据库上的 PostgreSQL READ COMMITTED 隔离级别;支付、重试和跨工作坊死锁不在范围内。
完成正常团体报名
本步骤中,你将把一份真实团体确认与它消耗的名额数量对应起来。
从 Workshop Desk 的 shell 开始。./ 运行当前目录中的程序。desk status 读取实际表,不修复它们。第一组结果列出 workshop_id|title|capacity|places_left|booked_seats,下一组列出 request_code|workshop_id|seats|desk。竖线分隔字段。
./desk status
工作坊 101 容量为 5,已有一个原始报名座位,还剩 4 个名额。工作坊 102 只有一个名额,尚无报名。完整保留工作坊 101 的 original-family。
切换到 Desk A。提示符 Desk A workshops=> 表明你在 PostgreSQL 的 psql 客户端内,连接数据库 workshops。在这里输入 SQL,而不是 shell 命令。分号结束一条 SQL 语句。连接是与数据库服务器的一段会话;pg_backend_pid() 标识其服务器进程。Desk B 中不同的值表示独立连接。
SELECT pg_backend_pid(), current_user;
用户为 workshop_write,进程 ID 会变化。BEGIN 开始事务,即一组在 COMMIT 时共同可见的变更。提交前,另一连接看不到你的新报名。ROLLBACK 丢弃未提交变更。在第一个示例中,没有其他柜台销售工作坊 101。
RETURNING 显示 UPDATE 实际修改的行。在同一事务中减去 2 个名额,并插入对应的两人报名。文本使用单引号;请求代码唯一标识这笔报名。
BEGIN;
UPDATE workshops
SET places_left = places_left - 2
WHERE workshop_id = 101
RETURNING workshop_id, places_left;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('normal-group', 101, 2, 'A');
更新返回 101 | 2。提示符包含 *,表示事务仍打开。切换到 Workshop Desk,检查已提交明细:
./desk status
它仍显示工作坊 101 剩余 4 个名额,且只有原始报名。切回 Desk A 并完成事务:
COMMIT;
切换到 Workshop Desk:
./desk status
现在 101 剩余 2 个名额,已报名 3 个座位:原来一个加上你的两人团体。实际报名为 normal-group|101|2|A。成功事务保留 capacity = places_left + booked_seats。第一个检查读取这些业务事实,不推断你的输入方式。
事务内 SQL 输错时,提示符可能变为 !。此时事务已中止,进一步 SQL 会失败,直到执行 ROLLBACK;。在同一个柜台连接中回滚,在 Workshop Desk 检查 ./desk status,并只重试尚未提交的工作。切勿盲目重复已提交请求。请求代码冲突是错误,不是第二份确认。
重现基于过期名额的决策
本步骤中,你将有意产生无效练习明细,观察为何非负计数器不足以防止超售,并将影响限制在工作坊 102。
切换到 Desk A。本实验刻意写入一个过期数字,而不是计算条件名额占用。不要把它作为最终报名方法。READ COMMITTED 隔离级别让每条语句在开始时看到已提交数据;之前的 SELECT 不会预留其读取的结果。
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT workshop_id, places_left FROM workshops WHERE workshop_id = 102;
切换到 Desk B。确认它的进程 ID 与 Desk A 不同,然后开始自己的事务并读取同一个名额:
SELECT pg_backend_pid(), current_user;
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT workshop_id, places_left FROM workshops WHERE workshop_id = 102;
两个 SELECT 都返回一个名额,但都没有预留它。切换到 Desk A,保存基于先前数字的决定:
UPDATE workshops SET places_left = 0 WHERE workshop_id = 102;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('unsafe-A', 102, 1, 'A');
保持 A 的事务打开。切换到 Desk B,只输入下面这条 UPDATE:
UPDATE workshops SET places_left = 0 WHERE workshop_id = 102;
提示符尚未返回。这是预期的锁等待,不是连接损坏。A 已修改此行,并将持有行锁直到事务结束。切换到 Workshop Desk:
./desk waits
实际 Desk B 后端的 wait_event_type 为 Lock,blocked_by 包含 A 的进程 ID。ID 会变化。观察工具只读取 PostgreSQL 活动;该状态不是由动画或定时器提供。
切换到 Desk A:
COMMIT;
切换到 Desk B。它的 UPDATE 现在完成。由于 WHERE 条件只指定工作坊,它仍能将已经为零的余额设为零。添加其确认并提交:
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('unsafe-B', 102, 1, 'B');
COMMIT;
切换到 Workshop Desk:
./desk status
工作坊 102 现在容量为 1,剩余名额为 0,却有 2 个已报名座位。unsafe-A 和 unsafe-B 都存在。余额满足数据库的非负检查,但一位顾客没有名额。仅仅等待并没有修正过期业务决定。下一步只移除这两笔明确可丢弃的试验,并替换决策方式。步骤检查接受此练习状态或正确修复后的最终状态;它不用终端历史证明你的调查过程。
先占用名额再确认
本步骤中,你将要求实际的条件名额占用先于确认写入,并恢复一次被取消的等待请求。
切换到 Desk A。DELETE 只删除 WHERE 条件选中的行。IN 指定两笔可丢弃请求代码;不要删除原始或正常报名。在同一事务中,只恢复练习工作坊 102:
BEGIN;
DELETE FROM bookings WHERE request_code IN ('unsafe-A', 'unsafe-B');
UPDATE workshops SET places_left = 1 WHERE workshop_id = 102;
COMMIT;
先在独立工作坊 104 上练习取消锁等待。A 将用 ROLLBACK 释放变更,因此此试验不创建报名:
BEGIN;
UPDATE workshops SET places_left = 0 WHERE workshop_id = 104;
切换到 Desk B:
BEGIN;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 104 AND places_left >= 1
RETURNING workshop_id;
B 等待期间,在 Desk B 按 Ctrl+C。PostgreSQL 报告 canceling statement due to user request,SQLSTATE 为 57014。SQLSTATE 是 PostgreSQL 的五字符错误类别。语句被取消后,此显式事务处于中止状态。演示它与普通空结果的区别:
SELECT 1;
这是预期错误:current transaction is aborted,SQLSTATE 为 25P02。在 Desk B 恢复:
ROLLBACK;
切换到 Desk A,释放其练习变更:
ROLLBACK;
现在保护工作坊 102 真正的最后一个名额。WHERE 条件把名额充足检查作为 UPDATE 本身的一部分。等待竞争更新结束后,READ COMMITTED 会针对更新后的行重新检查此条件。RETURNING 告诉你本事务是否实际占用了名额。
在 Desk A 中:
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 102 AND places_left >= 1
RETURNING workshop_id, places_left;
A 收到 102 | 0,表示占用了名额。只有因为返回了一行,才创建其对应确认:
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('safe-last-A', 102, 1, 'A');
保持未提交。切换到 Desk B:
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 102 AND places_left >= 1
RETURNING workshop_id, places_left;
B 等待。切换到 Workshop Desk,读取实际等待情况:
./desk waits
行锁不应阻止无关工作坊。仍在 Workshop Desk 中,打开一个短暂的第三连接。./db --desk A 使用相同预备数据库登录;此次临时 shell 调用独立于已打开的 Desk A 标签页。带引号的 here-document 将直到结尾 SQL 的所有行送给此客户端,然后关闭连接;结束标记要独占一行。报名中存储的 C 记录这第三个请求。
./db --desk A <<'SQL'
BEGIN;
UPDATE workshops SET places_left = places_left - 1
WHERE workshop_id = 103 AND places_left >= 1
RETURNING workshop_id, places_left;
INSERT INTO bookings (request_code, workshop_id, seats, desk)
VALUES ('other-workshop', 103, 1, 'C');
COMMIT;
SQL
当工作坊 102 仍被锁定时,它返回 103 | 2 并提交。切换到 Desk A:
COMMIT;
切换到 Desk B。它的 UPDATE 现在返回零行和 UPDATE 0:新的零余额不满足 places_left >= 1。这是成功执行但没有占用名额的 SQL 语句,不是 SQL 错误。不要插入确认。结束 B 的事务:
ROLLBACK;
切换到 Workshop Desk:
./desk status
工作坊 102 有一笔报名 safe-last-A|102|1|A,剩余零个名额。工作坊 103 有 other-workshop|103|1|C,剩余两个名额。工作坊 104 仍保留原来一个名额,没有报名;101 保留正常团体和原始家庭。现在每个工作坊都满足余额等式。
对于普通错误造成的新试验确认,先在该柜台 ROLLBACK 任何打开的事务。在此练习环境中,只能在一个事务内移除你自己的错误代码,并仅重算对应工作坊余额。例如,如果不小心在工作坊 103 提交了 typo-trial,在 Desk A 使用以下代码,再检查状态,然后重试目标请求。子查询汇总实际已报名座位;没有报名时,COALESCE(..., 0) 使用零。这是明确的练习恢复,不属于报名算法:
BEGIN;
DELETE FROM bookings WHERE request_code = 'typo-trial' AND workshop_id = 103;
UPDATE workshops
SET places_left = capacity -
(SELECT COALESCE(SUM(seats), 0) FROM bookings WHERE workshop_id = 103)
WHERE workshop_id = 103;
COMMIT;
没有造成该错误时,示例不会改变结果。切勿用它删除原始或任务要求保留的合法报名。下一个实验将名额占用和确认关联为一条可复用语句,使调用者无法在空占用结果后误插入。
总结
你创建了真实的正常报名,重现了过期读取导致的超额报名,并用条件 UPDATE 与同一事务内的确认保护了最后一个名额。原生锁观察展示了竞争柜台的等待,同时另一工作坊仍继续运行。你还取消了等待语句,并用 ROLLBACK 恢复已中止的事务。最终明细保留原始报名,每个确认座位都与剩余容量相符。



