这是审查报告里最后标「🔴 完全未查」的维度。查到了一个真实的超卖漏洞。
纪律不变:每条结论带判读依据,自己的误判也写进去。
| 项 | 结论 |
|---|---|
全仓 FOR UPDATE 使用 | ✅ 65 处(团队知道要加锁) |
| 酒店房量扣减 | 🔴 真实超卖漏洞 |
| 零售库存扣减 | ✅ 正确(FOR UPDATE + 事务内校验) |
DB 层 CHECK 约束(sold) | 🔴 0 个 ⇒ 无兜底 |
backend/app/routers/hotel.py · create_booking
余量校验(L39-53,事务内):
rows = await conn.fetch(
"SELECT biz_date, total, sold, status FROM room_calendar "
"WHERE merchant_id=$1 AND room_type_id=$2 AND biz_date >= $3 AND biz_date < $4", ...)
for i in range(nights_n):
total = int(r["total"]) if r else int(rt["total_rooms"])
sold = int(r["sold"]) if r else 0
if total - sold < rooms_count: # ← 读sold 的当前值
raise HTTPException(409, f"余量不足")
🔴 这个 SELECT 没有 FOR UPDATE ⇒ 读到的是快照值。
累加 sold(L1917-1924):
INSERT INTO room_calendar(..., total, sold, status)
VALUES($1,$2,$3,$4,$5,'open')
ON CONFLICT (merchant_id, room_type_id, biz_date)
DO UPDATE SET sold = room_calendar.sold + $5,
status = CASE WHEN room_calendar.sold + $5 >= room_calendar.total
THEN 'full' ELSE room_calendar.status END
🔴 无条件累加,没有 WHERE room_calendar.sold + $5 <= room_calendar.total。
T1 请求A: SELECT total,sold →读到 sold=9, total=10
→ 10-9 >= 1 ✓ 通过 → sold = 9+1 = 10 ✅ 正确
T2 请求B:(同时到达)
SELECT total,sold → **也读到 sold=9**(READ COMMITTED 快照)
→ 10-9 >= 1 ✓ **又通过**
→ sold = 10+1 = 11 🔴 超卖
根因:校验(SELECT)与累加(UPDATE)之间没有锁,且累加无条件。
事务是有的(conn.transaction()),但事务不防这种并发——它只保证原子性,不保证隔离下的读-改-写序列。
CHECK 约束: 0 ← 没有 sold <= total 的约束
唯一约束: 1(仅 room_calendar 三列的唯一键)
sold integer default 0
total integer default 1
default_transaction_isolation = read committed
应用层显式设 SERIALIZABLE = 无
⇒ READ COMMITTED 下,两个请求会读到同一个 sold 值。
--- 起点
total=2 sold=1 → 剩 1 间
--- 两个请求都做余量检查(照抄 hotel.py,无 FOR UPDATE)
两次读到 sold 分别为: 1 / 1 🔴 同一时刻读到同一个旧值
--- 请求A 累加(照抄 hotel.py 无条件累加)
A 之后: sold=2
--- 请求B 累加(它以为自己还有余量)
B 之后: sold=3 / total=2
--- 判定
超卖确认:卖出 3 间,只有 2 间 → 超 1 间
-- 带守卫的写法(方案B)
UPDATE room_calendar SET sold = sold + 1
WHERE room_type_id=1 AND biz_date='2026-11-01'
AND room_calendar.sold + 1 <= room_calendar.total;
-- 影响行数: UPDATE 0 ⇒ ��挡��
⇒ 加 WHERE sold + $5 <= total 后,第二单被数据库直接拒绝。
room_calendar 全表 82 行(= 基线)· 测试日期 0 行 · 超卖 0 行 · idle in transaction 0
prod health HTTP 200
| # | 错 | 怎么发现的 |
|---|---|---|
| 1 | 第一版复现选了错起始值(total=3, sold=1,两次+1 后正好=3)⇒ 测出「未超卖」,什么都没证明 | 输出写着「✅ 未超卖」⇒ 与推理矛盾 ⇒ 回查发现是我的场景设计错了 |
| 2 | 用两个真实连接跑 → ON CONFLICT DO UPDATE 本身会加行锁,两连接互相等锁,卡了 11 分钟,还留下 3 个 idle in transaction | 命令超时无输出 |
| 3 | SQL 里混了中文引号/转义 → 连错 3 次语法错误 | 每次都看报错才知道 |
⚠️ 第 2 条的教训:我为了"更真实"去开两个连接,结果制造了真实的生产库锁等待,
还留下 3 个占锁事务,必须手工 pg_terminate_backend 清掉。
⇒ 在生产库做验证,优先用「单连接 + ROLLBACK + 顺序执行」模拟,不要随意开并发连接。
backend/app/routers/retail.py · create_order
async with pool.acquire() as conn:
async with conn.transaction():
# 🔑 事务内行锁取商品(按 id 排序锁定,避免死锁)
recs = await conn.fetch(
"SELECT id, name, price_fen, stock, status, is_test FROM retail_products "
"WHERE id=ANY($1::bigint[]) AND merchant_id=$2 ORDER BY id FOR UPDATE", ...)
if int(rec["stock"]) < qty:
raise HTTPException(409, f"库存不足(现有 {rec['stock']},需 {qty})")
await conn.execute("UPDATE retail_products SET stock=$1, ...")
「锁 + 校验 + 改写」在同一事务内、同一行锁上 ⇒ 不可能超卖。
⇒ 零售的做法就是酒店该抄的。
| 零售 | 酒店 | |
|---|---|---|
| 事务 | ✅ | ✅ |
| 行锁 | ✅ FOR UPDATE | 🔴 无 |
| 校验用锁内数据 | ✅ | 🔴 用快照数据 |
| 扣减条件 | ✅ 受锁保护 | 🔴 无条件累加 |
同一项目、同一种业务、两种写法。 说明团队知道正确做法,只是没覆盖到酒店。
逐个查了同类累加,不是一处就停:
grep "sold = ... sold + | stock = ... stock + " 全仓 → 仅 hotel.py:1921 一处
| 模块 | 状态 |
|---|---|
retail.py 库存 | ✅ FOR UPDATE 正确 |
fleet.py 车队名额 | ✅ 无同类累加(未逐行判读) |
packages.py | ✅ 无同类累加 |
miniapp_workbench_redeem.py | ✅ 无同类累加 |
⚠️ fleet/packages 未逐行判读——只确认「没有 sold = sold + N 这种无守卫累加」。
room_calendar 共 82 行
sold > total 的行数 = 0
| 房型 | 日期 | sold/total | 风险 |
|---|---|---|---|
| 1 | 2026-08-17 | 8/8 | ⚪ 已过期 |
| 1 | 2026-09-23 | 5/5 | ⚪ 已过期 |
| 1 | 2026-09-25 | 3/3 | ⚪ 已过期 |
🔴 未来满房 = 0 行;未来差 1–2 间的 = 0 行 ⇒ 此刻没有可被超卖的在售房量。
| 维度 | 评级 |
|---|---|
| 存量超卖 | ✅ 无(0 行) |
| 当前可触发 | 🟡 低(未来无临界房量) |
| 代码漏洞本身 | 🔴 确凿(两个并发请求即可触发) |
| 何时会咬人 | 一旦有房量紧张(如total_rooms=3 卖到 2 间),两人同时下单即超卖 |
取消/退款时sold 会回退:hotel_sync.py:56
UPDATE room_calendar SET sold = GREATEST(sold - $3, 0), ...
⇒ 回退逻辑是有的(修复方案不需要动它,只需确保回退也走锁)。
方案 A(推荐,与零售一致):余量校验的 SELECT 加 FOR UPDATE
rows = await conn.fetch(
"SELECT biz_date, total, sold, status FROM room_calendar "
"WHERE ... ORDER BY biz_date FOR UPDATE", ...) # 🔑 锁住将被改动的行
⚠️ 必须 ORDER BY biz_date 排序锁(与零售同款做法)——否则多晚并发时可能死锁。
方案 B(兜底双保险):累加时加条件,冲突即拒
DO UPDATE SET sold = room_calendar.sold + $5, ...
WHERE room_calendar.sold + $5 <= room_calendar.total
-- 影响 0 行 ⇒ 抛 409 事务回滚
🔴 建议 A + B 都做:A 防超卖,B 防「锁失效时的意外」。
DB 层 CHECK 约束(最后一道防线,绕不过去):
ALTER TABLE room_calendar ADD CONSTRAINT ck_sold_le_total CHECK (sold <= total);
⚠️ 需先扫现存数据是否已有 sold > total(若有,得先修数据才能加约束)。
把零售的「事务内 FOR UPDATE + 校验」写成范式,加进
docs/代码审查标准与流程.md 的 BLOCKER 列表:
任何"检查余量→扣减"的序列,校验必须读到锁内的数据。
只在事务里、但没锁,等于没保护。
| 项 | 局限 |
|---|---|
| 超卖路径 | ✅ 由代码 + DB 约束双向证据确认,非推测 |
fleet / packages | ⚠️ 只确认「无同类累加」,未逐行判并发安全 |
| 现存数据是否已超卖 | 🔴 未查(SELECT ... WHERE sold > total) |
| 事务隔离级别 | ⚠️ 未查(若是 SERIALIZABLE,风险会小很多) |
我用正则 async def c_booking_create 定位函数,没找到,
一度以为「函数不存在 ⇒ 可能不是真实路径」。
真实函数名是 create_booking(SCOPE_MAP 里的 hotel.c_booking_create 是幂等 scope 名,不是函数名)。
⇒ 两套命名空间:幂等 scope 名 ≠ 函数名。
差点因为找错名字而漏掉整个超卖点。
建议立刻做两件事:
SELECT * FROM room_calendar WHERE sold > total —— 如果已经有超卖,得先处理存量然后查隔离级别 —— 若是默认 READ COMMITTED,那超卖是必然的(不是"理论上可能")。