不知道各位同行做医院类系统时有没有这种体验:需求文档翻下来全是"挂号""退号""排班""统计",听起来跟普通电商后台差不多,但真把边界划清楚之后,才发现患者端、医生端、管理后台三个角色的诉求天然互斥——患者要快、医生要稳、管理员要看得清。我之前搭过一套基于 Spring Boot 的医院预约挂号系统,走的是"全端协同"路线,也就是患者小程序、医生工作台、管理后台共用同一套业务中台,但各自持有不同的交互模型和数据视图。这篇文章就把这套系统的核心设计、表结构、并发控制、接口契约以及上线后踩过的坑一次讲清楚,适合正在做同类系统、或者准备从单体后台转向全端协同架构的同学参考。
1. 先想清楚一件事:全端协同到底协同的是什么
很多人一听"全端协同"就条件反射地认为是要上微服务、要搞消息队列、要把前后端彻底拆成宇宙级分布式。实际上,医院预约挂号这种业务,它的协同难点不在于技术栈有多新,而在于一个预约行为会同时改变多个角色的视图。患者端看到的是"号源还剩几个",医生端看到的是"下午第5号患者是谁",管理端看到的是"今日爽约率是否异常",如果三套系统各管各的,数据必然会产生不一致。
1.1 三个角色端在真实业务里的位置
先梳理一下真实医院场景下的典型工作流:
- 患者端(我这边落地为微信小程序,也可以换成App或H5):用户注册、选择科室、查看医生排班、锁定号源、支付挂号费、签到取号、申请退号。它的核心诉求是"操作足够短,别让用户排队等"。
- 医生端(Web工作台,登录后只看到当前科室和自己的排班):查看当日待诊列表、叫号、标记接诊状态、临时停诊。它的核心诉求是"信息要准,操作要快,别突然冒出一个已过号的患者"。
- 管理端(运营后台):配置科室、医生、号源规则,查看实时挂号量,处理退费审核,统计各科室业务情况。它的核心诉求是"权限清晰,数据可追溯,异常能预警"。
这三个端如果按传统方式开发,很容易做成三套独立的 CRUD,然后靠定时任务同步数据库。最直接的后果是:患者端看到有号,医生端已经停诊了;管理端修改了号源数量,患者端还挂着旧的余号。所以我从一开始就把"协同"定义为事件驱动下的状态一致——任何一端发生状态变更,其余两端必须消费到同一个事实。
1.2 为什么用 Spring Boot 做业务中枢而不是拆微服务
在这个项目里,我选择了 Spring Boot 3.x + MyBatis + MySQL + Redis 作为主技术栈,没有引入 Nacos、Sentinel 这类微服务组件。原因很现实:预约挂号的业务峰值通常在早上 8 点到 10 点的放号时段,虽然瞬时并发不低,但整体业务量级远没有到需要多服务横向扩展的程度。拆微服务意味着引入分布式事务、服务间调用链、部署运维成本,这些开销对一个小型医院信息科团队来说反而是负担。
Spring Boot 适合做这种"全端协同"的中枢,是因为它能把 Web 接口层、业务层、数据访问层清晰地组织在一个应用内部,同时通过 Actuator、定时任务、Redis 客户端等生态快速补齐周边能力。更重要的是,三个端只要对接同一套 REST API,业务规则只维护一份,就不会出现"前端绕过后端直接改库"这种可怕的事情。
1.3 协同前置约定:全局统一的时间、ID 和状态机
在全端协同的系统里,约定比接口更重要。我踩过的一个印象很深的坑是:小程序端用本地时间判断"今日号源是否已过期",而服务端按服务器时区计算,结果用户手机时区比服务器快 8 小时,凌晨 0 点看到一堆"已过期"的号源。所以我在项目里定了三条铁律:
- 所有接口传参和返回的时间一律使用时间戳毫秒值,前端不直接传日期字符串;只有展示层做格式化。
- 所有业务对象的 ID 统一使用雪花 ID,禁用数据库自增 ID 作为对外主键,避免用户通过 ID 遍历病情数据。
- 预约状态、订单状态、号源状态都通过状态机流转,至少包含初始值、注意防重入和非法迁移的判断,后文会详细说。
这三条约定虽然看起来简单,但它们保证了三个端在跨时区、跨设备、跨网络链路的情况下,看到的是同一套事实。
2. 号源池与排班模型:先让数据结构撑得住业务规则
预约挂号的业务规则比表面看起来复杂。普通电商是"库存-下单-支付",而医院是"医生排班-号源池-患者预约-签到就诊",中间还夹杂着停诊、退号、爽约、加号。如果表结构设计不好,后面写业务代码的时候会非常痛苦。
2.1 核心表结构的设计思路
我把核心表拆成了六张:科室表(department)、医生表(doctor)、排班表(schedule)、号源表(schedule_slot)、预约记录表(appointment)、支付订单表(payment_order)。这里重点说几张容易设计错的表。
排班表 schedule 记录"某医生在某天某时段坐诊"这个事实,字段包括日期、上午/下午时段、可预约总号数、已预约号数、停诊标记。这里要注意:排班表本身不是库存表,它只描述计划。真正做库存扣减的是号源表 schedule_slot,每一行代表一个具体的号位,比如"2025-06-10 上午 第3号",带有状态字段(可用、已锁定、已预约、已退号、已过期)。这样设计的原因是,患者预约的不是"上午这个时间段",而是"上午里面的某个具体号序",号序决定了他大概几点能见到医生。
预约记录表 appointment 则是整个协同系统的核心事实表,字段包括预约号、患者ID、排班ID、号源ID、状态(待支付、已预约、已签到、已完成、已取消、已爽约)、取消原因、创建时间。这张表会被患者端、医生端、管理端高频查询,所以索引设计一定要跟上:联合索引(doctor_id, appointment_date, status)、(patient_id, status)都要建。
2.2 状态机:避免"医生停诊后患者还能签到"的失控局面
全端协同最容易混乱的地方在于状态迁移。如果每个端都自己判断"能不能做某操作",就会出现竞态。我采用的状态机如下:
- 号源状态:AVAILABLE(可用)→ LOCKED(锁定)→ BOOKED(已预约)→ FINISHED(已完成),另外有 CANCELLED(已退号)、EXPIRED(过期)两个终点状态。
- 预约状态:PENDING_PAY(待支付)→ BOOKED(已预约)→ SIGNED(已签到)→ FINISHED(已完成);PENDING_PAY 可以退到 CANCELLED;BOOKED 在医生端停诊时可以被系统自动置为 CANCELLED,同时号源回滚为 AVAILABLE。
- 支付订单状态:CREATED → PAID → REFUNDING → REFUNDED / CLOSED。
状态机的判断逻辑只放在 Service 层,统一通过一个 StateMachine 组件控制,前端传任何状态都不会直接落库,必须通过业务接口执行对应动作。这样做的最大好处是:医生端把某个号源标记为停诊时,系统可以自动级联处理所有已预约的患者预约单和支付单,而不是靠各端"自觉"。
2.3 缓存层的 Key 设计:让"剩余号数"变成实时数据
在放号高峰期,患者端首页要频繁展示"某科室今日剩余号源",如果每次请求都去 MySQL count 一次,压力非常大。我这里用 Redis 做了两级缓存:
- Key 1:
schedule:info:{scheduleId},存储排班的基本信息(医生、日期、时段、总数、已预约数),数据结构用 Hash,方便单独更新已预约数。 - Key 2:
schedule:left:{scheduleId},是一个纯数字字符串,存剩余号数,供秒级展示使用。每次扣减成功时同步 decrement。
真正的扣减操作不直接操作缓存里的数字,而是通过 Lua 脚本保证原子性。缓存只负责展示和前置校验,最终以数据库操作为准。这个思路很重要:缓存可以丢,数据库不能错。所以我给缓存设置了 10 分钟到 30 分钟不等的过期时间,每次读取到空值就回源数据库重建缓存。
3. 放号瞬间的高并发抢号:锁、幂等与库存扣减的完整方案
预约挂号系统最容易出事故的环节,就是放号瞬间的大量并发请求。患者卡在"提交预约"按钮上反复重试,如果后端没做好控制,一张号源被两个患者同时锁定,或者同一个患者生成两条预约单,纠纷就来了。
3.1 超卖问题为什么会发生
先看一个最原始的伪代码:
// 错误示范:先查再扣,非原子操作 int left = slotMapper.selectLeft(slotId); if (left > 0) { appointmentMapper.insert(appointment); slotMapper.decrementLeft(slotId); }两个线程同时读到 left = 1,都满足条件,同时 insert,于是同一个号源被预约了两次。哪怕在 decrement 时加了 where 条件left > 0,也只能保证数据行不被扣成负数,不能保证业务上"一个号源只属于一个患者"。所以必须把"检查号源状态、写入预约单、扣减号源"放在同一个原子边界里。
3.2 Redis + Lua 实现原子占号
我的方案是先用 Redis Lua 脚本做前置占号,锁定成功后异步写数据库。Lua 脚本核心逻辑如下:
-- KEYS[1]: slot:{slotId} 号源当前状态 -- KEYS[2]: schedule:left:{scheduleId} 剩余号数 -- ARGV[1]: 患者ID -- ARGV[2]: 订单ID if redis.call('get', KEYS[1]) == 'AVAILABLE' then redis.call('set', KEYS[1], 'LOCKED') redis.call('decr', KEYS[2]) redis.call('hset', 'appointment:lock:' .. ARGV[1], ARGV[2], 'PENDING_PAY') return 1 end return 0这个脚本实现了三层保护:号源状态从 AVAILABLE 变成 LOCKED,剩余号数减少,同时记录当前锁定归属。锁定期限默认 10 分钟,10 分钟内未支付则定时任务回滚状态,把号源释放回 AVAILABLE,并 increment 剩余号数。
数据库侧的落库操作也要配合使用乐观锁。我在号源表 schedule_slot 里维护了一个 version 字段,更新时带上where status = 'AVAILABLE'条件,影响行数为 0 则说明号源已被别人抢占,直接抛出友好提示"手速太快,号源已被抢完"。
3.3 用户侧的多重防线:幂等号、限流与防重提交
锁住了号源,还挡不住用户手抖连续点了三次提交。为此我做了四件事:
- 前端在提交预约时生成一个 UUID 作为 requestId,后端 Redis 对
idempotent:{userId}:{requestId}进行 setnx,已存在的直接返回"请勿重复提交"。 - 后端接口按用户维度做简单限流,使用令牌桶思想:同一个 userId 对
/appointment/submit的请求频率限制为每 3 秒一次。 - 同一个患者对同一个排班只能有一条非终态预约单。通过数据库唯一索引
uk_patient_schedule_active实现,即使并发漏过去了,数据库也会拒绝第二条。 - 支付回调通知要支持幂等消费。微信或支付宝的支付回调可能重复推送,我在处理回调时先查 payment_order 的状态,如果已经是 PAID,直接返回成功,不再重复更新。
这里特别提醒:唯一索引是最容易被忽略的兜底手段。代码层再怎么防,也不能保证极端并发下没有漏网之鱼,而唯一索引是数据库层面的最后一道防线。
3.4 数据库最终一致性如何保证
Lua 占号和数据库落库之间有一个时间窗:Redis 已经标记 LOCKED,但数据库订单还没插入。如果此时应用宕机,Redis 里的锁会一直卡住,导致号源被"幽灵占用"。为此我加了一个补偿任务:
- 每 30 秒扫描一次 Redis 中所有 LOCKED 状态的号源,检测号源的锁定时间是否超过 10 分钟;
- 如果超过且数据库里没有对应的非终态预约单,说明占号已失效,脚本自动将 Redis 状态回滚为 AVAILABLE,剩余号数加一;
- 如果数据库里已有预约单但状态是 PENDING_PAY,则由定时任务发送"待支付即将超时"的通知。
这套补偿机制配合数据库乐观锁,基本能保证极端情况下号源不丢失、预约单不重复。它处理的不是正常流程,而是异常流程,但恰恰是异常流程决定了一套系统靠不靠谱。
4. 全端协同下的接口契约:三个端如何安全地对齐业务状态
系统里有几十个接口,我不打算逐个罗列,只讲清楚三组核心接口的设计逻辑。理解这些逻辑后,其他接口的设计都能顺出来。
4.1 患者端:从科室列表到支付成功的端到端链路
患者端最核心的流程是 5 步:查科室 → 查医生排班 → 选号 → 提交预约 → 支付。这里有两个关键设计。
第一,查医生排班时返回的余号数来自缓存,但提交预约时校验来自数据库。接口的入参是 scheduleId + slotId,而不是前端自己猜的"我要上午的号"。这样设计的目的是让服务端完全控制号源分发逻辑,前端永远不知道还有多少号"其实可抢",避免绕过业务规则。
第二,支付成功后回调处理有个容易遗漏的联动:预约单状态更新为 BOOKED,号源状态从 LOCKED 更新为 BOOKED,同时医生端工作台的"今日待诊列表"要能立刻看到新患者。这里我用的是 Redis 发布订阅(Pub/Sub),支付服务在同一个事务提交后发布一条appointment.booked事件,医生端通过 WebSocket 订阅该频道,实现秒级推送。
关于支付订单和预约单的一致性,可以参考以下时序:
患者提交预约 -> 创建预约单(PENDING_PAY) -> 唤起支付 -> 支付回调 -> 本地事务内更新支付单(PAID) + 更新预约单(BOOKED) -> 事务提交后发布事件 -> 同步更新 Redis 号源状态4.2 医生端:停诊、改号如何反向通知已预约患者
医生端的操作看起来只是改自己排班的状态,但它会直接影响患者端的既有预约。比如医生临时停诊,此时系统要做三件事:
- 将排班表标记为 STOPPED,号源池中所有剩余号位置为 EXPIRED;
- 查出该排班下所有 BOOKED 状态的预约单,逐单触发退款流程(调用支付平台的退款接口,记录退款流水号);
- 向受影响的患者推送停诊通知,预约单状态置为 AUTO_CANCELLED。
这个级联操作放在同一个 Spring 事务里会很重,因为调用第三方支付退款接口是耗时操作。我的做法是拆成两步:第一步主事务只更新排班和号源状态,同时把需要退款的预约单写入一张 refund_task 表;第二步由定时任务逐条处理退款,每处理一条更新一次状态。这样既保证了主流程快速响应,又避免了第三方接口抖动导致整个停诊操作失败。
医生端还有一个高频操作是"叫号"。叫号本质上是一个状态推进:当前号位从 WAITING 变为 CALLED,下一位患者从 SIGNED 变为 WAITING。这个操作我也会通过 WebSocket 推送到患者端小程序,让患者看到"前面还有2人,请准备候诊"。实际开发中不用追求消息的绝对实时性,允许 3 到 5 秒的延迟,但消息不能丢。
4.3 管理端:排班发布与对账清算
管理端的核心职能是配置和监控。排班发布要支持两种模式:手工逐日配置、批量生成模板。我建议做成"周模板 + 例外日期"的方式,例如某医生每周一三五坐诊上午 20 个号,管理员一键生成下个月的排班,再单独处理停诊日。排班发布接口在生成号源时要注意加事务边界,30 天的号源一次性生成速度极快,但因为涉及几千行 insert,一定要分批插入并记录生成日志,方便出问题时回滚。
对账清算则是支付环节的最后一道防线。我每天凌晨跑一次定时任务,把本地支付订单表与支付平台的对账单逐笔比对,核对字段包括订单号、金额、状态、支付时间。发现异常单(本地已支付但对账无记录、对账有记录但本地无单)自动标记为 NEEDS_REVIEW,并推送给管理员。这类对账逻辑平时用不上,但一用就能救命——我见过有的系统上线半年后才发现某个退款请求一直失败,就是因为缺了对账单校验。
4.4 消息推送与状态机在前端的落地
全端协同的最后一公里是消息推送。我在项目里没有引入 RocketMQ 这类重组件,而是用 Redis Pub/Sub + WebSocket 组合实现轻量级推送。Redis Pub/Sub 负责服务端内部事件分发,WebSocket 负责把事件推到三端的在线会话。
要提醒的是,Redis Pub/Sub 是"发后即焚"的,消费者不在线就丢失。所以对于关键业务事件(如支付成功、停诊通知),除了 Pub/Sub,还要落库一张通知表 notification。前端启动或重新连接时,先拉取未读通知列表,再走 WebSocket 增量接收。这个"离线拉取 + 实时推送"的组合,才能保证消息不丢不重。
5. 上线前我踩过的那些坑:从联调到压测再到监控
这套系统从开发到上线大概花了三个月,其中真正写代码的时间不超过一半,大量时间花在联调、压测、排查诡异问题上。下面这些坑,每一个都是真实案例,希望你能绕过。
5.1 联调阶段最让人崩溃的五个问题
- 跨端时间不一致:小程序端和服务端取的是不同时区的时间,导致号源显示"今天没有排班"。解决方法是统一接口传时间戳,前端只负责格式化,判断逻辑一律放后端。
- 乐观锁更新失败后没有友好提示:用户明明看到有号,提交时却提示"系统繁忙"。这是因为 slot 表的 version 字段在更新时被并发线程改了,但业务层没有捕获异常转换用户可理解的文案。后来我在全局异常处理器里专门加了并发冲突的映射文案。
- Redis 里的号源状态和数据库状态不一致:比如支付回调成功更新了数据库,但 Lua 脚本执行时 Redis 连接超时,库存没扣。后来我要求所有关键状态变更同时记录操作日志,并定期做一致性巡检脚本。
- 医生端 WebSocket 连接被容器断开:默认情况下 WebSocket 长连接经过 Nginx 代理,如果不配置 read timeout,连接约 60 秒就被掐断。需要在 Nginx 层设置
proxy_read_timeout 3600s,同时前端要实现心跳重连。 - 退款回调幂等:调用退款接口第一次失败后,定时任务重试成功,但管理后台显示了两条退款流水。原因是退款流水表没有加唯一索引,按外部退款号查重失败。加唯一索引后问题消失。
5.2 压测数据与性能调优
上线前我用 JMeter 模拟了 2000 个用户同时操作"查询科室排班+提交预约"的场景,最初结果很惨:接口平均响应时间 800ms,错误率 5%,数据库 CPU 跑到 90%。定位后做了三处优化:
第一,热点接口的数据库查询全部走 Redis 缓存。查询排班列表和医生列表这类读多写少的数据,加了一层本地缓存 Caffeine,过期时间 5 分钟,大大减少对 Redis 的访问。
第二,预约提交链路中,数据库连接池从默认的 HikariCP 配置调大到了最大 50 个连接,并设置了合理的等待超时时间。之前连接池太小,高峰期线程都在等数据库连接。
第三,把"查询剩余号源"这种接口从前端轮询改成了 WebSocket 服务端推送,前端每 10 秒轮询一次变成了只有事件发生时才会收到数据,QPS 直接降了一个量级。
优化后同样的压测场景,平均响应时间降到 120ms,错误率降到 0%。这里顺带说一句:压测一定要在测试环境加真实的 MySQL 慢查询日志和 Redis 慢日志监控,否则定位性能问题会像无头苍蝇。
5.3 监控与日志:问题发生后如何 5 分钟内定位
全端协同系统的日志分散在三个端,排查问题最怕"患者说下单失败,医生说没看到患者,管理员说订单存在"。所以服务端日志必须带上全局追踪 ID(traceId)。我在拦截器里为每个请求生成一个 traceId,写入 MDC,在返回头里也带上同一个值。这样患者提供订单号或请求 ID 后,开发就能直接通过日志平台查完整链路。
同时建议启用 Spring Boot Actuator 的 health 和 metrics 端点,配合 Prometheus + Grafana 做基础监控。重点盯五个指标:接口响应时间 P99、预约接口失败率、支付回调积压数、Redis 命中率、数据库连接池活跃数。这五个指标能覆盖 90% 的线上故障场景。
5.4 部署与安全要点
部署上,我的方案是一个 Spring Boot 应用 + 一个 MySQL 实例 + 一个 Redis 实例,全部用 Docker Compose 管理,应用跑在两台机器上通过 Nginx 做负载均衡。这里有一个容易被忽略的问题:多实例部署时,定时任务会重复执行。我的处理方式是给任务加上 Redis 分布式锁,抢到锁的实例才执行,这样既不用引入 XXL-Job,又能保证任务不重复跑。
安全方面,最少要做三件事:接口层面给三个端分别签发 JWT,并在网关或拦截器里做角色权限校验;管理端的接口额外校验 IP 白名单和操作审计日志;支付回调接口必须验签,不能只看请求来源,要使用支付平台 SDK 提供的验签方法。小程序端和 Web 端的登录态过期时间要区分:小程序端一般 7 天,管理后台建议 30 分钟自动登出。
6. 复盘:这套系统还能怎么延伸
做完这套系统后,我最大的感受是:医院预约挂号系统的技术难点不在某一块算法,而在如何让三个端在共享一套业务状态的前提下,仍然保持各自的体验流畅。Spring Boot 在这里的价值是提供了一个可靠的服务端容器,而真正决定系统成败的,是号源模型、并发控制方案、状态机设计和端与端之间的消息契约。
这套架构如果想继续延伸,有几个方向是现成的:接入药品支付和医保脱卡支付,需要新增支付渠道的适配层;接入院内 HIS 系统打通电子病历,主要是在预约完成后把患者 ID 和就诊序号同步给 HIS;如果门诊量增长到一定程度,可以考虑把管理端报表查询拆到只读从库,或者把消息推送升级为 RocketMQ。但这些都是后话,当前这套全端协同的设计已经足够支撑一个中小型医院的信息化需求。
最后分享一个我个人很坚持的习惯:每次上线新功能前,一定先做一遍"状态机全路径走查",从患者创建预约开始,到支付、签到、就诊、退号,把每个端能看到的界面记录一遍,再对比数据库中的状态流转是否符合预期。多花这一个小时,能少接无数个凌晨两点的故障电话。