
简介这是一套基于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 用来做号源缓存和分布式锁扛住放号瞬间的并发。如果你拿到的压缩包里用的是 SSHStrutsSpringHibernate或者 ServletJSP也不用慌核心业务逻辑是一样的只是配置方式不同。先确认 JDK 版本常见的是 JDK 8 或 JDK 11JDK 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 11JDK 已经装好但环境变量还没配可以按下面几步走。先确认 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 查询接口与缓存策略查询排班和剩余号源是读操作频率远高于写操作。常见做法是把当天或未来几天的号源缓存到 Rediskey 设计成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数据库连接串加serverTimezoneAsia/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/createpost.lua里构造请求体和 header-t4表示 4 个线程-c500表示 500 个连接-d30s表示持续 30 秒。压测完看结果里的Non-2xx responses和数据库里的remain_num如果remain_num变成负数说明防超卖还有漏洞。6.2 用日志和数据库核对最终一致性压测结束后写一条核对 SQLSELECT 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约束这样即使逻辑出问题数据库层面也会直接报错不会真的变成负数。最后别把号源扣减和订单插入拆到两个事务里这是血泪经验——拆开之后一旦中间宕机号就凭空消失了。希望这些能帮到你。本文还有配套的精品资源点击获取