医院门诊大厅最嘈杂的声音除了导诊台的咨询就是排队叫号系统里不断播报的“请XX号到XX诊室就诊”。很多人觉得这套东西不就是“排队取号屏幕显示语音播报”吗真做起来才知道里面全是坑。诊间过号、医技检查转诊、医生临时停诊、多队列优先规则任何一个边界情况没处理好护士台就会排起长队。今天把这个基于Spring Boot的医院排队叫号系统完整拆一遍从模块设计到核心队列逻辑再到实际部署中踩过的坑一次性讲清楚。这个项目不是简单的CRUD它是一个典型的医疗业务中台场景涉及多端协同患者端取号、医生端叫号、大屏端显示、语音端播报、高并发时段上午8点到10点门诊高峰、严格的业务状态流转待诊、已叫号、就诊中、已完成、过号。整套系统用Spring Boot做核心框架非常合适生态成熟、部署简单、开发效率高这也是为什么医院信息科的项目十有八九都选Spring Boot的原因。1. 项目整体设计与模块拆解思路1.1 需求边界与核心痛点分析做医院排队叫号系统之前先要分清楚一个概念医院排队不是简单的“先来先服务”而是一个多优先级、多队列、多状态流转的复杂业务场景。普通餐厅叫号系统拿来用肯定不行。我当年拿到这个需求时先和门诊护士长聊了一下午梳理出几个核心痛点第一个痛点是过号问题。三甲医院专家门诊一个上午看40个号患者不可能全部提前坐在诊室门口等。在候诊区玩手机、去缴费、去上厕所叫号叫不到人是最常见的纠纷来源。系统必须区分“叫号未到”和“过号重排”两种场景还得支持护士手动标记“过号”让该患者在当前队列尾部重新排队。第二个痛点是检查转诊衔接。患者在门诊看完病医生开了B超单患者缴费后要去超声科排队。超声科的人流和门诊完全不是一个节奏队列规则也不一样需要独立队列规则不能和门诊混在一起。第三个痛点是多诊室协同。一个科室可能有5个诊室同时开诊但只有一个候诊队列。医生不能抢号系统要按诊室分配队列还要支持医生临时停诊后自动转移队列。1.2 技术选型与架构决策的为什么核心框架选Spring Boot这个不用犹豫但有几个关键选型要单独解释一下队列存储选型Redis 还是内存我当时最纠结的就是排队队列到底放哪里。用Java内存里的队列比如DelayQueue或LinkedBlockingQueue实现简单但有两个致命问题一是应用重启后队列全部丢失二是多实例部署时队列无法共享。后来直接选Redis的Sorted Set来实现队列。原因很简单ZADD按序号入队ZRANGE按顺序取队列ZREM完成出队天然的原子操作并且ZREMRANGEBYRANK能轻松处理过号重排。Redis在医疗内网部署很普遍性能完全不是瓶颈。消息推送选型WebSocket 还是轮询叫号屏幕和医生端工作台需要实时响应最开始我想用SSEServer-Sent Events但考虑到大屏客户端、医生工作站、护士站多个端都要持久连接Spring Boot自带的WebSocket加上STOMP协议更全面。后面我会详细讲为什么不用轮询用轮询在高峰期就是灾难。数据库设计MySQL单库还是分库核心表就是医生排班表、队列记录表、取号记录表、过号记录表。复杂度不高单实例MySQL完全够重点是索引设计要贴合业务查询路径。1.3 子系统划分与核心流程定义完整系统拆成四个核心子系统这个划分直接决定了后期的开发效率第一个是取号子系统。支持患者自助机取号、护士台人工取号、手机端预约取号。取号的核心逻辑是生成排队序号这个序号有讲究格式通常是“科室拼音首字母三位数字”比如皮科就叫PK001。序号不能简单地1要考虑医生停诊后的序号回收否则会出现跳号或者重号。第二个是叫号子系统。医生端点击“呼叫下一个”系统从队列头部取出一个待诊患者状态变为“已叫号”同时推送消息到大屏和语音端。这里有个容易被忽略的点大屏显示和语音播报不一定同步患者听到语音后走进诊室需要时间系统要支持“重呼当前号”和“过号后再呼”两种操作。第三个是状态调度子系统。这是核心中的核心后面单独说。第四个是统计与监控子系统。管理者要看医生平均候诊时长、各科室高峰时段、爽约率这些指标。用定时任务把每天队列流水汇总成报表这部分用到Spring Boot的Scheduled踩坑在于定时任务要加分布式锁否则多实例部署时会重复执行。2. 核心数据模型设计与数据库实现2.1 关键表结构解析整个系统最核心的表是appointment_queue也就是候诊队列明细表。这张表设计得好不好直接决定了后续所有业务逻辑的复杂度。我把核心字段分享出来CREATE TABLE appointment_queue ( id BIGINT PRIMARY KEY AUTO_INCREMENT, org_id BIGINT NOT NULL COMMENT 院区ID, dept_id BIGINT NOT NULL COMMENT 科室ID, doctor_id BIGINT NOT NULL COMMENT 医生ID, queue_date DATE NOT NULL COMMENT 就诊日期, session_type TINYINT NOT NULL COMMENT 时段类型 1上午 2下午 3晚上, queue_no VARCHAR(20) NOT NULL COMMENT 排队序号 PK001, patient_id BIGINT NOT NULL COMMENT 患者ID, visit_type TINYINT NOT NULL COMMENT 就诊类型 1初诊 2复诊 3检查, status TINYINT NOT NULL COMMENT 0待诊 1已叫号 2就诊中 3已完成 4过号 5已取消, priority INT DEFAULT 0 COMMENT 优先级 复诊更高, source TINYINT DEFAULT 1 COMMENT 取号渠道 1自助机 2护士台 3手机端, call_times INT DEFAULT 0 COMMENT 已叫号次数, first_call_time DATETIME NULL COMMENT 首次叫号时间, finish_time DATETIME NULL COMMENT 完成时间, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_dept_doctor_date (dept_id, doctor_id, queue_date), KEY idx_patient_date (patient_id, queue_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT候诊队列明细表;几个设计要点status字段不要用字符串用TINYINT加注释查询效率高也方便状态机流转。priority字段用来实现复诊优先、老人优先等差异化排队策略这个字段还可以业务扩展成“军属优先”之类的需求具体规则由医院制度定技术侧只留扩展位。queue_no这个字段看起来简单生成逻辑其实有讲究。我是用Redis INCR按科室和日期的维度生成自增序号再拼上科室前缀。这样即使两台自助机同时取号也不会出现并发重号。2.2 医生排班与时段映射队列系统绕不开医生排班。这一块的经验是不要自己造轮子语义和医院信息系统HIS的排班保持一致。我在项目里做了doctor_schedule表关联dept_id、doctor_id、schedule_date、session_type再配上total_number总号源、remain_number剩余号源两个字段。排班的核心逻辑是时段映射。我最初忽略了节假日调休的问题结果清明节调班那天整个叫号系统直接停摆因为当天没有匹配到对应的排班记录。后来我在服务启动时做了排班预生成提前7天从基础数据平台同步节假日安排自动生成每日排班记录到schedule_detail表。排班数据的启停逻辑也很重要医生临时停诊时队列里还没叫到号的患者状态要批量改成“改约待处理”同时通知窗口护士引导患者改约或换医生。这看着简单实际意味着一个事务里要更新appointment_queue表的多行数据还得和队列缓存同步稍不注意就会有一批患者“卡”在旧的队列缓存里大屏显示有号实际医生端已经看不到人了。2.3 状态机流转与约束给这张表配一个状态机是避免脏数据的关键。我写了个枚举类QueueStatusEnum所有状态流转必须走统一入口方法不允许业务代码里随意UPDATE状态字段。public enum QueueStatusEnum { WAITING(0, 待诊), CALLED(1, 已叫号), WORKING(2, 就诊中), FINISHED(3, 已完成), PASSED(4, 过号), CANCELED(5, 已取消); private final int code; private final String desc; // getter... }合法的状态流转是WAITING - CALLED医生叫号CALLED - WORKING患者进入诊室医生点击开始就诊WORKING - FINISHED就诊结束CALLED - PASSED叫号三次未到标记过号PASSED - WAITING过号患者重新入队WAITING - CANCELED护士帮助取消这个状态机看起来简单但每次流转背后都跟着一系列的消息动作。比如从CALLED到WORKING大屏端要把当前呼号状态从“请就诊”变为“就诊中”语音端停止播报候诊区计数要减一。这些联动用Spring的事件机制做最舒服后面实操章节我会展示实现。3. 核心业务逻辑叫号队列的全程实现3.1 入队逻辑与序号生成机制入队是整个系统的第一个关键动作。患者取号本质上是往队列里插入一条记录。这个动作不是简单的INSERT INTO就行它还要同步在Redis里维护一个可供医生端弹出的有序队列。入队流程拆出来是四步第一步根据dept_id、doctor_id、queue_date生成当日当科室的队列序号。用Redis的自增键键名设计成queue:seq:{deptId}:{doctorId}:{yyyyMMdd}一个科室一个自增键互不干扰。 第二步把患者信息写入MySQL的appointment_queue表状态置为WAITING。 第三步向Redis Sorted Set加入元素key为queue:list:{deptId}:{doctorId}:{yyyyMMdd}member为queueIdscore为序号。 第四步如果该队列原本为空用手机号给患者发一条取号成功的通知。这里有个关键的体验细节患者取号时应该能直接看到自己前面还有多少人。计算前面人数最合理的是取当前患者序号在该医生队列中的rank值而不是简单用当前医生已叫号数去推算。用Redis的ZRANK取排名比扫表快得多。3.2 叫号逻辑与音频联动方案医生端点击“呼叫下一个”是整个系统最核心的操作。这个动作的完整流程要考虑到并发操作下不能两个医生同时弹出同一个患者。我的实现是给每个队列加了queue status控制用版本号乐观锁防止超叫。核心逻辑如下调用ZRANGE queue:list:{deptId}:{doctorId}:{date} 0 0取出队首元素然后执行一个Lua脚本原子性地把队首元素弹出同时把状态更新为“已叫号”两个动作合并成一步。为什么用Lua脚本因为“取队列头部”和“更新MySQL状态”是两个操作非原子情况下可能出现医生A弹出患者XMySQL还没更新完医生B又执行了操作结果患者X被叫了两次。音频这块是另一个坑。医院环境嘈杂大屏显示“请3号到1诊室”如果语音跟不上患者根本听不清。语音播报我用的是队列语音合成生成mp3片段后推送到窗口屏的播放器。有条件的医院可以用局域网内网TTS服务延迟更低不用每次生成文件。不同科室的播报模板不一样“请{queueNo}号患者到{roomName}就诊”这种模板化的合成方式最稳定。3.3 过号重排与优先规则算法过号处理是我觉得整个系统里最考验工程经验的地方。按国内医院普遍习惯过号患者不会重新排到队尾等半天而是“插入当前就诊患者后的第2位”这个规则各地医院略有差异。我把这个逻辑做成了可配置。我实际用的算法逻辑建立在Redis Sorted Set的score重排上过号患者的queueId从原位置移除重新加入队列但score要重算。怎么算取当前正在就诊患者的score 1然后找到该位置之后的所有元素把它们的score整体加1相当于集体往后挪一位为新插入的患者腾出位置。这个操作粗暴有效但并发下会有score冲突的风险。真正稳的做法上面说了是用Lua脚本串行化执行。串行化后的整体耗时实测在几十毫秒以内完全可以接受。复诊优先的逻辑更简单在取号入队时如果判断visit_type 2复诊直接把score设置为当前队列最小序号减去0.1。这样复诊患者永远排在队首前面体现“优先“的同时又不会搞乱序号展示。3.4 多队列聚合与分配策略一个科室5个诊室一个候诊队列分给多个医生这里有两种常见模式模式一是共享队列抢号模式。所有空闲医生从同一个队列里取号适合内科这种医生水平差异不大的科室。实现方式是把Redis的队列key从doctorId维度改成deptId维度医生端拉取时查deptId维度队列。模式二是独立队列模式。每个医生有自己的队列适合专家门诊。这种模式下护士台可以手动把患者从一个队列转到另一个队列比如老专家临时停诊把还在排队的患者批量转入另一位同级别医生的队列末尾。两种模式我在代码里是通过queue_type字段区分医生端工作台根据这个字段组装不同的查询逻辑。4. 多端协同大屏、医生端、护士端的交互实现4.1 大屏端 WebSocket 实时推送设计候诊大屏是整个项目的门面但很多开发者低估了它的技术复杂度。55寸电视Android盒子内存不大网络环境一般还要长时间稳定运行。我的选型是WebSocket STOMP协议客户端是Android盒子里的WebView页面。服务端核心推送流程不要直接给所有连接推全量数据要做“按科室订阅”的频道隔离否则整个院区几十块屏幕都订阅同一个频道一个科室叫号所有屏幕都收到消息带宽和渲染压力都很大。大屏数据分两层推送第一层是实时事件推送。比如CALL_EVENT叫号事件、FINISH_EVENT完成事件这类事件是即时性消息每次推的都是单条排队更新的内容。大屏接收到CALL_EVENT后直接从服务端拉取最新的队列Top5和当前叫号信息。第二层是定时全量刷新。我加了个30秒的全量同步作为兜底防止WebSocket断线重连后数据不同步。这块在后面“常见问题”里细讲。用Spring Boot实现时我推荐直接实现WebSocketHandler接口做原生WebSocket处理或者用Spring Integration的WebSocket支持看团队熟悉程度。关键是要写断线重连和心跳机制Android盒子在弱网下会频繁断连服务端要能识别无效连接并清理会话。4.2 医生端工作台的叫号节奏控制医生工作台我用的是前后端分离架构前端是Vue3后端就是Spring Boot提供REST接口。工作台上最核心的操作就四个呼叫下一个、重呼当前、过号、完成就诊。医生端有个真实需求很微妙叫号之后医生可能还在写上一个患者的病历患者已经进来坐下了。这时候如果直接自动把状态变为“就诊中”会打乱医生的节奏。所以我的逻辑是叫号后状态改为CALLED患者进入诊室医生点“开始就诊”才改状态。如果医生没点“开始就诊”大屏上一段时间后自动回到待诊状态防止前一个患者占着位置不进来。重呼功能我加了次数限制——同一个患者最多连续呼叫3次超过3次系统自动标记为过号这是护士台当时强烈要求的因为有些患者手机上叫号没声音但人其实就在附近放任重复呼叫会堵住队列。4.3 语音播报模块与排队公平性策略连大屏的音频播放器是我的一个独立模块。我做了个voice-service子服务接收消息队列消息调用吉林大学讯飞开放平台的TTS接口生成音频文件然后用局域网内的播控软件在指定区域播放。实际部署时发现一个体验问题人工播报和系统播报会打架。最开始语音采取“即时即播”医生点完叫号系统马上播报。结果护士台也在同时喊号形成双重噪音。后来我们改成叫号事件发生之后延迟8秒再播报给护士台一个“反应窗口期”如果护士人工喊了号系统就不播了。这个窗口期还能起到“患者看到大屏起身走过来”的时间缓冲作用。有人说这不就拖慢了叫号节奏吗但实际体验下来患者的就诊流畅度反而提升了。这个“8秒延迟”的细节就是纯粹的现场调试经验了不同医院人流特点不同有的医院人多嘈杂需要缩短到5秒有的医院觉得8秒都太赶。我在系统里做成了后台可配置项护士长可以自己调。4.4 护士端人工干预的操作场景护士端不是简单的“手动取号”它要处理的都是异常场景。一个最典型的场景患者拿着缴费单到导诊台说“我这个号没叫到医生说要检查完再回来”。护士的操作路径是搜索患者 - 查看当前状态 - 执行“重新入队”操作。这里的“重新入队”意味着把患者从已完成状态唤醒重新进入队列尾部。在数据层面我做了一个新的记录而不是复用旧记录这样可以保留完整的就诊痕迹。另一个场景是“加号”。医生临时同意加号护士要做的是手动创建一个队列记录并指定插入位置。加号患者的序号格式要特殊标出我用的是加号-001这种形式方便医生辨认。这个字段就是visit_type3的“加号”类型在统计报表里单独区分。5. 项目部署实践与常见坑点实录5.1 环境搭建与启动参数配置经验这个项目我用了Spring Boot 2.7.18版本配合JDK 8这是开发医疗项目的稳妥组合。JDK17也不是不行但医院内网环境一些老设备的兼容驱动可能没跟上我不想拿生产环境去赌。核心配置文件我拆成了三份application.yml公共配置、application-dev.yml开发环境、application-prod.yml生产环境。使用spring.profiles.active切换环境。生产环境的连接池配置是个重点。医院信息科给的数据库内核版本参差不齐我统一用HikariCP连接池配置核心参数spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000max-lifetime配到30分钟是为了防MySQL服务端wait_timeout断开连接。连接池参数不是越大越好医院峰值并发也就几百20个连接绰绰有余配多了反而增加数据库负担。Redis的配置我用了Lettuce连接池因为兼容性好支持Redis Cluster。生产环境是双节点主从加上哨兵模式保证高可用不追求太复杂。5.2 高频问题排查与解决方案全收录问题一WebSocket连接频繁断开现象大屏端每隔几分钟就断开重连屏幕上显示加载中患者看到大屏一闪一闪的。排查先看服务端日志发现WebSocketSession对象是正常创建和销毁的但客户端网络状态不佳。Android盒子的WebView和WiFi休眠策略会把WebSocket的长连接踢掉。解决客户端加心跳机制每30秒发送一个ping消息服务端如果在3个周期没收到心跳主动关闭连接并触发前端重连逻辑。同时服务端加WebSocketHandler的afterConnectionClosed回调清理会话对象防止内存泄漏。问题二叫号后大屏不更新但医生端显示已叫号现象医生点击呼叫下一个医生端正常进入下一个患者但候诊大屏迟迟不显示。排查这是典型的服务端推送与前端渲染不一致问题。WebSocket消息其实是推成功了但Android盒子的WebView在锁屏状态或网络切换时JS代码没来得及执行。解决前端加“WebSocket消息接收后主动拉取一次最新数据”的兜底逻辑。也就是说哪怕消息丢失30秒的全量同步也会自己兜住。问题三过号患者重新入队后位置不对现象护士点“重新入队”患者又排到了队列最后面等了40分钟还没到。排查重新入队时患者score被设置成了当前最大序号但队列里可能还有大量“已叫号”未完成、已过号未清理的数据导致新分数看起来是尾部实际却落后于前面的阻塞患者。解决重新入队前先清理该队列里的过号和已取消状态的关联数据再重算可排位次。这就是前面说的Lua脚本要一并处理的清理动作。问题四高峰期数据库连接占满现象上午9点到10点数据库连接数飙到满接口响应突然变慢。排查不是连接池太小而是部分慢查询占着连接不放。用慢查询日志定位到取号序号生成的SQL当时没走索引。解决给appointment_queue表加联合索引(dept_id, doctor_id, queue_date, status)把取号时查询当前队列最大序号的SQL从全表扫描降到索引覆盖扫描。问题五夜间批量任务执行了多次现象每天凌晨2点的报表统计任务在双实例部署环境下重复执行了两次报表数据翻倍。排查项目部署了两个节点Scheduled定时任务在每个节点上都跑了一遍没有做分布式互斥。解决加一个基于Redis的分布式锁用SETNX命令设置锁key为task:report:{yyyyMMdd}过期时间为任务预估执行时间的两倍。任务执行完主动删除锁没抢到锁的节点直接跳过本次任务。5.3 上线前的联调清单与交付物医院项目不像互联网产品能随时发版上线前要做充分联调。我列一份实际项目中验证过的交付清单可以直接参考取号流程自助机取号、护士台取号、手机端预约取号三条链路分别测试取号成功和失败场景。叫号流程单队列单医生、单队列多医生、多队列多医生三种模式分别验证呼叫、重呼、过号、完成。大屏联动叫号后大屏显示是否在2秒以内更新语音播报是否清晰与人工播报是否冲突。异常场景医生停诊批量改约、患者爽约自动过号、护士手动取消排队、跨时段转科排队。并发压测300人同时取号的场景下Redis序号生成是否无重复数据库写入是否稳定。定时任务凌晨报表汇总后数据是否准确节点重复执行是否被锁拦截。6. 写在最后个人实际开发经验总结这个系统从需求梳理到上线稳定运行前后花了我大概两个月时间。回头总结最想说的一点是技术难点从来不在堆功能而在把业务规则吃透。过号重排怎么排、医生停诊怎么通知、不同科室的优先级规则怎么配每一行代码背后都是真实的医院管理诉求。Spring Boot在整个项目里承担的角色非常“标准”它负责接住前端请求、调度服务逻辑、管理数据事务、提供监控端点。真正区分项目好坏的是你有没有把业务状态机设计清楚有没有把冲突场景在并发层面防住。架构上宁可大材小用也别少一个保障医院系统的价值不在于酷炫在于稳定。最后分享一个小技巧上线前如果条件允许把系统先接到医院信息科的测试环境用真实科室、真实医生、真实的排班数据跑一遍流程比你自己造一百条测试数据都有用。真实数据里才会出现“医生正好停诊但队列里还有40个人”“打印过号单的患者又跑回来投诉”这种极端业务场景这些问题提前暴露生产环境就能少挨骂。
