SpringBoot3+Vue3构建分布式医疗挂号系统实战解析

发布时间:2026/9/26 5:52:14
SpringBoot3+Vue3构建分布式医疗挂号系统实战解析
做医疗挂号系统最怕的不是功能写不完而是高峰挂号一冲服务直接雪崩。这个项目就是围绕 SpringBoot3 Vue3 搭建一个分布式医疗挂号系统把用户端、医生端、管理端拆开解耦用微服务思路解决高并发挂号、号源一致性、排班同步这些老问题。如果你正在做类似的后台管理系统、预约类平台或者想从单体项目往分布式架构过渡这篇文章会是一次挺完整的实战拆解。整个系统不是简单实现“换个框架写 CRUD”而是要回答几个很现实的问题怎么避免号源超挂怎么保证订单和库存一致怎么让定时放号和过期释放不重复执行怎么在挂了三个挂号实例的时候还能统一排查日志下面这些内容我都会按实际项目里踩过的坑来展开尽量少讲虚的多给能直接拿去用的方案。1. 先从业务痛点说起为什么挂号系统必须做分布式1.1 单体应用撑不住的那几分钟我见过不少医疗项目早期就是一个 Spring Boot 单体应用Oracle 或 MySQL 单库所有功能堆在一起。平时几十个人挂号毫无压力但每周一早上放号或者凌晨开放预约时几千上万人同时涌进来Tomcat 线程池最先被打满接着数据库连接池也撑不住最后整个系统卡死。这个场景很像医院大厅只有一个挂号窗口窗口后面的人越排越多窗口本身的处理速度没变队伍长度却把所有动线都堵死了。单体应用虽然不是不能优化加缓存、加限流、加读写分离都能扛一阵但医疗挂号的业务特性决定了它必须更早做拆分。因为挂号业务最核心的诉求不是“能查到号源”而是“同一个号源不能被两个人挂走”一旦在峰值期出现并发超卖后续的改号、退号、投诉会非常麻烦。1.2 拆出来的不仅是服务更是团队协作边界这个项目里我按照业务领域把系统拆成了用户认证服务、医生科室服务、排班号源服务、挂号订单服务、支付服务、通知服务、统计服务等几个独立模块。每个服务都有自己的数据库服务之间通过 OpenFeign 或消息队列通信。拆完之后最大的变化是挂号订单服务可以单独部署多个实例哪怕某一天流感爆发导致挂号量翻倍我也只需要把挂号服务横向扩容就好。排班号源服务不需要跟着一起扩因为它承载的写入压力没那么大。这种做法本质上是在给“热点”单独划隔离区避免一个模块出问题拖垮全局。当然拆分也有代价。原来在同一个事务里改表就行现在要跨服务调用了原来日志是连在一起的现在一个请求会散落在多个服务里。这也是为什么后面必须引入注册中心、分布式事务、链路追踪、分布式定时任务这些组件。不是制造复杂性而是拆分后不得不用这些工具来兜底。2. 技术选型SpringBoot3 Vue3 为主轴分布式组件怎么配2.1 SpringBoot3 升级带来的变化实际体验既然是 2024 年前后的项目SpringBoot3 已经不是“尝鲜”了。它强制要求 JDK17包名从 javax 迁到了 jakarta安全框架的配置方式也有变化。这些对老项目的迁移来说是成本但对我这种从零搭建的项目来说反而是好处新项目不需要兼容旧写法直接用新规范更干净。我使用的版本组合大致是SpringBoot 3.1.xSpringCloud 2022.0.3SpringCloudAlibaba 2022.0.0.0MySQL 8.0Redis 7Seata 1.7Nacos 2.2。这个组合在网上的资料比较多遇到问题也容易找到答案。第一次搭建时有人直接用 SpringBoot2 时代的依赖坐标导致 Nacos 和 OpenFeign 各种兼容报错后来统一把版本对齐到官方对应列表才稳定下来。后端骨架我习惯用 Maven 多模块管理父 pom 统一管理依赖版本子模块按服务拆分。每个服务独立一个模块共享的工具类单独抽一个公用的 common 模块。这样做的好处是依赖升级时只改父 pom服务间版本冲突会明显减少。2.2 Vue3 前端不是“升级版 Vue2”而是开发习惯改变前端这块Vue3 和 Vue2 最大的区别不是语法糖而是组合式 API 和 Proxy 响应式机制。Vue2 的 Options API 把数据、方法、生命周期拆成几个固定区块写小页面还行写一个包含挂号、排班、用户、支付的管理后台就会很散。Vue3 里的 setup 可以把同一业务的变量和方法放在一起代码内聚性好很多。我用 Vite 搭建前端工程生态上选择 Pinia 做状态管理、Vue Router 做路由、Element Plus 做后台组件库。Element Plus 是目前 Vue3 下最顺手的 UI 框架表格、表单、弹窗、日期选择器都覆盖得比较全。项目里我还用到了 uni-app 配合 Vue3 写移动端 H5 的兼容版本医生和患者的移动端体验基本靠这一套解决。在创建工程的时候可以直接用官方指令npm create vitelatest hospital-web -- --template vue-ts cd hospital-web npm install npm run dev2.3 中间件与组件清单下面这个表格是我整理出的核心中间件清单每个组件的存在都有明确目的。搭建时不要把中间件当成装饰而是先想清楚业务里哪一环会出问题再决定引入什么东西。组件用途选择理由Nacos注册中心 配置中心服务发现、动态配置周边生态成熟Spring Cloud Gateway统一入口网关路由、鉴权、限流、跨域集中处理OpenFeign服务间声明式调用比 RestTemplate 直观维护成本低Sentinel流量控制、熔断降级给挂号、查询等热点接口做保护Seata分布式事务AT 模式对业务侵入小适合订单库存场景Redis缓存 分布式锁号源预扣、重复提交控制、热点数据缓存RabbitMQ异步解耦、削峰支付回调、短信通知、订单状态变更MinIO对象存储头像、诊断报告、图片等文件存储XXL-JOB分布式定时任务放号、过期释放、报表统计任务统一调度3. 前端这部分Vue3项目不只是一个页面3.1 从 vite 脚手架到后台管理基座前端工程不能只做一个简单页面而是要支撑患者端、医生端、管理后台三种角色的操作。我建议团队里维护一个基础后台框架包含登录页、动态菜单、权限指令、通用表格组件、解析后端返回的 JSON 格式避免每个页面重复造轮子。目录结构大致是这样src/ api/ # 接口请求封装 assets/ # 静态资源 components/ # 通用组件 layout/ # 布局框架 router/ # 路由配置 stores/ # pinia 模块 views/ user/ # 用户端页面 doctor/ # 医生端页面 admin/ # 管理后台页面路由侧要把权限控制放进来未登录用户访问/admin时重定向到登录页。Vue Router 4 支持动态路由后台管理系统的菜单权限可以通过后端返回的菜单列表来生成而不是写死在前端。3.2 医院挂号页面的实时反馈与防重复提交挂号页面的核心不是“好看”而是给用户足够清晰的反馈。患者选完科室、医生、时间段后系统要立刻展示剩余号源数量而且要防止用户手滑多提交一次。我前端的做法是在提交按钮上绑定 loading 状态请求发出后立刻禁用按钮等后端返回结果后再恢复。这个在 Vue3 里很好实现一个 ref 变量就能控制template el-button typeprimary :loadingsubmitting clickhandleSubmit 确认挂号 /el-button /template script setup langts const submitting ref(false) async function handleSubmit() { if (submitting.value) return submitting.value true try { await submitOrder() ElMessage.success(挂号成功) } finally { submitting.value false } } /script这个在前端层面挡住了大部分重复点击但后端仍然要做幂等和分布式锁原理我后面专门讲。3.3 可视化看板与移动端兼容管理后台需要展示当日挂号量、医生出诊数、科室热度这些指标我用 ECharts 在 Vue3 里做了几个图表组件。Vue3 使用 ECharts 时需要注意在onMounted里初始化实例在onBeforeUnmount里销毁实例不然页面切换后图表会一直占用资源。这里有一个比较隐蔽的坑如果你在移动端项目里也使用 uni-app Vue3 ECharts需要注意 canvas 组件层级问题部分真机上图表会被原生组件遮挡。我的处理方式是用 renderjs 或 web-view 来承载图表尽量避免直接使用 canvas 混排版面。4. 分布式核心难点号源、锁、事务与幂等这一章是整篇博文的重头戏。挂号系统之所以难不是因为接口多而是并发高、一致性要求高。我把它拆成几个独立问题来解。4.1 号源扣减从数据库乐观锁到 Redis 预减最原始的扣号源写法是查询剩余号数判断大于零再执行减一。这种写法在并发下必然超卖因为两个请求同时读到剩余 1 个号各自判断可以挂然后各减一条实际号源就成负数了。第一步要改成乐观锁UPDATE schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0;上面这条 SQL 是关键数据库行锁会保证同一时刻只有一个事务能更新成功更新影响行数为 0 就说明号源已被抢完。这种写法能防止超卖但在峰量特别高时热点行更新会让数据库压力很大。所以我在系统里做了两级缓冲先用 Redis 预减号源。请求进来时先DECR某个号源 key如果结果 0说明号源还有进入后续业务如果结果为负数说明已抢完直接返回“号源不足”。等挂号订单真正创建成功后再异步把 Redis 里的扣减结果落库并做库存补偿对账。这里必须强调预减成功后如果后续业务失败比如支付超时、订单创建抛异常一定要回补号源。我使用 RabbitMQ 发送一个“扣减失败”的消息专门处理库存回补这和订单状态变更放在同一条消息链路上保证最终一致。4.2 分布式事务不是所有接口都需要强一致“订单与库存分布式事务”是分布式系统里最常被问到的场景。医疗挂号也一样挂号订单涉及用户服务、排班号源服务、支付服务。如果用户在挂号服务里创建了订单但支付服务扣款失败号源不能一直锁着不放也不能已经释放了却告诉用户挂号成功。我最早在这个项目里引入了 Seata 的 AT 模式用GlobalTransactional把创建订单、锁定号源、扣除预存款这几个操作包在同一个全局事务里GlobalTransactional(rollbackFor Exception.class) public void createOrder(CreateOrderDTO dto) { orderClient.create(dto); scheduleClient.lockStock(dto.getScheduleId()); accountClient.deduct(dto.getPatientId(), dto.getAmount()); }AT 模式的核心原理是通过代理数据源记录事务前后的镜像提交阶段自动生成反向补偿 SQL。优点是对业务代码侵入小缺点是事务耗时较长并发高时会拉低吞吐。如果挂号系统的用户量已经大到每秒上千单我会建议不要把强事务放进主链路而是采用本地消息表 定时对账。即先在自己服务内创建订单并写入待确认消息再由消息队列驱动号源锁定和扣款。这样做的原因是极端情况下可以允许支付结果短暂延迟但绝不能因为全局锁把整个挂号接口拖垮。4.3 分布式锁Redis锁别一锁了之防重复挂号的另一道防线是分布式锁。同一患者同时发两个请求即使前端按钮 loading 没有拦住后端也要保证只有一个请求能进入创建订单逻辑。很多人一开始会用SETNX实现锁但实际操作中会踩三个坑忘记设置过期时间宕机后锁永不释放释放锁时直接把 key 删了结果删掉了别人刚获取的新锁锁过期但业务还没执行完后续请求提前进入。我用的是 Redisson 的RLock它可以避免手动处理过期和续期问题。关键代码大致是RLock lock redissonClient.getLock(register:userId: patientId); boolean locked lock.tryLock(1, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(正在处理中请勿重复提交); } try { // 创建订单、扣减号源 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里锁的粒度要尽量细。我见过有人直接把“整个挂号服务”当锁 key结果不同用户之间也互相阻塞并发直接掉到个位数。正确的粒度是“患者 ID 排班 ID”这样同一个患者抢同一个号源时互斥不同患者之间不干扰。4.4 分布式ID与幂等控制数据库自增主键在分库分表场景下不能用UUID 虽然全局唯一但太乱作为索引会导致页分裂明显。我最终的方案是雪花算法。在 SpringBoot3 项目里用 MyBatis-Plus 的ASSIGN_ID或者 Hutool 的IdUtil.getSnowflakeNextId()都很方便。分布式 ID 不只是主键问题它还贯穿了订单幂等。前端在提交挂号请求时生成一个业务流水号requestId后端在 Redis 里保存这个requestId如果重复提交同一个requestId就直接返回第一次的处理结果。说白了就是给请求加唯一索引保证“用户多点了一下”不会生成两笔订单。4.5 分布式定时任务放号与过期释放挂号系统里定时任务非常多每天凌晨生成未来七天的号源、预约超时未支付自动释放、每周生成医生排班报表。如果定时任务跑在多个实例上同一个任务会被执行多次后果很严重。SpringCloud 架构下最简单的方案是 ShedLock它能利用数据库或者 Redis 实现任务锁但任务调度能力比较弱。我更推荐直接使用 XXL-JOB。它为每个任务分配执行器可以指定路由策略为“第一个”或“分片广播”多个实例时保证同一任务只在一个节点上执行。我用 XXL-JOB 时把“生成号源”和“释放过期号源”设计成两个独立任务。生成号源任务每天凌晨 2 点执行遍历医生排班规则批量插入号源记录释放过期任务每 5 分钟扫描订单表中超时未支付的记录把号源回补并更新订单状态为已取消。两个任务分开的好处是生成失败不会影响释放对账也方便。5. 上线前必须处理的日志、存储与部署细节5.1 log4j2 与 logback-spring.xml 的取舍很多后端开发在日志框架上争论 log4j2 和 Logback。SpringBoot3 默认使用 Logback而且 SpringBoot 的 starter 已经自带了 logback 依赖所以直接用logback-spring.xml是最省事的方案。我在项目里保留了 logback没有替换成 log4j2因为默认集成不容易出兼容问题而且 Logback 的性能对绝大多数业务系统足够。logback-spring.xml里有一个关键配置不能用错SpringBoot 多环境配置时要用springProfile标签来区分开发和生产日志级别。直接写死INFO会让开发环境日志打印过多写死DEBUG又会让生产环境刷屏。我一般把开发环境设为 DEBUG生产环境 INFO错误日志单独按天滚动保存。分布式系统里日志最重要的是 TraceId也就是链路 ID。我配置了一个过滤器在请求进入网关时生成traceId并放入 MDC所有服务在调用时透传这个 ID日志里就能按traceId串起来排查问题会快很多。5.2 MinIO 以及对象存储选型头像、诊断报告、支付凭证这些文件不能存数据库也不能放在应用服务器的本地磁盘。多实例部署后本地磁盘是不共享的用户上传的文件可能落到 A 节点下一次访问却被负载均衡转发到 B 节点图片就找不到了。所以在项目早期我就引入了 MinIO。MinIO 是自建对象存储兼容 S3 API部署简单单机版一条命令就能跑起来。如果团队没有运维能力维护 MinIO也可以在云端使用云厂商提供的对象存储服务它们的 API 也兼容 S3本质上是一样的选型。我选择 MinIO 的原因是可私有化部署适合医院内网环境。需要注意的一点是访问控制。不要把存储桶设成公开读所有文件上传后都要走服务端生成带过期时间的签名 URL患者只能在有限时间内访问报告避免隐私数据泄露。5.3 配置管理与环境隔离分布式系统里每个服务都要连 Nacos、Redis、MySQL如果这些配置写死在每个服务的application.yml里换环境就是灾难。我把配置分两层本地只保留应用端口和应用名其余关于数据库、Redis、MQ、对象存储的配置全部放到 Nacos 配置中心按dev、test、prod三个环境隔离。数据库密码这些敏感配置一定要加密。我在项目里用 Jasypt 对配置中的明文密码做加密启动时通过环境变量传入解密密钥。这样做虽然麻烦一点但至少上线后即使配置中心被拖库也不会直接把数据库密码暴露出去。6. 实战中的常见问题与排查技巧6.1 服务间调用链路不通我在联调阶段遇到最多的问题是服务在 Nacos 上已经注册了但 OpenFeign 调用总是报连接失败。排查这种问题我会按顺序做三件事先在 Nacos 控制台确认服务名和实例数是否正确再在调用方所在服务器上telnet目标服务的 IP 和端口确认网络通不通最后看目标服务日志确认它是否真的启动完成有没有报数据源初始化失败。还有一个很容易忽略的点SpringBoot3 项目下的 OpenFeign 使用负载均衡时要确保引入了spring-cloud-starter-loadbalancer否则 Feign 客户端会提示找不到可用实例。这不是依赖冲突就是没有加载 loadbalancer。6.2 分布式事务不生效Seata 的GlobalTransactional不生效十有八九是数据源没有经过代理。AT 模式要求 Seata 代理你的数据源自动生成 undo_log 快照。排查时先看 Seata 控制台有没有收到全局事务注册再看数据库里有没有undo_log表最后检查服务启动日志中有没有出现DataSourceProxy相关的输出。另外一个坑是 Seata 的事务分组名要和 Nacos 配置一致。我踩过一次本地配置的tx-service-group和 Seata Server 里不一致导致全局事务一直注册不上去前端看起来只是接口调用超时后端日志却没有任何回滚迹象。后来统一改成项目名加环境比如hospital-dev-tx-group问题就消失了。6.3 Redis锁误删引发的事故这里分享一个真实的失败场景早期用 setnx 实现锁获取锁之后执行业务业务执行时间比较长锁提前过期了紧接着另一个请求获取到了同一把锁。等第一个请求执行完它会直接执行DEL key把第二个请求的锁删掉了于是并发控制完全失效。解决办法是用 Lua 脚本保证“先判断 value 相同再删除”脚本如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本在生产环境很常用虽然 Redisson 已经帮我封装好了但我仍然会把脚本放在公共工具类里作为不用 Redisson 时的一个备选方案。6.4 Vue3 前端兼容性与接口联调前端我也会记一些坑。Vue3 TypeScript 项目里经常出现组件引入报错多数是因为类型声明缺失。用 Element Plus 组件时我习惯在tsconfig.json里配置types: [element-plus/global]这样模板中的组件类型提示才不会崩。还有一个奇怪的现象在部分 Edge 内核的浏览器里页面上的自定义按钮偶尔会和浏览器窗口控制冲突表现像是右上角的最小化/关闭按钮“点不动”。这类问题通常发生在嵌入 WebView 或者特殊壳的应用里优先检查是否调用了全屏 API、页面是否有遮挡层再确认浏览器版本是不是太旧。正常的 Web 页面不会遇到这个问题所以不用过度关注但做内嵌端时需要记住这个排查方向。最后这个项目做完我个人最大的体会是分布式不是终点而是要能解释清楚每个组件为什么存在。如果你打算照着做我建议先别一上来就拆七八个服务先用 SpringBoot3 Vue3 完成一个端到端的挂号单流程再把真正有并发的挂号服务拆出去配合 Redis 和 Seata 解决一致性问题你会从容很多。另外医疗系统对隐私和数据合规的要求很高哪怕只是练手项目也要养成好习惯日志不打印身份证号、手机号必须脱敏、文件访问必须鉴权。很多规范看起来不是“技术”但出了问题才是最折磨人的。希望这篇实战拆解对你有帮助也欢迎你在评论区聊聊自己踩过的挂号系统相关的坑。