☰
基于Java的医院预约挂号系统:高并发号源分配与防超卖实战
2026/10/7 6:34:25 网站建设 项目流程

简介:这是一套基于Java技术栈的医院预约挂号系统完整源码,面向具备Java Web基础、希望深入理解大型医疗平台架构的开发者与计算机专业学生。系统实现了用户注册登录、医生信息浏览、分时段预约、出诊时间维护及后台CRUD等核心业务,并涉及Spring Security权限控制、MyBatis或Hibernate持久层、MySQL数据库设计、Bootstrap或Vue前端交互、Log4j日志记录与SQL注入防护等实践要点,可作为课程设计、毕业设计或二次开发的参考蓝本。压缩包为zip格式,整体约37.3MB,文件总数暂未统计,主要包含Java源码、配置文件及前端静态资源等类型。目前已有449人学习下载,读者可从中梳理MVC分层结构、数据库表关系与前后端交互流程,并借鉴缓存、负载均衡及微服务拆分思路,提升Java Web开发与系统架构能力。

1. 从一份「基于Java的医院预约挂号系统.zip」说起:它到底能解决什么问题

如果你手上正躺着一份名为「基于Java的医院预约挂号系统.zip」的压缩包,或者你正打算自己动手做一个类似的东西,那这篇笔记就是写给你的。医院预约挂号系统本质上解决的是一个高并发下的号源分配问题:患者要在指定时间抢到指定医生的指定时段,医院要保证同一个号不会被两个人同时拿到,还要能随时放号、停诊、退号。它不是一个简单的增删改查后台,而是一个典型的「读多写少、写操作必须强一致」的业务场景。适合谁看?正在做 Java 课程设计的学生、准备面试时想拿一个真实项目练手的初中级 Java 工程师、以及需要快速搭一套挂号原型验证业务的小团队。下面我会按「技术选型 → 数据库设计 → 核心接口 → 并发防重 → 踩坑排查 → 进阶技巧」的顺序,把这份系统从解压到跑通的关键路径讲清楚。

2. 技术选型与工程结构:为什么是 Spring Boot + MyBatis 而不是别的

2.1 从「能跑起来」倒推技术栈

拿到一个 Java 项目压缩包,第一件事不是急着导入 IDE,而是先看它的依赖管理文件。常见的做法是 Maven 的pom.xml或 Gradle 的build.gradle。对于医院预约挂号这类业务,我一般会推荐Spring Boot + MyBatis + MySQL + Redis这套组合,理由很直接:

  • Spring Boot 负责把 Web 层、事务、定时任务这些基础设施一次性配好,省去大量 XML 配置;
  • MyBatis 对挂号这种需要精细控制 SQL 的场景比 JPA 更顺手,尤其是「扣减号源」这种必须写明确UPDATE ... WHERE的语句;
  • MySQL 存号源、订单、医生排班等核心数据,保证持久化;
  • Redis 用来做号源缓存和分布式锁,扛住放号瞬间的并发。

如果你拿到的压缩包里用的是 SSH(Struts+Spring+Hibernate)或者 Servlet+JSP,也不用慌,核心业务逻辑是一样的,只是配置方式不同。先确认 JDK 版本,常见的是 JDK 8 或 JDK 11,JDK 17 以上要注意某些老依赖的兼容性。

2.2 工程目录的快速体检

解压后先看目录结构,一个典型的 Spring Boot 挂号系统大概长这样:

hospital-registration/ ├── src/main/java/com/hospital/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑 │ ├── mapper/ # MyBatis 接口 │ ├── entity/ # 数据库实体 │ └── config/ # 配置类 ├── src/main/resources/ │ ├── application.yml # 主配置 │ ├── mapper/ # MyBatis XML │ └── static/ # 前端静态资源 └── pom.xml

体检时重点看三处:application.yml里的数据库连接、mapper目录下的 SQL 文件、以及controller里有没有挂号相关的入口。如果压缩包里带了sql脚本,先把它导入 MySQL,否则后面启动会一直报找不到表。

2.3 环境配置的实操步骤

假设你本机是 Windows 11,JDK 已经装好但环境变量还没配,可以按下面几步走。先确认 Java 版本:

java -version

如果提示找不到命令,就去系统环境变量里新建JAVA_HOME指向 JDK 安装目录,再把%JAVA_HOME%\bin加到Path里。配好后重新打开终端验证。接着导入数据库:

mysql -u root -p -e "CREATE DATABASE hospital_reg DEFAULT CHARACTER SET utf8mb4;" mysql -u root -p hospital_reg < hospital_reg.sql

然后修改application.yml里的spring.datasource.url、username、password,把url里的库名改成你刚建的hospital_reg。最后在项目根目录执行:

mvn clean package -DskipTests java -jar target/hospital-registration-0.0.1-SNAPSHOT.jar

看到Started Application in x seconds就说明启动成功了。如果启动失败,先看控制台第一段异常,八成是数据库连不上或者端口被占用。

提示:不要一上来就改代码,先把项目原样跑通,再动业务逻辑。这是排查问题的基准线。

3. 数据库设计与号源模型:挂号系统的地基怎么打

3.1 核心表结构与字段含义

挂号系统的数据库设计直接决定了后面并发控制好不好做。常见做法是至少五张表:department(科室)、doctor(医生)、schedule(排班)、registration(挂号订单)、patient(患者)。其中最关键的是schedule和registration。

表名关键字段说明
scheduleid, doctor_id, work_date, time_slot, total_num, remain_num, version排班与号源,remain_num 是剩余号数
registrationid, patient_id, schedule_id, status, create_time挂号订单,status 区分已挂号/已取消
doctorid, name, department_id, title医生基本信息
departmentid, name, location科室信息
patientid, name, id_card, phone患者信息

schedule表里的remain_num是并发扣减的核心字段,version是乐观锁版本号。registration表里schedule_id加patient_id建议建唯一索引,防止同一个人重复挂同一个号。

3.2 号源扣减的两种思路

号源扣减有两种常见做法:一种是「先查后改」,先SELECT remain_num判断大于 0,再UPDATE remain_num = remain_num - 1;另一种是「直接条件更新」,用一条 SQL 完成判断和扣减:

UPDATE schedule SET remain_num = remain_num - 1, version = version + 1 WHERE id = #{scheduleId} AND remain_num > 0 AND version = #{version};

第一种在并发下必然出问题,因为查和改之间有间隙,两个线程可能都查到remain_num = 1,然后都去扣减,导致超卖。第二种把判断和扣减放在同一条 SQL 里,利用数据库行锁保证原子性,返回影响行数为 0 就说明号已经被抢完或版本冲突。这是挂号系统防超卖的第一道防线。

3.3 排班与号源的生成逻辑

医生排班通常由管理员提前一周或一个月录入,系统根据排班模板批量生成schedule记录。常见做法是定义一个schedule_template表,记录某医生每周几、哪个时段、放多少个号,然后用定时任务在每天凌晨生成未来第 N 天的号源。生成时要注意:同一个医生同一天同一时段不能重复生成,可以用INSERT ... ON DUPLICATE KEY UPDATE或者先查后插加唯一索引兜底。

// 伪代码:批量生成号源 for (ScheduleTemplate tpl : templates) { LocalDate targetDate = LocalDate.now().plusDays(tpl.getOffsetDays()); Schedule schedule = new Schedule(); schedule.setDoctorId(tpl.getDoctorId()); schedule.setWorkDate(targetDate); schedule.setTimeSlot(tpl.getTimeSlot()); schedule.setTotalNum(tpl.getTotalNum()); schedule.setRemainNum(tpl.getTotalNum()); scheduleMapper.insertIgnore(schedule); // 唯一索引冲突则忽略 }

这段逻辑的关键是insertIgnore,它依赖数据库唯一索引来防止重复生成。参数offsetDays控制提前放号的天数,一般设 7 或 14。如果业务要求每天固定时间放号,就把这个任务交给定时任务框架,比如 Spring 的@Scheduled或者 Quartz。

4. 核心接口实现:从挂号到退号的完整链路

4.1 挂号接口的 Controller 与 Service 分层

挂号接口一般设计成POST /api/registration/create,请求体带patientId、scheduleId。Controller 层只做参数校验和调用 Service,真正的业务逻辑放在 Service 里。下面是一个简化的 Service 实现:

@Transactional(rollbackFor = Exception.class) public Result createRegistration(Long patientId, Long scheduleId) { // 1. 查询排班是否存在 Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule == null) { return Result.fail("排班不存在"); } // 2. 条件扣减号源 int affected = scheduleMapper.decreaseRemainNum(scheduleId, schedule.getVersion()); if (affected == 0) { return Result.fail("号源已抢完,请刷新重试"); } // 3. 写入挂号订单 Registration reg = new Registration(); reg.setPatientId(patientId); reg.setScheduleId(scheduleId); reg.setStatus(1); reg.setCreateTime(new Date()); registrationMapper.insert(reg); return Result.success(reg.getId()); }

这段代码有三个关键点:第一,@Transactional保证扣减和插入在同一个事务里,任何一步失败都回滚;第二,decreaseRemainNum返回 0 时直接返回失败,不继续插订单;第三,订单表上的唯一索引会在并发重复提交时抛异常,被事务回滚掉。参数schedule.getVersion()是乐观锁版本号,每次扣减都会加一,防止 ABA 问题。

4.2 退号与号源回滚

退号接口POST /api/registration/cancel要做两件事:把订单状态改成已取消,把号源加回去。顺序很重要,先改订单状态,再回滚号源,并且要判断订单当前状态是不是「已挂号」,防止重复退号。

@Transactional(rollbackFor = Exception.class) public Result cancelRegistration(Long regId, Long patientId) { Registration reg = registrationMapper.selectById(regId); if (reg == null || !reg.getPatientId().equals(patientId)) { return Result.fail("订单不存在"); } if (reg.getStatus() != 1) { return Result.fail("该订单不可取消"); } // 先改状态,利用状态条件更新防重复 int updated = registrationMapper.cancelById(regId); if (updated == 0) { return Result.fail("取消失败,请重试"); } // 再回滚号源 scheduleMapper.increaseRemainNum(reg.getScheduleId()); return Result.success(); }

cancelById的 SQL 要带上AND status = 1,这样即使两个请求同时进来,也只有一个能更新成功。号源回滚用remain_num = remain_num + 1,注意不要超过total_num,可以在 SQL 里加AND remain_num < total_num兜底。

4.3 查询接口与缓存策略

查询排班和剩余号源是读操作,频率远高于写操作。常见做法是把当天或未来几天的号源缓存到 Redis,key 设计成schedule:remain:{scheduleId},value 是剩余号数。每次扣减或回滚时同步更新缓存,查询时先读缓存,缓存没有再查数据库并回填。

public int getRemainNum(Long scheduleId) { String key = "schedule:remain:" + scheduleId; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return Integer.parseInt(cached); } Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule != null) { redisTemplate.opsForValue().set(key, String.valueOf(schedule.getRemainNum()), 5, TimeUnit.MINUTES); return schedule.getRemainNum(); } return 0; }

缓存过期时间设 5 分钟是个折中,太短起不到缓存作用,太长会导致号源显示不准。更严谨的做法是用 Redis 的原子递减命令DECR来扣减缓存,但那样就要处理缓存和数据库的一致性,复杂度会上升。对于课程设计或中小型项目,上面这种「数据库为准、缓存加速读」的策略已经够用。

5. 并发防重与超卖排查:那些年我们踩过的坑

5.1 避坑:号源超卖的三种典型现象

现象一:明明 remain_num 显示还有号,但两个患者同时提交都成功了,最后号数变成负数。原因是用「先查后改」的方式扣减,查和改之间没有锁。解决办法是改成条件更新UPDATE ... WHERE remain_num > 0,用影响行数判断是否成功。

现象二:同一个人短时间内重复提交,生成了两条挂号订单。原因是前端按钮没防抖,后端也没做幂等。解决办法是在registration表的(patient_id, schedule_id)上建唯一索引,插入冲突时捕获异常返回「请勿重复提交」。

现象三:退号时号源加回去了,但订单状态没改,导致可以无限退号刷号源。原因是退号逻辑没有先校验订单状态。解决办法是UPDATE registration SET status = 2 WHERE id = ? AND status = 1,用影响行数判断是否真的取消成功。

5.2 避坑:事务失效的常见原因

现象:扣减号源成功了,但插入订单失败,号源没有回滚。原因可能是@Transactional注解加在了 private 方法上,或者同类内部方法调用没有走代理。解决办法是把事务方法改成 public,并且从外部类调用。另一个常见原因是异常被 catch 了没有重新抛出,事务管理器感知不到异常就不会回滚。记住:@Transactional默认只对RuntimeException回滚,如果抛的是受检异常,要加rollbackFor = Exception.class。

5.3 避坑:Redis 缓存与数据库不一致

现象:数据库里号已经抢完了,但页面上还显示有号,用户点进去才提示失败。原因是缓存没有及时更新或删除。解决办法是每次扣减或回滚数据库后,同步更新 Redis 里的值,或者直接删除缓存 key,让下次查询回源。如果并发很高,可以用延迟双删:更新数据库后删一次缓存,隔几百毫秒再删一次。

5.4 避坑:定时任务重复执行

现象:号源被生成了两份,remain_num 翻倍。原因是定时任务在多实例部署时每个实例都跑了一遍。解决办法是用分布式锁或者数据库唯一索引兜底。简单做法是在任务执行前用 Redis 的SETNX抢锁,抢到才执行,执行完释放。更稳妥的是给schedule表加(doctor_id, work_date, time_slot)唯一索引,重复插入直接失败。

5.5 避坑:时间字段的时区问题

现象:排班日期显示差一天,或者放号时间对不上。原因是数据库、JVM、前端三者的时区不一致。解决办法是统一用Asia/Shanghai,数据库连接串加serverTimezone=Asia/Shanghai,实体类用LocalDate或LocalDateTime,不要用java.util.Date直接存。前端传日期字符串时明确格式,比如yyyy-MM-dd。

6. 进阶技巧:用压测验证你的挂号系统到底扛不扛得住

6.1 用 JMeter 或 wrk 模拟放号瞬间

系统跑通不代表能扛住真实场景。放号瞬间可能几百上千人同时点「挂号」,这时候才是检验防超卖逻辑的时候。我一般会用 JMeter 建一个线程组,模拟 500 个并发用户同时请求/api/registration/create,观察三个指标:成功挂号数是否等于总号源数、remain_num是否变成负数、响应时间是否在可接受范围。

# 用 wrk 快速压测(需要先安装 wrk) wrk -t4 -c500 -d30s --latency -s post.lua http://localhost:8080/api/registration/create

post.lua里构造请求体和 header,-t4表示 4 个线程,-c500表示 500 个连接,-d30s表示持续 30 秒。压测完看结果里的Non-2xx responses和数据库里的remain_num,如果remain_num变成负数,说明防超卖还有漏洞。

6.2 用日志和数据库核对最终一致性

压测结束后,写一条核对 SQL:

SELECT s.id, s.total_num, s.remain_num, COUNT(r.id) AS reg_count FROM schedule s LEFT JOIN registration r ON r.schedule_id = s.id AND r.status = 1 GROUP BY s.id HAVING s.remain_num != s.total_num - reg_count;

这条 SQL 会找出「剩余号数」和「已挂号数」对不上的排班。正常情况下remain_num应该等于total_num减去有效订单数。如果对不上,就去查那段时间的日志,看是扣减失败还是订单插入失败没有回滚。

6.3 一个我常用的排查习惯

每次改完并发相关的代码,我都会先把remain_num手动改成一个很小的值,比如 1,然后用两个终端同时发请求,看是不是只有一个成功。这个「最小化复现」的习惯帮我省了很多后悔药。另外,数据库的remain_num字段一定要加UNSIGNED约束,这样即使逻辑出问题,数据库层面也会直接报错,不会真的变成负数。最后,别把号源扣减和订单插入拆到两个事务里,这是血泪经验——拆开之后一旦中间宕机,号就凭空消失了。希望这些能帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询