ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

三甲医院挂号系统微服务重构实战:SpringBoot+SpringCloud Alibaba+Vue3

三甲医院挂号系统微服务重构实战:SpringBoot+SpringCloud Alibaba+Vue3 做医疗挂号平台真正刺激的不是写代码而是每天早上七点半到八点半这一个小时。我参与的这家三甲医院挂号项目高峰期每分钟要处理上万次号源查询号源一旦超卖患者就会堵在窗口质问医院运维电话打到手机上根本不带停的。早在单体架构阶段我们就被这个业务场景折磨得够呛后来彻底走了一遍微服务重构技术栈就是标题里那套SpringBoot SpringCloud Alibaba Vue 3。这篇文章把整个项目的服务拆分思路、核心调用链路、分布式事务与防超卖方案、前端对接方式以及上线前后的压测踩坑系统复盘一遍给正在做同类医疗挂号系统的人一个可参考的路线图。先交代一下背景。系统服务的对象是某三甲医院日均门诊量峰值在12000人次左右科室100多个医生排班按周滚动预约渠道包括App、微信公众号、小程序和院内自助机。从业务形态上看挂号只是入口背后牵扯排班、号源、支付、取消退号、问诊记录、消息通知一大堆环节。这种场景下做技术选型第一要考虑的不是什么架构技术最前沿而是什么架构能在早八点扛住流量尖峰同时让业务方排班规则随便改而不影响其他功能。1. 医疗挂号的业务压力逼我放弃单体架构很多人以为医疗系统又不搞电商秒杀并发能有多高实际跑过就懂了。医院的号源放号规则通常是提前一周的特定时间点统一放出一批号比如周一早上8点放下周全部号源瞬间的请求峰值完全不亚于一场小型秒杀。单体架构在这个场景下出现了三个很难调和的问题。1.1 早八点的号源战场我们最早的单体应用是Spring Boot 单机MySQL放号瞬间数据库连接池直接被打满。所有请求都在抢同一批表的读写排班表、号源表、订单表全在一个库里面行锁竞争极其激烈。一开始我们以为加个Redis缓存就能挡住查询流量实际做下来发现查询确实能挡掉一部分但真正下单的写请求全挤到数据库端一个慢查询就能拖垮整个应用。这种峰值不是偶发的而是每周固定出现而且会随着放号策略调整而变化。更麻烦的是除了App端微信公众号和小程序的后端逻辑也在同一个应用里任何一个渠道的异常流量都会互相影响。当时我印象很深某个周一早上订单服务OOM结果App、公众号、小程序一起挂门诊大厅自助机也连不上那个场面真的没法向医院信息科交代。1.2 单体架构的三种死法复盘下来单体架构在医疗挂号场景里逃不过三种死法。第一种叫数据库死法业务全在一个库表关联复杂慢SQL拖垮整体第二种叫发布死法排班规则、支付回调、问诊记录这些业务耦合在一起任何一个小功能上线都要全量回归一个月光发布流程就占掉一半时间第三种叫扩容死法水平扩容后session、定时任务、文件上传全部要处理做一次扩容等于做一次架构改造。尤其是排班规则医院业务方改排班频次和规则是很随意的事今天要支持半天号源明天要多科室联合排班这类改动集中落到一个服务里代码冲突频繁测试回归成本高。时间一长我们就意识到必须按业务域把系统拆开让每个域的改动互相隔离。医疗挂号平台的微服务化不是跟风是被业务复杂度推着走的必然选择。1.3 为什么选SpringCloud Alibaba这套组合当时技术选型摆在面前的有Dubbo和Spring Cloud两条路线。我们选了SpringCloud Alibaba核心原因是生态完整度和团队学习成本。SpringCloud Alibaba的核心组件Nacos同时承担了注册中心和配置中心两个角色对一个十人左右的后端团队来说能少维护一套组件就少维护一套。网关用Spring Cloud Gateway服务间调用用OpenFeign熔断降级用Sentinel这套组合在Spring Boot生态里接入成本最低资料也多。还有一个现实原因是团队熟悉Java和Spring如果为了追求所谓的性能去换Go或者引入Dubbo等于让团队从零开始踩一遍分布式环境的坑。微服务架构的关键在于把复杂业务拆清楚而不是把技术栈换得更炫。后文提到的所有分布式问题这套技术栈都有成熟解法这才是最重要的。2. 服务边界怎么划按业务域拆出九个服务微服务最怕的不是拆错服务之间调不通而是服务边界切得拍脑袋比划。我们第一期规划的思路很简单按业务域职责拆一个域一个服务避免跨库join宁可服务间多调一次接口也不允许A服务直接去查B服务的表。这样拆下来核心服务一共九个每个的职责边界都很清楚。2.1 服务清单与职责边界下面是最终落地的服务清单这个表我们内部争论过很多轮最后的结论是每个服务都得能被一句话讲清楚我是做什么的。服务名核心职责主要数据gateway-service统一入口、路由转发、JWT校验、限流路由规则、令牌auth-service登录认证、Token签发与刷新、用户角色权限用户凭证、角色权限user-service患者档案、就诊人管理、医生/科室信息维护用户档案、科室医生schedule-service排班生成、号源模板、放号策略、号源查询排班表、号源模板registration-service号源锁定、挂号单生成、退号、状态流转挂号单、号源占用记录payment-service支付单创建、支付回调处理、退款支付单、流水记录consultation-service在线问诊、电子病历、医嘱记录问诊记录、病历文书notification-service短信、公众号模板消息、站内信消息模板、发送记录file-service报告文件上传下载、PDF预览、视频回放文件元数据、MinIO对象这里有个容易被忽视的设计点把排班和挂号拆成两个服务。排班管的是有多少号、什么时间放挂号管的是某个号是否被锁定、挂号单状态怎么变。一开始我们打算合并后来发现排班规则变更是高频需求挂号状态流转是核心链路两者耦合在一起会导致任何一个改动都牵动核心交易拆开后排班团队和挂号团队可以并行迭代。2.2 边界划分的三条判断原则拆服务的过程中我总结了三条判断标准每次犹豫不决的时候就拿这三条出来核对。第一条是数据归属原则同一个数据只能有一个服务拥有写权限其他服务要改数据必须通过接口。第二条是变更频率原则高频变化的功能单独成服务比如排班规则而像用户档案这种低频变化的就稳定放着。第三条是业务故障隔离原则一个服务挂了不能拖垮核心挂号链路比如短信通知服务挂掉大不了消息延迟发送但挂号服务绝不能因为短信服务超时而阻塞下单。这三个原则说起来抽象实际判断时很具体。比如在线问诊和消息通知问诊属于核心业务但异步性很强消息通知更是不允许反向影响核心链路所以notification-service里所有调用都做成异步生产上通知服务宕机过两次挂号链路完全没受影响这就证明了隔离的价值。2.3 拆服务绕开的坑拆服务最大的坑不是技术是数据库。我们第一版方案里用户服务要读排班服务的号源数据来做展示有同事图省事直接配了跨库数据源一个SQL查了两个服务的库。这在开发环境跑得很欢上线后第一个故障就是排班服务发布变更表结构用户服务的SQL直接报错。后来定死了一条铁律任何服务不得跨库访问展示层数据需要聚合的统一由BFF层或网关层编排调用。另一个教训是服务拆分粒度。我们最初规划了十几个服务连科室字典都想了单独建一个服务。后来冷静下来把字典、科室这些纯静态数据统一放进user-service的缓存里数据量不大、变更频率极低完全没必要为了微而微。服务拆分的粒度跟团队规模、运维能力是强相关的十个人的团队养活三四十个微服务光版本管理就能把人逼疯。3. 一次完整挂号背后的服务调用链前面把服务拆清楚了接下来要回答一个关键问题一个患者从进入App到挂号成功这几十秒里系统内部到底发生了什么这条调用链是整个项目的核心也是最容易出问题的部分。3.1 从Vue前端点击到网关转发患者在前端页面看到某个科室的号源列表选了一个主治医师的上午号点击预约挂号。Vue前端把这个请求发到统一入口/api/registration/booking实际上这个域名解析到的是gateway-service。网关层做了三件事第一件是JWT认证从请求头里解析出token并校验签名把用户ID、角色信息解析出来放入请求头向下游传递第二件是动态路由根据请求路径里的前缀把请求转发到对应的服务实例第三件是限流针对/api/registration/**路径配置了基于Sentinel的QPS限流规则超过阈值直接返回当前挂号人数过多请稍后重试。这里我想强调一个设计细节网关不做业务逻辑只做路由和过滤。我们一开始在网关里写了号源校验逻辑结果网关变成了新的大单体改一次重新发布一次。后来把所有业务判断全部下沉到具体的微服务中网关只保留认证、限流、灰度路由这些横切逻辑清爽很多。3.2 关键取舍先锁号还是先下单这是整个挂号链路里最核心的设计问题。方案的A是等支付完成后才占用号源方案的B是点击预约先锁号生成待支付订单支付成功后再确认占用。我们选了B原因很简单如果不先锁号患者辛辛苦苦选了号、填了就诊人信息、跳到支付页面结果支付完成回来告诉你号源已经被别人抢走了这种用户体验放在医疗场景里就是投诉和纠纷。先锁号也有代价会带来锁而不付的问题。患者锁了号但不支付号源就被白白占住。我们的解决办法是给待支付订单设置了15分钟有效期超时自动解锁号源。这个机制在registration-service内部用一个延迟队列实现订单创建时发一条延迟消息15分钟后检查订单状态如果还是待支付就取消订单并释放号源。为了确保这个流程可靠队列消费失败时还有定时任务兜底扫描超时订单。3.3 支付回调与状态机的兜底设计支付完成后微信或支付宝支付平台会异步回调payment-service。这里千万不能让回调直接去改挂号单状态因为第三方接口不保证回调次数也不保证顺序。我们设计了一个事务状态机挂号单的状态有待支付-已支付-已预约-已取消-已完成五个状态状态流转只能按固定方向走。支付回调进来后payment-service先落支付流水然后通过RocketMQ事务消息通知registration-service推进状态。RocketMQ事务消息在这里有两个好处一是事务消息的本地事务保证了支付流水先落库二是消息的异步解耦让支付服务和挂号服务不必强依赖。如果挂号服务短时间不可用消息会在队列里积压恢复后继续消费不会丢数据。这条链路里最怕的是回调重复所以支付流水表上建了order_id channel的唯一索引重复回调直接被数据库拒掉。4. 分布式场景下绕不开的四道坎服务拆了调用链通了真正的硬骨头才开始。单体架构里一个本地事务就能解决的事情拆成微服务后全都变成了分布式问题。我们项目的核心痛点集中在四个方向分布式事务、防超卖、接口幂等和全局ID。一个个看。4.1 分布式事务用消息队列取代强一致医疗挂号这个场景里最典型的分布式事务是挂号单创建后同时要锁定号源并生成支付单。早期有同事提议用Seata的AT模式做全局事务我们分析后否决了这个方案。原因是AT模式本质还是两阶段提交事务链路中所有参与者都要长时间持有数据库锁在高并发放号场景下会放大锁竞争而且全局事务的回滚审计很麻烦。我们最终选择的是本地消息表 消息队列的最终一致方案。registration-service在自己本地事务里同时完成三件事创建挂号单、插入一条本地消息表记录、更新号源状态为锁定。本地事务提交后定时任务或消息发送组件把消息表里的记录发给RocketMQ下游payment-service消费消息后创建支付单。这个方案牺牲了强一致换来了可靠性和吞吐。实际运行中消息积压最严重的一次是支付服务发版重启期间恢复了大约3分钟的消息积压最终一致性完全满足业务要求。这里必须说清楚一个前提不是所有业务都能用最终一致性。比如号源锁定和挂号单创建这里必须是一个本地事务不能拆成先创建订单、再单独去锁号源的两步分布式事务。我们之所以能接受最终一致是因为从挂号单已创建到支付单已创建之间有15分钟支付超时保护窗口期足够大。4.2 号源防超卖Redis Lua、乐观锁、唯一索引三层防线号源就是医院的库存绝对不允许超卖。患者付费成功发现没号那等于事故。我们的防超卖设计分了三层每层挡住一种失败场景。第一层是Redis Lua脚本原子扣减。排班服务在发布号源时会把每个号源模板的剩余数量写入Redis格式是stock:{scheduleId}:{periodId}。用户请求锁号时执行一段Lua脚本脚本内先检查剩余数是否大于0再执行扣减。Redis单线程特性保证了这段脚本的原子性这是挡在最前面的并发防线。第二层是数据库乐观锁。Redis在极端情况下可能会丢数据或者与数据库不一致所以数据库层的号源表保留了remaining_count和version字段。更新语句写成update schedule_source set remaining_count remaining_count - 1, version version 1 where id ? and remaining_count 0受影响行数等于0就说明号源已扣完。这是兜底防线。第三层是数据库唯一索引。挂号明细表里建了schedule_id source_id period_id的唯一索引同一个号源在某个时间段只能被成功占一次即使前两层都失效这层也能挡住重复插入。三层防线的思路是Redis解决性能数据库兜底唯一索引守住最后底线。上线后做过一次故障演练模拟Redis宕机系统直接降级到数据库乐观锁层虽然响应变慢了但完全没有超卖。4.3 接口幂等同一个支付回调来了十次怎么办支付回调天然会重复而且渠道方的重试机制各不相同有的重试十几次。如果回调处理没有幂等设计最直接的后果就是患者被重复扣款通知轰炸或者订单状态被错误流转。我们的幂等策略用的是状态机加唯一索引的组合。具体来说payment-service收到回调后先查询支付单当前状态。如果已经是支付成功直接返回成功响应不再做任何状态更新。同时payment_callback_record表里对payment_no建了唯一索引同一个支付单的回调记录只能插入一条重复回调在这里就被数据库挡下。这样即使回调处理逻辑被并发触发了也无法重复推进状态。还有一个容易被忽略的幂等场景患者点击取消挂号按钮。用户以为第一次点击超时了又点了一次如果退号接口不幂等号源会被释放两次。这里的做法是退号请求带上业务方生成的request_id服务端用这个ID做去重已经处理过的请求直接返回相同的处理结果。4.4 全局ID生成雪花算法的时钟回拨隐患服务拆分后数据库自增主键就不够用了因为各服务的数据要聚合、要分表必须使用全局唯一ID。我们选了业内通用的雪花算法每个服务实例用workerId区分生成的ID是64位长整型时间戳加机器序号加自增序列。雪花算法本身很成熟但它有一个著名的坑时钟回拨。一旦服务器时间往回跳生成的ID就可能重复。我们的解决方式是在ID生成服务里加了一个简单的保护每次生成时记录上一次的时间戳如果发现当前时间小于上一次时间戳说明发生了时钟回拨此时拒绝生成ID并等待时间追上或者换用备用序列段。这个方案牺牲了极端情况下的可用性但保证了ID绝不重复。生产上NTP时间同步曾经触发过一次回拨因为我们加了保护只造成了约200毫秒的生成停顿没有出现重复ID。5. Vue前端如何跟微服务相处后端微服务化之后前端团队最关心的问题只有一个我到底该请求谁如果让前端感知到几十个微服务的地址那前端就会变成分布式架构的灾难现场。我们的前端技术栈是Vue 3 Vite Element Plus Pinia整体架构围绕网关设计前端从头到尾只认一个后端地址。5.1 网关统一出口前端眼里只有一个后端地址前端开发环境通过Vite代理把/api前缀的请求转发到网关服务生产环境通过Nginx把/api反向代理到网关的负载均衡地址。所有请求路径以模块划分比如/api/user/**、/api/schedule/**、/api/registration/**网关根据前缀做路由。前端不需要知道registration-service部署在哪个节点、注册中心里有多少个实例这些全是网关的职责。这么设计还有一个额外好处接口管理和联调变得很简单。后端服务拆分后前后端联调不再需要每个后端同学各自起服务再让前端连各自的地址前端开发时只要把网关地址配好网关会自动路由到注册中心里的对应服务。等到某个服务需要单独调试时再通过网关的灰度路由把特定请求转发到本地实例。5.2 动态路由与菜单权限医疗平台的用户角色很杂患者、医生、护士、管理员、医院信息科。不同角色登录后看到的菜单和操作按钮完全不同而且这些权限是后端控制的。我们的做法是登录成功后auth-service返回当前用户的角色和权限码列表前端根据权限码动态生成菜单和路由。Vue Router的动态路由实现方式是这样的路由表分为基础路由登录页、404页等和动态路由患者端、医生端、管理端各自的路由模块。用户登录后前端拿到权限码通过一个filterRoutes函数过滤出该用户有权限访问的路由模块再用router.addRoute动态注册到路由实例中。这里有个坑我提醒一下刷新页面后Vue Router实例会重置动态路由会丢失所以必须在全局路由守卫里做判断——如果store里没有用户信息先调接口拉取用户信息和权限重新生成路由再放行到目标页面。菜单权限这块我们用了更细的按钮级控制。页面里写一个v-permission指令指令的值是权限码如果当前用户的权限码列表里不包含该值就销毁对应DOM元素。这样同一个页面组件可以同时服务多个角色不用写一堆v-if判断。5.3 医疗场景的附件预览m3u8、PDF、图片医疗挂号就诊平台不只是挂号还包括在线问诊和检查报告查看。这些功能涉及到音视频和PDF预览。在线问诊的视频回放存在MinIO里转码后生成m3u8索引文件前端播放用了hls.js这个库的好处是不依赖浏览器原生HLS支持不管用户在哪个浏览器上打开都能直接播放不要求用户安装任何额外播放器。检查报告通常是一份PDF或者一堆图片。图片直接用img标签预览没问题PDF预览这里我们折腾过一段时间一开始用了浏览器内置的iframe预览方案在PC端Chrome上还行但移动端App的WebView里面兼容性很差白屏问题频出。后来换成PDF.js的pdfjs-dist包自己渲染PDF到canvas上兼容性好了很多还顺手实现了双指缩放和翻页。医疗场景里这些附件涉及患者隐私所以请求头里都带上了JWT网关会校验文件访问的权限码用户只能看自己绑定就诊人的报告。6. 部署、监控与压测微服务上线的最后一公里代码写得再漂亮部署不上生产、上线后两眼一抹黑一切都白搭。微服务架构对部署和监控的要求比单体高出一个量级我们在这个阶段踩的坑比写业务代码时多得多。6.1 环境分区与配置管理我们分了dev、test、pre、prod四套环境每套环境都独立部署一套Nacos。这里要特别说明一下为什么不共用一套Nacos配置放错环境会导致预发环境把生产数据库连了这种事故一旦发生就是灾难级别的环境隔离再怎么强调都不过分。Nacos里维护了每个服务的application.yml配置数据库连接串、Redis地址、消息队列地址都放在配置中心不在代码里写死。配置文件本身的版本管理也走过弯路。最开始的配置更新直接在Nacos控制台改结果改完也不知道谁改的、改成什么了。后来我们把配置的变更操作收敛到Git仓库用Nacos的配置监听功能监听Git上的变化通过一个发布平台一键推送指定版本的配置到各环境。出现配置问题时能快速回滚也能审计是谁在什么时间改了配置。6.2 压测暴露出的三个瓶颈系统上线前做了两轮JMeter压测第一轮就暴露了三个问题。第一个问题是数据库连接池不够用默认的maximum-pool-size: 10完全扛不住放号峰值一个接口慢查询就能占满连接池。调整策略是核心服务registration-service配到50其他服务30同时每个核心接口做了超时熔断超过3秒直接fail fast避免请求堆积拖垮整个服务。第二个问题是OpenFeign调用的线程池隔离。一开始没有给Feign配置独立的线程池默认用Tomcat的工作线程结果下游服务响应慢的时候Tomcat线程全部被阻塞新请求进不来。后来给Feign配置了独立的线程池和合理的超时时间下游抖动只影响调用方线程池内的请求不会堵死整个Web容器。第三个问题出在logback的同步日志。压测时发现打印日志的IO操作占了不少CPU高并发下日志队列频繁阻塞。后来切成了logback的异步appender并控制了日志级别和输出内容压测数据立刻好转。这个问题太小不压测根本发现不了。6.3 生产环境的监控体系监控体系我们拆成三层。第一层是服务健康状态Spring Boot Actuator的/actuator/health接口接入Prometheus每分钟抓一次配合Alertmanager配置规则服务down了1分钟就告警。第二层是链路追踪用SkyWalking接入全部服务每个请求从网关到各微服务调用链路上每一个节点的耗时都完整记录排障时直接看链路图一眼定位到哪个服务变慢。第三层是业务监控在关键业务节点埋点比如号源锁定失败次数、支付回调超时次数、退号失败次数这层监控反映的是真实业务健康度比单纯的接口耗时指标重要得多。生产上最有效的一次告警是业务监控发现的某天早上挂号成功率突然从98%掉到了85%链路追踪显示是排班服务一个慢SQL导致接口超时我们通过SkyWalking定位到具体SQL发现是排班表缺了一个联合索引加上索引后成功率恢复到99%。如果没有链路追踪和业务监控结合这种问题排查耗时至少翻倍。7. 项目复盘哪些设计是对的哪些白做了功项目上线运行半年后我们做了一次认真的复盘。回头看整个微服务改造有值得称道的决策也有现在想来纯属浪费精力的设计。7.1 三个经得起考验的决策第一个决策是网关统一入口加纯粹路由。当时有争论要不要在网关上做业务聚合最终坚持了网关只做横切逻辑这个坚持让网关服务一直很稳定发布频率极低。第二个决策是排班和挂号彻底分离。医院业务方后续改了三次排班规则因为排班服务独立演进核心挂号链路零影响这验证了按业务域拆分的正确性。第三个决策是号源防超卖的三层防线。这套设计在Redis宕机演练中证明了自己的可靠性后面接入了第三方渠道所有渠道的号源扣减都走同一个入口三层防线成了整个系统的定海神针。7.2 那些白做的功与过度设计复盘时承认了至少两点过度设计。第一点是引入了分布式定时任务框架一开始为了处理超时订单上了ElasticJob配置了三台机器分片执行不同的任务。实际上超时订单量每天只有几千条单台服务器的定时任务完全能处理引入分布式任务框架反而增加了部署和排查成本。后来删掉了ElasticJob换成Spring自带的Scheduled加分布式锁效果完全一样代码更简单。第二点是服务间通信过度追求异步化。咨询了业内方案后我们把一批本可以同步调用的接口全部改成了MQ异步结果就是很多前端请求要轮询查结果用户体感和排障复杂度都变差了。后来根据业务场景重新梳理只有真正跨服务且不需要实时返回的才走MQ比如消息通知、支付回调其余能同步坚决同步。7.3 如果重新来一次会怎么调整如果要重新开始这个项目我会在三个方面做出调整。第一先做核心链路的最小微服务集网关、认证、排班、挂号、支付这五个服务先上线问诊、通知、文件服务放到第二阶段不让业务方等太久。第二压测和监控前置第一轮压测应该在服务拆分完成后立刻做而不是等全部功能开发完再压测晚一个月就晚暴露一个月的问题。第三在项目启动时就定死服务间通信规范哪些用同步Feign哪些用MQ哪些用异步形成规范文档而不是让每个开发凭自己感觉选择。做医疗挂号平台这类系统我的核心体会是微服务架构解决的是业务复杂度和团队迭代效率问题而不是性能问题。真要追求并发极限单体加缓存加分库分表也能做到不错的水平但业务规则的隔离、多人团队的并行开发、故障隔离的灵活性这些才是微服务带来的真正价值。最后再分享一个小经验医疗系统的上线窗口非常宝贵每一次上线都有医院信息科和第三方渠道全程盯着。如果你是正在做类似系统的人一定要把回滚方案和应急预案写到上线文档里并且实际演练过。回滚方案不是写给别人看的是留给半夜被电话叫醒的自己的。
返回列表