预约系统案例

wen java案例 2

预约系统设计与实现案例

这是一个完整的企业级预约系统案例,实际项目中可根据业务体量裁剪,核心是掌握 资源建模 → 时段切分 → 状态机流转 → 冲突控制 这条主线,从简单到复杂逐步演进。

预约系统案例

业务场景说明

以“某线上门诊预约系统”为例,患者在线预约医生号源,医生按排班出诊,管理员可配置排班和号源数量。

核心用户角色与诉求:

角色 核心诉求 关键痛点
患者 快速找到可约时段 号源被抢、医生临时停诊
医生 控制接诊节奏 患者迟到、爽约、时间被碎片化
管理员 资源最大化利用 排班冲突、号源浪费

核心业务流程

患者浏览排班 → 选择时段 → 确认预约 → 签到就诊 → 完成/爽约
     ↑                                      ↓
  排班管理 ←———————— 管理员维护 ——————————

需求分析

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%
  • 号源利用率 = 已预约总数 / 总排班号源数
  • 爽约率 = 爽约数 / (已完成 + 爽约 + 取消)
  • 排班饱和度 = 已排班时长 / (全院医生 × 工作日时长)

十二、总结与核心要点

设计预约系统的核心要点:

  1. 号源扣减防超卖 — 乐观锁 + 条件更新兜底,高并发时用 Redis 预扣减
  2. 状态流转要收敛 — 状态机模式限制非法流转,明确终态
  3. 重复预约要拦截 — 数据库唯一索引兜底,接口层幂等键防重
  4. 取消/停诊要释放号源 — 使用异步补偿,保持数据最终一致
  5. 缓存双写一致性 — 更新数据库后主动失效缓存,设置过期兜底
  6. 操作可追溯 — 用户端操作日志 + 后台审计日志

推荐演进路线

单体 + MySQL乐观锁  →  单体+ Redis缓存/MQ  →  微服务化(按需)
    (日活千级)          (日活万级)           (日活百万级)

切忌一上来就做微服务+分布式事务,先保证核心链路正确,再考虑架构演进。

抱歉,评论功能暂时关闭!