资讯详情 医院智能挂号系统实战:Spring Boot + Redis 解决高并发抢号和超卖
📅 2026/10/9 3:47:10
简介医院智能挂号系统的设计与实现以PDF形式呈现面向医疗信息化开发人员、软件工程专业学生及相关系统架构师聚焦传统挂号流程效率低、特殊人群操作不便等痛点。内容系统梳理了手动预约、语音输入预约、图形预约、症状科普、看病笔记及看病流程引导等核心模块并从自然语言理解、语音识别精度、跨平台小程序前端、Java后端与云端数据库联动等角度解析了关键实现路径。资源为单文件PDF约1.88MB包含系统整体框架图、小程序异步请求示例、Java项目结构图及WxServlet类图等可复用设计素材便于读者快速掌握智能挂号系统的模块分层与接口设计。这些图表与代码片段可直接迁移至同类医疗预约场景降低从零搭建架构的试错成本同时覆盖了数据库安全、高并发处理与支付对接等工程化细节。目前已有99人学习下载适合作为毕业设计、课程项目或医院信息系统研发的参考文献与专业指导。1. 医院智能挂号系统从排队两小时到号源秒空先解决这三件事早上八点放号三秒被抢空线下窗口还在排着长队投诉单已经堆到院长桌上——医院智能挂号系统上线第一周这个画面我再熟悉不过。很多第一次做这个系统的同学以为智能就是界面好看、能选医生实际上真正决定成败的是三件事号源怎么建模、并发放号怎么扛、退号和停诊怎么回补。本文按我自己落地过的方案展开Spring Boot 3 Vue 3 前后端分离MySQL 存业务数据Redis 扛号源热数据从表结构设计一路写到线上踩坑。适合正在写课设、刚接手医院信息科项目、或者想从零搭一套挂号系统做仿真的读者——照着跑通一个最小可用版本不难难的是把边界条件想全。2. 挂号系统的数据设计六张核心表与排班规则怎么落库做挂号系统我最怕一上来就写界面。界面随时能改数据模型错了后面每一步都在还债。这一章先把表的边界划清楚再讲号源模型和排班规则这几件事定了业务代码只是往里填肉。2.1 六张核心表患者、医生、科室、排班、挂号单、支付流水挂号业务最小的闭环是患者选一个排班 → 生成一张挂号单 → 付一笔钱 → 到院就诊叫号。我一般把数据拆成六张表而不是把医生和排班塞进一张表。原因是医生有两个维度静态属性姓名、职称、挂号费和动态出诊状态哪天出诊、放多少号拆开后排班可以独立做每周生成、停诊调整不会回头改医生主数据。下面是核心 DDLMySQL 8.0、InnoDB、utf8mb4 是默认前提。CREATE TABLE department ( id bigint NOT NULL AUTO_INCREMENT, dept_name varchar(50) NOT NULL COMMENT 科室名称, floor_no int DEFAULT NULL COMMENT 所在楼层, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id), KEY idx_dept_name (dept_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT科室表; CREATE TABLE doctor ( id bigint NOT NULL AUTO_INCREMENT, dept_id bigint NOT NULL COMMENT 所属科室, doc_name varchar(50) NOT NULL, title varchar(20) NOT NULL COMMENT 主任医师/副主任医师/主治医师, reg_fee decimal(10,2) NOT NULL COMMENT 挂号费, status tinyint NOT NULL DEFAULT 1 COMMENT 1出诊 0停诊, PRIMARY KEY (id), KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生表; CREATE TABLE patient ( id bigint NOT NULL AUTO_INCREMENT, pat_name varchar(50) NOT NULL, id_card varchar(18) NOT NULL COMMENT 身份证号, phone varchar(20) NOT NULL, openid varchar(64) DEFAULT NULL COMMENT 微信openid可选, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_id_card (id_card), KEY idx_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT患者表;三张主数据表里patient.id_card必须唯一索引同一个身份证建两个档案后面会引发两个号都能挂、就诊时对不上人的纠纷。医生表和科室表之间只做了普通索引不建外键约束原因后面排错章节会说但这里先给结论医院系统里外键约束带来的锁开销和迁移成本大于它带来的完整性收益。CREATE TABLE schedule ( id bigint NOT NULL AUTO_INCREMENT, doctor_id bigint NOT NULL, dept_id bigint NOT NULL, work_date date NOT NULL, period tinyint NOT NULL COMMENT 1上午 2下午, total_slots int NOT NULL COMMENT 总号源数, used_slots int NOT NULL DEFAULT 0 COMMENT 已消耗号源冗余字段用于日结对账, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 2停诊, version int NOT NULL DEFAULT 0 COMMENT 乐观锁, PRIMARY KEY (id), UNIQUE KEY uk_doc_date_period (doctor_id, work_date, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排班表; CREATE TABLE registration ( id bigint NOT NULL AUTO_INCREMENT, reg_no varchar(10) NOT NULL COMMENT 号序如 Z001, order_no varchar(40) NOT NULL COMMENT 幂等业务号, patient_id bigint NOT NULL, schedule_id bigint NOT NULL, doctor_id bigint NOT NULL, dept_id bigint NOT NULL, source tinyint NOT NULL COMMENT 1线上 2窗口, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已就诊 3已退号 4已取消, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_schedule (schedule_id), KEY idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT挂号单; CREATE TABLE payment_flow ( id bigint NOT NULL AUTO_INCREMENT, reg_id bigint NOT NULL, amount decimal(10,2) NOT NULL, channel varchar(20) NOT NULL COMMENT wechat/alipay/cash, pay_status tinyint NOT NULL DEFAULT 0 COMMENT 0未支付 1成功 2失败, notify_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_reg_id (reg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付流水;schedule表我故意冗余了used_slots有人会觉得可以从 registration 表 COUNT 出来没必要存。但日结核对时MySQL 里排班数量和号单数量经常对不上有一个冗余字段做对账锚点能快速定位是号源扣了没生成单还是生成了单没扣号源。registration.status的取值顺序也提醒一点待支付和已支付是两个状态支付超时要定时关闭别把待支付当成有效号源。2.2 号源模型选型号段模式比时间段模式更接近门诊真实场景号源模型有两种常见做法号段模式和时间段模式。号段模式是一个排班只放一个整数号池比如上午放 50 个号患者进来依次拿 Z001、Z002……按顺序叫号。时间段模式是把上午切成长度 30 到 60 分钟的块每个块放 5 到 10 个号患者只要在块内到就行。挂号系统建议以号段模式为主因为门诊医生是按队列叫号的号段模式天然匹配时间段模式适合体检、儿保、康复这种约个大概时间的场景如果混用两套号池的查询和统计逻辑会交叉运维容易晕。号段模式下号序不是简单自增主键。窗口和线上共用同一个号池时如果各自从业务 ID 推断号序一定会出现两个人同时拿到 Z001。正确的做法是号序由独立的原子计数器生成低并发时用UPDATE schedule SET used_slots used_slots 1 WHERE id ? AND used_slots total_slots高并发时用 Redis INCR第 4 章会展开。号序的格式我习惯用前缀 三位序号比如上午是A001下午是P001有跨天重复不影响因为唯一性落在(schedule_id, reg_no)上。2.3 排班规则落库周模板、放号时间与停诊释放排班规则是医院侧最在意的功能。常见做法是维护一个周模板周一到周五某医生上午放 30 个、下午放 20 个周六周日减半。系统每天凌晨用定时任务按模板生成未来 7 到 14 天的schedule记录。模板本身可以单独一张表也可以直接通过定时任务把规则写死在代码里。我建议落成配置表因为科室调整出诊频率太频繁了改代码要发版改配置表只改一行。停诊处理是排班里最容易漏的需求。医生临时停诊schedule.status置为 2 之后之前挂出去的号怎么办正确做法不是把号单状态改成已退号就完了而是要触发停诊退号补偿给相关患者推送停诊通知号单进入待退费流程payment_flow发起原路退款。同时号池要回补——但停诊不是把号补回库里而是把号池清零因为那个排班不会再放号了。这个清池和回补的区别是第 5 章避坑内容里最典型的操作混乱点。3. 用 Spring Boot 把挂号主流程跑通事务边界与幂等控制这一章写一个能跑的最小闭环。先不引入 Redis 和消息队列只用 MySQL 的行锁和唯一索引也能支持到每秒几十笔对课设和院内 demo 来说完全够用。等流量预期上来再换第 4 章的方案。3.1 最小可复现的挂号接口Controller、Service、Mapper 三层怎么切后端我习惯按 Controller → Service → Mapper 三层切渠道差异线上微信、窗口、自助机放在 Service 里做策略分支而不是写满 if else 的 Controller。RestController RequestMapping(/api/v1/regs) public class RegistrationController { Resource private RegistrationService registrationService; PostMapping public ResultRegistrationVO create(RequestBody Valid RegCreateRequest request) { // request: patientId, scheduleId, orderNo(幂等业务号) return Result.ok(registrationService.create(request)); } }RegCreateRequest里三个字段最关键patientId、scheduleId、orderNo。orderNo由前端在用户点击提交挂号时生成 UUID 并随请求带上它是整个幂等控制的地基。如果不带这个字段后端每次只能靠业务字段去猜这是不是同一次操作猜错的概率很高。3.2 事务边界为什么扣号源与写挂号单必须同一个事务Service 核心代码如下注意Transactional加在方法上扣号源和写挂号单、写支付流水必须在同一个事务里完成。Transactional(rollbackFor Exception.class) public RegistrationVO create(RegCreateRequest req) { Schedule schedule scheduleMapper.selectByIdForUpdate(req.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { throw new BizException(排班不存在或已停诊); } if (schedule.getUsedSlots() schedule.getTotalSlots()) { throw new BizException(号源已满); } // 检查该患者在该排班下是否已有有效号单 Registration exist registrationMapper.selectValidByPatientAndSchedule( req.getPatientId(), req.getScheduleId()); if (exist ! null) { throw new BizException(您已挂过该号请勿重复挂号); } // 乐观扣减update 受影响行数为 0 说明被抢完 int affected scheduleMapper.incrUsedSlots(schedule.getId(), schedule.getTotalSlots()); if (affected 0) { throw new BizException(号源被抢完了); } String regNo buildRegNo(schedule.getPeriod(), schedule.getUsedSlots() 1); Registration reg Registration.builder() .regNo(regNo) .orderNo(req.getOrderNo()) .patientId(req.getPatientId()) .scheduleId(req.getScheduleId()) .doctorId(schedule.getDoctorId()) .deptId(schedule.getDeptId()) .source(1) .status(0) // 待支付 .build(); registrationMapper.insert(reg); paymentFlowMapper.insert(PaymentFlow.builder() .regId(reg.getId()) .amount(schedule.getRegFee()) .channel(wechat) .payStatus(0) .build()); return toVO(reg); }selectByIdForUpdate拿的是排班行的排他锁同一时间只有一个请求能往下走天然串行化所以第 4 章以前这个方案不会超卖。但它的代价是并发会排队80 个号如果有 500 人同时抢后面 420 人都在锁等待接口平均耗时会被拖上去。incrUsedSlots的 SQL 是UPDATE schedule SET used_slots used_slots 1 WHERE id ? AND used_slots total_slots把判断有没有号和扣一个号合并成一条语句比先 SELECT 再 UPDATE 安全得多。事务边界为什么必须包住这三步如果先扣号源再单独提交插入挂号单失败时号就凭空消失了。如果先写挂号单再扣号源扣减失败时会出现一张没有号源支撑的幽灵号单。3.3 幂等设计重复提交、断网重试不产生第二张挂号单移动端场景最常见的故障是用户点了挂号请求超时用户又点了一次。如果没有幂等两笔请求都成功患者拿到两个号退号还得走流程。uk_order_no唯一索引是幂等的地基。public RegistrationVO create(RegCreateRequest req) { try { return doCreate(req); } catch (DuplicateKeyException e) { // 同一 orderNo 重复提交直接返回已有挂号单 Registration reg registrationMapper.selectByOrderNo(req.getOrderNo()); if (reg ! null) { return toVO(reg); } throw new BizException(系统繁忙请稍后重试); } }这里有个血泪经验DuplicateKeyException不一定只由uk_order_no触发如果业务上还有别的唯一索引比如某张表的手机号唯一异常类型一样但语义完全不同。所以捕获后必须selectByOrderNo确认存在而不是直接吞掉异常。除了order_no幂等还要防止同一患者对同一排班重复挂号。代码里的selectValidByPatientAndSchedule就是干这个的查询条件只认状态 0、1、2待支付、已支付、已就诊不包含已退号和已取消。很多人在这一步偷懒直接对(patient_id, schedule_id)建唯一索引结果就是患者退号后想重新挂同一个排班永远被索引挡住——这个坑第 5 章还会细说。4. 并发放号Redis 号源扣减、分布式锁与限流防刷第三章的方案在并发上来之后会变成一场灾难。一个三甲医院的热门专家号放号瞬间能涌进来上千个请求MySQL 行锁串行化会让数据库连接池迅速耗尽。这一章讲怎么把号源扣减从数据库挪到 Redis。4.1 号源库存为什么放 Redis一次放号 1000 人抢的读写模型放号热点的访问模型高度集中在几个排班 ID 上比如 8 点放的某专家号80 个号源对应一个schedule_id。用 MySQL 扛这种单点写流量要么改造成按医生分库分表要么依赖 Redis 把写请求压到个位数毫秒。医院挂号系统选 Redis 更划算因为热点本身就那几个排班Redis 单线程模型天然串行化没有锁竞争问题。缓存结构我用两个 keyreg:slots:{scheduleId}存剩余号数reg:seq:{scheduleId}存当前发到第几号。放号前先由一个后台任务把total_slots灌进 Redis。剩余号数和序号分开存是为了 LBS 脚本里可以一个打印输出新序号一个减库存。4.2 用 Lua 脚本把查库存-扣库存-生成号序做成原子操作Redis 里即使单线程也不能写三条独立命令因为中间可能有其他请求插进来。正确做法是把判断、扣减、返回序号压成一个 Lua 脚本一次执行完。-- KEYS[1]: reg:slots:{scheduleId} 剩余号数 -- KEYS[2]: reg:seq:{scheduleId} 当前已发号数 -- ARGV[1]: 排班总号数 local slots tonumber(redis.call(GET, KEYS[1]) or 0) if slots 0 then return -1 end local seq tonumber(redis.call(GET, KEYS[2]) or 0) 1 redis.call(SET, KEYS[1], slots - 1) redis.call(SET, KEYS[2], seq) return seqJava 侧调用private static final String LUA_REDUCE_SLOTS --上面那段 lua 粘进来; public Long reduceSlots(Long scheduleId, int totalSlots) { DefaultRedisScriptLong script new DefaultRedisScript(LUA_REDUCE_SLOTS, Long.class); return redisTemplate.execute(script, Arrays.asList(reg:slots: scheduleId, reg:seq: scheduleId), totalSlots); }脚本返回 -1 代表号源没了返回正整数就是号序。这个号序写进registration.reg_no跨天不会冲突因为 key 里带了scheduleId。要注意DefaultRedisScript要设返回类型Long不设的话反序列化时容易翻车拿到的值可能是整数或 byte 数组类型强转直接 ClassCastException。Redis 扣减成功之后再走第三章的数据库事务写挂号单。两处没有跨系统事务必须引入补偿如果数据库异常导致挂号单没写成要把 Redis 的号补回去。补偿的坑在于幂等——补回操作不能比扣减多执行一次否则会把别人刚扣的号也加回去导致库存虚高。我的做法是给每个orderNo加一个补偿标记 keySETNX成功才执行 INCR。public void compensateSlots(Long scheduleId, String orderNo) { Boolean first redisTemplate.opsForValue() .setIfAbsent(reg:compensate: orderNo, 1, 1, TimeUnit.DAYS); if (!Boolean.TRUE.equals(first)) { return; // 已补偿过幂等 } redisTemplate.opsForValue().increment(reg:slots: scheduleId); }补偿标记的过期时间要大于订单最长生命周期我一般给一天。如果真的出现极端情况——补偿也丢了那就要靠第 6 章的日结核对 SQL 兜底把 Redis 剩余号和数据库实际有效号单数对一遍差多了就人工修这也是医院系统允许的底线。4.3 限流与防黄牛同患者并发挂号的拦截策略Redis 减库存解决的是号源不超卖但解决不了一个患者并发抢两个号和黄牛脚本刷号。两种拦截策略我一起上。第一种是同一患者频率限制用 INCR 加 EXPIREString limiterKey reg:limit: patientId : LocalDate.now(); Long count redisTemplate.opsForValue().increment(limiterKey); if (count 1) { redisTemplate.expire(limiterKey, 1, TimeUnit.MINUTES); } if (count 5) { throw new BizException(操作太频繁请稍后再试); }同一患者一分钟内超过 5 次操作就拦截这个参数要按实际场景调普通用户选号、确认、支付回调可能不止 5 次太严会误伤太松拦不住脚本。我测过一轮后把阈值定在 10 次上午放号前 30 秒可以临时调严到 3 次。第二种是实名约束挂号前必须绑定就诊人一个微信 openid 最多绑 5 个就诊人且绑定后一周内不能解绑重绑。这个规则写在业务代码里配合患者表的id_card唯一索引能把批量囤号的成本抬高。再加上 JWT 登录态过期续签微信授权后生成的 token 用 Redis 维护续签窗口黄牛拿一张 token 刷一天的情况也能堵住。5. 挂号系统上线必踩的四个坑超卖、退号回补与线程池打满5.1 号源超卖两个患者拿到同一个号现象放号后两个患者都收到挂号成功通知号序都显示 Z001到院后医生电脑上只看到一个患者。原因早期代码用了先 SELECT 判断 has_slots再 UPDATE used_slots两个请求同时读到剩余号数为 1都认为自己能扣。解决换第 4 章的 Lua 原子扣减或者退一步只用UPDATE ... WHERE used_slots total_slots判断受影响行数。真正上线时我把两条链路都留了Redis 做主扣减数据库乐观锁兜底任何一条报号源不足都直接拒绝。5.2 退号后号源没回来回补时机错了现象患者退号后页面上显示还有号一挂号却提示号源不足。原因扣减发生在 Redis退号时只更新了数据库状态没同步回补 Redis。更隐蔽的版本是先回补了 Redis但数据库退号事务回滚了Redis 库存虚高最后超卖。解决退号接口先完成数据库状态流转已支付 → 已退号事务成功后再异步回补 Redis而且要带上一个退号幂等标记防止消息重发放两次回补。这个坑还有一层早期我对registration表建了(patient_id, schedule_id)唯一索引结果患者退号后想重新挂同一排班时永远提示已挂过号。教训是唯一索引要建在语义上永远不重复的列上状态会变的业务规则应该靠查询条件控制。5.3 支付超时库里有单没支付号源被占死现象挂号单状态是待支付患者支付页面卡死退出30 分钟后号也没自动释放别人挂不进来。原因订单超时关闭机制没做好或者定时扫描任务没走索引压测时滞后了十几分钟。解决两段式处理——支付回调来时更新状态同时定时任务扫status 0 AND created_at 现在 - 30 分钟的单子关闭并回补号源。idx_status_created索引必须建否则一张几百万行的挂号单表全表扫数据库 CPU 直接飙红。扫描任务要幂等一个单子被扫到两次也不能回补两次号。5.4 放号高峰期接口 RT 飙升MySQL 连接池被打满现象8 点放号那一刻所有接口都变慢包括窗口挂号也被卡住数据库连接池报Connection is not available, request timed out。原因流量全打到了数据库特别是排班详情查询和号源扣减都走了 MySQL连接池被瞬时限死。解决把排班查询缓存到 Redis 或本地 Caffeine热点查询不碰数据库写入走 Redis 扣减后再异步落库。这里还涉及一个 Nginx 和 Java 线程池的联动问题——放号时后端处理不过来前端会反复重试把线程池也打满限流必须前置到网关层不能等请求到业务代码才拦。6. 进阶排队叫号实时推送与上线前的验证手段6.1 WebSocket 实时叫号心跳与断线重连的坑排队叫号适合用 WebSocket 推当前叫到 Z012比前端轮询省流量也更快。我用的 Spring 自带 WebSocket 支持每个科室维护一个 session 集合叫号时广播给订阅该科室的连接。心跳是必做的前端每 30 秒发一次ping发pong超过 90 秒没收到心跳就主动断开前端监听到断线后自动重连。很多人栽在 Nginx 默认 60 秒断开空闲连接上WebSocket 的proxy_read_timeout要调到 120 秒以上不然就会玄学断连。6.2 一条 SQL 核对日结号源、挂号单、支付流水三表对平我每次发布前必跑这条核对 SQL它能把Redis 和 MySQL 不一致这种隐藏问题暴露出来SELECT s.id, s.doctor_id, s.work_date, s.total_slots, s.used_slots, COUNT(r.id) AS valid_regs FROM schedule s LEFT JOIN registration r ON r.schedule_id s.id AND r.status IN (0, 1, 2) WHERE s.work_date CURDATE() GROUP BY s.id HAVING valid_regs ! s.used_slots;只要used_slots和有效号单数不一致就说明扣减和落库有一侧出了问题。上线初期我每天早晚各跑一次顺手把这些检查配置成定时任务成了我接手任何一个挂号项目的第一个习惯。6.3 放号模拟压测参数参考与结果判断压测脚本我一般用 JMeter 模拟 1000 个并发用户同时刷一个排班一轮跑下来看三个指标成功率不能出现订单创建失败但号源少了的情况、吞吐量要在 1 秒内消化完 80 个号、响应时间p95 小于 500 毫秒。压测后必须跑一次去重校验查同一个reg_no是否出现多次、同一个患者是否有两张未退号单SELECT reg_no, COUNT(*) FROM registration WHERE schedule_id #{scheduleId} AND status IN (0,1,2) GROUP BY reg_no HAVING COUNT(*) 1;至于线上真实放号能不能扛住我最后靠的还是那条日结 SQL——只要每天对平系统的底线就没破。做了两个医院项目这个习惯帮我把数据不一致的排查时间从一整天压缩到十分钟希望帮到你。本文还有配套的精品资源点击获取