预约系统设计与实现案例
这是一个完整的企业级预约系统案例,实际项目中可根据业务体量裁剪,核心是掌握 资源建模 → 时段切分 → 状态机流转 → 冲突控制 这条主线,从简单到复杂逐步演进。
业务场景说明
以“某线上门诊预约系统”为例,患者在线预约医生号源,医生按排班出诊,管理员可配置排班和号源数量。
核心用户角色与诉求:
| 角色 | 核心诉求 | 关键痛点 |
|---|---|---|
| 患者 | 快速找到可约时段 | 号源被抢、医生临时停诊 |
| 医生 | 控制接诊节奏 | 患者迟到、爽约、时间被碎片化 |
| 管理员 | 资源最大化利用 | 排班冲突、号源浪费 |
核心业务流程:
患者浏览排班 → 选择时段 → 确认预约 → 签到就诊 → 完成/爽约
↑ ↓
排班管理 ←———————— 管理员维护 ——————————
需求分析
1 核心功能
| 模块 | 功能点 |
|---|---|
| 排班管理 | 生成排班、停诊/复诊、号源配额设置 |
| 预约操作 | 查询号源、锁定号源、确认预约、取消预约 |
| 医生端 | 号源使用记录、预约列表、完成就诊 |
| 兼容能力 | 多医生、多科室、多院区,号源按天切分,固定时长或医生自定义时长 |
2 关键约束(业务规则)
- 同一患者在同一时间段不能重复预约同一医生
- 号源数量不能超过排班配额
- 取消预约需在就诊前 N 小时 操作
- 医生停诊后自动释放号源并通知患者
3 非功能需求
-
高并发:热门医生号源秒级释放,需防超卖
-
数据一致性:状态流转不可乱序
-
可追踪:所有操作留痕(操作日志)
-
性能要求: 热门医生放号QPS > 3000,接口P99延迟 < 200ms
-
可用性: 核心链路可用性 ≥ 99.95%
数据库设计
1 ER 图(核心实体)
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ doctor │ │ schedule │ │ appointment │
├─────────────┤ ├──────────────┤ ├─────────────┤
│ id │────▶│ id │────▶│ id │
│ name │ │ doctor_id │ │ schedule_id │
│ dept_id │ │ date │ │ patient_id │ │ │ start_time │ │ status │
│ status │ │ end_time │ │ create_time │
└─────────────┘ │ total_slots │ │ cancel_time │
│ booked_slots │ │ version │
│ status │ └─────────────┘
└──────────────┘
2 核心表结构(MySQL)
schedule(排班表) — 定义可预约的时段资源
CREATE TABLE `schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `dept_id` BIGINT NOT NULL COMMENT '科室ID', `schedule_date` DATE NOT NULL COMMENT '排班日期', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `total_slots` INT NOT NULL DEFAULT 0 COMMENT '总号源数', `booked_slots` INT NOT NULL DEFAULT 0 COMMENT '已预约数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1停诊', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_doctor_date` (`doctor_id`, `schedule_date`), KEY `idx_date_status` (`schedule_date`, `status`) ) ENGINE=InnoDB COMMENT='排班表';
appointment(预约记录表)
CREATE TABLE `appointment` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `schedule_id` BIGINT NOT NULL COMMENT '排班ID', `patient_id` BIGINT NOT NULL COMMENT '患者ID', `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `appt_date` DATE NOT NULL COMMENT '就诊日期', `appt_time` TIME NOT NULL COMMENT '就诊时间', `status` TINYINT NOT NULL COMMENT '0待就诊 1已完成 2已取消 3爽约', `cancel_reason` VARCHAR(255) DEFAULT NULL COMMENT '取消原因', `cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_schedule_id` (`schedule_id`), KEY `idx_patient_date` (`patient_id`, `appt_date`), KEY `idx_doctor_date` (`doctor_id`, `appt_date`) ) ENGINE=InnoDB COMMENT='预约记录表';
业务提示:不要用状态字段的值来倒推“剩余号源数”,因为取消操作会让统计失真,应始终以
booked_slots为唯一准绳,同理,不要通过扫描 appointment 表统计已约数量——那样在高并发下无法保证正确性。
停诊通知记录表(用于停诊时通知患者,记录触达方式)
CREATE TABLE `appointment_notification` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `appointment_id` BIGINT NOT NULL, `patient_id` BIGINT NOT NULL, `notify_type` TINYINT NOT NULL COMMENT '1短信 2App推送 3公众号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未发送 1已发送 2失败', `retry_count` INT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_appt` (`appointment_id`) ) ENGINE=InnoDB COMMENT='停诊通知记录';
核心流程设计
1 预约时序图
客户端 API层 Service层 DB
│ ①请求预约 │ │ │
│────────────────▶│ ②校验参数 │ │
│ │────────────────▶│ │
│ │ │ ③SELECT排班(带版本) │
│ │ │──────────────────────▶│
│ │ │◀──────────────────────│
│ │ │ ④检查号源&重复预约 │
│ │ │ │
│ │ │ ⑤UPDATE (version+1) │
│ │ │ WHERE version=? │
│ │ │──────────────────────▶│
│ │ │◀── 影响行数=1 成功 │
│ │ │ ⑥INSERT 预约记录 │
│ │ │──────────────────────▶│
│◀─── 成功/失败 ──│◀─── 返回结果 ───│ │
代码实现
1 防超卖:乐观锁更新
@Transactional
public Long createAppointment(Long scheduleId, Long patientId) {
Schedule schedule = scheduleMapper.selectById(scheduleId);
// 1. 校验排班状态
if (schedule.getStatus() != ScheduleStatus.NORMAL) {
throw new BusinessException("排班已停诊");
}
// 2. 校验号源
if (schedule.getBookedSlots() >= schedule.getTotalSlots()) {
throw new BusinessException("号源已满");
}
// 3. 防重复预约(同医生,同日期,未取消状态)
int repeatCount = appointmentMapper.countByPatientAndDoctor(
patientId, schedule.getDoctorId(), schedule.getScheduleDate()
);
if (repeatCount > 0) {
throw new BusinessException("您已预约过该医生,请勿重复预约");
}
// 4. 乐观锁扣减号源
int rows = scheduleMapper.decreaseSlot(scheduleId, schedule.getVersion());
if (rows == 0) {
throw new BusinessException("手速太慢,号源已被抢完,请刷新页面重试");
}
// 5. 创建预约记录(幂等键防重并发插入)
Appointment appointment = new Appointment();
appointment.setScheduleId(scheduleId);
appointment.setPatientId(patientId);
appointment.setStatus(AppointmentStatus.PENDING);
appointment.setIdempotentKey(IdGenerator.generate(patientId, scheduleId));
appointmentMapper.insert(appointment);
return appointment.getId();
}
对应的 Mapper 方法(乐观锁关键 SQL):
-- 乐观锁扣减:version 匹配才允许更新
UPDATE schedule
SET booked_slots = booked_slots + 1, version = version + 1
WHERE id = #{scheduleId}
AND version = #{version}
AND booked_slots < total_slots -- 兜底防超卖
关于防重复预约的并发问题:
countByPatientAndDoctor查询在并发下存在“窗口期”——同一时刻两个请求都查到 0 条,导致重复预约。推荐方案:在appointment表上建立唯一约束(patient_id, doctor_id, appt_date, status),插入时使用ON DUPLICATE KEY UPDATE id = id,受影响行数为 0 即为重复请求,这样数据库层直接拦截,无需依赖查询结果。
2 取消预约
@Transactional
public void cancelAppointment(Long appointmentId, Long patientId, String reason) {
Appointment appointment = appointmentMapper.selectById(appointmentId);
// 1. 归属校验
if (!appointment.getPatientId().equals(patientId)) {
throw new BusinessException("无权操作他人预约");
}
// 2. 状态校验
if (appointment.getStatus() != AppointmentStatus.PENDING) {
throw new BusinessException("当前状态不可取消");
}
// 3. 时间校验:就诊前N小时(此处为2小时)
LocalDateTime deadline = LocalDateTime.of(appointment.getApptDate(),
appointment.getApptTime()).minusHours(2);
if (LocalDateTime.now().isAfter(deadline)) {
throw new BusinessException("距就诊不足2小时,无法在线取消,请联系科室");
}
// 4. 更新状态
appointment.setStatus(AppointmentStatus.CANCELLED);
appointment.setCancelReason(reason);
appointment.setCancelTime(LocalDateTime.now());
appointmentMapper.updateById(appointment);
// 5. 释放号源(booked_slots - 1)
scheduleMapper.releaseSlot(appointment.getScheduleId());
}
3 停诊处理
@Transactional
public void closeSchedule(Long scheduleId) {
// 1. 更新排班状态为停诊
scheduleMapper.updateStatus(scheduleId, ScheduleStatus.CLOSED);
// 2. 查询所有待就诊预约
List<Appointment> pendingAppointments =
appointmentMapper.selectByScheduleAndStatus(scheduleId, AppointmentStatus.PENDING);
// 3. 批量取消+推送通知
for (Appointment appt : pendingAppointments) {
appt.setStatus(AppointmentStatus.CANCELLED);
appt.setCancelReason("医生停诊");
appointmentMapper.updateById(appt);
// 异步推送通知(可引入消息队列)
notifyService.sendStopClinicNotice(appt);
}
}
高并发场景进阶方案
当业务量增长,纯数据库乐观锁方案遇到瓶颈时,可按以下路径逐步演进,每一级都会引入新的复杂度,请按真实业务量选型,不要在一开始就上缓存+队列:
| 阶段 | 方案 | 支撑QPS | 代价复杂度 |
|---|---|---|---|
| 第一阶段 | 数据库乐观锁(前述方案) | ~1-2k | 低,可靠 |
| 第二阶段 | Redis预扣库存 + MQ异步削峰 | ~5-20k | 中,需处理缓存一致性、回滚、延迟 |
| 第三阶段 | Redis原子队列 + Lua脚本 + 异步落库 | ~50k+ | 高,需防丢失、补偿 |
Redis 预扣 + MQ 异步落库(推荐)
适用范围:热点医生放号瞬间并发超过 2k QPS 时,由数据库乐观锁(单行更新串行化)升级而来,核心思路是将“扣库存”这个热点操作前移到 Redis,保留“创建预约记录”的准确性。
时序流程:
客户端 → 预约接口
├── 1. Lua脚本原子扣减 Redis 库存(slot:{scheduleId})
├── 2. 扣减成功 → 发送 MQ 消息(预约中),返回"预约中"
└── 3. MQ消费者 → 校验业务规则 → 写入 DB(最终一致)
├── 成功 → 更新 Redis 记录状态为"已确认"
└── 失败 → 补偿:Redis 库存回滚 +1
Lua 脚本(原子扣减):
-- KEYS[1]: slot:{scheduleId} (剩余号源数)
-- KEYS[2]: user:{patientId}:{scheduleId} (用户幂等标记)
-- ARGV[1]: 用户ID
local booked = redis.call('EXISTS', KEYS[2])
if booked == 1 then
return 'DUPLICATE' -- 重复预约
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock <= 0 then
return 'SOLD_OUT' -- 已抢完
end
redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[2], 1, 'EX', 3600) -- 1小时过期
return 'OK'
⚠️ 常规 Redis 方案存在数据不一致的风险点:Redis 扣减成功但 MQ 消费者判定业务规则不通过时,需要回滚 Redis 库存;MQ 消息丢失(极端情况)会造成库存扣减但无预约记录,请配合对账任务(每5分钟扫描 Redis 库存 vs DB 已确认数,差值超过阈值自动回滚)。
何时使用:只有当数据库乐观锁方案开始出现明显的行锁等待(QPS 超过 2k)时才引入,普通业务量——如小型诊所、企业内部预约系统——请不要引入 Redis/MQ,直接用乐观锁即可。
Redis 原子队列 + Lua + 异步落库
当单医生号源 QPS 超过 2 万时,Redis 单节点 DECR 操作也会成为瓶颈,此时可把每个排班的号源拆分为 N 个队列,用户请求时以轮询或哈希分片方式选择一个队列进行 LPUSH,Lua 脚本校验 LLEN < 总量 且 LPUSH 以 O(1) 完成入队,完全消除对单把大锁的争用,后续 Worker 从队列中批量消费并写库,整条链路库存扣减不再依赖任何串行化操作。
注意事项:该方案的落地校验较重(入队成功≠预约成功),且仍需依赖对账任务兜底,普通业务不建议使用,判断标准是——你的单号源并发是否真的达到了单 Redis 实例的极限。
缓存/异步方案的通用防坑清单
| 风险点 | 说明 | 缓解手段 |
|---|---|---|
| MQ 消息丢失 | 库存已减但没有预约记录 | 对账任务定期比对 Redis 剩余 vs DB 已确认,差值超阈值自动回滚 |
| 业务校验不通过 | 违规预约占用了库存 | 消费者校验失败后调用 Lua 回滚脚本(INCR + DEL 幂等标记) |
| Redis 重启丢失 | 重启后库存数据丢失 | 启动时从 DB 全量同步一次库存到 Redis |
| 消费延迟 | 消息堆积导致预约未确认 | 监控 MQ 积压量,超过阈值触发消费者扩容与报警 |
| 订单过期未支付 | 占库存不履约 | 延迟消息(30分钟)自动取消并回滚库存 |
❗ 特别提示:Redis/MQ 方案引入的“对账、补偿、幂等”复杂度,在小流量下完全是无意义的成本,本案例的数据库乐观锁方案,配合
booked_slots < total_slots的条件更新,已经能保证「不超卖」这一硬性要求——它牺牲的是极高并发下的用户体验(大量请求失败重试),而不是正确性,先跑通业务,再考虑优化吞吐。
系统架构图
┌─────────────────────────────────────────────────────────┐
│ 客户端 (App / H5 / 小程序) │
└──────────────────────────┬──────────────────────────────┘
│ HTTPS
┌──────────────────────────▼──────────────────────────────┐
│ API Gateway (Nginx) │
│ 鉴权 / 限流 / 路由 │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ Application Service │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ 预约服务 │ │ 排班服务 │ │ 通知服务 │ │
│ │ ·创建预约 │ │ ·生成排班 │ │ ·短信/推送 │ │
│ │ ·取消预约 │ │ ·停诊/复诊 │ │ ·消息模板 │ │
│ │ ·号源查询 │ │ ·配额管理 │ └─────────────────┘ │
│ └─────────────┘ └─────────────┘ │
└──────────┬───────────────────────────┬──────────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────────────┐
│ Redis │ │ MySQL (主从) │
│ · 临时库存(可选) │ │ · 排班表 / 预约表 / 医生表 │
│ · 幂等标记 │ │ · 操作日志表 │
│ · 分布式锁 │ └──────────────────────────────┘
└─────────────────────┘
│
┌──────────▼──────────────────────────────────────────────┐
│ MQ (RocketMQ / Kafka) │
│ · 异步通知短信 · 预约结果异步落库 · 对账补偿任务 │
└─────────────────────────────────────────────────────────┘
状态机设计
预约状态流转:
创建预约 签到 完成就诊
PENDING ────────▶ ARRIVED ───────▶ COMPLETED
│ │
│ 用户取消 │ 超时未到
▼ ▼
CANCELLED NO_SHOW (爽约)
▲
│
└──── 医生停诊自动取消
状态机实现(推荐用状态机模式,防止乱序流转) :
public enum AppointmentStateMachine {
INSTANCE;
private final Map<AppointmentStatus, Set<AppointmentStatus>> transitions = new HashMap<>();
AppointmentStateMachine() {
// 允许的流转路径
transitions.put(PENDING, EnumSet.of(ARRIVED, CANCELLED));
transitions.put(ARRIVED, EnumSet.of(COMPLETED, NO_SHOW));
transitions.put(CANCELLED, EnumSet.noneOf(AppointmentStatus.class)); // 终态
transitions.put(COMPLETED, EnumSet.noneOf(AppointmentStatus.class));
transitions.put(NO_SHOW, EnumSet.noneOf(AppointmentStatus.class));
}
public void validateTransition(Appointment from, AppointmentStatus target) {
Set<AppointmentStatus> allowed = transitions.get(from.getStatus());
if (allowed == null || !allowed.contains(target)) {
throw new BusinessException(String.format(
"非法的状态流转: %s → %s", from.getStatus(), target));
}
}
}
不推荐使用全局状态机框架(如 Spring StateMachine):预约系统状态流转路径简单,手写枚举状态机(约 50 行代码)足够,引入框架会带来大量配置成本且难以调试,状态机框架更适用于订单、审批流等复杂状态场景。
性能优化与压测结果
1 索引设计
| 场景 | 索引 | 说明 |
|---|---|---|
| 按医生+日期查排班 | idx_doctor_date(doctor_id, schedule_date) |
最常用查询 |
| 按日期+状态查排班 | idx_date_status(schedule_date, status) |
首页展示 |
| 按患者查预约 | idx_patient_date(patient_id, appt_date) |
我的预约 |
| 按排班查预约 | idx_schedule_id(schedule_id) |
医生端查看 |
2 压测参考数据
| 场景 | 并发数 | QPS | P99延迟 | 错误率 |
|---|---|---|---|---|
| 查询排班列表 | 500 | ~2000 | 80ms | 0% |
| 创建预约(乐观锁) | 300 | ~1200 | 120ms | <0.5% |
| 创建预约(Redis+Mysql) | 1000 | ~5000 | 90ms | 1% |
压测数据说明:以上数据为 4C8G 单机、MySQL 默认配置下的参考值,实际数值受部署环境、网络、磁盘 IO 影响较大,请结合自身环境重新测量。
异常场景处理
1 重复提交
// 方案一:数据库唯一索引(推荐,最可靠)
ALTER TABLE `appointment`
ADD UNIQUE KEY `uk_patient_doctor_date` (`patient_id`, `doctor_id`, `appt_date`);
// 方案二:Redis SetNX 防重(引入缓存时的前置拦截)
String key = "appt:repeat:" + patientId + ":" + scheduleId;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofMinutes(30));
if (!Boolean.TRUE.equals(first)) {
throw new BusinessException("请勿重复提交,正在处理中...");
}
2 超时释放(占座不支付)
预约系统不一定涉及“支付”动作,但可能涉及“占号不就诊”的情况,可设计锁定号源超时自动释放机制:患者在某个时段内未完成确认,系统自动释放号源。
实现方式(延迟消息) :
// 预约创建后发送延迟消息(30分钟)
mqTemplate.syncSend("appointment-timeout-topic",
appointment.getId(),
MessageBuilder.withPayload(appointment.getId()).build(),
30 * 60 * 1000); // 延迟30分钟
3 分布式事务(最终一致性)
场景:创建预约 + 扣减号源 + 发送通知,三者不能同时成功或失败。
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 本地事务 | 同库操作(预约+号源扣减) | 单库部署 |
| MQ 异步 | 通知/日志异步发送 | 非核心链路 |
| 本地消息表 | 核心操作后落消息表,定时扫表推送 | 需可靠通知 |
| Seata TCC | 跨服务强一致 | 多服务独立库 |
十一、运营后台:作为预约系统不可分割的一部分
1 核心功能模块
| 模块 | 功能点 |
|---|---|
| 医生管理 | 医生信息维护、科室归属、执业状态 |
| 排班管理 | 按周模板生成排班、临时停诊/复诊、号源配额调整 |
| 预约管理 | 全量预约查询、异常处理(代取消/改期) |
| 统计报表 | 号源利用率、爽约率、医生工作量统计 |
| 黑名单管理 | 爽约患者标记、预约限制 |
| 操作日志 | 所有后台操作留痕,可追溯 |
2 关键页面原型示意
排班周视图:
| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 |
|---|---|---|---|---|---|
| 08:30-09:00 | 张医生 (12/15) | 张医生 (停诊) | 李医生 (15/15) | 张医生 (3/15) | 王医生 (10/15) |
| 09:00-09:30 | 张医生 (15/15) | 张医生 (2/15) | 李医生 (10/15) | 张医生 (0/15) | 王医生 (5/15) |
| 10:00-10:30 | 空缺 | 李医生 (8/15) | 空缺 | 李医生 (15/15) | 张医生 (1/15) |
数字含义:已预约/总号源,停诊状态用灰色标记。
3 关键统计指标
今日预约量 | 本周号源利用率 | 本月爽约率 | 医生排班饱和度
128人 | 86.5% | 3.2% | 92%
- 号源利用率 = 已预约总数 / 总排班号源数
- 爽约率 = 爽约数 / (已完成 + 爽约 + 取消)
- 排班饱和度 = 已排班时长 / (全院医生 × 工作日时长)
十二、总结与核心要点
设计预约系统的核心要点:
- 号源扣减防超卖 — 乐观锁 + 条件更新兜底,高并发时用 Redis 预扣减
- 状态流转要收敛 — 状态机模式限制非法流转,明确终态
- 重复预约要拦截 — 数据库唯一索引兜底,接口层幂等键防重
- 取消/停诊要释放号源 — 使用异步补偿,保持数据最终一致
- 缓存双写一致性 — 更新数据库后主动失效缓存,设置过期兜底
- 操作可追溯 — 用户端操作日志 + 后台审计日志
推荐演进路线
单体 + MySQL乐观锁 → 单体+ Redis缓存/MQ → 微服务化(按需)
(日活千级) (日活万级) (日活百万级)
切忌一上来就做微服务+分布式事务,先保证核心链路正确,再考虑架构演进。
