
简介这是一个基于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/checkindoctor-service叫号、暂停叫号、停诊处理POST /api/call/next、POST /api/call/pausescreen-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 MySQLWebSocket用于推叫号。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是绕不开的方案。连接管理也不必引入NettySpring自带的spring-boot-starter-websocket足够支撑几百个终端连接。2.3 一份可直接套用的依赖与配置参数表pom.xml里引入下面这几个依赖就够了版本号跟随工程的统一依赖管理不用单独锁定dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependencySpring Boot的Web模块提供REST接口和内置TomcatWebSocket模块用于叫号推送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?useUnicodetruecharacterEncodingutf8useSSLfalse 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字段如下字段名类型说明idbigint主键doctor_idbigint医生ID冗余该字段可避免查询时关联医生表schedule_datedate门诊日期time_slotvarchar(20)时段如08:00-08:30total_countint该时段总号数remained_countint剩余可约号数每次预约减1versionint乐观锁版本号兜底用create_timedatetime创建时间预约订单表patient_appointment记录患者的每一次挂号字段包括预约号、排班ID和状态字段名类型说明idbigint主键schedule_idbigint对应号源段patient_idbigint患者IDappointment_novarchar(20)取号凭证公众号或App展示statustinyint0待就诊 1排队中 2就诊中 3已完成 4过号 5已取消create_timedatetime创建时间排队记录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 MapLong, 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(); ListAppointment 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_type2的记录直接对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) { SetObject 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 Period5秒越短越能暴露并发窗口循环次数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命令拖了后腿。压测后把这两项检查完叫号系统才算真正具备上线条件——队列命令日志和锁等待记录往往比压测报告更能提前暴露问题。本文还有配套的精品资源点击获取