基于SpringBoot的医院排队叫号系统设计与实现
经常有读者私信问我这类选题怎么做正好最近刚帮人把一套基于SpringBoot的医院排队叫号系统从零跑到上线从需求梳理到部署踩了不少坑。趁周末把整个项目的核心设计、关键流程、踩坑实录完整整理出来这套东西无论是拿来当毕设还是实际接到类似的小型医疗信息化项目都能直接用上。先说结论基于SpringBoot框架的医院排队叫号系统本质上是一套围绕“号源生命周期”管理的实时业务系统。它的核心不是CRUD而是排队状态机的流转和多端实时同步。如果你能把这俩问题想明白了整个项目就成功了一大半。1. 项目整体设计与需求拆解1.1 医院排队叫号的业务痛点去医院看过病的人都懂诊室门口挤一堆人医生时不时探出头喊“下一个”患者既不知道前面还有几个人也不知道自己排到几点。这背后其实是三个问题信息不透明患者不知道前面排队人数只能干等。叫号效率低人工喊号靠嗓子听不见就过号医生还得反复喊。缺乏数据沉淀没有就诊量、平均候诊时长、医生接诊效率的数据没法做管理优化。所以一个合格的排队叫号系统起码要覆盖患者取号、分诊排队、医生叫号、大屏展示、语音播报、过号重呼、数据统计这几条链路。少了任何一环这套系统落地后都会被医护吐槽“还不如人工喊”。1.2 核心功能模块拆解把这个系统掰开看核心模块其实就六大块模块功能说明关键对象患者端现场取号、预约取号、扫码查看排队进度排队号码、队列状态分诊台人工分诊、指定医生、特殊患者优先排队科室、医生、号源医生工作站顺呼、重呼、过号、结束就诊当前排队患者候诊大屏实时展示当前叫号与等待队列WebSocket推送语音播报把叫号信息转成语音播出TTS、音频播放管理后台医生排班、号源规则、数据统计报表排班表、挂号记录每个模块单独看都不复杂但组合起来业务的完整闭环才算形成。做系统设计的时候我建议先把流程图画出来再定表和接口。流程走不通代码写再多也是白搭。1.3 为什么选SpringBoot而不是别的框架这个项目我以前带新人做的时候也有人问过“用SSH不行吗”“用Python Django是不是更快”。我的看法是SpringBoot就是当前做这类中小型医疗信息系统最稳妥的选择没有之一。逻辑很简单开发效率高以前搭SSM要写一堆XML配置SpringBoot的自动配置把这部分省掉了一套基础骨架建起来十分钟内搞定。生态成熟MyBatis-Plus做持久层、Redis做缓存、WebSocket做实时推送、Spring Security做权限控制全是现成方案社区案例也多。部署运维方便打包成可执行Jar服务器上装个JDK就能跑和Docker配合起来也很丝滑。医院IT那边不太愿意给应用装乱七八糟的中间件SpringBoot的“干干净净一个进程”就很友好。招人容易Java工程师满地走以后系统要维护、要扩展接手的人好找。如果你硬要说“SpringBoot太传统了”那也没错但项目的目标是稳定落地而不是炫技。能在医院现场环境里抗住并发、稳定不崩比用什么新潮框架重要得多。2. 数据库设计与核心表结构2.1 从业务流程推导核心数据表刚开始做这类系统的人最容易犯的错是一上来就设计一堆表结果表之间字段冗余、状态混乱。我自己的习惯是先拉业务主线再按主线找实体最后反推字段。排队叫号的主线极简患者 → 挂某个医生的号 → 进入候诊队列 → 被叫号 → 就诊 → 结束。顺着这条线走核心表就这么几张科室表 departmentid、科室名称、科室编码、位置、状态。医生表 doctorid、姓名、所属科室、职称、状态。排班表 doctor_scheduleid、医生id、排班日期、午别上午/下午、总号源数、已约号数、状态。患者表 patientid、姓名、手机号、身份证号脱敏存储、建档时间。排队记录表 queue_record核心表记录每一次排队的完整生命周期。叫号记录表 call_record记录每次叫号的行为用于统计和溯源。别过度设计。很多字段是后来加的最初只需要保证业务闭环。2.2 排队记录表的状态机设计queue_record 这张表是整个系统的灵魂它的状态字段 status 我建议设计成这样状态值含义说明0待叫号患者已取号正在队列等待1已叫号医生已呼叫等待患者进入诊室2就诊中患者已到诊室医生开始接诊3已完成本次就诊结束4已过号叫号后患者未及时到诊5已取消患者主动取消或分诊台取消状态流转的顺序是0 → 1 → 2 → 3这是主路径。从1可以分支到4过号从4可以回到1重呼。设计状态机的时候要把每个状态迁移对应的动作、操作人、触发页面都列出来写代码时才知道每个接口该干什么。举个实际例子医生点了“过号”按钮后系统要做三件事把当前排队记录状态改成4、把队列里下一个患者推到大屏和语音播报、给过号患者发一条短信或公众号通知。如果只改个状态那这个按钮就是个半成品。2.3 索引、并发与数据一致性排队场景最大的并发压力集中在取号和叫号两个操作上。数据库层面要做的保障是排队号码要按医生日期做唯一约束避免同一天同一个医生出现两个3号。状态字段要建索引因为查询队列基本都是where doctor_id? and status in (0,1) order by queue_no。就诊开始、结束时间字段要有默认值或允许为空避免插入时频繁判空。但数据库约束是兜底真正扛并发得靠Redis这点后面在取号模块里细说。3. 排队叫号核心流程的实现3.1 取号模块Redis原子自增生成号码取号是所有流程的起点也是并发压力最集中的地方。比如早上8点开诊几十个患者同时涌到自助机或分诊台如果直接读数据库的最大号码再加一必然会出现重复号。我的做法是用Redis的INCR命令生成每天的排队序号Key设计为queue:seq:{doctorId}:{yyyyMMdd}首次取号时设置过期时间避免Key堆积。// 取号核心逻辑 public QueueRecord takeNumber(TakeNumberRequest request) { String seqKey String.format(queue:seq:%s:%s, request.getDoctorId(), LocalDate.now().format(BASIC_ISO_DATE)); Long seq redisTemplate.opsForValue().increment(seqKey); // 首次设置过期时间防止key堆积 if (seq ! null seq 1L) { redisTemplate.expire(seqKey, Duration.ofHours(48)); } String queueNo String.format(%s-%03d, request.getDoctorCode(), seq); // 再写数据库状态为待叫号 QueueRecord record new QueueRecord(); // ... 组装字段 queueRecordMapper.insert(record); // 同时把队列信息推送到Redis队列供后续叫号时快速定位 String queueListKey String.format(queue:list:%s:%s, request.getDoctorId(), LocalDate.now().format(BASIC_ISO_DATE)); redisTemplate.opsForList().rightPush(queueListKey, String.valueOf(record.getId())); return record; }有几个细节要特别提醒先序号后入库顺序不能反。先拿Redis自增序号再插数据库这样即使数据库短暂抖动也不会出现号码发重。Redis的队列和数据库的queue_record是最终一致的关系。Redis列表负责“当前还有谁在等”数据库负责记录完整生命周期。如果Redis宕机了重启后要从数据库重新构建待叫号队列。自增序号每天要能从前一天断号处继续。比如昨天叫到50今天要叫1号所以Key里带日期是最稳妥的。3.2 叫号模块并发控制与状态流转医生端点“呼叫下一个”的时候系统要做几个动作从Redis队列左侧弹出下一个患者、把状态从0改成1、记录叫号时间、通过WebSocket把消息推给大屏和语音播报。关键代码如下public CallResult callNext(Long doctorId) { String queueListKey String.format(queue:list:%s:%s, doctorId, LocalDate.now().format(BASIC_ISO_DATE)); // 从Redis队列左侧取即最早排队的患者 String recordIdStr redisTemplate.opsForList().leftPop(queueListKey); if (StringUtils.isEmpty(recordIdStr)) { return CallResult.empty(当前没有候诊患者); } Long recordId Long.valueOf(recordIdStr); // 乐观锁更新状态只有待叫号(0)才能被叫号(1) int updated queueRecordMapper.updateStatus(recordId, 0, 1); if (updated 0) { // 状态已被其他操作改变重新放回队列或报错 redisTemplate.opsForList().leftPush(queueListKey, recordIdStr); return CallResult.error(患者状态已变更请刷新后重试); } // 记录叫号日志 callRecordMapper.insert(new CallRecord(recordId, doctorId, LocalDateTime.now(), CALL)); // 组装推送对象 QueueRecord record queueRecordMapper.selectById(recordId); webSocketService.pushToScreen(doctorId, buildScreenMessage(record)); textToSpeechService.speak(buildTtsText(record)); return CallResult.success(record); }这里的核心技巧是用乐观锁update ... where status0防止两个医生同时叫到同一个患者。实际线上虽然一个医生一个时间点只叫一个号但接口要防重否则大屏上会出现同一个人被叫两次。另外我强烈建议叫号操作不要玩事务。啥意思就是 Redis 弹队列、更新数据库、发WebSocket 这三件事别包在同一个Transactional里。因为 WebSocket 推送失败的话不能把已经完成的状态回滚掉否则患者明明被叫了数据库里还是待叫号现场就乱套了。我自己踩过这个坑后来把推送逻辑放到事务提交后执行或者干脆放到消息队列里异步处理问题就解决了。3.3 过号、重呼和特殊优先级处理叫号之后患者可能不在这就引出了过号逻辑。我的设计是医生端提供两个按钮“过号”和“重呼”。过号当前患者状态 1 → 4系统自动呼叫队列里的下一个患者。重呼当前患者状态 4 → 1系统重新推送一遍语音和屏幕。过号不是直接踢出去。现实中很多患者只是去了趟厕所回来发现广播喊过了就懵了。所以重呼至少要给两次机会或者让分诊台手动把过号患者插回队列到指定位置。我习惯的做法是过号后给患者留一个“重新排队”的入口次数限制在两次以内避免有人反复占用资源。// 过号处理 public void handleMissed(Long recordId, Long doctorId) { int updated queueRecordMapper.updateStatus(recordId, 1, 4); if (updated 0) { // 呼叫下一位 callNext(doctorId); // 通知患者 notifyService.notifyMissed(recordId); } } // 重呼处理 public void handleRecall(Long recordId, Long doctorId) { int updated queueRecordMapper.updateStatus(recordId, 4, 1); if (updated 0) { webSocketService.pushToScreen(doctorId, buildScreenMessage(queueRecordMapper.selectById(recordId))); textToSpeechService.speak(buildTtsText(queueRecordMapper.selectById(recordId))); } }特殊优先级主要给老人、孕妇、军人这类群体。我建议在取号时加一个priority字段0 普通、1 优先。取号时优先号排队序号照样生成但在叫号的时候先看优先队列里有没有人有就先叫。这个方法简单直接不用往数据库里塞复杂的排序逻辑。3.4 WebSocket实时推送方案详解大屏和语音播报要“实时”知道叫号信息传统HTTP轮询效率太低也容易造成数据库压力所以必须走WebSocket。SpringBoot里用原生WebSocketHandler就行了按科室维度推送到对应的候诊大屏。Component public class ScreenWebSocketHandler extends TextWebSocketHandler { private static final MapString, ListWebSocketSession SCREEN_SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 约定大屏连接时通过query参数传入科室id String deptId session.getAttributes().get(deptId).toString(); SCREEN_SESSIONS.computeIfAbsent(deptId, k - new CopyOnWriteArrayList()).add(session); } public void pushToScreen(String deptId, String message) { ListWebSocketSession sessions SCREEN_SESSIONS.get(deptId); if (sessions null || sessions.isEmpty()) { return; } sessions.removeIf(s - !s.isOpen()); for (WebSocketSession session : sessions) { try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { log.error(推送失败, e); } } } }大屏端连接的时候带上科室ID作为参数也就是类似ws://server/screen?deptId1这样的地址。服务端在握手的时候把参数解析出来存到session属性里后续推送就按科室维度分发。这里我想强调一个从实际部署中获得的经验WebSocket推送千万别只推最新的一个号。大屏上除了显示当前叫号还要显示后面三到五个等待号码所以推送的消息要带上完整的“当前叫号 候诊队列”数据一次推一坨别一个号码一次推送否则网络抖动时大屏数据就错乱了。从实际部署的经验来看前端收到消息后别急着刷新整个大屏先对比一下消息里的版本号或队列变更id只有数据真的变了才重新渲染。这个细节能省掉大量无意义的页面闪动。4. 大屏显示与语音播报的实现思路4.1 门诊候诊大屏的核心逻辑大屏是整个系统里最能看效果的部分也是答辩或演示时的加分项。从功能上看它要做三件事展示当前叫号大字居中突出科室、医生、号码、患者姓名脱敏后后两个字。展示候诊队列后面几位患者的号码和姓名。展示温馨提示比如“请XXX到3诊室就诊”“过号请到分诊台处理”。数据来源就是 WebSocket 推送。我第一次做的时候走了弯路让大屏每隔三秒轮询一次后端HTTP接口拿队列数据结果候诊区人一多后端接口压力特别大屏幕还老是闪。后来改成WebSocket主动推送压力瞬间就下来了大屏的数据也稳定不少。推送消息的JSON结构我建议做成这样{ type: CALL_NEXT, deptId: 1, current: { queueNo: A-012, patientName: 张**, doctorName: 李医生, roomName: 3诊室, callTime: 2025-01-12 09:15:30 }, waiting: [ {queueNo: A-013, patientName: 王**}, {queueNo: A-014, patientName: 赵**}, {queueNo: A-015, patientName: 刘**} ] }大屏端只负责按这个结构渲染展示逻辑别写复杂了。实际项目里我去医院看过大屏基本都是前端Vue或者原生JS写个简单的轮播页面后台数据完全靠WebSocket喂。4.2 语音播报对接方案语音播报做起来其实比很多人想象中简单。常见的方案有三种方案优点缺点适用场景内置TTS引擎如离线SDK免费、响应快、不依赖外网音色机械需要部署SDK医院内网部署云厂商语音合成API音色自然、支持多种音色需要联网、按调用量计费有公网条件服务端生成音频文件播放器可预生成播放稳定实时性略差大屏端定时播放我推荐的做法是服务端调用TTS接口生成mp3文件用媒体流推给前端播放或者干脆用浏览器自带的Web Speech API在C/S架构的大屏机上直接用前端合成语音。不过要提醒一句医院的网络环境有时候很封闭云厂商的API不一定通离线TTS SDK会更稳。如果你是在毕设demo阶段直接用浏览器原生合成就够了别一开始就接一堆第三方SDK等真正落地再替换。// 大屏端语音播报 function speak(text) { if (speechSynthesis in window) { const utterance new SpeechSynthesisUtterance(text); utterance.lang zh-CN; utterance.rate 1.0; window.speechSynthesis.cancel(); // 先取消之前的播报 window.speechSynthesis.speak(utterance); } }5. 常见问题与排查技巧实录5.1 并发场景下的号码与顺序问题问题表现某个瞬间两个患者拿到的号码重复了或者叫号时跳过了某个人。排查思路先看号码是通过什么生成的。如果你用的是数据库SELECT MAX(queue_no)加一那并发下必然重复这个没有悬念。如果用的是Redis自增还出现问题多半是取了号推队列的时候失败了导致队列里少了一个人数据库里有记录但Redis队列没有。解决方案号码用Redis INCR同时数据库做唯一约束兜底取号入库和推队列分开两步推Redis失败时要有补偿机制比如定时任务扫描最近五分钟入库但没进Redis队列的数据重新推一次。5.2 WebSocket频繁断开怎么排查问题表现大屏连上WebSocket后过一会儿就断线或者推送通知不到屏幕上。排查思路先分清是服务端断的还是网络断的。服务端断多半是异常没捕获导致session被关闭网络断则要看医院无线网络环境大屏位置离路由远也会掉线。可以在服务端定期发送心跳ping大屏端收到后回pong连续三次没回就主动重连。解决方案在大屏端写一个简单的重连机制断线后自动重连并带上断线前最后一条消息的ID作为参数服务端把之后的增量数据补齐重新推送。5.3 数据库查询慢怎么优化问题表现医生端点“呼叫下一个”按钮后要两三秒才出结果或者分诊台查询候诊列表时卡顿。排查思路打开慢SQL日志看一眼大部分情况下是查队列的SQL没走索引或者查询条件里用了LIKE %xx%。解决方案候诊列表查询固定走doctor_id status queue_no组合索引列表页不要全量查只取前二十条就够了因为大屏上也只显示前几个如果候诊记录已经超过几万行就按天分表比如queue_record_20250112历史数据自动归档。这里顺带提一个很多新人会忽略的点分诊台和大屏查的是“候诊队列”而不是“全部排队记录”。别把历史已经完成的数据也查出来那当然慢。5.4 一些细节上的坑时区问题医院服务器一般设置东八区SpringBoot启动时一定要显式指定spring.jackson.time-zoneGMT8否则数据库存的LocalDateTime和展示出来的时间可能差八个小时。脱敏展示大屏上患者名字千万别全显示用张**这种格式既是隐私要求也是合规红线。身份证号、手机号同理。推送失败别抛异常WebSocket推送失败就记日志然后继续别让主流程卡住。因为叫号是核心链路推送是次要链路推送失败不能影响叫号。DTO和实体类要分开别直接把数据库实体类返回给前端一来暴露敏感字段二来前端字段和数据库字段耦合太紧改表结构容易出问题。定时清理过期的排队记录每天凌晨跑个定时任务把前一天还没结束的待叫号和已叫号记录改成已过号或者已取消保持当天数据干净。5.5 关于SpringBoot版本选择这个项目如果是从零开始新建SpringBoot直接用2.7.x就够了稳定、资料多、跟主流教程兼容。3.x虽然也出了几年但要求JDK17起步一些老的项目依赖比如某些报表组件可能还不兼容。如果你是新写的项目并且团队愿意上用JDK17那3.x也可以但如果是要快速交活、稳妥上线的项目别折腾2.7.18配JDK8或者JDK11基本稳如老狗。我刚踩过从2.7升3.2的坑光一个javax改jakarta的包名迁移就够喝一壶的不是必要的升级没必要做。6. 扩展数据统计与报表思路排队叫号系统做完之后总要给医院端留点管理功能。最常见的就是数据统计报表某天每个医生的接诊量、平均就诊时长、过号率、患者平均等待时间。设计这些功能时只需抓住一个原则所有统计数据都从queue_record表和call_record表里算。比如平均候诊时长的SQLSELECT AVG(TIMESTAMPDIFF(MINUTE, create_time, start_time)) AS avg_wait_minutes FROM queue_record WHERE doctor_id #{doctorId} AND create_time BETWEEN #{startTime} AND #{endTime} AND status 3;这类报表查询在数据量小的阶段随便写写就行数据量大了再做汇总表每天凌晨跑批生成当天的统计快照。别一开始就上复杂的OLAP方案没必要。如果涉及到报表导出可以考虑集成现成的报表工具比如锐浪报表这类组件SpringBoot整合起来也不复杂重点是提前把查询出的数据集做规范报表工具只做呈现。这块可以等核心流程做完再补别一开始就陷进去。写在最后做这套系统的过程里我最大的体会是医疗类项目的难点永远不在技术上而在业务流程的完整性和细节的严谨性上。一个取号接口、一个叫号按钮看似简单背后却涉及并发控制、状态流转、消息推送、异常兜底一堆东西。你把这套链路想透了很多相似的系统银行排队叫号、政务大厅排队、餐厅等位你都能举一反三因为核心思路完全一致。最后分享一个小经验也是我反复跟身边人强调的写代码前先把状态机和推送时序图画明白把接口清单列完整再动手写你会节省至少一半的返工时间。这个系统的代码量其实不大核心表五张、核心接口十个左右单人可以在一到两周内完成主体功能。关键在于边界条件要提前想好尤其是过号、重呼、并发取号这些场景等有人真出问题了你再去补那就不叫开发叫救火了。如果你正在做类似的系统希望这篇文章能让你少走些弯路。