Java医院挂号系统并发与队列设计:Spring Boot+MyBatis源码拆解
简介基于Java的医院预约挂号排队叫号系统设计源码针对医院信息化中预约挂号和排队叫号的实际场景适合Java学习者、课程设计及毕业设计开发者参考。资源共273个文件核心是198个java文件涵盖预约登记、排班管理、叫号顺序控制等业务逻辑31个xml、13个yml与12个properties文件用于Spring等框架配置jar与mvnw辅助项目构建与运行整个zip包约619KB目录结构按功能模块划分便于快速定位。通过阅读源码可以理解医院预约挂号的流程设计、队列状态管理以及前后端配置方式为二次开发或功能扩展提供基础。目前已有660人学习下载对于需要完成相关课题或搭建医院业务系统原型的开发者有直接参考价值。1. 同是Java面试题里的“医院挂号系统”能扛住并发和叫得上号的没几个不少五年以上Java开发在面试时都写过“医院预约挂号系统”这个业务题但真正线下部署过的人会发现难点根本不在CRUD号源一旦被并发抢挂就会超卖签到之后患者又在诊室前挤成一团。本文要拆的这套源码是一个基于Java、Spring Boot体系的医院预约挂号与排队叫号项目272个文件中198个Java文件占了绝对大头xml和yml各占31与13个说明业务主体是服务端逻辑前端依赖接口驱动。对想研究“号源锁怎么加、排队队列用什么数据结构、叫号状态怎么流转”的开发者来说这份源码的价值正好落在这些细节上。2. 从272个文件反推架构Spring Boot MyBatis 的分层约束拿到一个源码包先别急着启动我习惯用文件分布判断项目边界。这里198个Java文件、31个xml、13个yml的组合非常典型xml中大部分是MyBatis的Mapper映射文件yml是Spring Boot多环境配置properties里还藏着数据源与框架参数。用一个命令就能把整个项目的包结构看透。find . -name *.java | sed s|/[^/]*$|| | sort | uniq -c | sort -rn | head -30这段命令的含义是先递归找出所有Java文件用sed去掉文件名只保留路径前缀再排序去重统计。从中能看到controller、service、mapper、entity这些包名各自的文件数量如果service层比controller层厚说明业务逻辑确实写在服务层而不是堆在接口里。结合源码的实际构成这套项目走的正是标准的Controller接收参数、Service编排事务、Mapper操作数据库的三段式。2.1 单模块还是多模块从gitignore和yml数量判断项目里有12个gitignore和13个yml这通常说明内部存在多个可独立启动的子模块或至少是多套profile配置。Spring Boot的约定是每个模块都有独立的pom.xml和application.yml而gitignore会在每个嵌套目录自动生成一份导致文件计数膨胀。启动类只会扫描当前模块的包路径所以如果源码被拆分成了gateway、business、common等模块改动业务代码时必须在正确的模块目录下操作否则会出现类找不到的诡异报错。对于这类多模块Spring Boot工程核心的依赖管理方式是在根pom里统一锁定版本号子模块只声明依赖不写版本。这样做的好处是升级框架时只需改动一处坏处是IDE导入时如果本地Maven仓库没有对应版本会卡在依赖解析上。遇到这种情况先检查根pom里定义的spring-boot-parent版本是否与当前JDK兼容Spring Boot 2.x对应JDK 8或11Spring Boot 3.x则强制要求JDK 17。2.2 三层架构中关键业务逻辑该放在哪一层预约挂号和排队叫号这类场景天然涉及事务和状态变更我一般会把核心流程写成Service层的多个方法由Controller只做参数校验和结果包装。例如一个典型的科室排班查询接口Controller负责接收前端传来的科室ID和日期参数Service层校验排班日期不能早于当天然后调用Mapper查询剩余号源。请看一个简化但完整的实现Service public class ScheduleService { private final ScheduleMapper scheduleMapper; public ScheduleService(ScheduleMapper scheduleMapper) { this.scheduleMapper scheduleMapper; } Transactional(readOnly true) public ListScheduleVO getAvailableSchedules(Long deptId, String date) { if (deptId null || date null) { throw new IllegalArgumentException(科室ID和日期不能为空); } if (date.compareTo(LocalDate.now().toString()) 0) { throw new IllegalArgumentException(只能查询今天及以后的排班); } return scheduleMapper.selectAvailableByDeptAndDate(deptId, date); } }这里用Transactional(readOnly true)标记只读事务可以引导数据库优化器走只读路径减少锁开销。参数校验放在Service层而不是Controller层是因为挂号和叫号接口可能被多个调用方复用不能依赖上层过滤非法参数。如果你在自己的项目里遇到“查询慢但不报错”的情况优先检查这类查询SQL是否在日期字段上建了索引排班表如果达到十万条以上没有索引即使逻辑写对了页面也会卡顿。3. 预约挂号不是写接口号源锁与订单状态机预约挂号最怕的就是超卖一个医生的号源只剩1个10个人同时提交结果10单全成功了。如果只在Service层用synchronized关键字锁方法虽然单机部署时有效但一旦水平扩容到两台实例就失效了。常见做法是把锁的维度放到数据库行上用SELECT ... FOR UPDATE锁住排班记录或者引入Redis做预扣减。这个系统的场景属于医院内部系统并发量在每秒几十到几百之间数据库行锁足够可靠引入Redis反而增加运维负担。3.1 号源表的锁设计悲观锁还是乐观锁先看一个最直接的排班表结构核心字段包括排班ID、科室ID、医生ID、号源总数、已预约数、排班日期和版本号。用悲观锁实现时预约接口会先查询排班记录并锁定该行然后判断已预约数是否小于号源总数满足条件才插入订单并更新已预约数。CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, total_count INT NOT NULL, booked_count INT NOT NULL DEFAULT 0, schedule_date DATE NOT NULL, version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_doctor_date (doctor_id, schedule_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的UNIQUE KEY (doctor_id, schedule_date)非常关键数据库层面保证同一个医生同一天只有一条排班记录这是防超卖的最后一道防线。如果你把唯一键建错或者漏掉应用层锁写得再严谨并发插入时也会产生两条指向同一医生同一日期的排班记录。我见过不少项目因为这个唯一键缺失导致后面的Order表关联到错误的排班ID统计报表乱成一团。3.2 秒杀式扣减号源用行锁撑住关键路径选择悲观锁方案时预约方法的执行顺序是先开启事务然后执行带FOR UPDATE的查询再判断余量。这种锁会持续到事务提交才释放所以事务里绝对不能做远程调用或耗时操作。请看核心代码Transactional public Long createAppointment(AppointmentRequest req) { Schedule schedule scheduleMapper.selectByDoctorAndDateForUpdate( req.getDoctorId(), req.getScheduleDate()); if (schedule null) { throw new RuntimeException(该医生当日无排班); } if (schedule.getBookedCount() schedule.getTotalCount()) { throw new RuntimeException(号源已约满); } scheduleMapper.increaseBookedCount(schedule.getId()); Appointment appointment new Appointment(); appointment.setScheduleId(schedule.getId()); appointment.setPatientId(req.getPatientId()); appointment.setStatus(BOOKED); appointmentMapper.insert(appointment); return appointment.getId(); }selectByDoctorAndDateForUpdate对应的Mapper SQL是SELECT * FROM schedule WHERE doctor_id ? AND schedule_date ? FOR UPDATE其中的FOR UPDATE会把这条排班记录加上排他锁第二个事务的相同查询只能等待。increaseBookedCount在MyBatis中通常写成UPDATE schedule SET booked_count booked_count 1 WHERE id ?这个自增由数据库完成避免读改写三步之间产生竞态。整个方法的耗时决定锁粒度把号源判断和订单插入放在一个事务里是最稳妥的做法。方案实现难度并发支撑失败处理数据库悲观锁低SQL加FOR UPDATE单表行锁适合百级QPS超时重试或直接报错版本号乐观锁中更新时校验version冲突多时重试成本高更新行数为0则重试Redis预扣高需引入中间件千级QPS以上需处理Redis与DB一致性上表对比了三种常见的号源控制方案这套源码配套的部署文档指向的是数据库行锁思路但如果你的医院场景要对接线上App号源可能在App端被大量用户同时点击就不得不考虑引入Redis预扣减。Redis扣减号源时用INCRBY操作保证原子性扣减成功后再写订单到数据库异步对账脚本定时把Redis余量同步回MySQL。3.3 订单状态机从待支付到就诊完成的流转预约成功只代表进入了预约队列患者还需要在就诊前完成签到取号整个订单状态才能推进。常见的状态设计是BOOKED已预约、CONFIRMED已确认、CHECKED_IN已签到、COMPLETED已完成、CANCELLED已取消、NO_SHOW爽约。状态更新必须带上前置状态条件防止乱序更新。Update(UPDATE appointment SET status #{targetStatus} WHERE id #{id} AND status #{currentStatus}) int compareAndSetStatus(Long id, String currentStatus, String targetStatus);这种写法在并发场景下先到先更新的请求成功后到的请求因为WHERE条件中status不匹配而更新0行应用层拿到0行就知道状态已经变了可以选择抛异常或触发补偿流程。很多看起来很奇怪的Bug比如“患者还没签到就已经完成就诊”基本都是因为状态更新时没有带上前置状态条件导致两个线程先后覆盖了同一条记录。4. 排队叫号队列模型状态流转与过号补偿预约是入口排队叫号才是诊室秩序的保证。患者到场签到后进入队列医生每看完一个患者就点击叫下一个大屏同步刷新。这套系统里排队队列不是用MQ实现的而是直接借助数据库表和状态字段完成FIFO流转。对于医院局域网环境这种设计的好处是部署简单、不引入额外组件坏处是并发量高时数据库压力集中在队列表上。4.1 队列表结构序号与状态字段决定顺序队列表的每一行代表一位进入候诊队列的患者。关键字段包括队列ID、排班ID、患者ID、排队序号、状态、签到时间和叫号时间。通过排队序号升序排列就能天然得到叫号顺序。CREATE TABLE queuing ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, queue_number INT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT WAITING, sign_in_time DATETIME DEFAULT NULL, called_time DATETIME DEFAULT NULL, visit_start_time DATETIME DEFAULT NULL, visit_end_time DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_queue (schedule_id, queue_number) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;其中status的取值包括WAITING等待中、CALLED已叫号、VISITING就诊中、FINISHED已完成、SKIPPED过号。把状态字段归一化到一张队列表的好处是查询简单一条SELECT就能同时拿到当前等待人数和正在就诊的患者坏处是状态字段太多时索引效率下降如果后续要支持分诊台和护士站同时操作需要在( schedule_id, status )上建立联合索引。4.2 叫号服务的FIFO实现锁定下一个待叫号患者叫号动作本质上是把队列中第一个WAITING状态的患者变为CALLED并把该患者信息推送至大屏。这里必须避免两个护士同时叫到同一个患者所以更新语句同样要带条件锁。Transactional public CalledPatient callNext(Long scheduleId, Long doctorId) { // 查询该排班下当前正在就诊的患者确认诊室已空闲 int visitingCount queuingMapper.countByScheduleAndStatus(scheduleId, VISITING); if (visitingCount 0) { throw new RuntimeException(当前有患者正在就诊请先结束就诊); } // 锁定下一个等待中的患者 Queuing next queuingMapper.selectNextWaitingForUpdate(scheduleId); if (next null) { throw new RuntimeException(候诊队列为空); } queuingMapper.compareAndSetStatus(next.getId(), WAITING, CALLED); return buildCalledPatient(next); }selectNextWaitingForUpdate的SQL按queue_number升序取第一条statusWAITING的记录并加上FOR UPDATE锁行目的是串行化多个护士端的叫号操作。需要注意的是如果队列表的行数非常多FOR UPDATE会锁住索引范围内的记录性能可能下降所以建议在排队高峰期对队列表做按天分表。这里的countByScheduleAndStatus判断当前诊室是否有患者正在就诊是为了实现“一诊室一患者”的业务规则防止医生还没结束上一个就诊就被叫进来新的患者。4.3 过号与爽约处理状态回退或标记跳过现实场景中患者不会一直等在候诊区叫号三遍没响应就需要处理。常见做法是允许护士把CALLED状态的患者标记为SKIPPED患者回来后再手动“召回”或重新排队。过号召回时既可以把患者插回原队列位置也可以让他重新排到队尾具体策略由医院业务规定。这套系统里我用过一个比较稳妥的方案过号记录原排队序号召回时优先插到当前正在就诊的患者之后而不是直接排到队尾这样对按时签到的患者更公平。操作动作原状态新状态触发条件患者签到WAITINGWAITING确认到场医生叫号WAITINGCALLED点击叫号开始就诊CALLEDVISITING患者进入诊室结束就诊VISITINGFINISHED医生点击完成叫号未响应CALLEDSKIPPED超时未到迟到召回SKIPPEDCALLED患者重新报到这张状态流转表建议贴在项目文档里因为排队叫号系统的逻辑复杂度不高但状态分支多接手的开发经常搞混“过号后还能不能叫号”这类问题。状态表同时是编写单元测试的用例清单把每个转换的前置状态和后置状态都覆盖到回归时就能拦住大部分逻辑改动引发的连锁问题。在实现时有一个容易忽略的细节当患者向医生报到后叫号系统会更新当前正在就诊的患者ID这时大屏需要根据排队序号实时刷新。这里就需要在内存中维护一个Map或队列来存储当前叫号状态每完成一个就诊就执行出队操作。我用过Java的ConcurrentLinkedQueue来处理这种场景多线程环境下队列的原子性由JDK保证不用担心并发安全问题。4.4 队列的存储与性能边界数据库队列 vs 内存队列医院挂号系统的队列数据量通常不会太大单日门诊量几千到上万数据库存储完全够用。但如果要做全院大屏展示实时排队进度需要在内存中维护一个当前叫号状态的快照数据库只做持久化。每个诊室可以理解为一个队列消费者每叫一个号就从内存队列弹出下一位患者同时将状态变更写回数据库。public class ClinicQueue { private final MapLong, ConcurrentLinkedQueueQueuing queues new ConcurrentHashMap(); public void enqueue(Queuing queuing) { queues.computeIfAbsent(queuing.getScheduleId(), k - new ConcurrentLinkedQueue()) .offer(queuing); } public Queuing pollNext(Long scheduleId) { ConcurrentLinkedQueueQueuing queue queues.get(scheduleId); return queue null ? null : queue.poll(); } }这段代码中ConcurrentLinkedQueue的offer和poll方法都是线程安全的多个护士端并发调用时不会取到同一个患者。ConcurrentHashMap使用computeIfAbsent对每个排班ID懒加载队列避免初始化时的重复创建。需要注意内存队列只能作为读缓存应用重启后队列数据要从数据库重新加载否则会丢失叫号进度。5. 把这套系统跑通Maven Wrapper 与关键配置源码包里同时存在mvnw和mvnw.cmd两个文件以及maven-wrapper.jar这说明项目启用了Maven Wrapper。它的作用是把Maven版本固定到工程级别团队里任何人拉下代码不用自己装Maven直接执行mvnw就能用指定版本构建消除了“本地能编译、同事环境编译失败”的经典问题。5.1 Windows与Linux下的构建差异Windows环境双击或命令行执行mvnw.cmd即可Linux或macOS则必须给mvnw加执行权限否则会报Permission denied。同时因为mvnw内部会下载Maven发行版第一次构建需要联网拉取依赖如果持续卡在下载阶段需要检查maven-wrapper.properties中配置的distributionUrl是否指向了可访问的镜像仓库。chmod x mvnw ./mvnw clean package -DskipTests这里-DskipTests的含义是跳过单元测试但不跳过测试代码编译如果你的目的是快速验证打包流程这个参数足够如果想连测试代码都不编译可以用-Dmaven.test.skiptrue。打包产物会生成在target目录下Spring Boot项目通常是一个可执行jar用java -jar命令即可启动。5.2 数据源与Redis配置要点因为项目同时包含yml和properties文件需要确认spring.profiles.active指定的默认profile指向哪个配置文件。如果匹配不来项目启动时会出现数据源连接失败或Redis连接超时。spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0url中serverTimezoneAsia/Shanghai这一项在MySQL 8以上必须显式指定否则日期字段会报时区错误。Redis的lettuce连接池参数max-active控制最大连接数默认是8如果排队系统的大屏轮询接口较多8个连接可能不够建议按实际并发上调到16或32调大后会占用更多文件句柄需要同时关注操作系统的ulimit限制。5.3 验证线路从数据库初始化到叫号联调按照经验顺一遍整个流程能快速确认系统是否健康。先执行项目附带的SQL脚本如果你自己复原表结构记得按排班、订单、队列的依赖顺序创建。启动成功且接口正常响应后直接调用挂号和叫号接口做联调# 查询某医生当日剩余号源 curl -X GET http://localhost:8080/api/schedule?doctorId10date2025-01-20 # 创建一个预约订单 curl -X POST http://localhost:8080/api/appointment \ -H Content-Type: application/json \ -d {doctorId:10,scheduleDate:2025-01-20,patientId:1001} # 患者签到进入排队队列 curl -X POST http://localhost:8080/api/queuing/sign-in \ -H Content-Type: application/json \ -d {appointmentId:123,scheduleId:456} # 医生叫下一个号 curl -X POST http://localhost:8080/api/queuing/call-next?scheduleId456四个请求按顺序执行后观察每次的返回体是否包含预期的状态变化。重点检查第二次请求返回的订单号和第四次请求返回的患者ID是否一致这个闭环通了说明预约、订单、排队、叫号的主链路没有断裂。如果中间某一步报错优先查看System.out或log文件里打印的SQL日志大部分问题出在表名或字段名与Mapper XML中的映射不一致。开发接口时我在Controller层返回给前端的状态码用的是200表示成功400表示参数错误409表示业务冲突比如号源已约满。这样前端轮询接口时只需要看状态码不用解析复杂的业务错误码。一个容易踩坑的地方是Spring Boot默认对请求体的JSON解析失败会返回400但错误消息是框架自动生成的英文提示这对联调不太友好可以在全局异常处理器里统一捕获HttpMessageNotReadableException并返回中文提示。6. 两个隐患并发扣减时的事务边界错误把所有表和代码刷完后我见过最多的线上故障来自事务注解放错位置。Transactional不能用在同类内部方法调用上因为Spring的事务代理默认只拦截外部调用同一个Service里a方法调b方法b上的Transactional会静默失效。如果你把号源扣减和订单插入分成两个方法内部调用时事务是失效的等于把两步操作暴露在两次独立事务里。Service public class AppointmentService { private final ScheduleService scheduleService; Transactional public Long createAppointment(AppointmentRequest req) { scheduleService.deductBookedCount(req.getScheduleId()); return saveAppointment(req); } }把扣减号源提取到另一个Service中让deductBookedCount方法上的事务被Spring代理识别会比在同一个类里自调用可靠得多。如果你在代码审查中看到同事把两个需要原子性的写操作放在同一个Service的内部方法链里这是一个值得当场指出的隐患。另一个排查方向是确认事务是否真的回滚默认情况下只有RuntimeException才会触发回滚受检异常不会。在addNewAppointment方法中如果抛出了自定义的受检异常事务是不会自动回滚的需要手动加上rollbackFor Exception.class参数。这类问题直接在方法运行时打断点看DataSourceTransactionManager的调用栈就能确认事务边界是否符合预期。上线前把预约和叫号接口压一遍压测结果接近预期后再出文档比看十遍代码都管用。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →