Concurrent Booking Race Condition — 2026-02-08 — 架构与稳定性
TOCTOU(Time-of-Check vs Time-of-Use)是并发系统中最经典的竞态条件之一。在预约场景中表现为:
关键点:在 t1 时刻,A 的 INSERT 尚未执行(或未 COMMIT),B 在默认 READ COMMITTED 隔离级别下看不到 A 未提交的数据,因此冲突检查返回"无冲突"。
| 场景 | 概率 | 严重性 | 说明 |
|---|---|---|---|
| 两个客户同时预约同一技师同一时段 | 中 | 高 | 高峰时段热门技师,尤其在限时促销/折扣活动中 |
| 同一客户快速双击"确认预约"按钮 | 高 | 中 | 前端防抖不够或网络延迟导致重复提交 |
| Kiosk + Web 同时预约 | 中 | 高 | 两个不同入口同时操作,无法通过前端互斥 |
| "任意技师"自动分配 + 指定技师预约 | 中 | 高 | 自动分配逻辑与手动指定可能选中同一技师 |
| 团体预约 + 个人预约竞争同一技师 | 低 | 高 | 团体预约占多个时段,冲突面更大 |
| 防护层 | 机制 | 位置 | 能否防并发? | 问题 |
|---|---|---|---|---|
| 第 1 层 | 应用层冲突检测 | conflict-detector.js |
不能 | TOCTOU 漏洞:检查和写入之间无锁 |
| 第 2 层 | 数据库事务 BEGIN/COMMIT | booking.js:1777 |
不能 | READ COMMITTED 隔离级别,不阻止并发 INSERT |
| 第 3 层 | EXCLUDE USING gist 约束 | employee_available_times 表 |
间接 | 约束在 available_times 表上,不在 appointments 表上;且同步有延迟 |
appointments 表中出现同一技师同一时段的双重预约。
employee_id + date 组合加排他锁。同一技师同一天的所有预约请求被串行化,消除 TOCTOU 窗口。
-- 在 BEGIN 之后立即执行
SELECT pg_advisory_xact_lock(
hashtext(employee_id || '::' || scheduled_date)
);
-- 锁在 COMMIT/ROLLBACK 时自动释放
关键优势:pg_advisory_xact_lock 绑定事务生命周期,COMMIT/ROLLBACK 时自动释放,不会死锁(除非应用层有循环依赖)。
-- 需要先安装扩展
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- 在 appointments 表上添加排斥约束
ALTER TABLE appointments ADD CONSTRAINT exclude_employee_time_overlap
EXCLUDE USING gist (
employee_id WITH =,
tsrange(scheduled_datetime, end_datetime) WITH &&
) WHERE (status NOT IN ('cancelled', 'no_show', 'void'));
| 入口 | 文件 | 行号 | 来源标识 |
|---|---|---|---|
| 公开预约(Web/Mobile) | api/public/booking.js | 1782 | customer_web / customer_mobile |
| 购物车预约 | api/cart_appointment.js | 374 | cart |
| 管理后台创建 | api/appointments.js | 777 | admin |
| Booking Service | services/booking/booking-service.js | 169 | online_portal |
| Walk-in 签到 | services/checkinService.js | 415 | walkin |
| 团体预约 | services/booking/group-booking-service.js | 58 | varied(被其他入口调用) |
当并发量从"美甲沙龙级"(几十 QPS)上升到"演唱会抢票级"(几万 QPS)时,会遇到以下问题:
当热门技师/资源被大量请求同时争抢时,advisory lock 或行锁会导致大量请求排队等待。表现为:响应时间飙升、连接池耗尽、级联超时。
每个等待锁的请求都占用一个数据库连接。如果锁等待时间长(比如事务内有外部 API 调用),连接池会被占满,后续请求直接失败。
当系统扩展到多个数据库实例(读写分离、分库分表)时,pg_advisory_lock 只在单个 PG 实例内生效,需要引入分布式锁。
类似电商秒杀的库存超卖。如果可用时段被视为"库存",高并发下可能多卖。
| 规模 | 方案 | 代表 | 适用场景 |
|---|---|---|---|
| 小型 (< 100 QPS) |
数据库约束 + Advisory Lock | 我们的方案 | 美甲/美容/牙科/小型诊所预约 |
| 中型 (100-1K QPS) |
乐观锁(版本号)+ 数据库约束 | Zenoti, Boulevard | 连锁沙龙/Spa、中型医疗 |
| 大型 (1K-10K QPS) |
Redis 分布式锁 + 消息队列 | Mindbody, OpenTable | 健身房/餐厅预约、在线教育 |
| 超大型 (10K+ QPS) |
预扣库存 + 异步确认 + CQRS | 12306, 大麦, Ticketmaster | 演唱会/火车票/大型活动 |
每条记录带版本号,更新时 WHERE version = $expected,失败则重试。适合冲突率低的场景。不阻塞,但重试开销大。
UPDATE slots SET booked = true
WHERE id = $1 AND version = $2
RETURNING id;
用 SET key NX EX ttl 实现。跨实例互斥。但需要处理锁续期(Redlock 或 Lua 脚本)、节点故障等复杂场景。
const lock = await redis.set(
`lock:emp:${empId}:${date}`,
requestId, 'NX', 'EX', 30
);
所有预约请求进入队列(如 SQS/RabbitMQ),按 employee_id 分区。消费者串行处理,彻底消除竞态。延迟增加,但吞吐量可控。
先在 Redis 中原子扣减库存(DECR),成功后异步写入数据库。12306 和票务系统核心模式。需要处理预扣超时释放。
btree_gist 扩展appointments 表添加 EXCLUDE 约束booking-lock.js 工具模块(封装 advisory lock)