简介排队叫号系统是典型的多服务窗口与患者高效匹配场景其本质上是对队列数据结构的工程化应用。在Java Web领域这类系统尤其能体现状态流转设计、并发控制与数据库优化等基础能力。基于Spring Boot与MySQL构建的医院排队叫号系统通过合理的库表设计如queue_record状态机和乐观锁机制可有效解决超卖、重复叫号等并发难题。系统覆盖取号、叫号、过号重排、大屏展示等完整流程既适合作为实战项目巩固技能也常用于医疗信息化系统的核心模块。本文将从队列原理出发深入拆解其数据流转、核心代码实现及生产环境中的常见问题排查帮助开发者掌握一套可落地的Java Web开发方法论。 你接手一个医院排队叫号系统的Java项目时第一眼看到源码可能会有点懵——界面有取号机逻辑、医生端叫号、大屏展示还有过号重排、预约时段这些概念。这套系统看起来业务不复杂但真要做出来涉及的技术点一点都不少。我之前为了搞清楚这套逻辑把整个设计流程重新梳理了一遍发现这其实是一个非常典型的Java Web实战项目特别适合用来巩固Sping Boot、MySQL、并发控制这些基本功也能作为面试聊项目经验的素材。这篇文章就把整个系统的设计思路、核心代码实现的要点以及我在实际开发中踩过的坑全部摊开来讲。1. 项目整体设计与思路拆解1.1 医院排队叫号到底解决什么问题医院排队叫号系统本质上解决的是多服务窗口诊室与多患者之间高效匹配的问题。想象一下没有叫号系统的门诊患者挤在诊室门口有人中途去检查、有人复诊插队、医生看诊速度又不固定现场必然乱套。数字化叫号系统把整个就诊流程做了标准化改造核心是引入了一个队列数据结构。患者到达后先取号进入一个等待池医生看完当前患者后通过系统从等待池中拉取下一位。这个过程看起来简单但有几个很关键的业务约束队列是按科室医生维度隔离的。患者挂的是哪个医生的号进入的就是哪个医生的等待队列不能串。状态流转是闭环的。从待就诊到已叫号再到就诊中已完成每个状态都要被系统记录方便追溯和管理。过号不能简单作废。患者去检查了、去缴费了回来时已经过号需要有过号重排机制通常会在当前队列降序重排而不是直接丢到最后。1.2 为什么选择用这套技术栈来做这套系统从实际业务需要出发选择了非常经典的技术栈组合前端Layui Bootstrap jQuery。可能有人觉得有点老但你要理解的是医院这类系统的使用者护士、导诊台工作人员对操作简洁性要求极高Layui的表格、表单、弹层组件开箱即用不需要复杂的前端工程化构建也没必要上Vue/React全家桶。后端Spring Boot MyBatis。Spring Boot负责把整个项目的配置自动化MyBatis则提供了灵活、可控的SQL编写方式。叫号系统涉及多表查询、统计报表、动态条件筛选用MyBatis写SQL比JPA更直观也更容易针对慢查询做优化。数据库MySQL Druid连接池。MySQL作为最通用的关系型数据库在这类业务场景下完全够用。Druid提供了监控功能可以看到当前连接池的使用情况、慢查询记录这对排查生产环境问题非常有帮助。选这套组合还有一个很现实的理由部署成本低。医院门诊部的服务器配置通常不会太好这套组合跑起来非常轻量。另外这套技术栈对应的人才市场存量非常大后续接手的团队维护起来没有学习成本。1.3 系统模块划分与数据流转我把整个系统按功能域拆成几大模块各模块之间的职责边界非常清晰模块核心职责关键数据系统管理用户、角色、权限、菜单保证不同角色看到不同界面sys_user, sys_role, sys_menu医生排班维护科室下医生的出诊时间、号源总数doctor_schedule排队叫号取号、叫号、过号处理、状态查询queue_record, queue_log大屏展示将当前队列状态投放到候诊区屏幕queue_record实时查询统计管理出诊量、平均等待时长、科室热度分析基于queue_record聚合数据流转的路径其实也很简单患者在取号机上拿号 → 系统在queue_record表中插入一条待就诊记录 → 医生端页面轮询/刷新看到自己的队列 → 医生点击叫号 → 系统更新状态、将该患者信息推送到大屏 → 患者入诊 → 医生再次点击完成或过号。这个流转过程中最容易出问题的就是从叫号到入诊之间涉及多个状态更新的并发数据一致性这部分我在后面单独展开讲。2. 核心细节解析与实操要点2.1 数据库设计的关键思考数据库表结构是整套系统的基础我逐个说核心表的设计思路。排队记录表queue_record是整个系统的交易主表核心字段如下CREATE TABLE queue_record ( id BIGINT PRIMARY KEY COMMENT 主键, queue_no VARCHAR(20) NOT NULL COMMENT 排队编号如A001, patient_name VARCHAR(50) NOT NULL COMMENT 患者姓名, patient_phone VARCHAR(20) COMMENT 患者手机号, doctor_id BIGINT NOT NULL COMMENT 医生ID, dept_id BIGINT NOT NULL COMMENT 科室ID, schedule_id BIGINT COMMENT 排班ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态:0待就诊 1已叫号 2就诊中 3已完成 4过号 5已取消, source_type TINYINT DEFAULT 1 COMMENT 号源类型:1现场号 2预约号 3复诊, create_time DATETIME NOT NULL COMMENT 取号时间, start_time DATETIME COMMENT 叫号时间, finish_time DATETIME COMMENT 完成时间, window_no VARCHAR(20) COMMENT 窗口/诊室号, index idx_doctor_status (doctor_id, status), index idx_create_time (create_time) ) COMMENT排队记录表;这个表有几个设计点要特别说明queue_no的设计。排班号可以按照科室字母前缀 三位流水号来生成例如内科是A001外科是B001。这样做的好处是大屏展示时患者可以快速找到自己的前缀区间同时数据库层面做前缀匹配也方便。status字段的语义。这里的状态是排队生命周期状态它把叫号过程中的阶段全部映射到数字上方便后端做逻辑判断。注意状态机设计上0可以流转到11可以流转到2或4过号2只能流转到3或4整个流转路径要设计好避免出现2跳回0这种非法状态。source_type字段。现场号和预约号是两类完全不同的业务现场号是到达即排队预约号是按时间点优先叫号。设计中实时叫号的核心规则是在处理完当前患者后如果有预约号到达时间已过则优先叫预约号否则叫最早的现场号。医生排班表doctor_schedule是控制每天号源数量和出诊状态的源头CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, schedule_date DATE NOT NULL, time_slot VARCHAR(20) COMMENT 时间段如上午/下午, total_number INT DEFAULT 0 COMMENT 总号源数, remain_number INT DEFAULT 0 COMMENT 剩余号数, status TINYINT DEFAULT 1 COMMENT 0停诊 1正常, UNIQUE KEY uk_doctor_date (doctor_id, schedule_date, time_slot) ) COMMENT医生排班表;这个表直接把医生某天某时段放多少号作为一条数据记录每次取号时就对remain_number做递减。这里有一个非常常见的坑并发取号时两个请求同时读到remain_number1然后同时减1结果变成0条或负数这就是经典的超卖问题。解决办法有两个方向一是用数据库行锁SELECT ... FOR UPDATE二是用乐观锁在update语句中加上WHERE remain_number 0条件。我推荐用乐观锁方案配合单次update代码少性能好具体写法在下一章展开。2.2 排队状态的完整流转与代码表达很多第一次写这类系统的朋友最大的困惑就是状态流转应该写在哪儿。我的做法是把状态流转封装成独立的Service方法不在Controller里拼业务逻辑。以医生叫号为例核心方法的职责是Transactional(rollbackFor Exception.class) public Result callNext(Long doctorId, Integer windowNo) { // 1. 找到当前医生状态为待就诊的最早记录 QueueRecord nextRecord queueRecordMapper.selectNextByDoctor(doctorId); if (nextRecord null) { return Result.error(当前没有等待患者); } // 2. 把上一条正在就诊中的记录置为已结束防漏 queueRecordMapper.finishCurrent(doctorId); // 3. 更新当前记录状态为已叫号记录叫号时间 queueRecordMapper.updateStatus(nextRecord.getId(), 1); // 4. 记录叫号日志 queueLogMapper.insertLog(nextRecord.getId(), 叫号, windowNo); return Result.success(nextRecord); }注意第2步把上一条就诊中的记录置为已结束这一步在业务上非常关键。如果没有这一条一个医生连续点两次叫号就会出现两个并行就诊中的状态数据就乱了。所以这套设计在实现叫下一个功能时永远都是先收尾、再叫新。另外为了支撑大屏和医生端的实时状态更新我在queue_record表上建立了doctor_id status的联合索引。大屏页面的查询条件通常是WHERE dept_id ? AND status IN (0, 1)索引设计时要注意把区分度高的字段放前面这样可以有效避免大屏轮询时的全表扫描。实际压测中如果发现查询性能不够还可以引入Redis缓存各科室当前等待数大屏直接读缓存1秒刷新一次对数据库的压力会小很多。2.3 前端页面设计与交互细节系统前端主要分三类角色页面医生工作站、分诊台护士端和大屏展示。医生工作站页面是核心交互区用Layui卡片布局上半部分展示自己当前队列的等待患者列表下半部分是下一个和过号操作按钮。每次点击操作后通过Ajax向后端发送请求成功后更新列表。这个页面不追求花哨但有几个提升使用体验的小细节当前就诊患者卡片增加高亮边框让医生扫一眼就知道现在是谁。排队列表默认按号序排列在当前患者就诊时候诊患者自动置灰避免误操作。医生端增加重呼按钮用于患者未及时到场时再次播报。大屏展示页面是另外一个技术难点它是若干个独立的大屏终端部署在候诊区通过浏览器全屏模式展示。大屏不能一直刷新页面所以用Ajax轮询每隔3秒拉取一次当前队列数据。3秒间隔是权衡过的太频繁会占用大量数据库连接资源太慢患者会感觉延迟明显。前端页面我额外做了登录拦截Spring Boot后端通过HandlerInterceptor实现权限验证每个角色只能访问自己的菜单和接口。具体来说角色-菜单关联表存了权限树接口层面用自定义RequirePermission注解做细粒度控制。比如叫号操作需要queue:call权限没有这个权限的用户即使发起请求也会被拦截。3. 实操过程与核心环节实现3.1 环境准备与项目初始化我在本地开发时使用的环境是JDK 1.8、MySQL 5.7、Maven 3.6。之所以仍然用JDK 1.8是因为医院的存量系统技术栈大多维持在这个版本迁移成本最低。JDK 1.8虽然没有最新的语言特性但足够稳定对Spring Boot 2.x系列的兼容性最好。项目初始化直接用Spring Boot 2.3.12版通过spring-boot-starter-web引入Web能力mybatis-spring-boot-starter连接数据库druid-spring-boot-starter配置连接池。另外还引入了pagehelper作为分页插件在统计模块中大量使用。启动类和数据源的配置有几点需要注意尤其是数据库连接池的参数直接关系到高并发场景下系统的表现spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_queue?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 time-between-eviction-runs-millis: 60000 validation-query: SELECT 1这里有几个参数是实战经验不是随便写的max-wait设为60秒含义是线程池中拿不到连接时最长等待60秒超过则报错。在高峰期如果连接池被占满这个等待时间能防止请求无限阻塞。time-between-eviction-runs-millis是连接池中空闲连接回收的周期配置默认是60秒保持默认即可不要为了省资源调大否则空闲连接会被MySQL服务端主动断开造成Connection is not available异常。3.2 自定义注解实现接口权限控制权限控制我采用自定义注解的方式这个在真实项目中非常实用。实现思路是定义一个RequirePermission注解。写一个拦截器从请求头中解析当前登录用户的权限列表。在拦截器中对方法上的注解值做校验。这个方案的好处是权限逻辑从业务代码中完全脱离出来每个接口上只需要加一行注解可维护性大大提升。Controller层看起来非常干净RequestMapping(/queue/call) RequirePermission(queue:call) public Result callNext(RequestParam Long doctorId) { return queueService.callNext(doctorId, null); }数据库层面权限表可以设计成经典的RBAC模型用户-角色-菜单权限点。我建了5张表sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu。登录时加载用户拥有的所有权限点标识如queue:call、system:user:add放进Redis缓存权限校验时从缓存里取效率非常高。3.3 并发取号场景的乐观锁实现前面提到取号时存在超卖风险这里给出具体的乐观锁实现代码public Result takeQueueNumber(TakeNumberDTO dto) { // 1. 查询医生今日排班信息 DoctorSchedule schedule scheduleMapper.selectByDoctorAndDate( dto.getDoctorId(), new Date()); if (schedule null || schedule.getStatus() 0) { return Result.error(医生今日未出诊); } // 2. 尝试扣减剩余号数核心SQL使用乐观锁 int updateRows scheduleMapper.decreaseRemainNumber( schedule.getId(), schedule.getRemainNumber()); if (updateRows 0) { return Result.error(号源已满); } // 3. 生成排队编号并插入记录 String queueNo generateQueueNo(dto.getDeptId(), schedule); QueueRecord record QueueRecord.builder() .queueNo(queueNo) .patientName(dto.getPatientName()) .doctorId(dto.getDoctorId()) .deptId(dto.getDeptId()) .status(0) .sourceType(dto.getSourceType()) .createTime(new Date()) .build(); queueRecordMapper.insert(record); return Result.success(queueNo); }对应的Mapper方法是关键update iddecreaseRemainNumber UPDATE doctor_schedule SET remain_number remain_number - 1 WHERE id #{id} AND remain_number 0 /update这里只用了一条UPDATE语句就完成了两个目的扣减号数、判断号数是否耗尽。由于MySQL的UPDATE是行级原子操作两个并发请求同时执行时只有一个请求能执行成功不需要加额外锁。这个方案比SELECT FOR UPDATE效率更高因为省去了一次查询加锁和一次更新的交互时间。在我压测时同一排班下用JMeter开100个并发线程同时取号最终结果没有任何一条超出总号源数完全满足要求。3.4 过号重排的业务规则实现过号重排看起来是个小事但实现不当很容易引起医患纠纷。系统里的规则是当医生点击过号后该患者状态变为过号系统将其在原队列位置之后的待就诊患者之后重新插入也就是降序重排。重新插入时排队编号保持不变所以大屏上这个号会重新出现但入诊顺序会被排到当时已等待患者的最前面一位待就诊之后。具体实现方式可以有两种简单方案过号时直接把sort_order字段设置为当前最大序号1那这个号就排到最后了。这种方式简单但不公平。合理方案过号后将sort_order设置为当前所有待就诊记录中最大sort_order0.5通过小数位在数据库中排序实现过号患者插入到所有待就诊患者之前、当前已叫号患者之后的效果。我采用的是第二种方案SQL如下UPDATE queue_record SET sort_order ( SELECT max(sort_order) 0.5 FROM queue_record WHERE doctor_id #{doctorId} AND status 0 ) WHERE id #{recordId}小数的引入可能会让部分人觉得数据不干净但其实只要在最终排序查询中对sort_order做ORDER BY sort_order ASC处理对业务展示完全没有影响这也是生产环境中常见的处理手段之一。3.5 门诊大屏的数据展示与轮询细节门诊大屏我使用了ECharts 表格结合的方式来展示。上半部分是当前呼叫的患者信息卡片中间是分科室等待人数概览下半部分是按科室维度滚动展示的待就诊队列。核心的数据接口是listByDeptGetMapping(/screen/queue) RequirePermission(screen:view) public Result screenQueue(RequestParam Long deptId) { ListQueueRecord waitingList queueRecordMapper.selectWaitingByDept(deptId); MapString, Object data new HashMap(); data.put(current, waitingList.stream().filter(q - q.getStatus() 1).findFirst().orElse(null)); data.put(waiting, waitingList.stream().filter(q - q.getStatus() 0).collect(Collectors.toList())); data.put(waitCount, waitingList.size()); return Result.success(data); }大屏每个3秒轮询一次这个接口的查询条件是dept_id status。在高峰期比如上午9点到11点一个科室的待就诊人数在30-50人之间大屏终端同时打开3-5个单接口QPS其实不会太高。但如果全院有20个科室大屏同时轮询数据库每秒会收到不少查询请求。所以我在服务端增加了一层基于Caffeine的本地缓存缓存时间设置为2秒也就是和前端轮询频率错开保证任何时刻数据库只收到一次实际查询达到削峰填谷的目的。这个优化在低配服务器上尤其有效我实测过在没有缓存时30个大屏并发轮询会导致数据库CPU短暂飙高加上缓存后完全平稳。4. 常见问题与排查技巧实录4.1 并发场景下重复叫号问题现象两个医生或同一个医生在多个窗口登录同时操作系统出现同一位患者被叫号到两个诊室的情况。原因医生端页面操作时叫号请求发出后没有立刻禁用按钮用户连续点了几次或者多窗口登录导致同一个医生ID的请求并发到达后端。排查先检查queue_record表里同一时间status1的记录数量正常情况下一位医生只能有一条已叫号记录。如果出现多条说明状态流转没有做幂等保护。修复我在Service层叫号逻辑中对医生的当前状态做了一次校验。具体做法是在Mapper中增加一个条件更新的方法update idcallNextWithLock UPDATE queue_record SET status 1, start_time NOW() WHERE id #{id} AND status 0 /update这个WHERE status 0就是乐观锁的核心。只有状态为待就诊的记录才能被成功叫号已经叫过的记录再收到请求也不会更新成功。同时前端在点击叫号后把按钮设为disabled双保险。经验凡是涉及状态流转的更新强烈建议在SQL语句中带上当前状态条件而不是select出来再判断。这样既避免了并发问题也减少了一次查询交互。4.2 MySQL连接被断开报错Connection is not available现象系统运行几个小时甚至一整晚后偶尔出现接口报错日志里有Connection is not available, request timed out。原因MySQL服务端默认的wait_timeout是8小时。连接池中的空闲连接超过这个时间未被使用MySQL端会主动断开。Druid连接池不知道服务端已经断开把断开的连接分配给请求自然报错。解决办法Druid连接池有一个testWhileIdle参数默认true会在空闲时定期检查连接有效性。检查频率由time-between-eviction-runs-millis控制。我最终把配置调成validation-query: SELECT 1test-while-idle: truetime-between-eviction-runs-millis: 60000每60秒检查一次空闲连接这样即使8小时没有流量连接池也会每分钟探活一次把失效连接剔除掉从根本上避免了这个问题。4.3 大屏轮询导致数据库压力大现象医院有多个候诊区每个候诊区一台大屏就诊高峰期轮询频繁数据库慢查询增多。排查思路先看监控中的慢SQL日志发现大量重复的SELECT * FROM queue_record WHERE dept_id ? AND status IN (0, 1) ORDER BY sort_order这类语句。虽然已经有索引但30个大屏每3秒一次轮询等于每秒10个查询高峰期翻倍MySQL单机扛不住。优化手段在Service层加Caffeine本地缓存过期时间2秒。把大屏轮询接口和医生端操作接口做数据隔离大屏读取缓存数据不直接打数据库。大屏前端调整为收到数据后等3秒再发下一次请求避免网络波动时集中轰炸。缓存方案上线后同样的轮询频率下数据库QPS下降了90%以上效果立竿见影。需要注意的点是本地缓存只适合单机部署如果系统是多实例部署还需要考虑用Redis共享缓存保证所有实例读到的数据一致。4.4 状态显示异常患者已入诊但大屏仍显示等待现象医生端已经点了完成但大屏上的记录还停留在已叫号状态。原因大屏轮询接口和叫号状态更新之间没有强制一致。医生端完成操作后调用的是finish接口而大屏轮询的是screen/queue接口。如果两个接口分别访问了不同副本的数据库主从分离场景从库存在复制延迟就会出现短暂的不一致。解决对内网项目建议把大屏查询和医生端操作打在同一个数据源上主库牺牲一点读写分离的性能换取强一致。如果必须主从分离至少要保证大屏查询走主库或者在从库延迟较大时增加一个最终一致的提示机制——比如大屏界面上加一个数据每3秒自动刷新字样用户就更容易接受短暂延迟。4.5 就诊流程卡住医生端看不到任何患者现象早上开诊后医生端显示当前没有等待患者但实际上分诊台已经放了10个号。排查先确认取号时插入的queue_record记录的doctor_id是否正确。再确认当前查询条件的status是否写错。比如取号插入时status0但医生端查询条件误写成了status2。最后确认是否按科室隔离了数据是否存在权限范围限制导致医生只能看到部分数据。这类问题90%以上是数据隔离条件写错导致的。排查时可以先去数据库手动执行一遍SQL看看结果集是否符合预期快速定位到底是SQL问题还是代码问题。5. 运行部署与后续扩展方向5.1 本地运行与打包发布项目在本地运行时直接用IDEA启动Application类即可。如果要用外部Tomcat部署Spring Boot打包成War包时需要修改启动类继承SpringBootServletInitializer并重写configure方法这是我常遇到的部署坑。不过现在用Spring Boot的内嵌Tomcat打成Jar包直接java -jar运行更省事我推荐优先使用这种方式。打包命令mvn clean package -DskipTests生产环境部署时我会额外编写一个启动脚本设置JVM参数java -Xms512m -Xmx1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -jar hospital-queue.jar一个只有几百人同时在线的医院内网系统1G堆内存基本够用。Metaspace要给足因为Spring Boot MyBatis的类加载量不小MaxMetaspaceSize没配置的话刚启动没多久就可能报OutOfMemoryError。5.2 如何扩展成一号通/全流程管理系统现在的排队叫号系统只是门诊业务的一部分。如果有精力继续演进可以考虑以下几个方向对接公众号/小程序取号在现有取号接口基础上增加一个移动端入口患者在家就能取号到院后凭二维码扫描签到减少现场排队。对接HIS系统从医院HIS同步挂号数据实现预约号直接进队列现场号作为补充。这个方向需要掌握HL7等医疗数据交换协议但业务扩展性会大大增强。增加语音播报引擎当前大屏是视觉展示体验好但覆盖率有限。增加语音播报模块比如通过TTS引擎生成当前呼叫患者姓名可以让远处或者视力不太好的患者也能快速响应。引入消息推送替代轮询大屏的3秒轮询可以换成WebSocket或者SSEServer-Sent Events医生端叫号后服务端可以实时推送消息到大屏代替客户端轮询响应更快、资源消耗更低。我在后续版本中已经用SSE做了一版改造整体代码量不大但体验提升很明显。6. 写在最后的实操心得做这个项目时我最大的感受是简单的业务也有复杂的边界场景。你以为排队叫号就是加一减一实际做下来要面对的是超卖、状态流转、过号重排、轮询压力、空闲连接失效等一堆问题。这些问题的解决思路很多是通用的像乐观锁、本地缓存、幂等更新换个场景照样能用。如果你正准备用这套源码做毕设或者项目经验补充我的建议是不要只盯着能不能跑起来把每个核心模块的为什么要这样设计想清楚。面试官问叫号系统怎么防止重复叫号你能答出更新时带status条件做乐观锁这个问题就过关了能进一步答出过号时使用sort_order0.5实现重排面试官会认为你真的理解业务细节。项目本身的技术难度不算高但把细节打磨到位它完全可以在简历上成为一个有分量的实战经验。最后分享一个小技巧在开发时可以在MySQL中开启通用日志general_log把系统运行的所有SQL都记录下来。排查问题时翻看general_log你能看到前端每个操作背后真实执行的SQL语句很多奇怪的现象其实就是一条SQL的条件写错了。排查完记得关掉不然日志文件会把磁盘占满。本文还有配套的精品资源点击获取