后端系统工程 28:稀缺名额的准入与库存预留
一个预约名额只有 40 个、同一时刻却有几百个请求到达的场景,至少要回答四件事:同时进入数据库的请求怎样限量,超出的请求是排队还是立刻拒绝;40 个名额怎样保证不多发;客户端在等待中断开或超时重发时,名额会不会被占两次;占了名额却迟迟不付款的预留到期后怎样归还,归还任务重跑时会不会多还。本篇用 examples/flash-sale 中的最小服务逐一回答。商品、用户和请求都是合成数据,SKU 固定为 sku-lab-01,用户为 user-0042 这类编号,域名只用 shop.example.test。订单与支付留给下一篇,本篇只交付“名额预留”这一层。
需求与范围
需求按可检查的形式写成五条:
- 任一时刻有效预留(状态为 HELD 或 CONFIRMED)的数量不超过库存总数。
- 同一个
request_key无论发送多少次,最多产生一条预留;同键换用户或换 SKU 视为错误请求。 - 同一用户在同一 SKU 上最多持有一条有效预留。
- 单个服务进程内同时访问数据库的预留请求不超过并发上限;等待位置有界,满了立即返回 503。
- 到期的 HELD 预留由释放任务归还库存;释放任务重复执行、并发执行或在中途被杀后重跑,库存都只归还一次。
排除项:多个服务进程之间的全局准入、订单与支付、缓存层、数据库主备切换、吞吐和延迟测量。
教学规模假设如下,这些数字只用于设计实验输入,不对应任何真实活动:单 SKU 库存 10 到 40 件;同时到达的请求 120 到 150 个;单进程准入上限 4、排队位 8;预留有效期 60 秒,实验里通过把 expires_at 改到过去来模拟到期。本地 PostgreSQL 的 max_connections 为 200,因此直连数据库的并发线程上限取 150。
接口
服务只暴露两个入口:
1 | |
503 的语义取自 RFC 9110 §15.6.4:服务端暂时无法处理,客户端可以按 §10.2.3 的 Retry-After 稍后再试。这里的约定比 RFC 更窄:503 只在请求尚未触碰数据库时返回,因此客户端可以放心用同一个 request_key 重试,不需要先查询。
数据模型与状态机
库存与预留各一张表,定义在 Inventory.SCHEMA:
1 | |
守恒式是本章所有断言的落点:total - available = 有效预留行数。代码里的 Inventory.drift 计算两边之差,每个用例结束都断言它为 0。available 列是可以重算的派生值,reservation 表才是事实。CHECK (available <= total) 只能拦住“归还到超过总数”的错误,拦不住“归还过多但仍未超过总数”的错误,后面的负向对照正好落在这个缝隙里。
部分唯一索引的用法来自 PostgreSQL 16 文档 11.8 的 Example 11.3:唯一性只约束满足谓词的行。一个用户可以有任意多条 RELEASED、EXPIRED 记录,但 HELD 与 CONFIRMED 合计只能有一条。
图 28-1:预留状态机(方括号内为迁移的前置条件,“拒绝”为不落库的返回结果)
stateDiagram-v2
[*] --> HELD: reserve [request_key 新、该用户无有效预留、available > 0]
[*] --> 拒绝: SOLD_OUT / USER_LIMIT / KEY_REUSED,预留行随保存点回滚
HELD --> CONFIRMED: confirm [status='HELD' 且 expires_at >= now()]
HELD --> RELEASED: release [status='HELD'],同语句 available+1
HELD --> EXPIRED: expire [status='HELD' 且 expires_at < now()],同语句 available+1
CONFIRMED --> CONFIRMED: release / expire 条件不匹配,归还 0
拒绝 --> [*]
每条迁移的 WHERE 子句都带着 status = 'HELD'。迁移是否发生由 UPDATE 的影响行数判断,归还的件数只取自这次迁移实际返回的行,这条规则决定了重放安全。
正常路径
一次预留在一个事务里做三步:以 ON CONFLICT (request_key) DO NOTHING 插入 HELD 行;若插入成功,对热点行做条件扣减;扣减失败则回滚到保存点,撤销刚插入的行。关键代码与 Inventory.reserveInTx 一致:
1 | |
这里有两个容易被忽略的细节。第一,INSERT 参考页规定 ON CONFLICT 只对 conflict_target 推断出的仲裁索引采取替代动作,其余唯一索引的冲突照常报错。代码只把 request_key 指定为仲裁目标,于是同键重放走 DO NOTHING,而同一用户换键再来会触发部分唯一索引的 23505,被翻译成 USER_LIMIT。第二,expires_at 用 now() 计算,按文档 9.9.5,它是事务开始时间,在事务内不变。
两个唯一索引同时存在还带来一个并发细节,它在复核运行中暴露过一次。16 个线程用同一个键并发预留时,后到的插入不一定先撞上 request_key:它也可能在 reservation_one_active 上等待先到事务提交,然后以 23505 报错。早期实现把所有 23505 都翻译成 USER_LIMIT,于是 C28-04 出现过 {HELD=1, REPLAY=14, USER_LIMIT=1},同一个请求的重放被报告成“该用户已有预留”。错误是间歇的,取决于哪个索引先被检查,单次通过不能说明没有问题。修正后的代码在 23505 分支先按 request_key 读一次:对方已提交,READ COMMITTED 下新语句能看到这一行,查到就是重放。修正后连续 4 次运行,C28-04 均为 {HELD=1, REPLAY=15}。修正前那次失败的输出与修正后的前三次输出保存在 evidence/28/fix-history/,第四次即 evidence/28/output.txt。
不超卖的保证来自 available > 0 这个条件被求值的时机。PostgreSQL 16 文档 13.2.1 写明:READ COMMITTED 下 UPDATE 遇到已被并发事务修改的行,会等待对方结束;对方提交后,对行的最新版本重新求值 WHERE,满足才更新。因此 150 个事务都排在同一行上,每一个看到的都是前一个提交后的 available。
- 成立条件:所有扣减都通过这条带条件的 UPDATE,且库存行只有一行;隔离级别为 READ COMMITTED(本章唯一验证的级别);在 REPEATABLE READ 及以上,并发扣减同一行会以 40001 失败,需要调用方重试,本章代码没有实现重试。
- 观察位置:C28-03 的
outcomes {HELD=40, SOLD_OUT=110}、Inventory.active返回 40、drift为 0。 - 失败边界:若改成“先 SELECT available,再在应用里判断,再无条件 UPDATE”,判断与写入之间的窗口就不受行锁保护;若库存拆到多行而不重新设计条件,单行条件也不再代表全局约束。
进程内准入
数据库的条件扣减保证正确,却不限制排在热点行上的事务数量。150 个连接同时等一把行锁,每个都占着一个数据库后端进程。准入层把这个数量限制在进程内:
1 | |
permits 是公平模式的 Semaphore。Java SE 21 的类说明指出,不带超时的 tryAcquire() 不遵守公平设置,会直接拿走可用许可;带超时的版本遵守 FIFO。准入因此是“有空位就插队进入,没空位才按到达顺序等”。排队位由一个计数器限制,先加再判断,超出立即减回并拒绝,所以同时等待者不会超过 maxWaiting。
HttpServer 的执行器也会影响有界排队。若用 Executors.newFixedThreadPool(32) 作执行器,按该方法的说明,工作线程从共享的无界队列取任务;HttpServer 的说明又写明所有请求都在交给执行器的任务中处理。两者叠加,第 33 个以后的请求会先在一个看不见的无界队列里等待,再进入 Admission,有界排队就失效了。因此实现为每个请求一个虚拟线程,排队只发生在 Admission 内部。TCP accept 队列与内核缓冲仍在 Admission 之外,它们不计入任何计数。
C28-02 用确定性调度验证边界:上限 2、排队位 1、等待 300ms。主线程先拿满两个许可,等后台等待者就位后再发第四个请求,得到 REJECTED_QUEUE_FULL;不释放许可时等待者超时得到 REJECTED_WAIT_TIMEOUT;释放一个许可后,新的等待者得到 ADMITTED_AFTER_WAIT。
C28-05 把准入放到 HTTP 入口:120 个客户端同时到达,上限 4、排队位 8、等待 400ms、每次处理额外停 30ms,库存 30。收到 503 的客户端按 20ms 递增退避,用同一个 request_key 重试,直到得到 201 或 409。本次运行的输出:
1 | |
被准入的次数恰好是 120,每个客户端一次,503 的总次数与准入拒绝计数一致。503 次数和“等待后进入”的次数取决于线程调度,换一次运行会变;稳定的结论只有断言里的那几条:同时处理数不超过 4、最终 30 个成功、90 个售罄、守恒为 0。
- 成立条件:所有预留请求都经过同一个 Admission 实例;处理代码在 finally 中调用
exit()。 - 观察位置:
Admission.maxInFlightSeen与各拒绝计数;C28-05 的断言。maxWaitingSeen在拒绝分支之后才更新,对它的断言只是代码结构的自检,等待数有界的依据是上文“先加计数、超出即减回”的论证。 - 失败边界:多个服务进程各自持有一个 Admission,全局并发是进程数乘以上限;执行器若带无界队列,排队会在 Admission 之前无限增长。
重复请求与客户端断开
C28-04 让 16 个线程同时用 req-dup 预留,结果 {HELD=1, REPLAY=15};再让 8 个线程以同一用户、不同键预留,结果 {HELD=1, USER_LIMIT=7},库存只少了 2 件。前者靠的是 request_key 的唯一约束:13.2.1 写明,ON CONFLICT DO NOTHING 在 READ COMMITTED 下可能因另一个尚不可见事务的结果而不插入,第二个事务会等第一个提交再判断冲突,再用新语句读出已提交的行返回 REPLAY。
客户端断开的处理原则是:服务端一旦准入,就把事务做完,不去探测连接是否还在。C28-06 用裸 socket 发出完整请求,100ms 后关闭连接,服务端处理需要 600ms。事件序列如下:
图 28-2:客户端断开后凭 request_key 查询与重放(C28-06 实测顺序)
sequenceDiagram
participant C as 客户端
participant S as ReserveServer
participant DB as PostgreSQL
C->>S: POST /reserve request_key=req-gone
Note over C: 100ms 后关闭连接,未读响应
C->>S: GET /reservations/req-gone
S-->>C: 404 status=NOT_FOUND(预留事务尚未执行)
S->>DB: 600ms 后执行预留事务并提交
C->>S: 轮询 GET /reservations/req-gone
S-->>C: 200 status=HELD
C->>S: POST /reserve request_key=req-gone(重试)
S-->>C: 200 outcome=REPLAY
输出里第一行查询是 query right after disconnect: status=NOT_FOUND,随后轮询得到 HELD,重试得到 outcome=REPLAY reservation=1 status=HELD,库存只扣 1 件。这条记录说明 404 只代表“此刻没有已提交的预留”,不能当作失败结论。客户端拿到确定结果的方式是用同一个键重放:要么新建,要么返回已有记录,两种结果都不会多占名额。
售罄结果不落库是本设计的一个取舍。SOLD_OUT 时保存点回滚掉了预留行,同键重放会重新尝试,若中间有名额被释放,第二次可能成功。这与需求第 2 条并不冲突,因为最多仍然只有一条预留。
到期释放与重放
释放任务的全部逻辑在一条语句里,与 Inventory.EXPIRE_SQL 一致:
1 | |
文档 7.8.4 规定,WITH 中的数据修改子语句与主查询使用同一快照,彼此看不到对方对目标表的改动,只能通过 RETURNING 传递数据。这里正好利用这一点:归还件数只来自 moved 返回的行,也就是这一次真正完成 HELD → EXPIRED 迁移的行。重放时这些行已不再是 HELD,moved 为空,归还为 0。两个释放任务并发时,第二个在行锁上等待,第一个提交后按 13.2.1 对最新行版本重新求值 status = 'HELD',不再匹配。
C28-07 的输出依次是:首次释放归还 8 件,立即重放归还 0 件,4 线程并发重放合计 0 件;剩余 12 个到期后由 4 线程并发释放,合计恰好 12 件。C28-08 让确认线程与释放线程同时处理 40 个已到期的 HELD,本次结果 confirmed=27 returned=13 status {CONFIRMED=27, EXPIRED=13},两者之和为 40,库存只为 EXPIRED 行归还。这里的确认用的是 confirmHeldInTx,它不看到期时间,只要释放任务还没处理就允许确认,供下一篇的支付回调使用;划分比例随调度变化,“每行恰好被一方处理”是稳定结论。
- 成立条件:状态迁移与库存归还在同一事务;归还件数只由迁移成功的行决定;所有改写预留状态的语句都带
status = 'HELD'。 - 观察位置:
EXPIRE_SQL的返回值;C28-07、C28-08 的断言;drift。 - 失败边界:把归还拆到另一个事务,或按“查出来的 id 个数”而不是“迁移成功的行数”归还。
故障反例
C28-10 去掉状态检查:Negative.unconditionalExpire 按给定 id 无条件改为 EXPIRED,再按 id 个数归还。库存 10 件,10 件已预留,其中 5 件到期,释放任务被重放一次:
1 | |
available 回到 10 并没有违反 available <= total,CHECK 约束放行了;随后 10 个新请求全部成功,有效预留变成 15 件。只有守恒式能发现这个偏差,数值为 -5。
C28-11 用独立 JVM 复现另一类错误:释放任务先查出到期 id,在第一个事务里只归还库存并提交,第二个事务才改状态。时间线如下:
1 | |
C28-12 是用户维度的反例:没有部分唯一索引,两个事务先查“该用户有无有效预留”,用 CyclicBarrier 让两者都查完再插入,结果同一用户出现 2 条有效预留;换回带索引的实现,两个线程经屏障同时发起预留,得到一个 HELD、一个 USER_LIMIT。
三个反例里,决定是否写入的条件要么不存在(C28-10),要么不与写入同处一个事务(C28-11),要么在写入前就已求值(C28-12)。保证来自写在 WHERE 子句或唯一索引里、由数据库在写入那一刻判定的条件。事务本身并不保证正确,保证来自写在 WHERE 子句或唯一索引里、由数据库在写入那一刻重新判定的条件。
崩溃恢复
C28-09 对正确的释放任务做同样的 kill -9。子进程在一个事务中执行 EXPIRE_SQL,打印 STAGE uncommitted returned=5 后停住。父进程此时读到 available 仍为 0,因为未提交的修改对其他事务不可见(文档 3.4)。随后父进程执行 kill -9,子进程退出码 137。文档 55.2.9 规定连接异常终止时未结束的事务一律回滚;20.3.1 又指出,未开启 client_connection_check_interval 时,服务端要到下一次读写套接字才发现断连,因此用例先轮询 pg_stat_activity,等该后端消失后再检查。
恢复流程就是重放:
- 等待旧任务的数据库会话结束(
application_name = 'b3-28-release-worker'的后端数为 0)。 - 检查到期且仍为 HELD 的行,本次为 5 行,
available仍为 0。 - 重新执行
EXPIRE_SQL,归还 5 件;再执行一次,归还 0 件。 - 用
drift复核守恒;本次之后 10 个新请求只有 5 个成功,有效预留 10。
第 1 步的等待不是正确性的前提,它让第 2 步的观察结果确定:即使不等,新任务也会在行锁上排队,等旧事务回滚后再判断。
三种方案的比较
表 28-1:三种方案的计数(C28-13 为 120 线程、40 件、每人一次;边界列来自 C28-14、C28-15 的专用输入)
| 方案 | 正确性来源 | 本次计数 | 已观察到的边界 |
|---|---|---|---|
| A 单热点行条件更新 | available > 0 在行锁下重新求值 |
成功 40,剩余 0 | 持有行锁未提交时,后来者等待,回滚后按新版本成功 |
B 预拆分名额行 + FOR UPDATE SKIP LOCKED |
每个名额行最多一个 holder_key |
成功 40,无名额 80,40 个不同持有者 | 被锁的空名额会被跳过,出现“假无名额” |
| C 进程内存先扣、再写销售记录 | 各节点份额之和等于库存,且内存计数不丢 | 正常运行成功 40 | 节点按配置份额重启后多卖 20,对账 60 > 40 |
B 的边界在 C28-14 中构造:2 个名额,t1 占用 1 号但不提交,t2 跳过被锁的 1 号拿到 2 号,t3 再来时两行都不可用,返回无名额;t1 回滚后实际还剩 1 个名额。SELECT 参考页说明,跳过被锁的行会给出不一致的数据视图,不适合一般用途,可用于多个消费者访问队列式表时避免锁竞争。同样的交错放在 A 上,t3 在行锁上等待 300ms 仍未返回,t1 回滚后 t3 成功。A 把竞争集中到一行,B 把竞争分散到多行,代价是可能在仍有库存时对用户说售罄,需要靠重试或补发弥补。
C 的问题在于计数只在内存里。C28-15 中 node-1 卖完 20 件份额后按配置重启,又卖出 20 件,销售记录 60 行超过库存 40,只能事后对账发现,无法在写入时拒绝。按落库记录重算剩余份额再启动,则不再多卖。C 还有一种未测的失败:内存已扣、销售记录未写时进程崩溃,名额凭空消失,表现为少卖。
在本章的规模假设下,A 的正确性论证最短,所有保证都能落到一条 SQL 的条件上,因此作为下一篇订单流程的库存基础。B 适合热点行等待成为瓶颈、且业务能接受“假无名额”的场景;C 只有在能接受事后对账、并且重启时一定按落库记录恢复的情况下才可考虑。三者的取舍依据都是上面的计数与边界,本章没有测量吞吐,不对它们的性能排序。
证据与限制
运行入口是 examples/flash-sale/run-28.sh,它启动端口 55428 的集群 b3-28,重建库 flash28,只编译 lab.common 与 lab.flash.reserve,运行 C28-01 至 C28-15,输出写入 evidence/28/output.txt。本次运行的汇总行为 SUMMARY passed=95 failed=0,退出码 0。实验类别:C28-02 是确定性调度的进程内模型;C28-09、C28-11 是独立 JVM 加 SIGKILL 的多进程集成;其余是真实 PostgreSQL 事务、约束与锁的组件实验。
本机 /dev/shm 只有 64MB 且已满,initdb 无法创建默认的 posix 动态共享内存。脚本通过 pg.sh 的 PGBIN 变量给 initdb 追加 dynamic_shared_memory_type=mmap。文档 20.4.1 说明该选项用数据目录中的内存映射文件模拟共享内存,适用于其他共享内存设施不可用的情形;它不改变事务、锁与 WAL 的语义。
证据不覆盖的内容写在 evidence/28/LIMITS.md:多进程之间的全局准入;accept 队列和内核缓冲中的排队;请求体未发完或排队等待中断开的情形;数据库自身崩溃、主备切换与网络分区;方案 C 的真实进程重启与“扣了没记”的少卖;数据库时钟跳变。503 次数、等待次数、确认与到期的划分随调度变化,不应作为可复现的数值引用。
本批导航
- 上一篇:27 测试账户的余额与账本
- 下一篇:29 订单、支付模拟与库存补偿
- 本批索引:仓库
batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md - 运行入口:
batch-3/examples/flash-sale/run-28.sh - 证据目录:
batch-3/evidence/28/ - 本篇工程与证据快照:backend-systems-28.tar.gz(SHA-256 见同目录
backend-systems-28.tar.gz.sha256)。正文中的examples/、evidence/、SOURCES.md路径都相对仓库的batch-3/目录,与快照包内布局一致。






