超卖 / 并发安全审查 · 2026-10-09 10:45

这是审查报告里最后标「🔴 完全未查」的维度。查到了一个真实的超卖漏洞。
纪律不变:每条结论带判读依据,自己的误判也写进去。

一、总结论

项结论
全仓 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()),但事务不防这种并发——它只保证原子性,不保证隔离下的读-改-写序列。

🔴 DB 层无兜底(生产库实测)

CHECK 约束: 0        ← 没有 sold <= total 的约束
唯一约束: 1(仅 room_calendar 三列的唯一键)
sold integer default 0
total integer default 1

三、🔬 实证复现(在生产库跑,全程 ROLLBACK)

隔离级别已确认

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命令超时无输出
3SQL 里混了中文引号/转义 → 连错 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风险
12026-08-178/8⚪ 已过期
12026-09-235/5⚪ 已过期
12026-09-253/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 名 ≠ 函数名。

差点因为找错名字而漏掉整个超卖点。

十、下一步

建议立刻做两件事:

  1. 扫现存数据:SELECT * FROM room_calendar WHERE sold > total —— 如果已经有超卖,得先处理存量
  2. 给酒店加锁(方案 A+B)

然后查隔离级别 —— 若是默认 READ COMMITTED,那超卖是必然的(不是"理论上可能")。