Spring Boot医院挂号系统实战:乐观锁防超卖与定时任务释放号源
2026/9/11 8:20:54 网站建设 项目流程

简介:面向Java毕业设计或课程设计学生的Springboot+Vue医院挂号预约管理系统完整项目,包含前后端源码、数据库脚本及配套文档,可直接支撑课题设计、期末大作业与答辩演示。项目采用Springboot+Mybatis搭建后端接口,Vue构建前端页面,运行环境基于JDK1.8、MySQL5.7与Maven3.3,经过严格调试,适合尚未确定毕设方案或需要完整实战项目的Java学习者借鉴。资源包共668个文件,压缩包约17.07MB,以Java源码(156个)、Vue文件(113个)、JS脚本(63个)、SVG图标(159个)为主,辅以SQL脚本、XML配置、CSS样式与批量启动脚本,前后端代码与部署材料一目了然。附有一键安装/启动脚本、数据库脚本及开发说明文档,且已有44人学习;可直接作为毕业设计项目使用,便于对照实现挂号预约、排班管理等核心模块,减少从零搭建的重复工作。

1. 医院挂号预约系统:为什么值得拆而不是照着 CRUD 抄

很多 Java 学习者的第一个毕业设计会做成"管理员维护科室、医生维护排班、用户选号预约"的三层列表页,表建好、接口调通、页面能跳转就算验收。但这个项目明显不是那种作业型 CRUD:预约会同时面对号源扣减、医生排班冲突、过期订单释放三个问题,恰好是 springboot 面试题里常问的乐观锁、事务边界、定时任务和前端路由状态管理的落点。如果你已经在按 Java 学习路线走,做过单体管理系统,那拆这个项目的价值不只是多个"java 毕设"成品,而是把一次真实预约行为从头到尾的工程约束看明白。下面按照后端表设计、预约核心闭环、Vue 前端工程化、部署脚本四层往下拆,每层都会给出可以直接抄进你自己项目里的代码和参数。

2. Spring Boot 后端表结构设计:ER 关系、索引与号源扣减的数据基础

2.1 核心表拆解与职责边界

这套系统的数据模型不是一张挂号记录表打天下,而是把"组织架构—医生资源—排班计划—预约流水"拆成四层。其中最容易忽略的是doctor_schedule(排班表)和appointment_record(预约记录)之间的边界:排班表存的是"某天某个号源池还剩多少",预约记录存的是"谁占了哪个号",两者通过schedule_id关联,而不是在预约记录里直接存doctor_idappointment_date。这样可以避免同一医生同一天的时间段在修改排班时产生数据不一致。

表名职责关键字段
sys_user用户与角色,前端登录态来源id, username, password, role
hospital_info医院基础资料id, name, level, address
department科室目录,树形结构id, name, parent_id
doctor_info医生信息与所属科室id, dept_id, name, title, specialty
doctor_schedule医生排班与号源池id, doctor_id, work_date, time_slot, total_count, remain_count
appointment_record预约流水与订单状态id, schedule_id, user_id, status, create_time

设计时有个容易踩的坑:不要把password字段直接用明文存,但这个项目在还原时一般会用 MD5 或 BCrypt。如果导师要求看 ER 图,appointment_recorddoctor_schedule是多对一,doctor_scheduledoctor_info是多对一,doctor_infodepartment是多对一。ER 图里把这三条关系画清楚,比堆出十张表更有说服力。

2.2 用 ER 关系确认表关联,避免做成分散的单表 CRUD

真正落地时,我会先把排班表和预约记录表的手工建表语句写好,再补外键逻辑。下面是排班表与预约记录表的核心 DDL,去掉了与本文无关的审计字段:

CREATE TABLE `doctor_schedule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `doctor_id` bigint(20) NOT NULL COMMENT '医生ID', `work_date` date NOT NULL COMMENT '出诊日期', `time_slot` varchar(20) NOT NULL COMMENT '上午/下午/晚间', `total_count` int(11) NOT NULL DEFAULT 20 COMMENT '总号源数', `remain_count` int(11) NOT NULL DEFAULT 20 COMMENT '剩余号源数', `version` int(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), KEY `idx_doctor_work_date` (`doctor_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';

这段 DDL 有两个容易被忽略的设计点。第一个:time_slot是字符串而不是时间戳,因为医院排班的最小粒度通常就是上午、下午、晚间三个枚举值,用时间戳反而会让前端联动下拉框变复杂。第二个:idx_doctor_work_date是联合索引,查询"某医生某天的排班"时能直接命中索引,如果只建doctor_id单列索引,MySQL 在回表时还要过滤work_date,数据量上来后响应时间会明显变慢。

预约记录表与排班表通过schedule_id关联,这决定了后面扣减号源时不需要在业务代码里用"先查排班再更新预约"的顺序。表结构里一定要有status字段,建议用整数枚举:0 待支付、1 已确认、2 已取消、3 已完成。这样可以避免删除流水,便于后续统计医生工作量。

2.3 索引设计:为什么余号查询会慢在遗忘覆盖索引上

预约系统的查询高频场景有两个:首页按科室找医生、确认预约时查指定排班的余号。第一个场景用doctor_info.dept_id单列索引就能解决,第二个场景则要注意SELECT的字段范围。如果查询语句写成下面这样:

EXPLAIN SELECT id, doctor_id, work_date, time_slot, total_count, remain_count FROM doctor_schedule WHERE doctor_id = 12 AND work_date BETWEEN '2025-03-01' AND '2025-03-07';

执行计划里typerefkeyidx_doctor_work_date,看起来走了索引,但如果表里加了remarkcreate_by这样的冗余字段,MySQL 认为回表成本比索引扫描更低时,会退化成Using where的全表扫描。验证办法是看Extra列是否出现Using index。没出现时,可以把查询字段收敛到索引列,或者把work_date从 BETWEEN 改成>=<的区间写法,后者对优化器更友好。

提示:毕业设计答辩时如果被问到"你的系统数据量大了怎么办",不要回答加缓存,先回答"我在 work_date 上建了联合索引,并限制了查询字段",这比空谈 Redis 可靠得多。

3. 预约核心闭环:登录鉴权、排班查询与防超卖的乐观锁实现

3.1 JWT 登录与请求上下文传递

后端鉴权方案有很多种,这个项目里比较合适的是 JWT,因为 Vue 前端是独立部署的,需要无状态 token 来维持会话。核心代码可以收敛到一个工具类加一个拦截器:

public class JwtUtil { private static final String SECRET = "your-secret-key"; private static final long EXPIRE = 1000 * 60 * 60 * 2L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }

createToken把用户 ID 和角色塞进 token 的 claim 里,而不是把整个用户对象放进去,这样 token 体积小,Redis 都没必要引入。EXPIRE设成 2 小时,适配门诊挂号这种短会话场景,如果做的是住院系统,这个值应该调大到 24 小时。拦截器里解析 token 后,把 userId 放到 ThreadLocal 中,后续的挂号下单接口直接取当前登录用户 ID,就不用每次请求都传一遍用户参数了。

3.2 排班余号查询接口:参数校验与状态过滤

排班查询容易写错的地方是把"历史排班"也查出来。实际业务里,用户只能看到work_date >= CURDATE()的数据。用 MyBatis 的查询方法可以直接在 SQL 里过滤:

@Override public List<DoctorScheduleVO> listAvailableSchedules(Long doctorId, String date) { return scheduleMapper.selectAvailableSchedules(doctorId, date); }

对应 XML 里写这条 SQL:

<select id="selectAvailableSchedules" resultType="com.hospital.vo.DoctorScheduleVO"> SELECT ds.id, ds.work_date, ds.time_slot, ds.total_count, ds.remain_count, di.name AS doctor_name, di.title FROM doctor_schedule ds LEFT JOIN doctor_info di ON ds.doctor_id = di.id WHERE ds.doctor_id = #{doctorId} AND ds.work_date >= #{date} AND ds.remain_count > 0 ORDER BY ds.work_date, ds.time_slot </select>

这里用LEFT JOIN查医生姓名,避免前端拿到doctor_id后再发一次请求。remain_count > 0这个条件非常重要,它把号源已满的排班在列表阶段就过滤掉,减少前端的无意义点击。ORDER BY按日期和时段排序,保证上午排前面,不会出现同一天时段乱跳。

3.3 预约动作:乐观锁扣减号源与事务补偿

预约接口是整个系统最容易出 Bug 的地方,因为需要保证"扣减号源"和"生成预约记录"要么同时成功、要么同时失败。这里的防超卖不能用"先 SELECT 再 UPDATE"的方式,而要用带条件的原子更新:

@Transactional(rollbackFor = Exception.class) public AppointmentRecord createAppointment(Long scheduleId, Long userId) { DoctorSchedule schedule = scheduleMapper.selectByIdForUpdateCheck(scheduleId); if (schedule.getRemainCount() <= 0) { throw new BizException("当前号源已约满"); } int updated = scheduleMapper.deductRemainCount(scheduleId); if (updated == 0) { throw new BizException("预约冲突,请刷新后重试"); } AppointmentRecord record = new AppointmentRecord(); record.setScheduleId(scheduleId); record.setUserId(userId); record.setStatus(0); appointmentRecordMapper.insert(record); return record; }

deductRemainCount对应的 SQL 是UPDATE doctor_schedule SET remain_count = remain_count - 1 WHERE id = #{scheduleId} AND remain_count > 0。这里的关键逻辑是:数据库行锁会保证同一时刻只有一个事务能执行这条 UPDATE,当剩余号源为 0 时,影响行数是 0,代码抛出业务异常触发事务回滚,预约记录不会被插入。这种做法不需要引入分布式锁,也不需要 select for update,在高并发场景下吞吐量比悲观锁高很多。

@Transactional把扣减和插入放在同一个事务里,如果插入预约记录失败,号源会自动回滚。注意rollbackFor = Exception.class不能省略,因为 Spring 默认只回滚 RuntimeException,如果抛的是自定义 checked exception,不回滚的话就会出现"号扣了但订单没生成"的严重事故。

3.4 超时未支付订单的定时清理

线上系统里最容易被忽略的业务规则是:用户占号后 30 分钟不支付,系统要自动释放号源,否则别人永远约不上。这个功能用 Spring Task 很简单:

@Component public class OrderTimeoutTask { @Scheduled(cron = "0 */5 * * * ?") public void releaseTimeoutOrders() { List<Long> expiredIds = appointmentRecordMapper.selectExpiredIds(30); if (expiredIds.isEmpty()) { return; } appointmentRecordMapper.batchCancel(expiredIds); appointmentRecordMapper.batchIncrementRemainCount(expiredIds); } }

selectExpiredIds(30)查的是status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE),这里用数据库时间而不是应用服务器时间,避免多实例部署时机器时钟不一致。批量释放号源时必须先批量更新预约记录状态,再批量增加 remain_count,顺序不能反,否则在定时任务执行到一半时,用户看到号源恢复了但订单状态还是待支付,会重复点击预约。

@Scheduled(fixedDelay = 5000)cron的区别是:fixedDelay 是上次执行完再等 5 秒,cron 是固定时间点触发。释放号源这种任务用 cron 更合适,因为需要在整点或者每 5 分钟整点执行,而不是跟着任务结束时间漂移。

4. Vue 前端工程化:路由守卫、Axios 封装与科室医生联动表单

4.1 为什么前端要按模块拆路由而不是一页式堆接口

很多课程设计的前端是"一个页面里放三个 Tab",组件全部堆在 App.vue 里。这个项目既然选了 Vue,就应该按业务模块拆路由。拆路由不光是代码结构问题,更重要的是路由守卫需要知道当前用户能不能访问某个页面。医院系统里,管理员能看到排班管理页,普通用户只能看到预约页,这种权限控制放在路由层比放在按钮层的实现成本低。

const routes = [ { path: '/', component: Layout, children: [ { path: 'departments', name: 'DepartmentList', component: () => import('@/views/department/index.vue'), meta: { title: '科室列表', requiresAuth: true } }, { path: 'appointment', name: 'Appointment', component: () => import('@/views/appointment/index.vue'), meta: { title: '在线挂号', requiresAuth: true } } ] } ]

requiresAuth是路由元信息,在全局前置守卫里判断,不需要每个页面重复写登录校验逻辑。() => import()是路由懒加载,这样首屏只加载必要组件,app.feac833a.css这种打包产物也能被浏览器更快缓存。路由名称建议用固定命名约定,后面做面包屑导航和页面跳转时直接按 name 跳转,不写死字符串路径。

4.2 Axios 请求拦截与 401 统一跳转

Axios 封装的重点不是把代码抽出来,而是把"token 自动附加""401 自动跳登录""错误信息统一提示"三件事在拦截器里处理好。实际写法可以这样:

service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('未登录或登录过期')) } if (res.code !== 200) { ElMessage.error(res.msg || '系统异常') return Promise.reject(new Error(res.msg)) } return res } )

请求拦截器里从localStorage拿 token,每次请求自动塞到请求头,后端拦截器统一解析。响应拦截器里判断res.code是 401 时直接清 token 跳登录页,避免每个页面写重复的"登录过期"逻辑。注意这里不能只判断 HTTP 状态码,因为有些后端在业务异常时也会返回 200 + code 非零的组合。

ElMessage.error是 Element Plus 的全局消息组件,归类为轻提示。这里有一个工程化细节:统一错误提示放在拦截器里,业务页面里就不要再弹一次,否则用户会看到两个报错弹窗,这是 Vue 项目实战中新手最容易犯的重复提示问题。

4.3 科室医生联动表单与预约弹窗

预约页的核心是"选择科室 → 选择医生 → 选时间 → 确认预约",这是一个典型的级联数据流场景。页面上维护两个响应式变量,监听科室变化后重新请求医生列表:

watch: { departmentId: async (newVal) => { doctorList.value = [] doctorLoading.value = true if (newVal) { const res = await getDoctorListByDept(newVal) doctorList.value = res.data } doctorLoading.value = false } }

这里的doctorLoading必须和空列表一起重置,否则切换科室时页面会短暂显示上一个科室的医生数据。用户选完医生后,前端拿到doctorId去调排班接口,把返回的work_datetime_slot渲染成可点选的卡片。预约按钮要加disabled状态,当选中时段余号为 0 时置灰,但要注意这只是一种营销层面的防呆,后端仍然是要做幂等和乐观锁校验的,前端disabled不能替代后端remain_count > 0

Vue 组件里的async/await写事件处理函数时,要注意catch块不能吞掉错误后继续走后续逻辑。比如创建预约接口失败后,页面上必须把按钮的 loading 状态重置掉,否则用户会认为按钮卡住了。常见做法是finally里统一设置submitting.value = false,避免成功和失败两个分支各写一遍。

5. 部署脚本与上线前的调优检查清单

5.1 入口脚本拆解:install、run、build 三个 bat 干什么

项目包里带的1-install.bat2-run.bat3-build.bat表面上只是双击执行的批处理,实际上分别对应环境准备、开发运行、生产构建三个阶段的标准化操作。拆开看没有技术门槛,但放在一起就能避免"换了一台电脑就不知道先干嘛"的问题。

@echo off echo [STEP 1] Maven 依赖安装... call mvn -f pom.xml clean install -DskipTests echo [STEP 2] 启动 Spring Boot 服务... call mvn -f pom.xml spring-boot:run

2-run.bat是开发环境使用的方式,mvn spring-boot:run会直接编译并启动内嵌 Tomcat 服务。3-build.bat一般建议用clean package -Dmaven.test.skip=true,先执行清理再打包,避免上一次编译的脏 class 被打进 jar 里。这里要特别注意 Maven 版本和 JDK 版本的匹配关系:项目说明里是 Maven 3.3 + JDK 1.8,如果你的机器装的是 JDK 17,mvn clean package大概率会报UnsupportedClassVersionError,解决办法不是升级 Maven 版本,而是先确认JAVA_HOME环境变量指向了 JDK 1.8 安装目录。

5.2 生产环境配置差异与常见启动失败对照表

开发环境一切正常,部署到服务器上起不来的情况,九成出在application.yml的配置差异上。数据库连接、端口、上下文路径这三项是最常见的差异点。生产环境里我喜欢把数据库密码通过环境变量注入,避免把账号明文打到镜像里:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ${DB_USERNAME:root} password: ${DB_PASSWORD:root} driver-class-name: com.mysql.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml

连接串里的serverTimezone一定要显式指定,MySQL 5.7 如果不带这个参数,从 8.0 驱动解析日期时间类型时会报 CST 时区错误。driver-class-name在管理后台导入数据库脚本时也值得留意,JDK 1.8 对应的是com.mysql.jdbc.Driver,如果项目里用了 MySQL 8.0 驱动,类名要改成com.mysql.cj.jdbc.Driver,否则启动报 ClassNotFound。下面这张对照表来自我实际部署时的排错记录:

错误现象原因处理方式
Access denied for user数据库账号密码不对,或该账号只允许 localhost确认DB_USERNAMEDB_PASSWORD注入值
Unknown databaseMySQL 里没有创建对应该 schema 的库Navicat 里新建 hospital_db 再导入 sql
Port 8080 was already in use服务端口被占用换端口或在启动脚本里先杀掉旧进程
Failed to configure a DataSource环境变量没传,默认值 root/root 连不上检查启动脚本里 DB_HOST 配置

5.3 上线前性能验证与线程池调优

预约挂号的高峰期集中在放号那一刻,大概率会出现瞬时并发。除了业务层的乐观锁,还有必要检查内嵌 Tomcat 的默认线程池参数。Spring Boot 的默认线程池值是 200,但每台机器性能不同,不能盲目调高。常见参数调整如下:

server: tomcat: threads: max: 400 min-spare: 40 accept-count: 200 max-connections: 10000

accept-count是等待队列长度,当请求超过max线程数后,新请求会进入队列等待,如果队列也被占满就直接拒绝连接。这几个参数的组合逻辑是:请求先占min-spare的空闲线程,不够时扩容到max,再不够进accept-count队列,队列满后再拒绝。压测时可以通过一个简单的 SQL 观察锁竞争情况:

SHOW STATUS LIKE 'innodb_row_lock_current_waits';

这条语句返回的数值如果在压测持续上升,说明号源扣减 SQL 的行锁等待严重,此时先检查doctor_schedule表的索引有没有真正命中,再考虑把扣减频率从每请求一次改成批量化合并。还有一种做法是把排班余号提前加载到本地内存,预约成功后异步回写数据库,但毕业设计阶段不建议用这个方案,因为会增加数据一致性的解释成本。更稳妥的调优办法是改事务传播机制,只把"扣减号源 + 插入预约记录"放入事务,把日志写库和短信通知全部挪到事务外,降低每个请求的事务占用时间。

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

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

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

立即咨询