ARTICLE DETAIL

资讯详情

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

SSM+MySQL医院预约挂号系统:号源模型、并发扣减与避坑指南

SSM+MySQL医院预约挂号系统:号源模型、并发扣减与避坑指南 简介这是一套面向高校计算机专业学生与Java初学者毕业设计、课程设计场景的医院预约挂号系统完整源码包基于SSM框架与MySQL数据库开发配套说明文档与LW资料可帮助读者快速搭建一个可正常运行的挂号预约项目解决选题无参考、环境难跑通的问题。压缩包共1386个文件约27.49MB以jsp页面、java后端代码、js脚本与css样式为主辅以png、gif、jpg等界面素材以及sql数据库脚本、xml配置和properties文件前后端与数据库资源齐全。系统覆盖个人中心、用户管理、科室信息管理、医生管理、出诊信息管理、预约时间段管理、挂号预约管理、问题反馈与解答管理、系统管理等模块功能结构贴近真实业务。目前已有104人学习下载适合需要完整赛题方案、可运行代码与数据库脚本的读者参考便于对照理解SSM分层结构与预约挂号业务流程。1. 医院预约挂号系统到底在解决什么问题从排队三小时到点两下手机如果你在医院信息科待过或者帮老师做过一个 Java 毕业设计你一定见过这样的场景早上七点半门诊大厅已经排了四五十人的长队导医拿着喇叭喊「挂内科的往左边走」窗口里的工作人员一边敲键盘一边接电话。这套流程的核心痛点不是「人多」而是号源信息不透明、分配靠先到先得、退号无法回流。医院预约挂号系统要干的事就是把这套线下排队逻辑搬到线上让患者能提前看到哪个医生、哪个时段还有号让医院能把号源按规则释放和回收。这个标题里的技术栈是 SSMSpring SpringMVC MyBatis MySQL这是国内高校 Java Web 方向毕业设计最主流的组合没有之一。原因很实际SSM 的配置量适中既不像纯 Servlet 那样什么都手写也不像 Spring Boot 那样把大量细节藏在自动配置里答辩时老师能问出东西你也能讲清楚每一层在干什么。MySQL 则是教学环境里最容易装、最容易查、资料最多的关系型数据库。这套系统适合谁如果你是计算机相关专业的应届生正在找一个「功能完整、技术栈主流、能讲清楚业务逻辑」的毕业设计题目医院预约挂号系统是一个很稳的选择。它的业务边界清晰——患者、医生、科室、号源、预约记录五张核心表就能撑起整个系统同时它又有足够的扩展空间比如分时段预约、爽约惩罚、排班模板这些都能在答辩时作为「系统亮点」展开。我见过太多同学在这个题目上翻车不是因为技术太难而是因为一开始就没想清楚号源到底怎么扣。是下单就扣还是支付才扣退号之后号源怎么回滚并发下两个人同时抢最后一个号怎么办这些问题不解决代码写得再漂亮答辩时老师一句「你这里超卖了怎么办」就能把你问住。接下来的内容我会按「先立住模型再动手实现最后排坑」的顺序把这个系统从零到一讲透。2. 号源模型与数据库设计五张表撑起整个预约流程2.1 为什么号源不能直接挂在医生表上很多同学的第一反应是医生表加一个remain_count字段预约一次减一退号加一完事。这个做法在单机、低并发、不考虑排班的情况下能跑但它有三个致命问题。第一医生和号源是一对多的关系。一个医生可能上午出诊、下午不出诊周一在总院、周三在分院如果号源直接挂在医生表上你没法表达「这个医生这周三上午在总院还有 20 个号」这种粒度。第二退号回流需要记录。如果只改数字你根本不知道这个号是谁退的、什么时候退的、有没有被重新预约。第三并发扣减没有原子性保障。两个线程同时读到remain_count 1各自减一写回结果变成 -1这就是经典的超卖。正确的做法是把「排班」和「号源」拆开。排班schedule描述「某医生在某天某时段在某诊室出诊」号源slot描述「这个排班下每个时间段有多少个号、已约多少个」。这样设计之后医生、排班、号源三层结构清晰退号只需要操作号源表排班模板可以复用。2.2 核心表结构与字段说明下面是我一般会用的建表语句字段名和类型都经过实际项目验证可以直接抄。注意version字段是给乐观锁用的后面讲并发控制时会展开。-- 科室表 CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 科室名称, description VARCHAR(200) DEFAULT NULL COMMENT 科室简介, status TINYINT DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生表 CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, department_id INT NOT NULL COMMENT 所属科室, title VARCHAR(20) COMMENT 职称主任医师/副主任医师等, intro VARCHAR(500) COMMENT 擅长领域, avatar VARCHAR(200) COMMENT 头像路径, status TINYINT DEFAULT 1, INDEX idx_dept (department_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排班表描述医生某天某时段的出诊安排 CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, work_date DATE NOT NULL COMMENT 出诊日期, time_period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL COMMENT 总号源数, booked_slots INT DEFAULT 0 COMMENT 已预约数, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, time_period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约记录表 CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id INT NOT NULL, schedule_id INT NOT NULL, seq_no INT NOT NULL COMMENT 就诊序号, status TINYINT DEFAULT 1 COMMENT 1已预约 2已取消 3已完成 4爽约, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_seq (schedule_id, seq_no), INDEX idx_patient (patient_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 患者表简化版实际可复用用户表 CREATE TABLE patient ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL COMMENT 存MD5或BCrypt, real_name VARCHAR(30), phone VARCHAR(20), id_card VARCHAR(20) COMMENT 身份证号 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计决策值得展开。schedule表上的uk_doctor_date_period唯一索引保证同一个医生同一天同一时段不会出现两条排班记录这是业务规则在数据库层面的兜底。appointment表的uk_schedule_seq唯一索引保证同一个排班下不会出现重复的就诊序号这是防止并发插入重复号的关键。version字段放在schedule表上而不是appointment表上因为并发冲突发生在号源扣减这个动作上锁的粒度应该是排班级别。2.3 号源扣减的两种时机与选择理由号源什么时候扣直接决定了系统的并发模型和用户体验。常见做法有两种下单即扣用户点击「确认预约」的瞬间booked_slots加一同时插入appointment记录。优点是实现简单号源实时准确缺点是如果用户不支付假设有支付环节号源会被占用一段时间需要额外的超时释放逻辑。支付后扣用户下单时只生成一个待支付订单支付成功回调时才真正扣减号源。优点是用户体验好不会因为犹豫而占号缺点是并发窗口更大支付回调可能重复需要幂等处理。对于毕业设计这个场景我建议用下单即扣 定时任务释放超时未支付订单的方案。原因很实际答辩时老师更关注你「有没有考虑并发」而不是「支付流程有多完整」。下单即扣能让你把乐观锁、唯一索引、事务这些点讲清楚定时任务又能体现你对「号源回流」的理解。如果你选了支付后扣反而要花大量篇幅解释支付回调的幂等性偏离了预约挂号这个核心业务。3. SSM 三层架构落地从 Controller 到 Mapper 的完整链路3.1 项目结构与依赖配置SSM 项目的目录结构在不同 IDE 下略有差异但核心分层是一致的。我一般会按下面这样组织src/main/java/com/hospital/ ├── controller/ // 接收请求参数校验 ├── service/ // 业务逻辑事务边界 │ └── impl/ ├── mapper/ // MyBatis 接口 ├── entity/ // 数据库实体 ├── dto/ // 传输对象 └── common/ // 统一返回、异常处理 src/main/resources/ ├── spring-dao.xml // 数据源 MyBatis ├── spring-service.xml // 事务 Service 扫描 ├── spring-web.xml // MVC Controller 扫描 └── mapper/ // XML 映射文件Maven 依赖里除了 Spring 全家桶和 MyBatis还需要mybatis-spring做整合druid做连接池jackson做 JSON 转换。版本号我不写死你用 IDEA 创建 Maven 项目时选稳定的 release 版本即可注意 Spring 5.x 和 JDK 8 的搭配最稳JDK 17 需要额外处理模块化问题新手不建议碰。3.2 预约核心接口的实现与事务控制预约接口是整个系统的心脏它要完成三件事检查号源是否充足、扣减号源、插入预约记录。这三步必须在同一个事务里否则会出现「号扣了但记录没插进去」或者「记录插了但号没扣」的脏数据。Service public class AppointmentServiceImpl implements AppointmentService { Autowired private ScheduleMapper scheduleMapper; Autowired private AppointmentMapper appointmentMapper; Override Transactional(rollbackFor Exception.class) public Result book(Integer patientId, Integer scheduleId) { // 1. 查询排班带版本号 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule null) { return Result.fail(排班不存在); } if (schedule.getBookedSlots() schedule.getTotalSlots()) { return Result.fail(号源已约满); } // 2. 乐观锁扣减version 必须匹配且已约数小于总数 int affected scheduleMapper.decreaseSlot( scheduleId, schedule.getVersion()); if (affected 0) { // 说明在查询和更新之间有人改了数据 return Result.fail(当前预约人数较多请重试); } // 3. 计算就诊序号并插入预约记录 int seqNo schedule.getBookedSlots() 1; Appointment appt new Appointment(); appt.setPatientId(patientId); appt.setScheduleId(scheduleId); appt.setSeqNo(seqNo); appt.setStatus(1); appointmentMapper.insert(appt); return Result.success(appt); } }对应的 Mapper XML 里扣减语句必须带上版本号条件update iddecreaseSlot UPDATE schedule SET booked_slots booked_slots 1, version version 1 WHERE id #{id} AND version #{version} AND booked_slots lt; total_slots /update这段代码的逻辑说明先查后改的模式下version字段充当了「数据没被别人动过」的凭证。如果两个线程同时查到version 3第一个线程更新成功把 version 变成 4第二个线程的WHERE version 3就匹配不到任何行affected 0直接返回失败让用户重试。booked_slots total_slots这个条件则是最后一道防线即使版本号碰巧匹配也不会把号源扣成负数。参数说明scheduleId是排班主键patientId从登录 session 里取不要从前端传否则用户可以伪造别人的 ID 来预约。seqNo用booked_slots 1计算配合唯一索引uk_schedule_seq即使并发下算出相同的序号数据库也会拒绝第二条插入事务回滚号源自动恢复。3.3 退号与号源回流的实现细节退号不是简单地把booked_slots减一。你需要先校验这条预约记录的状态是不是「已预约」如果是「已取消」或「已完成」就不能再退。然后更新预约状态为已取消同时把号源减回去。Override Transactional(rollbackFor Exception.class) public Result cancel(Integer appointmentId, Integer patientId) { Appointment appt appointmentMapper.selectById(appointmentId); if (appt null || !appt.getPatientId().equals(patientId)) { return Result.fail(预约记录不存在); } if (appt.getStatus() ! 1) { return Result.fail(当前状态不可取消); } // 更新预约状态 int updated appointmentMapper.cancel(appointmentId); if (updated 0) { return Result.fail(取消失败请重试); } // 号源回流 scheduleMapper.increaseSlot(appt.getScheduleId()); return Result.success(); }appointmentMapper.cancel的 SQL 要带上status 1条件这是幂等性的保证如果两个请求同时退同一个号只有一个能更新成功另一个affected 0直接返回。号源回流用booked_slots booked_slots - 1注意加booked_slots 0条件防止减成负数。这里有个容易被忽略的点退号之后这个序号会不会被重新分配我的做法是不重新分配序号只增不减。因为患者可能已经拿着这个序号去排队了如果号源回流后新预约的人拿到同样的序号现场会乱。号源回流只是让booked_slots减一新预约的人拿到的序号是booked_slots 1可能会和之前退号的序号重复但配合唯一索引重复时插入会失败需要重试。更稳妥的做法是单独维护一个seq_counter字段只增不减但这对毕业设计来说有点过度设计用唯一索引兜底就够了。4. 并发预约的避坑与排查那些答辩时被问住的瞬间4.1 超卖问题为什么加了事务还是卖多了现象压测时用 JMeter 开 50 个线程抢 10 个号结果booked_slots变成了 13预约记录也有 13 条。原因事务只保证原子性不保证隔离性。默认的REPEATABLE READ隔离级别下两个事务可以同时读到booked_slots 9各自判断「还有号」然后各自加一写回。这就是典型的读-改-写竞态。解决用乐观锁版本号或悲观锁SELECT ... FOR UPDATE。乐观锁适合冲突少的场景实现简单悲观锁适合冲突多的场景但会阻塞。毕业设计里用乐观锁就够了把version字段加上更新时带上版本条件失败就重试或提示用户。如果老师追问「重试几次」你可以说「前端提示用户手动重试或者后端做一次自动重试」。4.2 重复预约同一个患者怎么约到了两个号现象用户快速点击两次「确认预约」生成了两条预约记录扣了两个号。原因前端没有防重复提交后端也没有做幂等校验。第一次请求还没返回第二次请求已经进来了。解决在appointment表上加唯一索引uk_patient_schedule (patient_id, schedule_id, status)但status会变这个索引不好用。更实际的做法是在 Service 层加一个「同一患者同一排班只能有一条有效预约」的校验用SELECT ... FOR UPDATE锁住患者的预约记录或者用 Redis 分布式锁如果项目里引入了 Redis。最简单的方案是前端按钮点击后置灰后端在插入前查一次SELECT COUNT(*) FROM appointment WHERE patient_id ? AND schedule_id ? AND status 1虽然不能完全防住并发但配合唯一索引能兜住大部分情况。4.3 号源显示不准列表页显示有号点进去说约满了现象排班列表页显示「剩余 5 个号」用户点进详情页点预约提示「号源已约满」。原因列表页和详情页查的是同一份数据但中间有时间差。列表页渲染时还有 5 个号用户看了 10 秒才点这 10 秒里号被抢完了。解决这不是 bug是分布式系统的常态。正确的做法是在详情页和提交预约时都做一次实时校验列表页的剩余号数只作为参考。前端可以在提交前再查一次号源如果变了就提示用户「号源已更新请刷新」。答辩时如果老师问「怎么保证实时性」你可以说「号源是准实时的最终一致性由数据库的唯一索引和乐观锁保证」。4.4 事务不回滚为什么抛了异常号还是扣了现象在book方法里手动throw new RuntimeException()结果booked_slots还是加了 1。原因Spring 的Transactional默认只对RuntimeException回滚如果你抛的是Exception或者捕获了异常没往外抛事务不会回滚。另外如果book方法被同一个类里的另一个方法直接调用this 调用代理不生效事务也不会回滚。解决Transactional(rollbackFor Exception.class)显式指定回滚异常类型。确保book方法是通过 Spring 代理调用的不要在 Controller 里直接new AppointmentServiceImpl()。如果需要在同类方法间调用用AopContext.currentProxy()或者把方法拆到不同的 Service 里。4.5 连接池耗尽压测到 100 并发就卡死现象JMeter 开到 100 线程时请求全部超时日志里出现GetConnectionTimeoutException。原因Druid 连接池默认最大连接数是 8 或 10100 个并发请求同时要连接大部分在排队等连接等不到就超时。解决在spring-dao.xml里调大maxActive比如设成 50同时设置合理的maxWait比如 3000ms。但要注意连接数不是越大越好MySQL 默认最大连接数是 151你设太大反而会把数据库拖垮。毕业设计环境下maxActive 20到50之间足够应对答辩演示。另外检查一下有没有连接泄漏比如手动获取 Connection 没关闭或者 MyBatis 的 SqlSession 没释放。5. 从能跑到能讲答辩演示与代码复现的几个关键技巧5.1 用一条 SQL 验证号源一致性答辩时老师最可能让你「现场证明一下没有超卖」。你可以准备一条对账 SQL在演示前跑一遍把结果展示出来SELECT s.id, s.total_slots, s.booked_slots, COUNT(a.id) AS actual_booked FROM schedule s LEFT JOIN appointment a ON a.schedule_id s.id AND a.status 1 GROUP BY s.id HAVING s.booked_slots COUNT(a.id);这条 SQL 的逻辑是schedule表里记录的已约数应该等于appointment表里状态为「已预约」的记录数。如果查出来有行说明数据不一致要么是扣了号没插记录要么是插了记录没扣号。正常情况下应该返回空结果。这个技巧我在实际项目里也常用上线前跑一遍心里有底。5.2 演示顺序的设计先正常流程再异常流程很多同学答辩时一上来就演示「预约成功」老师看完觉得「就这」。更好的顺序是先演示一次正常预约让老师看到完整链路然后演示一次「约满」的场景展示系统怎么拦截最后演示一次「退号后重新预约」展示号源回流。这样老师能看到你对边界情况的处理印象分直接拉满。演示数据要提前准备好。比如某个排班的total_slots 3你提前约掉 2 个现场约第 3 个成功约第 4 个失败。退掉一个之后再约又能成功。整个过程控制在 2 分钟内不要现场改数据库不要现场重启服务。5.3 代码复现时最容易卡住的三个点第一个是数据库连接配置。spring-dao.xml里的jdbcUrl要带上useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8否则 MySQL 8.x 会报时区错误或 SSL 警告。用户名密码写你本地的不要抄网上的。第二个是MyBatis 映射文件路径。spring-dao.xml里配置mapperLocations时路径要写classpath:mapper/*.xml并且确保src/main/resources/mapper/目录下有对应的 XML 文件。如果报Invalid bound statement八成是 XML 没被扫描到或者 namespace 和 Mapper 接口的全限定名不一致。第三个是事务不生效。检查spring-service.xml里有没有开tx:annotation-driven transaction-managertransactionManager/以及transactionManager的 bean 有没有正确注入dataSource。如果用了Transactional但没生效先看 Spring 日志里有没有Cannot proxy target class之类的警告。5.4 一个我踩过的坑别在实体类里用基本类型Schedule实体类里的bookedSlots和totalSlots我一开始用的是int。后来做退号的时候booked_slots - 1如果数据库里是 0减完变成 -1MyBatis 映射到int不会报错但业务上已经错了。改成Integer之后至少能在 Service 层做null判断。更稳妥的做法是在数据库层面加CHECK (booked_slots 0)但 MySQL 5.7 不支持 CHECK8.0 才支持。所以我在 Service 层加了一行if (schedule.getBookedSlots() 0) return Result.fail(号源异常);虽然看起来多余但能防止脏数据扩散。这个习惯我保持到现在实体类字段能用包装类型就用包装类型数据库字段能加约束就加约束Service 层能加校验就加校验。三层防护总有一层能兜住。希望帮到你。本文还有配套的精品资源点击获取
返回列表