场馆预约系统容易挂在「看似有库存」
球场、泳池、排练厅、会议室式运动场馆,业务看起来就是选场地选时段下单。真正的故障集中在:超卖、候补乱序、支付成功却无预约单、到场核销与订单态不一致、天气停场退款算不清、教练/陪练资源与场地库存双锁死锁。小程序首页再漂亮,也救不了库存锁与状态机的结构性问题。
本文按阿里云拼装思路,把场馆预约系统拆成 领域分层、预约单状态机、库存与候补、表结构、下单到核销时序、退改签与失败模式、观测。不编造场馆营收数字,只谈工程如何保证「同一块场地同一个时段只卖出约定容量」。

领域分层
| 分层 | 职责 | 阿里云侧常见落点 |
|---|---|---|
| 接入层 | 鉴权、限流、防刷 | API 网关、WAF |
| 目录域 | 场馆、场地、价格日历 | RDS + Redis |
| 库存域 | 时段库存、锁、释放 | Redis + RDS |
| 交易域 | 预约单、支付、退款 | 应用服务 + 消息队列 |
| 候补域 | 候补队列、兑现 | RocketMQ/MNS + 定时 |
| 核销域 | 入场码、闸机/人工核销 | RDS + 对象存储(凭证) |
| 资源域 | 教练/器材附加资源 | 与场地库存协同锁定 |
| 通知域 | 预约成功、停场、候补成功 | 队列 + 短信/订阅消息 |
硬原则:
- 库存预扣与支付成功确认分离——预扣有 TTL;支付回调确认落永久占用。
- 候补不是轮询改库存——取消/释放事件驱动候补兑现。
- 核销只改变履约态,不回写 invent 以外的售卖真相——售卖以订单与库存分录为准。
预约单状态机
CREATED:已建单,待预扣HOLDING:库存已预扣,待支付PENDING_PAY:拉起支付PAID:支付成功,预约生效CHECKED_IN:已核销入场COMPLETED:履约结束CANCELLED:用户/系统取消(需释放库存)CLOSED_TIMEOUT:支付超时关单并释放REFUNDING/REFUNDED:退款中/完成SUSPENDED:停场挂起(待改期或退款)
候补单独立:WAITLISTED → OFFERED(短时兑现窗)→ ACCEPTED/EXPIRED/CANCELLED。
约束:HOLDING 超时必须自动释放且幂等;PAID 后取消走退款策略与库存回补事件;停场不要直接物理删订单。

库存模型与锁
推荐「资源槽」模型:(venue_id, space_id, slot_start, slot_end) → capacity、sold、held。
预扣算法:
- Redis Lua 或 DB 条件更新:
held = held + nonly ifsold+held+n <= capacity。 - 写入
hold_record(order_id, slots, expire_at, idem_key)。 - 支付成功:
held -= n; sold += n,写库存分录。 - 超时/取消:
held -= n或sold -= n(视状态),发StockReleased。
附加教练资源:使用 按资源类型排序的顺序锁(例如先锁场地再锁教练),避免 ABBA 死锁;失败全额回滚预扣。
价格日历版本化:下单快照 price_version 与金额,后续改价不影响已支付单。
表结构草图
venue/space:场馆与场地slot_inventory:时段库存booking_order:预约单(用户、金额、状态、idem_key、price_snapshot)booking_item:明细(space_id、时段、数量)hold_record:预扣waitlist_entry:候补(优先级、入队时间、状态)checkin_ticket:入场核销码(order_id、code_hash、status)refund_order:退款audit_log
唯一:下单幂等键;核销码哈希;支付 pay_request_id。

时序:下单到入场
- 用户选场地时段,
POST /bookings带幂等键 → 预扣成功HOLDING→ 返回支付参数。 - 支付成功回调:校验金额与订单态,转
PAID,生成入场码,发通知。 - 到场:扫码或闸机,
CHECKED_IN;重复扫码返回已核销。 - 用户取消:校验取消窗,创建退款,库存回补,触发候补兑现。
- 停场通知:订单
SUSPENDED,用户选改期(新库存预扣)或退款。
候补兑现:监听 StockReleased,按优先级弹出 OFFERED,短 TTL 内支付;失败则下一名。
失败模式
- 支付回调早于建单可见:回调处理器需可重试;以
pay_request_id幂等。 - 预扣成功支付失败:TTL 释放;用户重复下单用新幂等键或复用未过期 hold。
- 候补超卖:兑现时再次原子预扣,失败则跳过该人。
- 核销码转发:码绑定用户与当日场次,核销需登录态或二次门禁。
- 部分场地联票:明细行分别占库存,退款按行策略。
- 时钟偏差:时段边界以服务器时区为准,前端只展示。
阿里云拼装提示
- 热点时段库存放 Redis,RDS 做权威对账与日终校正。
- 支付与退款回调进 RocketMQ/MNS,消费者更新订单。
- 入场码与协议 PDF/图片可放 OSS。
- 日志进 SLS:预扣失败、回调延迟、候补转化。
函数计算适合 TTL 扫描与候补弹出;核心下单路径建议容器服务以保证冷启动不受影响。
改期、联票与停场规则工程化
场馆预约系统上线后,产品规则会持续变:下雨停场、会员可改期一次、工作日晚间不可退、亲子场需两张入场码。不要把规则散落在 if-else 页面里,建议:
policy_version:取消窗、改期次数、停场处置(自动退 / 优先改期)、违约金计算公式。- 下单时绑定
policy_version;事后改政策只影响新单。 - 改期 = 对原单作「释放 + 新持有」同一编排事务;新库存预扣失败则保持原预约。
- 联票/套餐拆成
booking_item多行,退款按行计算,避免整单金额对不上场地明细。
停场事件应由运营发布「空间 + 时段」影响面,系统批量将命中订单打入 SUSPENDED 并通知;禁止手工逐单改状态无事件。
接口示例
POST /bookings下单预扣POST /bookings/{id}/pay-callback(或支付网关通知转内部)POST /bookings/{id}/cancelPOST /bookings/{id}/reschedulePOST /checkins核销POST /waitlist
事件:StockHeld、BookingPaid、StockReleased、WaitlistOffered、CheckedIn。
观测与测试
指标:预扣成功率、支付转化、超卖次数(应为 0)、候补兑现率、核销成功率、退款时效、TTL 释放延迟。
测试:并发下单抢最后库存、支付超时释放、回调重复、候补两人竞态、改期失败回滚、停场批量挂起、核销重放、联票部分退。
核销码与闸机对接
入场码不要存明文可逆值在日志里:存 code_hash,向用户展示短码或二维码 JWT(含 order_id、exp、签名)。闸机或验票端:
- 验签与过期。
- 条件更新
checkin_ticket为已核销(乐观锁或状态机守卫)。 - 返回场地与时段给闸机屏;失败原因码区分「未支付 / 已核销 / 非本场馆 / 未到入场窗」。
人工核销台共用同丁 API,额外写 operator_id。重复扫返回成功态幂等,避免闸机重试造成客诉「怎么失败了」。
会员卡与课时包如何嵌进预约
很多场馆预约系统还叠「会员免费时段」「课时抵扣」。建议:
- 下单时选择支付方式:
CASH/MEMBER_BENEFIT/MIXED。 - 权益扣减走独立账本,与场地库存预扣同一编排:要么都成功,要么都回滚。
- 混合支付先锁库存,再锁权益,再拉起现金差额支付;任一失败按逆序释放。
不要把「会员剩余次数」与 slot_inventory.sold 写进同一行更新,职责混杂后很难对账。
报表与日终校正
Redis 热库存可能与 RDS 漂移(进程崩溃未释放等)。日终任务:
- 扫描未过期
hold_record应已释放却未释放的行,补偿释放。 - 用
booking_item中PAID/CHECKED_IN/COMPLETED重算 sold,输出差异。 - 差异进工单,禁止无审计自动改 sold 除非差异模型置信度极高且双人确认。
阿里云上可用函数计算定时触发,结果写入 RDS 与 SLS。
防刷与黄牛
热点赛事或周末晚间场:
- 下单接口按用户与设备限流。
- 未支付关单过快的账号提高验证强度。
- 候补与购买共用实名与风控标记。
- 对「多次占坑不支付」计入信用,缩短 HOLD TTL 或限制并发 hold 数。
这些是产品+风控策略,技术要提供计数与配置版本,而不是写死魔法数。
对多场馆集团,建议把「组织-e��馆-场地」做成租户内层级,库存与报表按场馆过滤;跨场馆通兑会员则在权益账本上表达,而不是把所有场地库存合成一张大表。这样某馆停业切流时,不必重写交易域。
小结
场馆预约系统的技术核心是 可过期的库存持有、支付确认的原子占坑、候补事件化兑现、核销与退改可审计。阿里云上 Redis + RDS + 队列 + 网关足够拼出稳定底座;先把状态机与锁顺序写对,再叠加会员营销与智能推荐也不迟。