
简介在医疗信息化建设中医院排队叫号系统是缓解候诊混乱、提升就医体验的关键基础设施。其核心并非简单的队列先进先出而是涉及多角色协同、状态机流转与高并发场景下的数据一致性。本文从数据库表设计出发详解了号源排班、排队记录与呼叫日志的建模思路并重点剖析了基于Spring Boot与WebSocket的实时叫号链路包括医生一键呼叫、大屏同步推送与语音播报队列等实现细节。同时针对过号重排、预约号与现场号混合调度、停诊批量退号等边界规则给出了可配置化的策略方案。结合实际部署中遇到的并发取号重复、WebSocket断连等典型问题总结了工程化的解决方案帮助开发者从Demo走向可用为中小型医院落地轻量级叫号系统提供参考。 先聊点实在的。这套“基于Java的医院排队叫号系统设计源码”我最初并不是为了写代码而写而是被自家楼下社区医院诊室门口那种“堵成一团护士扯着嗓子喊名字”的场面刺激到了。后来正好有朋友在二线城市的一家专科医院信息科他们想换掉用了快十年的老式呼叫器我花了一个多月的时间从数据库设计做到大屏端上线走了不少弯路也沉淀了一些很实际的方案。这篇东西不是教科书式的项目说明书而是一个真实可运行的Java实现拆解适合正在做Java课程设计、毕业设计或者想在中小型医院里落地一个轻量级叫号系统的开发者参考。如果你只是想要一个“能跑起来交差”的Demo那网上一抓一大把。但这篇文章我想跟你聊的是为什么排队系统会并发出问题、数据库表怎么设计才扛得住早高峰、WebSocket推送和语音播报怎么配合才不卡顿——这些才是真正决定系统能不能从“演示”走到“可用”的细节。1. 从诊室门口的混乱说起这套系统到底在解决什么问题1.1 医院叫号混乱的根源不是“排队”本身很多开发者第一次接到“排队叫号系统”需求时第一反应就是“这不就是一个队列先进先出嘛”。实际上医院的排队场景远比快餐店、银行复杂得多。我调研那家医院的时候发现几个很现实的痛点患者分不清自己到底是“几号”医生喊名字听不清喊完没听到就过号过号了又开始扯皮。医生叫号全凭记忆看到门口一群人问“谁是下一个”患者互相推诿。预约号、现场号、复诊号混在一起谁先谁后根本没规则。遇到医生临时停诊患者全部滞留护士逐个解释效率极低。所以这个系统的核心价值不是“排队”而是秩序重建。它必须同时解决三类人的问题对患者要有一个明确的等待位置和可预期的等待时间对医生要一个一键叫号、过号重呼、暂停就诊的操作台对分诊台/管理层要有实时状态监控和数据回溯能力。1.2 模块划分这不是一个大屏程序而是四个子系统我设计这套源码时没有把代码堆在一个Spring Boot工程里完事而是按业务流程拆成了四个主要模块模块核心功能用户角色分诊台管理端挂号建档、号源发放、停诊退号、数据统计护士/导诊医生工作站呼号、过号重呼、暂停/恢复、完成就诊医生候诊大屏端实时队列展示、语音播报、公告信息患者后台管理科室排班、医生管理、日志查询信息科/管理员技术栈我选的是Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis WebSocket前端大屏用的是原生HTML Vue3CDN引入 浏览器SpeechSynthesis语音接口。为什么不选那些重型的消息队列中间件因为我太清楚了医院内网环境里能跑起来的就是JDK、MySQL、Redis你引入一套RabbitMQ、Kafka光让信息科老师配合装环境就是一场灾难。这套组合的合理性在于Spring Boot负责接口和事务控制MyBatis-Plus减少大量单表CRUD代码Redis解决分布式环境下的队列号发放万一医院配了两台应用服务器做负载不会出现重号WebSocket负责把医生的呼叫动作实时推送到所有大屏。2. 数据库设计是整套系统的地基核心表结构与号码状态机2.1 我建了四张核心业务表其实整个系统的核心表并不多真正在设计上有讲究的是号源排班表和排队记录表。我先把建表结构和关键字段写出来。科室表deptdept_id、dept_name、dept_location、doctor_count。医生表doctordoctor_id、dept_id、doctor_name、title、status1出诊 0停诊。排班号源表schedule这是整个系统的关键表一个排班代表“某医生某天上午出了30个号”。CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, schedule_date DATE NOT NULL, period TINYINT NOT NULL COMMENT 1上午 2下午, total_number INT DEFAULT 30 COMMENT 总号源数, used_number INT DEFAULT 0 COMMENT 已发放号数, status TINYINT DEFAULT 1 COMMENT 1正常 0停诊, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_doctor_date_period (doctor_id, schedule_date, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;排队记录表queue_record每个患者拿到的“号”本质上就是这张表里的一条记录。它记录的是“谁、在哪个排班下、拿到了几号、当前是什么状态”。CREATE TABLE queue_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, patient_name VARCHAR(50) NOT NULL, patient_id_card VARCHAR(18), queue_no VARCHAR(10) NOT NULL COMMENT 如A001、B015, queue_type TINYINT DEFAULT 1 COMMENT 1现场 2预约, status TINYINT DEFAULT 1 COMMENT 1待叫号 2已叫号 3已过号 4已完成 5已退号, call_count INT DEFAULT 0 COMMENT 已呼叫次数, first_called_time DATETIME, last_called_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_schedule_status (schedule_id, status), KEY idx_queue_no (schedule_id, queue_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;呼叫日志表call_log很多人会忽略这张表但我强烈建议加上。因为医院一旦发生医患纠纷信息科要能查“这个患者到底被叫了几次、几点叫的、医生什么时候点击的完成”。这就是审计追踪。CREATE TABLE call_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, queue_record_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL COMMENT CALL/RECALL/PAUSE/RESUME/COMPLETE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 号码状态机从WAITING到COMPLETED的生命周期我实际开发时把排队记录的状态定成了五个这张状态机的流转图在脑子里一定要清晰1 待叫号WAITING挂号完成、扫码或现场取号后进入队列在候诊大屏上显示等待中。2 已叫号CALLED医生点击“呼叫下一个”系统把状态从未叫变为已叫大屏播报声音和文字患者进入诊室。3 已过号OVERDUE呼叫三次或超过一定时间患者未到医生点击“过号”状态变为过号患者回来后重新进入队列末尾或顺延。4 已完成COMPLETED医生点击“完成就诊”整个生命周期结束患者状态从“已叫号”变为“已完成”。5 已退号CANCELLED患者主动退号或医生停诊时批量退号。这个状态机的设计看似简单但它直接决定了后续所有业务规则怎么写。比如过号后重新排队不是把状态改回去而是新建一条queue_record记录但患者信息、原号码要保留在日志里。为什么这么设计因为在真实的医院场景里过号重排涉及“原来那个号是10号现在变成最后一个了那之前的10号记录如果直接改成最后一个数据会被覆盖后续查证非常麻烦”。所以我宁可把原记录留在库里新生成一条排队记录通过原record_id关联。这也是我给所有做类似系统的朋友提的第一个醒不要修改过号原记录的状态为待叫号而是要新建记录。3. 实时叫号链路医生点击“下一个”大屏和语音是怎么知道的3.1 后端呼叫接口的设计思路排队系统最核心的前后端交互就是“呼叫下一个”。这个操作看起来简单——查队列头、改状态、推送——但是要考虑的并发问题非常多。我在Controller层暴露了这样一个接口PostMapping(/api/call/next) public ResultQueueRecordVO callNext(RequestBody CallNextRequest request) { // request 中包含 scheduleId、doctorId return queueService.callNext(request); }Service层完整的逻辑是校验医生当前排班状态是否正常避免医生已经停诊还能叫号。查询当前排班下、状态为“待叫号”的记录按queue_no升序取出第一条。如果拿不到待叫号记录说明队列已经空了直接返回“当前无等待患者”。如果可以拿到使用乐观锁更新这条记录UPDATE queue_record SET status 2, last_called_time NOW(), call_count call_count 1 WHERE id ? AND status 1。更新成功后写入call_log日志。通过WebSocket把完整的呼叫信息推送给订阅了该诊室/科室主题的所有客户端。你以为这就完了还差一步。医生的电脑端跟大屏看到的东西其实不一样。医生端要看到的是当前叫了谁、剩余多少人。大屏端要看到的是当前叫号、上一组叫号、过号提示。这两者的数据形态不同所以我在第6步推送时不是只推一个患者对象而是推一个包含“当前呼叫患者”和“队列剩余数量”的聚合消息。这样前端拿到消息后不需要再发一次HTTP请求去查数据直接本地渲染延迟极低。3.2 WebSocket的Topic设计一个科室一个通道才是正确的有些项目为了省事只做一个全局的WebSocket通道所有大屏都订阅一个地址然后靠消息体里的科室ID来过滤。这种做法在演示阶段没什么问题但一旦科室多了流量都挤在一个通道上而且前端每次收到消息都要判断“这跟我有关系吗”非常笨拙。我采用的是按科室维度建立TopicWebSocket的路径设计为ws://服务器地址/ws/queue/{deptId}这样分诊台大屏只订阅它所在科室的Topic后台管理端可以同时订阅所有Topic。Spring的WebSocket客户端支持通配符订阅管理端直接订阅/topic/queue/*就行。具体的服务端推送核心代码如下Autowired private SimpMessagingTemplate messagingTemplate; public void pushCallMessage(QueueCallMessage message) { String destination String.format(/topic/queue/%d, message.getDeptId()); messagingTemplate.convertAndSend(destination, message); }前端使用Vue3 VueUseuseWebSocket的话代码非常简洁const { status, data, send, close, open } useWebSocket( ws://${location.host}/ws/queue/${deptId}, { autoReconnect: true, heartbeat: true } )这里要重点提醒一下WebSocket连接必须在Nginx层配置较长的超时时间而且前端要开心跳。我上线第一周大屏经常出现“呼叫了没反应”的情况排查下来发现是Nginx的proxy_read_timeout默认60秒WebSocket隔了一段时间没有数据交互就被切断连接了。后来我在Nginx配置里加了location /ws/ { proxy_pass http://backend-server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }同时前端每30秒发送一个心跳包双向保障连接存活。从那之后大屏端基本没有再出现过掉线问题。3.3 语音播报不能想播就播播报队列的坑语音播报看起来简单用浏览器的SpeechSynthesis接口调一句“请A015号张三到3号诊室就诊”就行。但实际踩坑是浏览器SpeechSynthesis有播放队列如果你连续调用两次speak()第二次会把第一次还没播完的话打断。医生在系统里快速连续点了两次“呼叫下一个”大屏就会只播第二次的声音第一次的就被吞掉了。解决办法是自己在项目里维护一个语音播报队列const speechQueue [] let isSpeaking false function speak(text) { speechQueue.push(text) if (!isSpeaking) { processSpeechQueue() } } function processSpeechQueue() { if (speechQueue.length 0) { isSpeaking false return } isSpeaking true const text speechQueue.shift() const utterance new SpeechSynthesisUtterance(text) utterance.onend () { processSpeechQueue() } speechSynthesis.speak(utterance) }这样即使是连点两次语音也会按顺序播放不会互相打断。这套逻辑很好理解把语音播报当成一个生产者-消费者模型讲完了再从队列里取下一条。4. 过号、预约号、停诊这些边界规则才是排队系统最麻烦的部分4.1 过号处理策略不同医院逻辑完全不同不能写死我之前在一家三甲医院做需求调研时他们信息科老师直接告诉我说“我们这里的规矩是过号作废过号患者来了自己去分诊台重新排队。”但另一家私立医院又说“我们过号之后顺延三个号叫到第4个人的时候你回来直接优先插到前面。”这两种策略都对只是适用场景不同。我最终在设计上做了可配置化overdue_action配置为INVALID表示过号作废配置为RESHUFFLE表示过号后顺延N个号重排。overdue_wait_count表示顺延的号码个数默认3。overdue_call_count表示最多呼叫几次后才判定为过号默认3次。overdue_wait_minutes表示医生点击“过号”后患者多少分钟内回来可以“重新激活”超过时间则完全作废。过号处理的代码逻辑我放在了一个独立的策略类里避免把业务规则散落在Service各个方法中Component public class OverdueReshuffleStrategy implements OverdueStrategy { Autowired private QueueRecordService queueRecordService; Override public void handleOverdue(QueueRecord overdueRecord) { // 1. 原记录标记为已过号不再参与排队 // 2. 在队列末尾插入一条新记录关联原记录ID // 3. 新记录拥有一个新的queueNo如B015(过) // 4. 推送过号提示到大屏 } }这种设计的好处是如果以后医院变了规则我只需要再实现一个OverdueStrategy接口替换Bean即可不用动呼叫、大屏、统计等任何其他模块。4.2 预约号和现场号混合调度用“分段窗口”而不是简单插队预约号和现场号如何混合排队是很多初学开发者最容易做错的地方。有人会简单粗暴地“预约号永远排在最前面”结果就是现场号患者等到天荒地老。有人又全部按到达时间排队结果预约好的患者来了还要等很久预约就失去了意义。我采用的方案是按时间段划分优先窗口。具体是这样每个排班里把上午、下午诊次拆成多个15分钟的时段。比如某个医生上午8:00-10:00的20个号源里8:00-8:15这个时段属于“预约优先窗口”。在这个时间窗内按预约时间到达的患者排在所有现场号前面。如果预约患者还没到就把当前时段剩下的预约号释放给现场号保证队列不满。实际实现时我在queue_record表里加了一个字段priority_start_time用来计算“当前患者应该排在哪个优先窗口”。取队首时先按这个优先级排序而不是单纯按queue_no排。这个逻辑写的代码不算多但效果很好。上线后现场患者的投诉率比其他科室明显降低。4.3 停诊批量退号必须用事务包住消息不能漏医生临时停诊是整个系统里最容易出Bug的场景。因为停诊不仅仅要改schedule表的status还要把所有未完成就诊的queue_record全部改为退号状态同时还要通过WebSocket推送停诊公告。有人会想这不是很简单吗一个UPDATE queue_record SET status 5 WHERE schedule_id ? AND status IN (1,2)就完事了。但真实情况是这期间可能正好有患者在大屏前排队状态刚改完大屏那边还显示“等待中”患者就干等着。所以停诊处理一定要在事务提交后主动向所有相关客户端推送一条停诊消息告诉大屏“该诊室已停诊请患者前往分诊台”。事务提交后再推送是为了避免WebSocket推送成功但数据库回滚导致大屏上显示停诊了实际号源还能继续挂号的逻辑错乱。我在代码里的做法是使用Spring的Transactional并在事务提交后利用TransactionSynchronizationManager.registerSynchronization执行推送动作确保推送一定发生在数据库变更成功之后。这是很多项目容易忽略的细节。5. 实际开发与部署中踩过的一些坑5.1 并发取号时的号码重复问题第一次联调时我用JMeter模拟6个分诊台护士同时给患者发号结果出现了两个患者拿到同一个号码的情况。原因就是先SELECT MAX(queue_no)再INSERT的操作不是原子的。解决办法有两个层面。第一层是数据库层面的唯一索引我在queue_record表上加了一个UNIQUE KEY uk_schedule_no (schedule_id, queue_no)保证即使代码有Bug数据库也会拒绝重复的号码。第二层是应用层用Redis的INCR命令生成每日自增序号String redisKey String.format(queue:seq:%d, scheduleId); Long seq redisTemplate.opsForValue().increment(redisKey); String queueNo String.format(%s%03d, prefix, seq);Redis的INCR是原子操作在分布式环境下不会出现重号。再加上数据库唯一索引兜底基本杜绝了这个问题的复现。但要注意Redis的序号和数据库的一致性问题。如果Redis被清理过缓存里没有当前最大序号就会从头开始。所以我在每天第一次取号时会先检查Redis是否存在该key不存在则用setIfAbsent初始化一个比数据库当前最大序号大1的起始值。5.2 WebSocket断连和大屏长期挂机后的内存问题大屏和WebSocket服务器之间是长连接如果前端页面一直挂着不刷新时间久了会积累大量监听器或订阅回调造成内存泄露。在Vue3中如果组件是用onMounted里创建的WebSocket连接一定要在onUnmounted里关闭。我接手过好些个网上能下载到的Java医院叫号系统源码很多都没处理好这个问题。我自己写的时候把WebSocket连接封装成了一个全局单例管理好订阅和退订避免大屏页面被反复切换路由时创建大量连接。同时在后端服务端使用Spring的WebSocketHandler时要记得在afterConnectionEstablished和afterConnectionClosed里维护会话集合连接关闭时一定要移除Session否则会慢慢堆积最终把应用服务器的内存耗光。5.3 和小票打印机、扫码枪的兼容性问题医院分诊台通常会有一个热敏小票打印机患者挂完号后打出一张排队凭条。这里有个坑某些热敏打印机只支持ESC/POS指令你在Java里用普通的PrintStream直接输出中文会乱码。正确做法是使用专门的esc-pos-encoder库或者在打印内容前把中文字符转为GBK编码再输出。扫码枪其实简单很多本质就是一个USB键盘扫码后自动输入一串数字加回车。在医院系统里我通常会在挂号输入框监听回车事件自动触发查询。这样护士用扫码枪一扫患者就诊卡患者名字就能带出来不用手动敲了。这个小细节看似不起眼但分诊台护士用起来非常顺手减少了很多录错名字的情况。6. 这套系统的扩展方向和源码使用建议6.1 从科室级走向全院级我做的这套系统最初只是覆盖门诊部5个科室后来医院想扩大到全院所有科室。这时候需要增加的不仅仅是大屏数量而是一个统一的号源中心服务。原来的schedule表要抽象成“资源池”不同科室、不同医生的排班都往资源池里申请号源。同时患者可以通过微信小程序提前取号到院后扫码激活排队资格——激活这个动作很关键它决定了患者是否算“到达现场”。如果让我再改版一次我会把医院排队叫号系统拆成两个独立的微服务一个负责“号源与排队状态管理”一个负责“消息推送与语音播报”。两者之间通过异步消息通信。这样当大屏数量增加时可以单独给消息推送服务扩容而不影响排队状态的主流程。6.2 对拿到源码的开发者的一些诚恳建议我知道很多读者拿到这套源码第一反应是“先跑起来看看”。这当然没错但我建议你按这个顺序来研究代码不然容易淹没在细节里先看数据库初始化脚本搞清楚四张核心表之间的关系。再跑一次项目用分诊台账号挂一个号看queue_record里多了一条什么状态的记录。然后用医生账号点击“呼叫下一个”同时打开浏览器控制台看WebSocket消息是如何发出来的大屏数据是怎么变化的。最后再去读Service层的实现这时候你会发现自己已经能猜到每行代码是干什么用的了。按照这个路线走比从Controller一层一层往下点要高效得多。6.3 语音播报和显示内容必须符合医院场景最后一点是使用层面的建议。大屏不只是放一个号在那里它应该同时展示当前叫号、排队余量、过号提示、停诊公告等多个区域。我在推出第一版时大屏上只放了当前号码结果发现患者还是喜欢凑到诊室门口因为“只看到号码不知道还要等多久”。后来我加上了“前面还有几位”这个信息候诊区站着等的人明显变少了。语音播报的语速和内容也值得打磨。“请A015号张三到3号诊室就诊”和“请A015号到3号诊室就诊”后者更保护患者隐私。在妇科、皮肤科等科室患者对名字被广播这件事是很敏感的。所以我把是否播报姓名做成了配置项由医院自己决定开还是关。这套项目开发下来我最大的感受是真正的排号系统难点不在“队列”而在“规则”和“异常处理”。如果把过号、停诊、预约优先等场景都处理好整个系统的稳定性会远超预期。希望这篇拆解对正在做类似项目的你有帮助。本文还有配套的精品资源点击获取