简介:在医疗信息化建设中,医院排队叫号系统是缓解候诊混乱、提升就医体验的关键基础设施。其核心并非简单的队列先进先出,而是涉及多角色协同、状态机流转与高并发场景下的数据一致性。本文从数据库表设计出发,详解了号源排班、排队记录与呼叫日志的建模思路,并重点剖析了基于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 + Vue3(CDN引入)+ 浏览器SpeechSynthesis语音接口。为什么不选那些重型的消息队列中间件?因为我太清楚了,医院内网环境里,能跑起来的就是JDK、MySQL、Redis,你引入一套RabbitMQ、Kafka,光让信息科老师配合装环境就是一场灾难。
这套组合的合理性在于:Spring Boot负责接口和事务控制,MyBatis-Plus减少大量单表CRUD代码,Redis解决分布式环境下的队列号发放(万一医院配了两台应用服务器做负载,不会出现重号),WebSocket负责把医生的呼叫动作实时推送到所有大屏。
2. 数据库设计是整套系统的地基:核心表结构与号码状态机
2.1 我建了四张核心业务表
其实整个系统的核心表并不多,真正在设计上有讲究的,是号源排班表和排队记录表。我先把建表结构和关键字段写出来。
科室表(dept):dept_id、dept_name、dept_location、doctor_count。
医生表(doctor):doctor_id、dept_id、doctor_name、title、status(1出诊 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;排队记录表(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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;呼叫日志表(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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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 Result<QueueRecordVO> 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来过滤。这种做法在演示阶段没什么问题,但一旦科室多了,流量都挤在一个通道上,而且前端每次收到消息都要判断“这跟我有关系吗”,非常笨拙。
我采用的是按科室维度建立Topic,WebSocket的路径设计为:
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 + VueUse(useWebSocket)的话,代码非常简洁:
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号诊室就诊”,后者更保护患者隐私。在妇科、皮肤科等科室,患者对名字被广播这件事是很敏感的。所以我把是否播报姓名做成了配置项,由医院自己决定开还是关。
这套项目开发下来,我最大的感受是,真正的排号系统难点不在“队列”,而在“规则”和“异常处理”。如果把过号、停诊、预约优先等场景都处理好,整个系统的稳定性会远超预期。希望这篇拆解对正在做类似项目的你有帮助。
本文还有配套的精品资源,点击获取