☰
基于Spring Boot的智能预约挂号系统实战:高并发防超卖与业务建模
2026/10/13 13:07:02 网站建设 项目流程

做在线预约挂号系统这件事,听起来无非是把线下的排号搬上线,但真正用 Spring Boot 落地一版“智能在线预约挂号管理系统”之后,我才意识到这里面布满了坑。本文会沿着需求梳理、技术选型、数据库建模、智能规则落地、高并发防超卖、上线运维这六条主线,完完整整复盘这套系统的设计实现过程。无论你是在准备毕业设计,还是要接手一个实际的医院预约项目,按这个思路走,能少踩很多不必要的坑。

1. 挂号系统到底在解决什么问题:先绕开技术聊业务

我一直坚持一个观点:业务复杂度决定了技术方案的边界。如果一个系统的业务需求没想清楚,后面加再多中间件也只是在给烂设计打补丁。在线预约挂号这个场景看起来很“简单”——患者选医生、选时间、提交预约,但真正坐下来梳理的时候会发现,每一条规则都牵扯到流程、状态、权限和异常恢复。

1.1 三个用户角色,三种完全不同的诉求

挂号系统的用户绝对不是“患者”一类。仔细拆,至少有三个角色:患者、医生、平台管理员。他们关心的事情完全不一样。

患者端的核心诉求是“找得到、约得上、流程短”。患者在意的不是技术细节,而是能不能快速按科室、按症状找到合适的医生,预约界面能不能一眼看到剩余号源,预约后能不能及时收到通知。所以患者端界面必须信息清晰,操作步骤尽量控制在四步以内。

医生端和护士工作站更关心的是“排班能不能灵活调整”“出诊记录是否准确”“爽约患者怎么处理”。如果医生临时停诊,护士需要能一键停诊并自动通知已预约患者;如果医生加号,号源池要能实时扩容。

管理员端则要处理科室管理、医生排班审批、号源池监控、黑名单管理、统计报表这些重活。很多团队把管理端当作“附带功能”来写,这是巨大的误区。预约系统的真正工作量,至少一半都在管理端。排班规则、号源总量、预约时段、限额策略都是在管理端配置的,它的灵活性直接决定系统能不能适应不同医院的实际运营模式。

1.2 预约流程的主线:排班、锁号、支付、履约

把流程画出来,主线其实是清晰的:

  1. 管理员配置科室、医生、医生排班计划,系统根据排班生成号源。
  2. 患者查询号源,选择某一天某个医生的某个时段。
  3. 系统锁定号源并生成预约单,如果涉及在线支付则进入支付环节。
  4. 患者按时到院取号/签到,医生接诊,预约单流转为“已完成”。
  5. 如果患者爽约,系统需要记录违约并在后续预约中做限制。

这里的关键在于锁号。号源天然是稀缺资源,一个时段就一个位置,不能让两个人同时约到。所以系统从生成号源那一刻开始就要考虑并发控制,这也是后面第五章重点展开的内容。

1.3 非功能需求也要在开工前定调

很多人做系统只盯着功能列表,忽略了非功能需求,比如:

  • 早高峰抢号场景下,系统要支持多少并发?我们当时按某医院早八点放号的场景估测,峰值 QPS 在 200 左右,预留两倍余量设计。
  • 预约单状态一致性要求多高?支付回调、停诊通知、号源释放都是一连串状态流转,任何一个环节不对账,患者就会有“约上又没了”的体验。
  • 系统的可维护性如何?医院运营制度经常调整,节假日、临时停诊、专病门诊这些都要能快速配置。

我在项目启动时把这些非功能需求写成了《需求确认表》,每个模块开发前都对照确认。这件事非常值得做,后面所有架构决策都源于这张表。

2. 技术选型与架构设计:单体应用为什么是这个场景的优选

网上很多帖子一上来就谈微服务、分布式事务、K8s,我觉得得分场景。预约挂号系统本质上是业务逻辑集中、数据一致性要求高、并发峰值有上限的管理系统,单体架构 + 适当缓存完全能扛住,而且维护成本低得多。

2.1 技术栈选型及理由

我们最终采用了这样一套技术栈:

层次技术选型选择理由
后端框架Spring Boot 3.x + JDK 17生态成熟、自动化配置、社区资料丰富,开发效率高
ORMMyBatis-Plus单表 CRUD 非常高效,逻辑删除、分页插件开箱即用
数据库MySQL 8.0关系型事务可靠,预约业务和排班报表都依赖强一致
缓存/并发控制Redis 5.x用于号源预扣减、分布式锁、验证码、热点排班缓存
消息队列RabbitMQ用于异步发送通知、订单过期延迟处理,可替换为 RocketMQ
前端Vue3 + Element Plus管理端和患者端界面复用组件,开发速度快
部署Docker Compose + Nginx单机/双机部署足够,避免一上来就上容器编排平台

对,没有选微服务,也没有选云原生全家桶。原因很简单:项目团队仅五个人,业务复杂度也不到微服务拆分的规模。单体应用在事务处理上天然简单,预约扣号这种强一致场景用本地事务就能解决,一旦拆成微服务,分布式事务的复杂度会成倍增加。

2.2 代码分层与模块划分

代码结构上采用经典的分层架构:

com.example.hospital ├── controller // 接口层,DTO 参数校验 ├── service // 业务层,事务控制、规则引擎调用 ├── mapper // 数据访问层,MyBatis-Plus Mapper ├── domain/entity // 实体类 ├── config // 配置类、拦截器、Redis、MQ ├── common // 公共返回结果、异常、工具类 └── rule // 智能排班与预约规则引擎

模块按业务边界横向划分:user-module、schedule-module、appointment-module、payment-module、notice-module。在单体架构里,这些模块通过 Java 包管理即可,不需要拆成独立服务。这么做的好处是事务边界依旧在同一个进程内,调试方便;后续如果真的需要拆,也可以按模块边界平滑演进。

2.3 权限模型设计:JWT + 角色注解

预约系统的权限比较简单,三种角色天然隔离。我们使用 JWT 做无状态登录,用户在登录接口提交手机号+验证码,后端校验后签发 token,Redis 中维护 token 与登录用户的映射,有效期 24 小时。

接口层使用自定义@RequireRole注解配合拦截器做权限拦截:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { Role[] value() default { Role.PATIENT }; }

拦截器读取 JWT 中的角色信息,没有权限直接返回 403。医生端录入出诊记录、管理端配置排班规则这些接口都打上了对应角色限制。这里要注意一个细节:JWT 一旦签发无法主动吊销,管理员封禁用户后必须同时把 Redis 中的 token 删除,否则封禁不生效。我当时就漏了这个关联操作,后来补了一个统一登出的逻辑。

3. 数据库建模:预约系统的核心表怎么设计

预约系统的数据库设计是整个项目中最能体现功力的地方。我见过很多同学的课设把预约搞得像“购物车”——一张订单表打天下,最后查号源、查排班、统计医生工作量全都卡死。核心问题在于没有把号源、排班、预约、支付这几个概念拆清楚。

3.1 基础用户与组织模型

用户表我拆为patient_info(患者信息)、doctor_info(医生信息)、sys_user(系统账号)三张。为什么不合并?因为三类对象的字段差异很大——患者有医保卡号、过敏史,医生有职称、科室、擅长领域,系统账号有密码字段。合并成一张大表会导致大量空字段和判断分支。

科室表department和医生表doctor_info通过dept_id关联。特别注意科室关系在现实中多了一层“院区”,如果有多个院区,建议加一个hospital_branch表。我们的第一版没有院区概念,后来部署时被临时加需求狠狠教育了一次。

3.2 号源排班表与时段设计

排班表doctor_schedule是整个号源体系的核心,我的设计如下:

CREATE TABLE `doctor_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `doctor_id` bigint NOT NULL COMMENT '医生ID', `work_date` date NOT NULL COMMENT '出诊日期', `period_type` varchar(10) NOT NULL COMMENT '时段类型:AM/PM/EVENING', `period_start` time NOT NULL COMMENT '时段开始时间', `period_end` time NOT NULL COMMENT '时段结束时间', `total_slots` int NOT NULL COMMENT '总号源数', `remaining_slots` int NOT NULL COMMENT '剩余号源数', `rule_group_id` bigint DEFAULT NULL COMMENT '适用的预约规则组', `status` tinyint NOT NULL DEFAULT '1' COMMENT '排班状态:上架/下架/停诊', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_doctor_work_period` (`doctor_id`, `work_date`, `period_type`) ) ENGINE=InnoDB COMMENT='医生出诊排班表';

为什么把“某一天的上午/下午/晚上”设为一个时段,而不是“09:00-09:30”这种精细片段?因为大多数医院的实际运营单位就是上午/下午,精细到分钟反而会让排班配置异常复杂。如果未来需要精细化,可以在period_type中扩展,比如MORNING_ONE、MORNING_TWO。

这里特别强调:remaining_slots是保留字段,用于数据库层扣减,这是防超卖的第一道保险。version是乐观锁字段,每次更新时检查。

3.3 预约记录与支付记录的事务边界

预约记录表appointment_order保存完整的预约闭环信息:

CREATE TABLE `appointment_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务单号', `patient_id` bigint NOT NULL, `patient_name` varchar(50) NOT NULL, `doctor_id` bigint NOT NULL, `schedule_id` bigint NOT NULL, `schedule_date` date DEFAULT NULL, `period_type` varchar(10) DEFAULT NULL, `appointment_status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已预约 2已完成 3已取消 4爽约', `pay_amount` decimal(10,2) DEFAULT '0.00', `expire_time` datetime DEFAULT NULL COMMENT '待支付截止时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_patient` (`patient_id`, `appointment_status`) ) ENGINE=InnoDB COMMENT='预约订单表';

事务边界非常重要。预约单在未支付前,不能长期占用号源,所以引入expire_time字段,如果 30 分钟未支付,由定时任务取消并释放号源。支付完成后状态迁移到“已预约”。这里不建议把支付金额和预约状态放在同一事务里,因为支付回调是外部系统的异步请求,如果回调失败,预约单要能单独补偿。

支付表payment_order则单独记录支付流水,并且要求pay_no唯一,保证渠道重复回调时幂等。

数据模型设计完,我有个习惯:先画一张状态机图,标注哪些状态通过哪些业务动作流转。预约状态机从“待支付”到“已完成”一共五个状态,每个状态都对应代码里的一段状态校验逻辑。

4. “智能”部分如何落地:规则引擎与排队预估

标题里有“智能”两个字,很多同学第一反应是上机器学习模型、搞推荐系统。但实际医院场景里,所谓智能更多是“业务规则的可配置化 + 数据的自动决策”。医生推荐、时段分配、爽约限制、排队提醒这些都可以通过规则引擎来实现,不一定需要算法模型。

4.1 什么是挂号场景的“智能”:先管理好规则

我理解的智能分三个层次:

  • 第一层:把医院已有的线下规章制度转成可配置的在线规则,比如“一个患者 24 小时内同一科室最多预约一次”“同一患者同一天不能约两个不同科室的号”。
  • 第二层:系统根据历史爽约率、号源余量、医生接诊速度,自动推荐更合适的时段给患者。
  • 第三层:才是真正意义上的个性化智能推荐,比如基于患者症状自动分诊。

第一层是基础,做好之后系统就已经比大多数传统排队系统“聪明”了。我们设计了一个简单的规则引擎:数据库维护规则组rule_group,每条规则包含条件字段(患者ID、科室ID、日期范围、次数上限)和动作字段(拒绝预约、提示警告、加入黑名单)。代码中通过策略模式实现RuleValidator接口:

public interface RuleValidator { void validate(BookingContext context); }

在预约提交时,遍历该排班绑定的规则组内所有启用状态规则。比如“同一患者当日预约次数限制”这条规则,会先查询appointment_order中当天已预约数量和已取消数量,如果达到阈值,直接抛出异常。

这种方式完全不需要算法训练,但规则用起来非常灵活。运营人员能在后台可视化管理规则,新增规则时只加一个实现类,主流程代码完全不动。

4.2 号源推荐时段的简单优化

在患者选择时段时,我们根据剩余号源数、就诊时间段的历史空闲率、医生平均看诊时长做了一个加权打分,把用户最可能预约成功的时段排在前面。说直白点就是:上午十点通常是高峰期,下午两点半相对人少;如果上午八点的号剩得多,就优先推荐这个时段。打分的权重存数据库配置,运营人员可以自己调。

这部分我们只用了“基于规则 + 日志统计”的方式,没有用太复杂的算法,但用户体验提升非常明显。预约页面不再是一列时间挨着空转,首选的三个时段基本是当下最优解。

4.3 候诊队列与预计等候时间

一个容易被忽略的智能场景是“预计等待时间”。患者预约成功了,但还是不知道到了医院要等多久。我们设计了一个候诊队列估算模块:候诊患者进入队列后,系统记录每位患者的签到时间、医生叫号时间、就诊结束时间,用滑动窗口计算医生平均看诊时长,再结合队列长度给出预估:

预计等待时长 = 当前排队人数 × 近30分钟平均看诊时长 - 已等待时间

数据来自医生端工作站的“签到/过号/完成”事件,每五分钟由后台任务刷新一次。在患者端展示“当前您前面还有 X 人,预计等待约 Y 分钟”,患者对系统的满意度会大幅提升。

这套东西不算什么黑科技,但它确实需要把“事件写入”“统计计算”“缓存展示”这套链路打通。我当时的实现是:事件写入 Redis 的 List/Push 结构,后台任务聚合计算后写入缓存,患者端接口直接读缓存,避免高频查询数据库。

5. 高并发抢号场景:防止“超卖”的完整方案

预约挂号系统最紧张的时刻就是放号早上八点。几千个患者同时刷新同一个科室,考验的是系统的并发控制能力。如果这里不做专门设计,数据库一行remaining_slots被两个请求同时读到,最后就出现两个患者约到同一个号位的不可收拾局面。这就是典型的“超卖”问题。

5.1 从库存模型的角度重新理解号源

把号源想成商品库存就很好理解。每个排班的“可预约数”就是库存,预约动作就是“扣库存+生成订单”。但和秒杀系统有一点不同,预约不是谁手快谁就永远赢,还存在 30 分钟未支付释放库存的机制,所以号源是动态变化的。

防超卖的手段有三个层级:

  1. 数据库层的乐观锁更新
  2. 数据库唯一索引兜底
  3. Redis 预扣减提前淘汰部分请求

5.2 Redis 预扣号 + 数据库兜底

我们最终采用“Redis 预扣减 + 数据库最终一致性”的方案。放号前,系统把排班的号源总数加载进 Redis,例如:

schedule:slot:1001 = 50

预约请求到达时,先执行 Redis 的decR操作,如果减完后的值小于 0,说明号源被抢完,直接返回“号源已约满”。只有 Redis 预扣成功才进入后续的业务校验和数据库操作。

数据库层面,更新剩余号数时使用带条件的原子更新:

int updated = scheduleMapper.deductSlot(schedule.getId());

对应 SQL:

UPDATE doctor_schedule SET remaining_slots = remaining_slots - 1, version = version + 1 WHERE id = #{id} AND remaining_slots > 0

如果updated == 0,说明数据库层面已经没号了,需要回滚 Redis 扣减操作。这里有个技术要点:Redis 扣减和数据库更新不能放在同一事务里,因为 Redis 操作不受 MySQL 事务控制。做法是先 Redis 扣减,再 DB 事务更新,如果 DB 失败,就在 catch 块中执行 Redis 的自增回滚补偿。

5.3 崩溃、超时、支付不回调怎么处理

Redis 和数据库都处理好了,还有一类问题是“业务状态异常”。比如用户完成 Redis 扣减、MySQL 生成了预约单,但支付 30 分钟后未完成,此时号源要释放,释放之后要给被拒的用户重新放号吗?答案是:不用人工干预,定时任务扫描expire_time小于当前时间的待支付订单,执行取消并把号源回补。

这个回补操作同样要考虑并发:取消预约和患者手动取消冲突时,要通过状态字段做 CAS(Compare And Swap),只有appointment_status从 0(待支付)变成 3(已取消)才能执行回补号源。

支付回调的重试也是必须要做的。微信/支付宝回调允许重复通知,我们对同一个pay_no的重复回调做幂等处理——用payment_order表的唯一索引约束“一单单,一次成功入账”,回调时如果该单已经是“支付成功”状态,直接返回成功应答,不再重复更改预约状态。

5.4 并发验证测试怎么设计

并发代码写完一定要验证。我们当时用 JMeter 模拟了 200 个并发用户同时抢 50 个号源,重点看两个指标:

指标预期结果
数据库最终剩余号数0,不多不少
成功生成的预约单数小于等于 50,无重复预约
失败请求返回信息明确提示“号源不足”,无内部异常
系统响应时间 p95小于 800ms

测试中还特别关注了 Redis 扣减后 DB 更新失败的补偿场景,故意模拟数据库超时,确认 Redis 数量能正确回补,不会出现“Redis 显示没有号但数据库还有号”的严重不一致。

这里要提醒的是:压测不是压一次就够了。我们发现了两个隐藏问题,一个是 Redis 连接池在高峰时被占满,另一个是订单表的二级索引在高频插入时出现锁等待。都是通过压测暴露的。所以并发代码上线前,压测挂机至少跑三小时,观察各项中间件的曲线。

6. 上线后踩过的坑与优化方向

系统第一版上线后,业务运行基本稳定,但真正让人成长的是各种边界问题和刁钻需求。我把实战中踩过最深的几个坑列在这里,希望你能绕过。

6.1 零点放号与日历边界

医院实际运营中经常出现“零点放未来七天号源”的情况。单纯把号源生成时间设在零点,会遇到一个隐蔽问题:如果患者用的是客户端选择的时区和服务器有偏差,日期前后会差一天。我们用 Java 8 的LocalDateTime统一后台日期处理,前端提交日期统一为yyyy-MM-dd字符串,所有时间计算都基于服务器时区,不采用客户端时间戳。

零点刷新号源会瞬间产生大量缓存写操作,建议用一个定时任务错峰生成未来 N 天的排班数据,而不是全部集中在零点一次性生成。错峰后,即使生成失败也能在半小时内自动重试。

6.2 医生排班变动后的通知与状态同步

医生临时停诊在真实医疗场景中太常见了。停诊操作会把该排班状态改为下架,但已经预约成功的患者怎么办?不能只是简单取消,必须触发“停诊通知”流程:将患者预约单状态改为“已取消(停诊原因)”,通过短信/公众号模板消息通知患者,并且把号源释放量改为零,保证该时段不再被约。

这看起来是个业务功能,但技术上有几个关联点:缓存中该排班的状态需要主动失效,Redis 中的号源 key 要删除,否则存量数据会让患者端展示出“有号但实际停诊”的状态。

6.3 缓存一致性:发布排班后患者端数据不刷新

我们早期的实现是患者端查询号源时先读 Redis,没有则回源数据库。管理员在后台修改排班后,Redis 中的旧数据没及时更新,导致患者端看到的是十几分钟前的数据。后续改成“发布排班即删除 Redis 中相关 key”,并引入一个简单的事件机制:调度模块修改排班后,发送领域事件,缓存模块监听事件删除对应缓存。这一步解决了很多运营投诉。

这里的教训是:缓存一致性不用一上来就上 Caffeine + Redis 多级缓存,先保证“发布即清除”这一条,就能覆盖 90% 的场景。

6.4 后续可以继续扩展的方向

系统跑顺之后,我一直在想“智能”还能走多远。目前看有四个方向值得做:

  • 基于患者病史和症状的辅助分诊,把患者推荐到更对口的科室,减少盲目挂号。
  • 智能爽约管理:对历史爽约率高的患者,在预约时加验证码或者限制其预约热门号源。
  • 打通院内电子病历系统,医生在接诊前就能看到患者的既往预约与检查记录摘要。
  • 提供候诊实时播报和停车引导,把预约服务的体验延伸到“到院之后”。

在线预约挂号系统的本质,是用技术手段把稀缺号源分配得更公平、更高效。Spring Boot 提供了很好的工程底座,真正的难点从来不在于框架本身,而在于业务流程的完整建模、并发场景的严谨处理,以及在运营压力下保持系统的稳定和灵活。把一个单体系统做扎实,学会基于业务场景做取舍,可能比追逐“最热技术栈”更有长期价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询