ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot医院挂号系统实战:乐观锁防超卖与定时任务释放号源

Spring Boot医院挂号系统实战:乐观锁防超卖与定时任务释放号源 简介面向Java毕业设计或课程设计学生的SpringbootVue医院挂号预约管理系统完整项目包含前后端源码、数据库脚本及配套文档可直接支撑课题设计、期末大作业与答辩演示。项目采用SpringbootMybatis搭建后端接口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_id和appointment_date。这样可以避免同一医生同一天的时间段在修改排班时产生数据不一致。表名职责关键字段sys_user用户与角色前端登录态来源id, username, password, rolehospital_info医院基础资料id, name, level, addressdepartment科室目录树形结构id, name, parent_iddoctor_info医生信息与所属科室id, dept_id, name, title, specialtydoctor_schedule医生排班与号源池id, doctor_id, work_date, time_slot, total_count, remain_countappointment_record预约流水与订单状态id, schedule_id, user_id, status, create_time设计时有个容易踩的坑不要把password字段直接用明文存但这个项目在还原时一般会用 MD5 或 BCrypt。如果导师要求看 ER 图appointment_record到doctor_schedule是多对一doctor_schedule到doctor_info是多对一doctor_info到department是多对一。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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;执行计划里type是refkey是idx_doctor_work_date看起来走了索引但如果表里加了remark、create_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 ListDoctorScheduleVO listAvailableSchedules(Long doctorId, String date) { return scheduleMapper.selectAvailableSchedules(doctorId, date); }对应 XML 里写这条 SQLselect idselectAvailableSchedules resultTypecom.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() { ListLong 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_date和time_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.bat、2-run.bat、3-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:run2-run.bat是开发环境使用的方式mvn spring-boot:run会直接编译并启动内嵌 Tomcat 服务。3-build.bat一般建议用clean package -Dmaven.test.skiptrue先执行清理再打包避免上一次编译的脏 class 被打进 jar 里。这里要特别注意 Maven 版本和 JDK 版本的匹配关系项目说明里是 Maven 3.3 JDK 1.8如果你的机器装的是 JDK 17mvn 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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_USERNAME、DB_PASSWORD注入值Unknown databaseMySQL 里没有创建对应该 schema 的库Navicat 里新建 hospital_db 再导入 sqlPort 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: 10000accept-count是等待队列长度当请求超过max线程数后新请求会进入队列等待如果队列也被占满就直接拒绝连接。这几个参数的组合逻辑是请求先占min-spare的空闲线程不够时扩容到max再不够进accept-count队列队列满后再拒绝。压测时可以通过一个简单的 SQL 观察锁竞争情况SHOW STATUS LIKE innodb_row_lock_current_waits;这条语句返回的数值如果在压测持续上升说明号源扣减 SQL 的行锁等待严重此时先检查doctor_schedule表的索引有没有真正命中再考虑把扣减频率从每请求一次改成批量化合并。还有一种做法是把排班余号提前加载到本地内存预约成功后异步回写数据库但毕业设计阶段不建议用这个方案因为会增加数据一致性的解释成本。更稳妥的调优办法是改事务传播机制只把扣减号源 插入预约记录放入事务把日志写库和短信通知全部挪到事务外降低每个请求的事务占用时间。本文还有配套的精品资源点击获取
返回列表