简介:这份资源是面向计算机专业学生与Java初学者的一套医院预约挂号系统毕业设计完整资料,包含论文与可运行源码,适合作为课程设计、毕业设计选题或Java Web入门练手项目。压缩包共约2000个文件,整体86.78MB,以gif图片、xml配置、htm与aspx页面、css样式、cs与js脚本为主,另含doc论文、docx说明、sql数据库文件(mdf/ldf)及少量dll组件,覆盖前端页面、后端逻辑与数据库脚本等模块。资源已有208人学习下载,具备一定参考热度。读者可从中获取完整的系统需求分析、功能模块划分、数据库表结构设计、论文撰写框架与源码实现思路,便于对照理解预约挂号、科室医生管理、用户登录等核心业务的开发流程,也可作为二次开发与答辩准备的参考素材。
1. 从一份课程设计到能跑的门诊挂号系统:Java 方案到底值不值得做
很多同学第一次接触「基于 Java 的医院预约挂号系统设计与实现」这类题目,是在课程设计或毕业设计阶段。标题看着平平无奇,真动手才发现它把 Java Web 的几块硬骨头全串在了一起:用户与权限、号源并发、时间片管理、订单状态机、数据库事务。它不是一个 CRUD 练习,而是一个典型的「读多写少、但写的那一下必须准」的业务系统。挂号的核心矛盾只有一句话:同一个医生、同一个时段,绝不能卖出两份号。这句话决定了你后面所有的表结构、锁策略和接口设计。
这套方案适合谁?适合已经会写 Servlet 或 Spring Boot 基础接口、但没做过真实并发约束的开发者;也适合需要一份能讲清楚设计取舍的完整项目的人。它解决的不是「怎么连数据库」,而是「怎么在并发下保证号源不超卖、状态不乱跳、超时能回收」。下面我按一个能真正跑起来的顺序,把选型、建表、核心接口、并发控制和排错讲透,你照着做就能得到一个可演示、可压测、可写进文档的系统。
2. 先把领域模型定死:号源、排班与订单三张核心表怎么设计
很多人一上来就写 Controller,结果写到一半发现「号源」这个概念没定义清楚,返工重来。血泪经验是:挂号系统的复杂度 80% 在数据模型,代码只是把模型翻译一遍。所以这一章先把三张核心表和它们的关系钉死,再谈接口。
2.1 为什么是「排班 → 号源 → 订单」而不是「医生 → 号源」
最常见的错误设计是让医生直接挂一堆号源,结果没法表达「某医生周三上午在 A 院区、下午在 B 院区」这种真实排班。正确做法是引入一层排班(schedule):排班描述「谁、在哪、哪天、几点到几点、放多少个号」,号源(slot)是排班按时间片切出来的最小可预约单元,订单(appointment)则是对某个号源的占用凭证。
这样分层的好处是:停诊只需把排班置为不可用,号源批量失效;加号只需在排班里追加号源数量;退号只需释放订单对应的号源状态。三者职责清晰,后面加「专家号/普通号不同价格」「上午/下午不同时段」都不用改核心结构。
| 表名 | 关键字段 | 作用 | 关键约束 |
|---|---|---|---|
| schedule | id, doctor_id, dept_id, work_date, period, total_slots, status | 描述一次排班 | (doctor_id, work_date, period) 唯一 |
| slot | id, schedule_id, slot_no, start_time, end_time, status, version | 最小可预约单元 | (schedule_id, slot_no) 唯一 |
| appointment | id, slot_id, patient_id, status, create_time, expire_time | 占用凭证 | slot_id 上建唯一索引(针对有效订单) |
注意 slot 表里的 version 字段,这是后面乐观锁要用的,先埋下。appointment 的 slot_id 唯一索引不能简单建成全表唯一,因为退号后要允许重新预约,常见做法是加一个「有效标记」列参与联合唯一,或者用状态过滤的部分索引(MySQL 8 支持函数索引,PostgreSQL 支持 partial index)。
2.2 建表 SQL 与字段含义
下面这段 SQL 可以直接在 MySQL 8 上执行,注释里写清了每个字段为什么存在。
-- 排班表:一次排班对应某医生某天某半天 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL COMMENT '医生ID', dept_id BIGINT NOT NULL COMMENT '科室ID', work_date DATE NOT NULL COMMENT '出诊日期', period TINYINT NOT NULL COMMENT '1上午 2下午', total_slots INT NOT NULL COMMENT '放号总数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停诊', UNIQUE KEY uk_doc_date_period (doctor_id, work_date, period) ) COMMENT '医生排班'; -- 号源表:排班切出来的时间片,是并发争抢的最小单位 CREATE TABLE slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, slot_no INT NOT NULL COMMENT '第几号,从1开始', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0可约 1已约 2锁定中', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_schedule_slot (schedule_id, slot_no), KEY idx_schedule_status (schedule_id, status) ) COMMENT '号源'; -- 订单表:一次预约的凭证 CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, slot_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已确认 2已取消 3已完成', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME NOT NULL COMMENT '待支付超时时间', KEY idx_patient (patient_id, status), KEY idx_slot (slot_id, status) ) COMMENT '预约订单';逻辑说明:slot 的 status 用 0/1/2 三态而不是布尔,是因为「锁定中」这个中间态在「下单未支付」场景里必须存在,否则并发下会出现两个请求都读到「可约」然后都去改。version 字段配合后面的乐观锁更新。appointment 的 expire_time 是超时回收的依据,没有它,用户下单不付款就会永久占号。
参数说明:period 用 TINYINT 而不是字符串,省空间且便于索引;slot_no 从 1 开始方便前端展示「第 3 号」;expire_time 建议设为 create_time + 15 分钟,这个值要可配置,别写死。
2.3 状态机:订单状态不能随便改
订单状态必须用状态机约束,否则会出现「已取消的订单又被确认」这种脏数据。常见做法是在 Service 层用一个 Map 定义合法迁移,任何不在表里的迁移直接抛异常。
// 合法状态迁移:key 是当前状态,value 是允许到达的状态集合 private static final Map<Integer, Set<Integer>> TRANSITIONS = Map.of( 0, Set.of(1, 2), // 待支付 -> 已确认 / 已取消 1, Set.of(2, 3), // 已确认 -> 已取消 / 已完成 2, Set.of(), // 已取消是终态 3, Set.of() // 已完成是终态 ); public void changeStatus(Appointment appt, int target) { Set<Integer> allowed = TRANSITIONS.getOrDefault(appt.getStatus(), Set.of()); if (!allowed.contains(target)) { throw new IllegalStateException("非法状态迁移: " + appt.getStatus() + " -> " + target); } appt.setStatus(target); }逻辑说明:把迁移规则集中在一处,比散落在各个 if 里可靠得多。参数说明:状态码要和数据库注释保持一致,改一处必须改另一处,建议用常量类而不是魔法数字。这一步做完,你的系统在「状态正确性」上就已经超过大部分课程设计了。
3. 用 Spring Boot 把预约主链路跑通:从抢号到落库
模型定好后,主链路其实只有四步:查可约号源、锁定号源、创建订单、支付确认。难点全在第二步的并发控制。这一章给出可运行的接口实现,并解释每个参数为什么这么设。
3.1 项目结构与依赖
我一般用 Spring Boot 3 + MyBatis-Plus + MySQL 8,JDK 17。依赖不用多,核心就这几个。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>逻辑说明:Redis 不是必须的,但后面做「号源缓存 + 分布式锁」会用到,先加上。参数说明:MyBatis-Plus 版本选 3.5.x 即可,别追最新,稳定优先。数据库连接池用默认 HikariCP,最大连接数按你压测的并发量调,一般 20 起步。
3.2 抢号接口:乐观锁 + 唯一索引双保险
抢号的核心 SQL 只有一句:UPDATE slot SET status=1, version=version+1 WHERE id=? AND status=0 AND version=?。影响行数为 1 才算抢到,为 0 说明被别人抢先,直接返回失败。这是乐观锁,不需要显式加锁,性能好。
@Service public class AppointmentService { @Autowired private SlotMapper slotMapper; @Autowired private AppointmentMapper apptMapper; @Transactional(rollbackFor = Exception.class) public Long grab(Long slotId, Long patientId) { // 1. 读出当前号源,拿到 version Slot slot = slotMapper.selectById(slotId); if (slot == null || slot.getStatus() != 0) { throw new BizException("号源不可约"); } // 2. 乐观锁更新:只有 status 和 version 都匹配才成功 int rows = slotMapper.lockSlot(slotId, slot.getVersion()); if (rows == 0) { throw new BizException("手慢了,号已被抢"); } // 3. 创建待支付订单,expire_time 15 分钟后 Appointment appt = new Appointment(); appt.setSlotId(slotId); appt.setPatientId(patientId); appt.setStatus(0); appt.setExpireTime(LocalDateTime.now().plusMinutes(15)); apptMapper.insert(appt); return appt.getId(); } }对应的 Mapper XML:
<update id="lockSlot"> UPDATE slot SET status = 1, version = version + 1 WHERE id = #{slotId} AND status = 0 AND version = #{version} </update>逻辑说明:先查后改之间有时间窗,但乐观锁的 WHERE 条件保证了只有 version 没变才能改成功,所以窗口内被别人改了就会失败,安全。参数说明:@Transactional保证订单插入和号源更新在同一事务,任一步失败都回滚。expire_time 的 15 分钟要抽成配置项,别硬编码。
提示:乐观锁在高并发下失败率会上升,如果压测发现大量「手慢了」,说明冲突激烈,可以退化成「Redis 预扣减 + 异步落库」,但那是进阶方案,课程设计阶段乐观锁足够。
3.3 超时未支付自动回收
用户抢到号不付款,号源必须能回来。常见做法是定时任务扫描过期订单,把订单置为已取消、号源置回可约。这里要注意顺序:先改订单再改号源,且都要在事务里。
@Scheduled(fixedDelay = 60000) // 每分钟扫一次 @Transactional(rollbackFor = Exception.class) public void recycleExpired() { List<Appointment> expired = apptMapper.selectExpired( LocalDateTime.now(), 0 /* 待支付 */); for (Appointment appt : expired) { appt.setStatus(2); // 已取消 apptMapper.updateById(appt); // 号源置回可约,version 继续加一 slotMapper.releaseSlot(appt.getSlotId()); } }逻辑说明:selectExpired的 SQL 是WHERE status=0 AND expire_time < now(),走 idx_slot 之外的 expire_time 索引更佳,建议给 expire_time 单独建索引。参数说明:fixedDelay 用 60 秒是权衡,太频繁浪费,太慢用户等得久。releaseSlot 的 SQL 是UPDATE slot SET status=0, version=version+1 WHERE id=?,注意这里不加 version 条件,因为回收是系统行为,允许覆盖。
3.4 查询可约号源:别让前端拉全表
查询接口要按排班维度返回,且只返回可约的号源。常见做法是SELECT * FROM slot WHERE schedule_id=? AND status=0 ORDER BY slot_no,配合 idx_schedule_status 索引,毫秒级返回。
public List<SlotVO> listAvailable(Long scheduleId) { return slotMapper.selectList( new LambdaQueryWrapper<Slot>() .eq(Slot::getScheduleId, scheduleId) .eq(Slot::getStatus, 0) .orderByAsc(Slot::getSlotNo) ).stream().map(SlotVO::from).toList(); }逻辑说明:只查 status=0,已约的号不返回,前端就不用过滤。参数说明:如果号源量大,可以加分页,但一个排班的号源通常几十个,不分页也扛得住。这里可以顺手把结果缓存进 Redis,key 用slot:schedule:{id},TTL 设 30 秒,抢号成功后主动删缓存。
4. 并发压测下暴露的四个坑:从超卖到缓存不一致
系统能跑通不代表能扛住。这一章是我在本地用 JMeter 压测时踩出来的坑,每条都按「现象 → 原因 → 解决」写,你大概率也会遇到。
4.1 坑一:压测出现超卖,同一号源卖出两份
现象:100 个并发抢 10 个号,最后订单表里有 12 条有效记录。原因:有人把乐观锁写成了「先查 status,再无条件 update」,中间没有 version 条件,两个线程都查到 status=0,都更新成功。解决:update 的 WHERE 必须同时带 status 和 version,且判断影响行数。这是最经典的翻车点,代码看着对,跑起来就错。
4.2 坑二:事务里调用了远程接口,导致锁持有时间过长
现象:压测时吞吐量上不去,数据库连接池被打满。原因:有人在@Transactional方法里调了短信通知接口,网络耗时几百毫秒,事务一直不提交,行锁不释放。解决:把非数据库操作挪到事务外,用事务同步回调或消息队列异步处理。记住一个原则:事务里只做数据库操作,越短越好。
4.3 坑三:Redis 缓存与数据库不一致,用户看到假号源
现象:号已经被抢完,列表页还显示可约,点进去才报错。原因:抢号成功后没删缓存,或者删缓存失败没重试。解决:抢号成功后在事务提交后删缓存(用TransactionSynchronizationManager注册回调),并给缓存设短 TTL 兜底。别用「更新缓存」,并发下更新也会乱,删除更安全。
4.4 坑四:定时任务在多实例部署下重复执行
现象:单机没问题,部署两个实例后,同一个过期订单被回收两次,号源 version 被多加。原因:@Scheduled在每个实例都会跑。解决:用分布式锁(Redis SETNX)保证同一时刻只有一个实例执行,或者干脆把定时任务拆成独立服务。课程设计单机跑没事,但你要知道这个边界。
注意:压测时把日志级别调到 WARN,否则大量 INFO 日志会拖慢系统,让你误以为是代码问题。
5. 让系统更像真实门诊:分时段放号与验证技巧
前面四章把主链路和并发讲完了,这一章说一个能让你的项目在答辩或评审里加分的进阶点:分时段放号。真实门诊不是一次性放完所有号,而是按时间段放,比如 8:00-8:30 放 5 个、8:30-9:00 放 5 个。实现上不用改表结构,只要在生成 slot 时按时间片切分即可。
// 按 30 分钟一片,把排班切成多个 slot public void generateSlots(Schedule schedule) { LocalTime start = schedule.getPeriod() == 1 ? LocalTime.of(8, 0) : LocalTime.of(13, 30); int perSlot = 5; // 每片放 5 个号 int total = schedule.getTotalSlots(); int slotNo = 1; LocalTime cursor = start; while (slotNo <= total) { for (int i = 0; i < perSlot && slotNo <= total; i++) { Slot slot = new Slot(); slot.setScheduleId(schedule.getId()); slot.setSlotNo(slotNo++); slot.setStartTime(LocalDateTime.of(schedule.getWorkDate(), cursor)); slot.setEndTime(LocalDateTime.of(schedule.getWorkDate(), cursor.plusMinutes(30))); slot.setStatus(0); slot.setVersion(0); slotMapper.insert(slot); } cursor = cursor.plusMinutes(30); } }逻辑说明:外层按时间片推进,内层在每片里放固定数量的号,slot_no 全局递增保证唯一。参数说明:perSlot 和片长(30 分钟)都应该是配置项,不同科室可能不一样。这段代码放在排班创建后触发,别放在查询时算,否则每次查询都要重算。
验证方法上,我习惯用三个层次:单元测试验状态机迁移、集成测试验抢号并发(用 CountDownLatch 模拟 100 线程)、手工验超时回收(把 expire_time 改成 10 秒等一分钟看号源是否回来)。三层都过,基本可以放心演示。
最后一个技巧:给号源列表加一个「预计等待时间」字段,用slot_no * 平均问诊时长估算,前端展示出来,用户会觉得系统很专业。这个字段不用存库,查询时算就行。
我自己做这类系统最大的教训是:别急着写界面,先把并发和状态这两件事在 Service 层用测试跑通,界面只是壳。壳可以丑,但号不能超卖,状态不能乱跳。希望帮到你。
本文还有配套的精品资源,点击获取