简介:这是一个基于Java的医院预约挂号排队叫号系统设计源码,面向需要学习医院业务流程与Java Web开发的学生或初级开发者,可用于毕业设计、课程项目或实际系统改造参考。工程围绕预约挂号、分诊排队、叫号显示与后台管理展开,涵盖Java后端逻辑、XML配置文件、YAML环境配置以及属性文件等多类资源,方便读者理解从数据持久化到接口交互的完整结构。压缩包共273个文件,大小仅619KB,除主要Java源码外,198个Java文件构成业务核心,31个XML文件多用于映射或配置,13个YAML文件支持环境部署,12个properties文件管理参数,还包含Maven包装器、日志与启动脚本,整体轻量易部署,适合快速导入IDE进行二次开发。目前已有660人学习下载,适合希望掌握预约挂号系统设计思路、模块划分与工程配置的Java学习者参考。资源目录层级明确,代码量适中,可据此梳理叫号算法、号源分配等核心逻辑,并在本地运行测试后针对业务模块进行扩展。
1. 基于Java的医院预约挂号排队叫号系统,难的不是并发,是状态
一个门诊日,患者通过App或自助机预约了某个医生的号,但能不能顺利进到诊室,取决于后台这套基于Java的医院预约挂号排队叫号系统能不能把“预约-签到-排队-叫号-就诊”每一步的次序和数据状态衔接好。很多人以为难点在高并发,实际上医院单日门诊量约在几千到一万量级,真正让系统翻车的是号源被超卖、过号患者插队、大屏数据不同步这类状态流错误。本文从一个可重构的源码工程视角出发,讲清楚Spring Boot在预约环节如何防超卖,Redis在叫号环节如何承担队列,WebSocket如何把叫号结果实时推到各端,并把过号、复诊、停诊这些边界情况的处理一并说明。适合正在做医疗信息化,或者想用Java完整落地一套真实业务流的开发者参考。
2. Java技术栈下的模块边界:预约、排队、叫号该由哪些服务负责
先明确一点:医院的门诊系统不适合把“预约”“排队”“叫号”拆成三个独立部署的微服务。它的终端数量在几百个规模,部署一体化的Spring Boot应用反而更好维护。真正要拆的是模块边界,不是物理进程。
2.1 用户端、医生端、大屏端在系统中的角色划分
从终端角色看,这套系统至少包含四类使用方:患者App访问预约与排队进度,医生工作台负责叫号与停诊操作,叫号大屏实时展示当前队列,管理后台维护排班与号源规则。对应的模块建议按接口分组,而不是各写一套业务逻辑。常见做法是这样:
| 模块 | 职责 | 核心接口示例 |
|---|---|---|
| patient-service | 预约、取消、签到、查看排队进度 | POST /api/appointment、POST /api/checkin |
| doctor-service | 叫号、暂停叫号、停诊处理 | POST /api/call/next、POST /api/call/pause |
| screen-service | 大屏与语音播报的实时数据 | WS /ws/screen/{deptId} |
| admin-service | 排班生成、号源配额、医生坐诊时间 | POST /api/schedule/generate |
这四个模块共用同一个数据库,服务层通过Java接口互相调用,避免为了“微服务”而引入分布式事务。实际开发里最省事的做法是单工程但分包清晰,scheduler包管排班,queue包管叫号,push包管推送,每个包内自包含对应的表和Mapper。
2.2 Spring Boot + Redis + WebSocket的选型依据
用Java做这套系统最常见的组合就是Spring Boot + MyBatis + Redis + MySQL,WebSocket用于推叫号。Spring Boot负责提供REST API和定时任务,MyBatis负责SQL的完全可控性,Redis负责队列和锁,MySQL负责最终一致性。
2.2.1 为什么排队队列要交给Redis而不是MySQL
排队序列在一名医生一个门诊日里可能达到几十上百次操作,每次叫号都要从队头取出并更新状态。如果全部落在MySQL上,一个DELETE FROM queue WHERE id = ?加上UPDATE status会触发两次行锁与一次binlog写盘,医生端每点一次“下一号”都可能卡在数据库等待上。Redis的List结构天然支持从头部弹出一个元素、从尾部推入一个元素,LPOP和RPUSH都是O(1)操作,正好对应叫号和签到入队。更重要的是Redis还可以给队列key设置EXPIRE,门诊结束时自动清理当天的临时数据,MySQL则不需要为排队这种临时数据留太多历史记录。
2.2.2 叫号广播为什么离不开WebSocket
大屏幕上的“请3号张三到5诊室”如果靠前端轮询接口,查询间隔只能在1到3秒,患者端App和医生工作台之间会看到明显的时间差。WebSocket在医生点击叫号后直接把推送动作发出,浏览器或App收到消息的延迟通常在几十毫秒级别。因此当系统要求大屏响应在500毫秒以内时,WebSocket是绕不开的方案。连接管理也不必引入Netty,Spring自带的spring-boot-starter-websocket足够支撑几百个终端连接。
2.3 一份可直接套用的依赖与配置参数表
pom.xml里引入下面这几个依赖就够了,版本号跟随工程的统一依赖管理,不用单独锁定:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency>Spring Boot的Web模块提供REST接口和内置Tomcat,WebSocket模块用于叫号推送,Redis模块管理队列与分布式锁,MyBatis-Plus用来少写Mapper样板代码。注意Redis的序列化器要手动配置成StringRedisSerializer,不然LPUSH之后从客户端看到的key是一串乱码。
application.yml里的几个关键参数需要按实际终端数量调整:
spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 datasource: url: jdbc:mysql://localhost:3306/outpatient?useUnicode=true&characterEncoding=utf8&useSSL=false hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000max-active控制Redis连接池的最大连接数,考虑到WebSocket连接本身独立,32个连接足够日常业务。Hikari的maximum-pool-size建议不超过数据库实例CPU核数乘以2加1,默认20对单库实例是合理值。连接超时都设置成3秒,避免网络抖动时线程长时间挂起。
提示:Redis连接池参数和大屏在线数有关。如果医院部署了几十个科室大屏,每个大屏维持一个WebSocket长连接,Redis连接复用时需要适当调大
max-total,否则会出现Cannot get Jedis connection。
3. 数据库表设计与号源防超卖:预约系统的第一道闸门
很多系统的预约逻辑是先查剩下几个号,再生成订单,最后扣减号源。这套流程在并发窗口达到几十人时会漏出超卖。要守住第一道闸门,表设计必须让扣号这个动作在一条SQL里原子完成。
3.1 号源表、预约订单、排队记录三张核心表的字段设计
每个医生的号不是凭空产生的,而是由排班任务先生成号源段,再被预约逐个消耗。对应两张主表加一张辅助表就可以支撑整个链路。
表名建议设为doctor_schedule,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| doctor_id | bigint | 医生ID,冗余该字段可避免查询时关联医生表 |
| schedule_date | date | 门诊日期 |
| time_slot | varchar(20) | 时段,如08:00-08:30 |
| total_count | int | 该时段总号数 |
| remained_count | int | 剩余可约号数,每次预约减1 |
| version | int | 乐观锁版本号,兜底用 |
| create_time | datetime | 创建时间 |
预约订单表patient_appointment记录患者的每一次挂号,字段包括预约号、排班ID和状态:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| schedule_id | bigint | 对应号源段 |
| patient_id | bigint | 患者ID |
| appointment_no | varchar(20) | 取号凭证,公众号或App展示 |
| status | tinyint | 0待就诊 1排队中 2就诊中 3已完成 4过号 5已取消 |
| create_time | datetime | 创建时间 |
排队记录queue_record在患者签到入队时写入,记录入队顺序和当前状态,便于后续统计候诊时长。
3.2 用一条UPDATE语句锁住号源,避免超卖
在Java服务里调用下面的Mapper方法:
UPDATE doctor_schedule SET remained_count = remained_count - 1, version = version + 1 WHERE id = #{scheduleId} AND remained_count > 0调用前的业务校验可以查一遍时段是否存在,但真正的防超卖判断完全只在SQL条件里。当两个请求同时进入时,MySQL会在这个行记录上加排他锁,后进入的事务必须等前一个提交生效后再执行减一,因此不会出现两个请求都把剩余数从1减成0的情况。remained_count > 0这个条件必须放在WHERE里,任何先SELECT再UPDATE的方式都会在并发下出现竞态。
返回的int updated就是本次扣减影响的记录数。如果为0,说明号源已经耗尽或排班已被删除,此时直接抛出业务异常,不再往下走生成订单的逻辑:
int updated = scheduleMapper.consumeSchedule(scheduleId); if (updated != 1) { throw new BizException("所选时段号源约满,请选择其他时间"); } appointmentMapper.insert(appointment);这里有一个事务顺序问题需要留意:号源扣减和订单插入必须在同一个事务里,而且consumeSchedule要作为事务里的第一条写操作。先插订单再UPDATE会导致后一个事务拿不到锁,重试成本变高;正确顺序是先拿到号源锁,再写订单,事务提交后锁释放。
3.2.1 并发窗口下的异常捕获与补偿
即便SQL层面守住了超卖,还有一层数据库主键或唯一索引冲突需要处理。建议在patient_appointment表上建立uniq_schedule_patient(schedule_id, patient_id)唯一约束,这样同一个患者在同一时段重复提交时,后插入的请求会抛DuplicateKeyException。在Java端把这个异常翻译成友好提示,同时写一个@TransactionalEventListener监听事务提交失败,将号源回补回remained_count。
提示:MySQL默认隔离级别是
REPEATABLE READ,但上面的UPDATE语句因为走主键条件,实际加的是行锁,不需要额外SELECT ... FOR UPDATE,也不建议把隔离级别改成READ COMMITTED来迁就业务。
3.3 状态字段的流转规则
预约订单的状态不应该散落在多个字段里,一个status加一个update_time就够了。状态机的关键规则是主流程按0→1→2→3单向推进,4过号和5取消作为分支状态离开主流程。被医生叫号后超过一定时间未到的患者,要单设一条4过号状态,而不是直接改回待就诊,否则无法追溯“叫过号但没有来”的过程数据。同一张预约订单表里,是否属于复诊再由一个source_type字段区分,主流程不做过重设计。
4. 排队叫号链路的Java实现:从签到入队到叫号推送
4.1 签到入队:用Redis列表维护每个医生当天的候诊顺序
患者来到医院在自助机上签到,服务端要做两件事:更新预约订单状态为“排队中”,然后把这条预约记录推进该医生当天对应的Redis列表。Java里的实现形如下面这样:
public Long checkIn(Long appointmentId, Long doctorId, LocalDate scheduleDate) { String queueKey = String.format("clinic:queue:%d:%s", doctorId, scheduleDate); Long queueNo = redisTemplate.opsForList().rightPush(queueKey, appointmentId.toString()); redisTemplate.expire(queueKey, Duration.ofHours(12)); QueueRecord record = new QueueRecord(); record.setAppointmentId(appointmentId); record.setDoctorId(doctorId); record.setQueueNo(queueNo); record.setStatus(1); queueRecordMapper.insert(record); appointmentMapper.updateStatus(appointmentId, 1); return queueNo; }rightPush把患者追加到队尾,返回的queueNo是列表当前的元素个数,天然就是排队号。expire设置12小时而不是永久,门诊结束后的临时数据自动过期,不必专门写清理任务。推送完Redis之后才写数据库排队记录,如果数据库写入失败但Redis已经入队,可以用定时任务扫描不一致数据回补。医院的场景不是大促,用最直观的同步双写即可。
4.2 医生端叫下一号的完整逻辑
医生在工作台点击“叫下一号”,后端从Redis列表头部弹出第一个元素,再更新状态并通过WebSocket广播:
public CallNextResult callNext(Long doctorId, LocalDate scheduleDate) { String queueKey = String.format("clinic:queue:%d:%s", doctorId, scheduleDate); Object appointmentIdValue = redisTemplate.opsForList().leftPop(queueKey); if (appointmentIdValue == null) { return CallNextResult.empty(); } Long appointmentId = Long.valueOf(appointmentIdValue.toString()); Appointment appointment = appointmentMapper.selectById(appointmentId); int updated = appointmentMapper.updateToCalling(appointmentId); if (updated != 1) { throw new IllegalStateException("该患者当前状态不允许叫号"); } CallNextResult result = new CallNextResult(); result.setAppointmentId(appointmentId); result.setPatientName(appointment.getPatientName()); result.setQueueNo(appointment.getQueueNo()); result.setRoomName(doctorRoomMapper.selectRoomByDoctorId(doctorId)); result.setDoctorId(doctorId); pushService.pushToScreenAndPatient(result); return result; }对应的Mapper更新语句要带状态条件:
UPDATE patient_appointment SET status = 2 WHERE id = #{appointmentId} AND status = 1leftPop从列表头部取出元素,正好对应先进先出的叫号顺序。状态更新加WHERE status = 1,保证只有“排队中”的患者能被叫号,如果数据不一致,返回0会被捕获并抛出异常,防止同一个患者被两个医生的终端各叫一次。pushService内部维护一个按doctorId分组的WebSocket Session池,把结果同时推送向诊室大屏和患者App。
各状态值在排队流程中的含义如下:
| 状态值 | 含义 | 触发动作 |
|---|---|---|
| 0 | 待就诊 | 预约成功时 |
| 1 | 排队中 | 签到入队后 |
| 2 | 就诊中 | 医生叫号后 |
| 3 | 已完成 | 就诊结束 |
| 4 | 过号 | 叫号后超时未到 |
4.3 WebSocket推送叫号大屏,患者端实时刷新
Spring Boot接入WebSocket只需要一个配置类加上一个带@ServerEndpoint的端点类。大屏端连接地址按科室划分,患者端按预约单号订阅,两者都从同一个CallNextResult里取数据:
@ServerEndpoint("/ws/call/{doctorId}") public class CallEndpoint { private static final Map<Long, Session> SESSIONS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("doctorId") Long doctorId) { SESSIONS.put(doctorId, session); } @OnClose public void onClose(@PathParam("doctorId") Long doctorId) { SESSIONS.remove(doctorId); } public static void send(Long doctorId, String message) { Session session = SESSIONS.get(doctorId); if (session != null && session.isOpen()) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error("push call result failed, doctorId={}", doctorId, e); } } } }SESSIONS用ConcurrentHashMap保存每个医生的最新连接,一个医生对应一台诊室电脑,最多再加一两块大屏,不存在Session冲突问题。需要提醒的是@ServerEndpoint在每个连接建立时都会创建一个新实例,所以静态Map要定义为static,否则端点对象的字段无法跨连接共享。消息内容直接用JSON.toJSONString(result)即可,患者端在onMessage里解析并更新界面。
4.4 过号、复诊、停诊三类边界的处理方案
边界情况最容易成为现场事故,分别说下处理方式。
过号处理需要在叫号之外加一个定时任务,医生叫号后开始计时,超过三分钟未进入诊室,服务端把该患者状态改为4过号,并将其移到本医生队列尾部:
@Scheduled(cron = "0 */1 * * * ?") public void checkMissed() { LocalDate today = LocalDate.now(); List<Appointment> callingList = appointmentMapper.selectByStatus(2, LocalDateTime.now().minusMinutes(3)); for (Appointment item : callingList) { appointmentMapper.updateStatus(item.getId(), 4); String key = String.format("clinic:queue:%d:%s", item.getDoctorId(), today); String value = item.getId().toString(); redisTemplate.opsForList().remove(key, 0, value); redisTemplate.opsForList().rightPush(key, value); } }redisTemplate.opsForList().remove(key, 0, value)中的第二个参数0表示删除所有匹配元素,确保过号患者不会因为残留数据被叫第二遍。重新放回队尾保持了“后来者先看”的公平性,但也只是一个默认策略,真实现场一般会在护士站保留人工干预入口。
复诊患者不再走预约订单主流程,而是在queue_record表中记一条source_type=2的记录,直接对Redis队列使用leftPush插到队首,占用诊室医生的一个即时窗口。
停诊操作较特殊,任何时候医生取消上班,都要先删掉Redis里的队列key,再把所有排队患者的订单状态批量改成停诊并推送停诊通知。这个动作必须用有唯一标识的任务记录防重,避免重复执行导致Redis队列被清理两次后状态错乱。
5. 叫号规则高阶调优与一套可验证的压测方法
5.1 候诊时长与预约时段加权的优先级队列
如果医院不想严格按签到顺序叫号,而是让预约早的人优先,那Redis的List结构就不够了。可以换成ZSET,把患者的预约时间映射成score,约得早的排前面。设预约时间为appointmentTime,迟到的分钟数作为惩罚因子:
private double buildScore(LocalDateTime appointmentTime, int delayMinutes) { double base = appointmentTime.getHour() * 3600 + appointmentTime.getMinute() * 60; return base + delayMinutes * 5; }每个患者入队时调用zSetOperations().add(queueKey, appointmentId, score),叫号时用下面的方法取score最小的一位:
private Long popMinFromQueue(String queueKey) { Set<Object> members = zSetOperations.range(queueKey, 0, 0); if (members == null || members.isEmpty()) { return null; } Object appointmentId = members.iterator().next(); zSetOperations.remove(queueKey, appointmentId); return Long.valueOf(appointmentId.toString()); }这个实现里range和remove不是原子的,但因为叫号动作已经被数据库状态机兜底,真正并发量又很低,问题不大。如果Redis和SDK版本支持,可以直接用popMin原子完成。这种方案的优点是“预约优先”和“迟到惩罚”合成了一个可调整的数值,延迟越久分值加得越多,不会像过号直接判死那样绝对。
5.2 并发场景下的JMeter压测参数与断言
预约接口的压测目标是确认consumeSchedule这条UPDATE在并发下不会超卖,排队接口的压测目标是确认Redis队列在几百个请求下不会丢元素。JMeter里一般这样设:
| 参数项 | 建议值 | 说明 |
|---|---|---|
| 线程数 | 50 / 100 / 200 | 三档跑三遍,观察曲线 |
| Ramp-Up Period | 5秒 | 越短越能暴露并发窗口 |
| 循环次数 | 100 | 单档执行1万次请求 |
| HTTP请求 | POST /api/appointment | 参数用CSV配置越多患者ID越真实 |
| 断言 | 响应200且返回预约号 | 只断言200不够,要断言业务字段 |
压测完成后重点看两个指标:响应时间99分位值要低于1500毫秒,业务返回“号源约满”的条数加成功预约数要等于总请求数,二者相加不为0就说明有请求被异常吞掉。Redis侧直接把INFO stats里的expired_keys与前一天同一时段的数值对比,就能发现队列清理是否过量。
5.3 基于日志链路追踪的排错技巧
线上排查“患者明明签到了,大屏却没有显示”这类问题时,靠普通日志很难把一次操作的调用串起来。用一个简单的MDC过滤器就能解决:
@WebFilter(urlPatterns = "/api/*") public class TraceFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 12); MDC.put("traceId", traceId); try { chain.doFilter(request, response); } finally { MDC.remove("traceId"); } } }在logback的pattern里加上[%X{traceId}],每个请求从进接口到推完WebSocket的整条链路上的日志都带着同一个ID,配合Redis的SLOWLOG GET按耗时排序,能同时定位是数据库锁等待还是Redis命令拖了后腿。压测后把这两项检查完,叫号系统才算真正具备上线条件——队列命令日志和锁等待记录,往往比压测报告更能提前暴露问题。
本文还有配套的精品资源,点击获取