并发预约竞态条件分析

Concurrent Booking Race Condition — 2026-02-08 — 架构与稳定性

1. 问题本质:TOCTOU 竞态

TOCTOU(Time-of-Check vs Time-of-Use)是并发系统中最经典的竞态条件之一。在预约场景中表现为:

核心问题:冲突检查(SELECT)和数据写入(INSERT)之间存在时间窗口。两个并发请求可以同时通过检查,然后都写入成功,产生双重预约。

时序图:无锁情况下的竞态

t0
请求 A: SELECT 检查冲突 → 无冲突 ✅
t1
请求 B: SELECT 检查冲突 → 无冲突 ✅
t2
请求 A: INSERT 预约 → 成功
t3
请求 B: INSERT 预约 → 也成功 💥

关键点:在 t1 时刻,A 的 INSERT 尚未执行(或未 COMMIT),B 在默认 READ COMMITTED 隔离级别下看不到 A 未提交的数据,因此冲突检查返回"无冲突"。

2. 触发场景

场景概率严重性说明
两个客户同时预约同一技师同一时段 高峰时段热门技师,尤其在限时促销/折扣活动中
同一客户快速双击"确认预约"按钮 前端防抖不够或网络延迟导致重复提交
Kiosk + Web 同时预约 两个不同入口同时操作,无法通过前端互斥
"任意技师"自动分配 + 指定技师预约 自动分配逻辑与手动指定可能选中同一技师
团体预约 + 个人预约竞争同一技师 团体预约占多个时段,冲突面更大

3. 当前防护体系(修复前)

防护层机制位置能否防并发?问题
第 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 表中出现同一技师同一时段的双重预约。

4. 修复方案:Advisory Lock + EXCLUDE 约束

方案 C:pg_advisory_xact_lock(主防线)

原理:在事务开始后、冲突检查前,对 employee_id + date 组合加排他锁。同一技师同一天的所有预约请求被串行化,消除 TOCTOU 窗口。
-- 在 BEGIN 之后立即执行
SELECT pg_advisory_xact_lock(
  hashtext(employee_id || '::' || scheduled_date)
);
-- 锁在 COMMIT/ROLLBACK 时自动释放

时序图:有 Advisory Lock 的情况

t0
请求 A: BEGIN → advisory_lock(emp1::2026-02-08) → 获得锁 🔒
t1
请求 B: BEGIN → advisory_lock(emp1::2026-02-08) → 等待... ⏳
t2
请求 A: SELECT 检查 → INSERT → COMMIT → 释放锁
t3
请求 B: 获得锁 → SELECT 检查 → 发现冲突 ❌ → ROLLBACK

关键优势pg_advisory_xact_lock 绑定事务生命周期,COMMIT/ROLLBACK 时自动释放,不会死锁(除非应用层有循环依赖)。

方案 A:EXCLUDE USING gist 约束(终极兜底)

-- 需要先安装扩展
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'));
为什么两个都要?

5. 所有预约创建入口(6 个)

入口文件行号来源标识
公开预约(Web/Mobile)api/public/booking.js1782customer_web / customer_mobile
购物车预约api/cart_appointment.js374cart
管理后台创建api/appointments.js777admin
Booking Serviceservices/booking/booking-service.js169online_portal
Walk-in 签到services/checkinService.js415walkin
团体预约services/booking/group-booking-service.js58varied(被其他入口调用)
注意:团体预约服务是被 public/booking.js 和 cart_appointment.js 调用的,本身不是独立入口。但 advisory lock 需要在调用方(外层事务)中加,而非在 group-booking-service 内部加。

6. 业界更大规模并发下的挑战

当并发量从"美甲沙龙级"(几十 QPS)上升到"演唱会抢票级"(几万 QPS)时,会遇到以下问题:

6.1 热点行锁争用(Lock Contention)

当热门技师/资源被大量请求同时争抢时,advisory lock 或行锁会导致大量请求排队等待。表现为:响应时间飙升、连接池耗尽、级联超时。

6.2 数据库连接池耗尽

每个等待锁的请求都占用一个数据库连接。如果锁等待时间长(比如事务内有外部 API 调用),连接池会被占满,后续请求直接失败。

6.3 分布式环境下的一致性

当系统扩展到多个数据库实例(读写分离、分库分表)时,pg_advisory_lock 只在单个 PG 实例内生效,需要引入分布式锁。

6.4 超卖问题(Overselling)

类似电商秒杀的库存超卖。如果可用时段被视为"库存",高并发下可能多卖。

7. 业界最佳实践

规模方案代表适用场景
小型
(< 100 QPS)
数据库约束 + Advisory Lock 我们的方案 美甲/美容/牙科/小型诊所预约
中型
(100-1K QPS)
乐观锁(版本号)+ 数据库约束 Zenoti, Boulevard 连锁沙龙/Spa、中型医疗
大型
(1K-10K QPS)
Redis 分布式锁 + 消息队列 Mindbody, OpenTable 健身房/餐厅预约、在线教育
超大型
(10K+ QPS)
预扣库存 + 异步确认 + CQRS 12306, 大麦, Ticketmaster 演唱会/火车票/大型活动

各方案详解

乐观锁(Optimistic Locking)

每条记录带版本号,更新时 WHERE version = $expected,失败则重试。适合冲突率低的场景。不阻塞,但重试开销大。

UPDATE slots SET booked = true
WHERE id = $1 AND version = $2
RETURNING id;

Redis 分布式锁

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 和票务系统核心模式。需要处理预扣超时释放。

我们的选择:Advisory Lock + EXCLUDE 约束是美甲沙龙量级的最佳方案。简单可靠,不引入额外中间件依赖。如果未来扩展到连锁品牌(100+ 门店同时运营),可以考虑升级到 Redis 分布式锁。

8. 实施清单