基于Java的医院排队叫号系统:从取号到叫号的完整落地路径

发布时间:2026/10/7 15:03:51
基于Java的医院排队叫号系统:从取号到叫号的完整落地路径 简介这是一套基于Java开发的医院排队叫号系统设计源码面向计算机专业学生、Java Web开发者及需要完成课程设计或毕业设计的技术人员。系统应用于医院各门诊科室通过接收HIS单据信息结合患者签到情况、医生排班与患者优先级生成排队队列缓解就诊、检查、取药时的排队无序、医生工作量不均与就诊环境嘈杂等问题。资源包共228个文件以82个java源文件与76个jsp页面为核心辅以31个png、8个jpg等界面素材以及html页面、sql脚本与properties配置压缩包约7.06MB涵盖医生叫号、预约问诊、预约检查、预约取药、缴费等完整业务模块。目前已有832人学习下载读者可借此掌握排队队列生成逻辑、HIS数据对接思路与Java Web项目分层结构适合作为二次开发或功能扩展的参考基础。1. 基于Java的医院排队叫号系统从取号到叫号的完整落地路径医院门诊高峰期挂号窗口前排长队、候诊区人挤人、医生诊室门口围一圈人——这是很多中小医院信息科最头疼的场景。一套基于 Java 的医院排队叫号系统核心要解决的就是把「谁先来、谁在等、该谁进」这件事从人工喊话变成系统调度。它通常包含取号机端、候诊区大屏、医生叫号端和后台管理四个部分技术栈以 Spring Boot MyBatis-Plus MySQL WebSocket 为主前端大屏用 Vue 或纯 HTML 轮询。适合谁做医院信息科自研、软件外包公司接单、Java 课程设计选题以及想拿一个完整业务系统练手的 Java 工程师。源码层面这类项目的难点不在 CRUD而在叫号顺序的并发安全和多端状态同步。2. 叫号系统的数据模型与核心表设计2.1 科室、队列、号源三张主表怎么拆排队叫号系统的数据模型如果一开始拆错后面改起来就是血泪经验。我一般会拆成三张核心表科室表department、队列表queue、号源记录表ticket。科室表存科室名称、叫号策略顺序叫号/优先叫号、当前叫号序号队列表存每个科室下的排队规则比如是否支持过号重排、过号后保留几位号源记录表是核心每取一个号就插一条记录包含号序、状态、取号时间、叫号时间、完成时间。为什么要把队列单独拆出来而不是塞在科室表里因为一个科室可能有多个诊室每个诊室独立叫号但共享一个队列队列表就是这层抽象。号源记录表的状态字段是整个系统的灵魂常见状态有等待中WAITING、已叫号CALLED、就诊中SERVING、已完成FINISHED、已过号SKIPPED、已作废CANCELLED。状态流转必须单向不能从 FINISHED 回到 WAITING否则大屏显示会乱。CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 科室名称, call_strategy TINYINT DEFAULT 1 COMMENT 1顺序 2优先, current_seq INT DEFAULT 0 COMMENT 当前叫号序号, status TINYINT DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE queue ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, room_no VARCHAR(16) COMMENT 诊室号, skip_keep TINYINT DEFAULT 3 COMMENT 过号后保留位数, INDEX idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, queue_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT 号序, patient_name VARCHAR(32), status TINYINT DEFAULT 0 COMMENT 0等待 1已叫 2就诊 3完成 4过号 5作废, take_time DATETIME DEFAULT CURRENT_TIMESTAMP, call_time DATETIME, finish_time DATETIME, INDEX idx_dept_status (dept_id, status), INDEX idx_take_time (take_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;上面这段建表语句里ticket表的idx_dept_status联合索引是关键因为叫号时最频繁的查询就是「查某科室下状态为等待的最小号序」没有这个索引号一多查询就会慢。seq_no不要用自增主键代替因为过号重排时号序需要重新计算而主键不能改。skip_keep字段控制过号后保留几位比如设为 3表示过号后往后顺延 3 个号再叫一次这是医院场景里很实用的一个参数。2.2 号序生成为什么不能用数据库自增很多人第一反应是让seq_no用数据库自增简单省事。但医院叫号有个特殊需求上午的号和下午的号要分开或者不同号别普通号/专家号要分开排。如果直接用自增号序会跨时段连续患者看到「上午 001 到 050下午 051 开始」会觉得奇怪。更合理的做法是按「科室 日期 时段」维度生成号序每天重置。我一般会在 Redis 里维护一个 key格式是queue:seq:{deptId}:{date}:{period}用INCR命令生成号序。Redis 的原子性保证了并发取号不会重号比数据库行锁轻量得多。如果项目不想引入 Redis退而求其次用 MySQL 的SELECT ... FOR UPDATE锁住科室行再取号但并发一高就容易锁等待。下面是用 Redis 生成号序的代码片段Service public class SeqService { Autowired private StringRedisTemplate redisTemplate; // 生成号序period 为 1上午 2下午 public int nextSeq(Long deptId, String date, int period) { String key queue:seq: deptId : date : period; Long seq redisTemplate.opsForValue().increment(key); // 首次生成时设置过期时间为当天结束避免 key 堆积 if (seq ! null seq 1) { redisTemplate.expire(key, Duration.ofHours(24)); } return seq.intValue(); } }这段代码的逻辑是每次取号调用nextSeqRedis 的increment返回自增后的值第一次返回 1 时设置 24 小时过期。参数deptId区分科室date区分日期period区分上下午。注意expire只在 seq 等于 1 时设置避免每次取号都刷新过期时间导致 key 永不过期。如果 Redis 挂了要有降级方案比如直接查数据库当前最大号序加一但要在事务里加锁。3. 叫号核心逻辑状态机与并发控制3.1 叫号状态流转的代码实现叫号系统的核心是一个状态机。患者取号后状态是 WAITING医生点击「叫号」后状态变 CALLED患者进入诊室后医生点「开始就诊」变 SERVING看完点「完成」变 FINISHED。如果叫了号患者没来医生点「过号」变 SKIPPED系统根据skip_keep决定是否重新插入队列。状态流转必须用乐观锁或悲观锁保护否则两个医生同时叫号可能叫到同一个人。我一般用 MyBatis-Plus 的updateById配合版本号字段或者直接在 SQL 里加WHERE status 0条件。下面是一个叫号方法的实现Service public class CallService { Autowired private TicketMapper ticketMapper; // 医生叫号取当前科室等待中号序最小的患者 Transactional public Ticket callNext(Long deptId) { // 查询等待中最小号序加行锁防止并发 Ticket next ticketMapper.selectOne( new QueryWrapperTicket() .eq(dept_id, deptId) .eq(status, 0) .orderByAsc(seq_no) .last(LIMIT 1 FOR UPDATE) ); if (next null) { throw new BizException(当前没有等待患者); } // 更新状态为已叫号条件里带 status0 做二次校验 int rows ticketMapper.update(null, new UpdateWrapperTicket() .eq(id, next.getId()) .eq(status, 0) .set(status, 1) .set(call_time, new Date()) ); if (rows 0) { throw new BizException(叫号冲突请重试); } next.setStatus(1); return next; } }这段代码有两个关键点一是FOR UPDATE行锁保证同一时刻只有一个事务能查到这条记录二是update时WHERE status 0的二次校验即使锁没拦住更新条件也能兜底。参数deptId是科室 ID返回的Ticket对象包含号序和患者信息前端拿到后推送到大屏。如果并发量不大FOR UPDATE完全够用如果号源紧张、并发高建议改成 Redis 分布式锁。3.2 WebSocket 推送大屏更新的最小实现候诊区大屏要实时显示当前叫号用轮询也能做但体验差、服务器压力大。WebSocket 是更合适的方案。Spring Boot 集成 WebSocket 很简单加spring-boot-starter-websocket依赖配置一个WebSocketHandler医生叫号后往对应科室的 session 推送消息。Component ServerEndpoint(/ws/call/{deptId}) public class CallWebSocket { // 按科室分组保存 session private static MapLong, SetSession deptSessions new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(deptId) Long deptId) { deptSessions.computeIfAbsent(deptId, k - ConcurrentHashMap.newKeySet()).add(session); } OnClose public void onClose(Session session, PathParam(deptId) Long deptId) { SetSession sessions deptSessions.get(deptId); if (sessions ! null) sessions.remove(session); } // 供叫号服务调用推送最新叫号信息 public static void push(Long deptId, String message) { SetSession sessions deptSessions.get(deptId); if (sessions null) return; for (Session s : sessions) { if (s.isOpen()) { s.getAsyncRemote().sendText(message); } } } }这段代码用ServerEndpoint注解声明 WebSocket 端点路径里的{deptId}让不同科室的大屏连到不同分组。deptSessions用ConcurrentHashMap保证线程安全push方法遍历该科室所有 session 发送消息。注意getAsyncRemote是异步发送避免阻塞叫号主流程。大屏端用原生WebSocketAPI 连接ws://host/ws/call/1即可收到消息后更新页面上的号序和患者姓名。4. 取号端与医生端的接口设计4.1 取号接口的幂等与防刷取号接口是系统入口必须考虑幂等和防刷。患者可能连点两次取号按钮或者网络抖动导致重复提交。常见做法是前端生成一个requestId后端用 Redis 做幂等校验同一个requestId在 5 秒内只处理一次。PostMapping(/ticket/take) public ResultTicket take(RequestBody TakeReq req) { // 幂等校验requestId 作为 key5 秒内重复直接返回上次结果 String idempotentKey take:idem: req.getRequestId(); Boolean ok redisTemplate.opsForValue() .setIfAbsent(idempotentKey, 1, Duration.ofSeconds(5)); if (Boolean.FALSE.equals(ok)) { throw new BizException(请勿重复取号); } // 校验科室是否启用、是否在放号时段 Department dept deptMapper.selectById(req.getDeptId()); if (dept null || dept.getStatus() ! 1) { throw new BizException(该科室暂未开放); } int seq seqService.nextSeq(req.getDeptId(), LocalDate.now().toString(), req.getPeriod()); Ticket ticket new Ticket(); ticket.setDeptId(req.getDeptId()); ticket.setSeqNo(seq); ticket.setStatus(0); ticket.setTakeTime(new Date()); ticketMapper.insert(ticket); return Result.ok(ticket); }setIfAbsent是 Redis 的原子操作只有 key 不存在时才设置成功返回 true 表示首次请求。参数requestId由前端用 UUID 生成period区分上下午。注意幂等 key 的过期时间不要设太长5 秒足够覆盖用户误操作设太长会导致正常重试被拦截。如果项目没有 Redis可以用数据库唯一索引兜底在ticket表加request_id唯一索引。4.2 医生端叫号、过号、完成三个动作的接口约定医生端界面通常就三个按钮叫号、过号、完成。接口设计要清晰避免医生误操作。我一般约定/doctor/call叫下一个/doctor/skip过号当前患者/doctor/finish完成当前就诊。三个接口都要求传ticketId后端校验该 ticket 是否属于当前医生所在科室防止跨科室操作。过号逻辑稍微复杂把当前 ticket 状态改为 SKIPPED然后根据skip_keep决定是否重新插入。如果skip_keep为 3表示过号后往后顺延 3 个号再叫。实现方式是把该 ticket 的seq_no改为「当前最大号序 3」状态改回 WAITING。这样它就会排在后面重新被叫到。如果skip_keep为 0表示过号作废状态直接改 CANCELLED。PostMapping(/doctor/skip) public ResultVoid skip(RequestParam Long ticketId) { Ticket t ticketMapper.selectById(ticketId); if (t null || t.getStatus() ! 1) { throw new BizException(当前无叫号中的患者); } Queue q queueMapper.selectById(t.getQueueId()); if (q.getSkipKeep() 0) { // 顺延重排取当前最大号序 skipKeep Integer maxSeq ticketMapper.selectMaxSeq(t.getDeptId()); t.setSeqNo(maxSeq q.getSkipKeep()); t.setStatus(0); // 回到等待 } else { t.setStatus(4); // 直接过号作废 } ticketMapper.updateById(t); return Result.ok(); }这段代码里selectMaxSeq是一个自定义 SQL查当前科室最大号序。注意重排后seq_no可能超过当天总号源这没关系号序只是排序依据不要求连续。skipKeep参数从队列表读取不同科室可以配不同值比如儿科可以设大一点因为家长带孩子容易错过叫号。5. 部署与避坑那些让系统翻车的细节5.1 避坑清单叫号系统最常见的 5 个问题现象一大屏显示重复叫号同一个号被叫两次。原因通常是 WebSocket 推送和前端轮询同时存在两条通道都更新了页面。解决方法是二选一要么纯 WebSocket要么纯轮询不要混用。如果必须混用前端要做去重根据ticketId判断是否已渲染。现象二过号重排后号序错乱出现负数或超大值。原因是selectMaxSeq在并发下返回了旧值两个过号操作拿到同一个 maxSeq。解决方法是在selectMaxSeq的 SQL 里加FOR UPDATE或者用 Redis 原子递增生成重排号序。现象三取号机断电重启后号序从 1 开始。原因是号序存在 Redis 但没做持久化或者用了内存变量。解决方法是 Redis 开启 AOF 持久化或者号序落库启动时从数据库恢复当天最大号序。现象四医生端叫号后大屏延迟十几秒才更新。原因是 WebSocket 消息发送用了同步sendText阻塞在某个慢连接上。解决方法是改用getAsyncRemote并给 session 设置发送超时超时直接关闭连接。现象五高峰期取号接口响应超过 3 秒。原因通常是ticket表数据量太大查询等待队列时全表扫描。解决方法是给(dept_id, status, seq_no)建联合索引并且每天凌晨归档历史数据到备份表主表只保留当天数据。5.2 从单机到多诊室部署时要注意的三个参数部署时第一个要调的是数据库连接池大小。叫号系统读多写少但取号瞬间并发高HikariCP 的maximumPoolSize建议设为 CPU 核数 × 2 磁盘数一般 10 到 20 够用。设太大反而会因为连接切换开销导致响应变慢。第二个是 WebSocket 的maxSessionIdleTimeout。候诊区大屏可能一整天不操作如果超时设太短连接会被断开大屏就不更新了。建议设为 0 表示永不超时或者设 8 小时覆盖全天门诊。第三个是 Redis 的maxmemory-policy。号序 key 和幂等 key 都是临时数据建议设为allkeys-lru内存满时自动淘汰最久未使用的 key。但要注意如果 Redis 同时存了其他持久数据不要用allkeys改用volatile-lru只淘汰设了过期时间的 key。6. 用压测验证叫号顺序一个可复现的并发测试脚本系统做完怎么验证叫号顺序在并发下不会乱我一般写一个 JMeter 或 Java 多线程测试模拟 50 个患者同时取号然后检查号序是否有重复。下面是一个用 Java 线程池模拟并发取号的测试代码public class ConcurrentTakeTest { public static void main(String[] args) throws Exception { int threads 50; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); SetInteger seqSet ConcurrentHashMap.newKeySet(); AtomicInteger duplicate new AtomicInteger(0); for (int i 0; i threads; i) { pool.submit(() - { try { // 调用取号接口拿到号序 int seq takeTicket(1L, 1); if (!seqSet.add(seq)) { duplicate.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); System.out.println(重复号序数量 duplicate.get()); System.out.println(实际生成号序数量 seqSet.size()); } // 模拟调用取号接口实际替换为 HTTP 请求 private static int takeTicket(Long deptId, int period) { // 这里调用 SeqService.nextSeq 或发 HTTP 请求 return 0; } }这段测试代码用CountDownLatch让 50 个线程同时开始ConcurrentHashMap.newKeySet()收集号序add返回 false 说明号序重复。跑完看duplicate是否为 0以及seqSet.size()是否等于 50。如果重复数大于 0说明号序生成有并发问题回去检查 Redis 的increment是否真的原子或者数据库锁是否生效。压测时还要注意测试环境要和生产环境一样配 Redis不要用本地缓存糊弄。我吃过这个亏本地测试用AtomicInteger生成号序一点问题没有上线换成 Redis 集群后因为网络分区出现重号。后来养成习惯压测必须连真实中间件哪怕数据量小。另外测试完记得清理测试数据别把测试号混进正式队列不然第二天门诊叫号就翻车了。这套方案从表设计到并发控制再到压测验证基本覆盖了医院排队叫号系统的核心链路。源码层面Spring Boot MyBatis-Plus Redis WebSocket 的组合足够撑起中小医院的门诊量真正要花心思的是状态流转的边界和并发下的顺序保证。希望帮到你。本文还有配套的精品资源点击获取