ARTICLE DETAIL

资讯详情

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

SpringCloud微服务实战:医院预约挂号系统设计与实现

SpringCloud微服务实战:医院预约挂号系统设计与实现 做好医院预约挂号这类微服务项目老实说我踩过的坑比想象中多得多。表面看SpringBoot Vue SpringCloud 就是一套全家桶拼起来但真正上手会发现从号源库存、专家排班、分布式锁到柔性事务每个环节都藏着一堆细节等着你。这篇文章我按自己做仁康医院预约挂号系统的全过程来梳理——不吹架构有多高大上就讲清楚每一步为什么这么选、怎么落地、踩过哪些坑。适合正在做微服务毕设、想转企业级开发、或者打算用这个项目作为求职敲门砖的同学参考。这个项目本质上是一个典型的中小型企业级微服务系统。它解决的痛点很直接医院挂号高峰期并发量大、号源需要实时扣减、多个业务线患者端、医生端、管理端数据需要联动。如果用单体架构做代码倒是不难写但几万人同时抢一个专家号的时候单库单表根本扛不住而且后续每改一个功能都要全量部署风险太大。所以我最终确定用 SpringCloud 微服务方案把系统按业务边界拆成独立服务再通过注册中心、网关、配置中心把整个体系串起来。1. 项目整体拆解与业务模块分析1.1 医院预约挂号场景下的真实业务需求仁康医院预约挂号系统表面上只是一个挂号平台但它覆盖了完整的医疗就诊前链路。我从患者、医生、管理员三个视角梳理了一下需求范围其实挺宽患者端要能注册登录、浏览科室和医生排班、在线预约挂号、支付挂号费、查看挂号和退号记录、接收预约成功通知。医生端要能查看自己的排班计划、维护出诊时间、查看已预约患者列表、对患者进行爽约标记或者停诊操作。管理端则涉及科室管理、医生管理、号源池配置、排班规则设定、数据统计报表。这个业务模型里最核心的实体是排班-号源-订单这条链路。一个医生一天有几个出诊时段每个时段放多少号每个号对应一个号源记录患者抢号成功后生成一个预约订单同时号源状态从可用变成已锁定再到已占用。这条链路一旦在多服务间流转就必须保证状态一致。我当时把整个系统按业务域拆分成这些服务用户认证服务负责患者、医生的注册登录、JWT签发与Token校验。科室与医生服务管理医院科室树、医生信息、职称、擅长领域。排班与号源服务核心之一管理医生排班计划、生成号源、锁定和释放号源。预约订单服务核心之二创建挂号订单、处理支付回调、退号逻辑。支付服务对接微信/支付宝模拟支付也可以接一个沙箱环境。消息通知服务负责短信、站内信、WebSocket消息推送。网关服务统一入口负责路由转发和Token鉴权。系统管理服务管理员权限体系、基础数据维护。这八个服务能覆盖大多数医院预约系统的业务需求。其中排班号源和预约订单是重中之重后面细化讲。1.2 微服务拆分粒度如何权衡不能为了拆而拆第一次设计微服务的人最常见的毛病就是拆分过度。一个小系统恨不得拆成二十个服务结果每个服务就两三个表服务间疯狂远程调用延迟高、调试难、事务没法做最后还比不上单体。我做拆分时候的底线是一个服务至少承载一个完整的业务能力且这个能力有独立的生命周期或存储边界。 比如排班号源服务它不只是处理增删改查而是承载了号源生成、锁定、释放这套完整的领域逻辑预约订单服务承载了订单状态机流转和支付结果处理用户认证服务承载了凭证的签发校验。这就有明确的业务边界了。反过来看科室管理和医生信息如果拆成两个服务就完全没有必要——它们的存储和生命周期高度耦合拆了只会增加无意义的服务间通信。我把它们合并到科室与医生服务中借助本地事务解决数据一致性简单可靠。我需要特别强调一点微服务不是免费的。拆出来的每个服务都意味着要独立部署、独立维护日志、独立做监控和限流。对于毕设或者中小项目5到10个服务的粒度是合理的。再往上就需要专门的运维团队来支撑了。另外服务间是采用 REST API 同步通信还是引入消息队列异步通信我的结论是查询链路用同步状态变更链路尽量异步化。比如查看医生排班列表网关直接转发给排班服务同步返回但挂号成功后的推送通知完全走 MQ 异步不阻塞主流程。这个能在保证体验的同时降低耦合。1.3 前端Vue工程如何承载多端业务这个项目的前端我用的是 Vue 3 Vite TypeScript Element Plus Pinia。整套方案其实是经过权衡的。Vue 3 的组合式 API 在处理复杂的表单和动态路由场景下明显比选项式更灵活Vite 的冷启动速度和 HMR 体验在开发阶段能显著提升迭代效率Element Plus 对后台管理类页面组件覆盖非常全医院科室树、排班表格、订单列表都有现成组件可用。前端的难点不在页面本身而在于如何把患者端、医生端、管理端三种角色放进同一个工程里。我用动态路由方案用户登录后根据角色返回可访问的路由表然后调用 addRoute 动态挂载。菜单也是根据权限过滤后生成实际效果就是患者登录进去看到的是预约挂号、我的预约、个人中心管理员登录进去看到的是科室管理、排班管理、统计报表。还有一点前端工程化要提前处理。比如环境变量管理开发环境请求网关地址是 http://localhost:8000生产环境可能是 https://api.renkang.com统一在 .env.development 和 .env.production 里配置然后通过 axios 基地址读取避免在代码里到处写死。这个看起来简单但我接手过不少项目就是因为这个写死引发了一堆跨域和联调问题。2. 核心技术选型与工程骨架搭建2.1 SpringCloud全家桶选型注册中心、配置中心、网关、熔断SpringCloud 全家桶里面的组件在2026年的技术选型和前几年差别挺大。Eureka 已经不再维护服务注册中心我直接用了 Nacos——它同时承担了注册中心和配置中心的职责依赖少的优势很明显。而且 Nacos 自带控制台可以可视化查看每个服务的健康状态、权重调整、配置动态刷新排查问题时特别方便。网关层我用 Spring Cloud Gateway 而不是 Zuul。Gateway 基于 WebFlux 响应式模型性能上限远高于 Zuul 1.x而且它内置了 Sentinel 适配器可以比较轻松地接入限流和熔断。路由配置我放在 Nacos 配置中心改动不需要重启网关动态生效。远程调用组件选型我是从 OpenFeign 和 Dubbo 之间纠结过的。Dubbo 的性能更强适合超大规模高并发场景但学习成本高接口定义约束也更严格OpenFeign 基于 HTTP 协议Spring Cloud 生态融合度高配置声明式接口开发效率高。考虑到仁康这个系统的业务复杂度OpenFeign 完全够用。我最后统一用 OpenFeign LoadBalancer 做负载均衡再加一层 Sentinel 做熔断降级这样易用性和稳定性都兼顾了。举一个比较典型的例子预约订单服务创建订单后需要调用排班号源服务锁定一个号源。用 OpenFeign 声明接口只需要一行 FeignClient(schedule-source-service) 就能实现远程调用Noah 全程不用关心 HTTP 细节。配合 Sentinel 的 fallback 方法一旦号源服务超时或者报错订单服务可以回退提示当前预约人数过多请稍后重试不会直接把异常抛给前端。2.2 SpringBoot版本与依赖管理SpringBoot 版本的坑我专门说一下。这个项目我用的 SpringBoot 3.2.x SpringCloud 2023.x这是一个比较新的组合。SpringBoot 3.x 最大的变化是 Java 17 基线这意味着 JDK 版本至少要 17。如果你还在用 JDK 8就别想着硬上 SpringBoot 3 了老老实实 SpringBoot 2.7.x SpringCloud 2021.x 反而更稳。版本匹配上需要注意SpringCloud 和 SpringBoot 是严格配套的版本号不能随便搭。SpringCloud 2023.x 对应 SpringBoot 3.2.xSpringCloud 2022.x 对应 SpringBoot 3.0.x/3.1.x。乱搭配会直接出现类找不到或者 JAR 包冲突。我是通过 Spring Initializr 创建的工程依赖也用 BOM 管理统一版本在最外层定义的 dependencyManagement 里锁定了 Spring Cloud Alibaba 版本。Spring Cloud Alibaba 的版本也有讲究。我用的是 2023.0.1.0内部会管理 Nacos Client、Sentinel 这些依赖版本不需要自己挨个指定。如果你不引入 Alibaba 生态那 Nacos 得自己单独配版本跟 Spring Cloud 的版本适配又是一个细节坑。依赖管理实践上我强烈建议所有子模块只要引入业务依赖不直接写具体版本号统一交给父 POM 管理。为什么因为微服务是多模块工程如果每个服务各自写版本号升级依赖的时候要全局搜一遍改一遍漏掉一个就可能导致隐性的版本不兼容。统一管理之后升级依赖只动一处全局生效。2.3 前端Vue环境配置与工程初始化方案前端的初始化不复杂但环境配置有坑。我推荐用 create-vite 脚手架创建工程npm create vitelatest renkang-web -- --template vue-ts cd renkang-web npm install npm install element-plus pinia axios vue-router这一步之后要马上安装依赖。这里有一个我在实际项目中经常看到的坑npm 版本和 Node 版本不匹配会触发一长串的 ERESOLVE 错误。遇到这种情况要么升级 Node推荐用 nvm 管理要么在安装命令后面加 --legacy-peer-deps 临时绕过。但根因还是 Node 版本。TypeScript 的配置需要单独检查 tsconfig.json确保 paths 配置好了路径别名。我习惯把 指向 src 目录{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }这样 import 路径就从相对路径地狱里解放出来了。同时 vite.config.ts 里也要同步配置别名import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { port: 3000, proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })这段配置里的 proxy 非常关键。开发阶段前端访问 /api 开头的请求直接由 Vite 开发服务器代理到网关端口 8000这样本地开发不会触发浏览器跨域问题。等到上线部署Nginx 再做一层 /api 到网关的转发前后端实现解耦。2.4 服务端多环境配置与构建流程微服务项目最怕什么环境配置混乱。数据库地址、Redis 地址、Nacos 地址在 dev、test、prod 三个环境下完全不一样。如果每次发布手动改配置一定会出事故。我的做法是每个服务只保留一个 bootstrap.yml或 application.yml配置里不写具体环境参数全部从 Nacos 配置中心拉取。Nacos 上的配置按 dataId 做环境隔离命名规则形如scheduling-service-dev.yml、scheduling-service-prod.yml。服务启动时根据启动参数 spring.profiles.active 加载对应配置。构建流程我用 Maven 多模块管理。父工程的 packaging 是 pom下面是 API 模块存放 DTO/FeignClient 接口、common 模块公共工具、统一返回值、异常处理、各业务服务模块。构建打包时阿里云效或 GitLab CI 里执行一条命令mvn clean package -DskipTests。这在本地测试时需要特别注意配置文件位置Maven打包后的 jar 不会包含外部配置文件所以生产环境的配置不能让 Nacos 覆盖不到。我遇到过一个项目因为 Nacos 地址配错服务启动成功但注册不到注册中心网关根本找不到后端服务前端所有请求 503。排查了半天最后发现是 bootstrap.yml 少了 spring.config.import 配置。SpringCloud 2023 之后Nacos 配置的导入需要显式声明不然 Nacos 配置中心的数据不会自动加载。这个细节容易浪费很多时间。3. 核心业务落地挂号高并发与分布式锁实战3.1 专家号放号场景的并发模型仁康医院的专家号源每周一早上八点统一放号。这个场景的本质是一个短时高并发抢购一个专家上午就放 20 个号但想抢号的患者可能几千人。如果还是常规的查库存-扣库存-下单数据库在那一瞬间会被打爆。我先分析这个并发模型的特点读多写少大量请求查号源列表和余量真正写号源的只占少数。写操作有临界资源号源数量有限扣减必须原子化。业务链路长锁定号源、创建订单、支付回调、更新状态中间有网络延迟。针对读多写少我用 Redis 做号源缓存。放号前把排班信息、号源余量同步到 Redis查询请求直接走缓存数据库只在缓存缺失的时候兜底。针对临界资源竞争在扣减号源时引入分布式锁保证同一排班同一时刻只有一个线程能扣减。针对链路长用消息队列异步处理通知类动作主流程只保留锁定号源和创建订单。3.2 Redis分布式锁的正确姿势与常见坑最早我实现分布式锁用的是 SETNX 加 time expire后来发现这个方案有致命缺陷如果业务执行时间超过了锁的过期时间锁会自动释放其他线程就会乘虚而入同一号源被扣两次。这就是分布式锁里最典型的锁过期问题。正确做法是引入 Redisson 来解决。Redisson 的 lock 底层自带看门狗机制默认过期时间是 30 秒如果业务还没执行完看门狗会每 10 秒自动续期一次。只要 Redisson 客户端还在运行锁就不会因为超时被误释放。代码很简单Autowired private RedissonClient redissonClient; public boolean lockSource(Long scheduleId, Long patientId) { String lockKey lock:schedule:source: scheduleId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待3秒锁的过期时间30秒看门狗自动续期 boolean tryLock lock.tryLock(3, 30, TimeUnit.SECONDS); if (!tryLock) { return false; } // 处理业务逻辑扣减号源创建订单 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这个方案里我要特别提示几个容易踩的坑锁的粒度要够细。很多人图省事一把锁锁整个 Redis 键空间结果系统吞吐量直接崩了。锁粒度应该是排班ID级别甚至号源ID级别不是全局级别的锁。比如同一个专家上午的号锁 key 是 schedule ID不同专家之间完全不干扰多把锁并行执行。finally 里的 unlock 不能省。如果业务抛出异常导致锁没释放会造成死锁。你也可能见过直接在 try 块的末尾调 unlock一旦中间抛异常就永远走不到那行代码这个坑实战中很容易遇到。加锁之后一定要做二次校验。锁只是保证并发安全业务上还得确认号源确实还有余量。我在加锁成功后会重新查询 Redis看余量是否大于0是才扣减避免把已经挂号的号源覆盖掉。3.3 号源扣减与异步削峰设计分布式锁解决了单点竞争但别忘了 Redis 和 MySQL 之间的数据一致性。我选择缓存优先异步落库模式放号时排班服务把号源总量同步到 Redis比如 key: schedule:source:1001 - 20。患者抢号时网关转发到预约订单服务预约订单服务先加分布式锁然后执行 Redis 的原子操作Long remain redisTemplate.opsForValue().decrement(lockKey); if (remain 0) { // 号源已扣完回补并返回失败 redisTemplate.opsForValue().increment(lockKey); return Result.error(号源已约满); }decrement 是原子操作天然避免了check-then-act的竞态问题。紧接着通过 MQ 发送一条号源锁定事件排班号源服务消费消息后更新 MySQL 中号源状态。这样哪怕 Redis 数据因为某些原因丢了MySQL 的数据还能恢复出来。这里要特别注意Redis 的 decrement 返回小于0说明扣超了必须马上回补 increment并且返回失败。否则库存变成负数后续所有请求都会错误地认为有号。另外扣减成功的请求并不代表订单创建成功。支付环节可能失败用户可能放弃支付。这时候需要定时任务来释放长期未支付的号源。我用 Spring 的 Scheduled 实现了一个定时释放任务每隔5分钟扫描一次订单表中已锁定但未支付且超过30分钟的记录将其状态改为已过期同时把 Redis 号源余量 increment 回补再通过 MQ 通知排班服务释放号源。这个定时任务是系统的兜底保障没有它医院号源会被未支付订单白白占用。3.4 网关层限流与防刷策略高并发场景光解决数据一致性问题还不够系统还要能在大流量冲击下稳定运行。网关是流量入口我在 Spring Cloud Gateway 里集成了 Sentinel 的网关限流模块。限流规则的核心维度是API 级 QPS和用户级并发两级。API 级 QPS 接口设置预约挂号接口单机 QPS 上限 500超出直接返回系统繁忙请稍后重试保护后端号源扣减服务不被突发流量打垮用户级并发同一患者在滑动时间窗口内1分钟最多发起5次挂号请求防止脚本绕过来刷号。这个限流规则不是拍脑袋定的需要压测来校准。我用 JMeter 对挂号接口做了一次 2000 并发压测发现后端处理能力在线程池配置合理的情况下稳定在 700 QPS 左右超过这个值接口耗时就开始显著上升。所以 QPS 限制 500 是留了缓冲余量的既不会浪费系统性能也保证极端流量下服务不会雪崩。还有一点容易被忽略网关层限流只能限制流量进入不能限制恶意用户在短时间内大量注册账号。仁康系统还做了一个滑块验证码组件登录和挂号都触发验证码。验证码本身不复杂但拦住了绝大多数脚本行为。如果条件允许还可以接入腾讯云或阿里的风控验证服务但毕设项目用滑块验证码已经足够。4. 分布式事务与数据一致性4.1 挂号链路为什么会产生跨服务数据一致性问题单体架构里一个事务可以同时更新号源表和订单表要么全部成功要么全部回滚。微服务拆分之后号源表在排班号源服务里订单表在预约订单服务里各自有独立的数据库原来那个万能事务就失效了。这就是分布式事务问题的来源。仁康系统的挂号链路里牵涉的数据一致性场景包括患者点击挂号预约订单服务创建一条待支付订单同时调用排班号源服务锁定号源。订单创建成功但号源锁定失败订单要自动关闭把号源回补。支付服务回调通知订单已支付订单服务篡改订单状态同时要给排班服务发消息告知号源已占用。支付状态突变但号源状态不变更用户会在已支付但无号的尴尬状态。退号流程用户申请退号订单状态变成已取消同时号源余量增加。这里同样要保证两边状态同步。很多初学者遇到这个问题第一反应是寻找分布式版本的强事务方案。我明确告诉你成熟的分布式事务方案比如两阶段提交在微服务架构里代价太大锁资源的时间太长吞吐量断崖式下降一般企业根本不会用它来做高并发场景。4.2 柔性事务与本地消息表方案我对仁康系统采用的是一套柔性事务模型核心思想是最终一致性。下单的第一步不强求所有服务同时完成而是允许中间状态存在靠消息重试和补偿机制保证最终所有服务都到达一致状态。本地消息表是柔性事务里最经典、也最实用的实现思路很巧妙在预约订单服务的数据库里建一张本地消息表message_ack。创建订单时在同一本地事务里向消息表插入一条lock_source_status0的待发送记录并修改订单状态。这个本地事务涵盖了两张表可以保证状态一致。事务提交后有一个异步任务捞取消息表里待发送的记录通过 MQ 发送给排班号源服务发送成功以后把消息状态置为1。如果发送失败异步任务会重试重试次数上限是5次超过5次进入人工补偿队列。排班号源服务消费这条消息时先查号源状态如果号源还未锁定就执行锁定并返回如果已经锁定说明消息重复投递直接忽略并返回成功。每条消息都带一个全局唯一的消息ID消费端按消息ID做幂等。这个方案很接地气不需要引入 Seata 之类的额外中间件也不需要改现有的业务代码结构。通过记录-发送-确认-补偿四步基本能覆盖95%的业务场景。我建议做微服务毕设或者中小型项目优先掌握本地消息表方案它的成熟度经得起生产环境验证。4.3 Seata AT模式与TCC模式的取舍如果你非要引入开源的分布式事务框架Seata 是目前最主流的选择。它支持 AT、TCC、SAGA 模式。AT 模式最简单对业务代码侵入最小Seata 通过代理数据源自动拦截 SQL记录前后镜像利用全局锁保证事务隔离。但我必须泼一盆冷水AT 模式的性能开销并不小。它每一次 SQL 都会额外执行一次 SELECT 镜像 undo_log 写入全局事务提交阶段还要做一轮两阶段提交。在挂号这种高并发写入场景下性能瓶颈会很突出。所以我只在非高频但正确性要求极高的操作比如排班初始化管理员批量生成医生一周排班不允许部分成功使用 Seata AT而在抢号的高频路径上坚决不用全局事务用本地消息表 幂等 重试保证最终一致。TCC 模式更适合需要精细控制资源状态的场景Try 阶段锁定号源Confirm 阶段确认锁定Cancel 阶段释放号源。但它需要你自己写大量补偿代码工作量成倍增长。我给一个判断标准如果你的系统规模不大团队对 Seata 不熟练优先用本地消息表如果业务上确实需要全局事务而且你能接受额外引入的分支事务管理再上 Seata ATTCC 除非是资深的架构师来把控否则建议直接放弃。4.4 幂等设计与重复消息防护说到柔性事务有一个绕不开的话题是幂等。MQ 消息的重试和重复投递是常态订单服务收到两次支付回调也是常态。如果把支付回调当成部署在同一个事务里的本地方法调用重复执行就会导致订单状态被覆盖、号源被重复锁定。我的幂等设计原则所有对外接口和消息消费必须通过请求唯一ID进行幂等校验。具体实现分三步接口层前端每次挂号请求携带一个全局唯一的 requestIdUUID存入 Redis设置过期时间10分钟。第二次携带相同 requestId 的请求直接返回第一次的执行结果。消息层每条 MQ 消息都带唯一消息ID消费端先查询本地去重表 idempotent_record如果消息ID存在说明已消费直接返回 ACK。状态机校验订单状态变化严格走状态机。只有待支付才能变已支付只有已支付才能变已取消。如果收到一个把已取消变成已支付的请求直接拒绝。这里有个挺容易忽略的问题Redis 里用于幂等校验的 key 如果过期了重复请求还是会打穿。所以我在 Redis 幂等校验之外数据库唯一索引还要兜底一次。订单表对 request_id 建唯一索引请求第二次插入时数据库会报冲突异常被捕获转成重复请求的友好提示。Redis 数据库双保险是我验证过最能挡住重复请求的方案。5. 前端Vue工程要点与前后端联调实现5.1 统一请求封装与Token管理机制前端工程如果直接把 axios 散落在各个页面里那维护起来就是一场灾难。我把 axios 实例统一封装在 src/utils/request.ts 中做了三件核心事情请求拦截器里从 Pinia 的 userStore 中读取 Token通过请求头 Authorization 携带。响应拦截器里做统一错误码处理后端所有返回都遵循 { code: 200, data: ..., message: ... } 格式拦截器在 code 不为200时弹出错误提示并返回 Promise.reject页面基本不需要重复写错误处理。Token 刷新有一个需要提前规划的方案。JWT 的 accessToken 只有2小时有效期患者在挂号页停留时间长突然跳转到提交订单时发现登录过期体验极差。我实现了一个响应401自动刷新Token的逻辑// 标记当前是否正在刷新Token let isRefreshing false let queue: ((token: string) void)[] [] service.interceptors.response.use( (response) response, async (error) { if (error.response?.status ! 401) { return Promise.reject(error) } if (isRefreshing) { return new Promise((resolve) { queue.push((token) { error.config.headers.Authorization Bearer ${token} resolve(service(error.config)) }) }) } isRefreshing true try { const { data } await axios.post(/api/auth/refresh, { refreshToken }) const newToken data.data.accessToken queue.forEach((cb) cb(newToken)) queue [] error.config.headers.Authorization Bearer ${newToken} return service(error.config) } finally { isRefreshing false } } )这个方案的核心思路是第一个401请求触发刷新其他401请求先排队挂起等Token刷新完成后用新Token重放。如果不做队列管理大量401请求会同时触发刷新造成 Token 刷新接口被并发击穿。这是我线上真实遇到的问题。5.2 动态路由与菜单权限控制动态路由的实现逻辑在前面提了一点展开说一下。路由权限控制的难点在于你不可能在项目初始路由表里写死所有角色能访问的页面因为这样前端代码就暴露了未授权页面的路由信息安全上存在隐患。正确做法是后端负责路由表下放前端只加载公共路由登录页、注册页、首页登录成功后根据角色拉取可访问路由动态挂载。我定义了一个路由配置数据结构interface RouteItem { name: string path: string component: string meta: { title: string; icon: string; roles: string[] } children?: RouteItem[] }后端返回这个结构后前端用 componentMap 统一映射组件名到对应的 import 函数再调用 router.addRoute 逐个挂载。有一个很关键的时序问题刷新页面时 Pinia 里的路由表会清空此时直接刷新一个深层路由比如 /admin/schedule/list会出现找不到路由的白屏。我处理方案是在路由守卫里判断如果目标路由 name 为空但用户已登录就重新拉取路由表然后执行 next({ ...to, replace: true })。这样虽然会跳转一次但最终能回到目标页。这个细节如果提前不做做完动态路由后一刷新就白屏会让人很崩溃。5.3 跨域问题与网关路由配置的前后端协同前后端联调时跨域是绕不开的话题。一次典型的挂号请求链路是浏览器 - Vite dev server(3000) - Gateway(8000) - 预约订单服务(8083)。浏览器发出的请求目标地址是 Vite 的服务地址所以从浏览器的视角看并不存在跨域。但如果你不走 Vite proxy直接请求 http://localhost:8000/api/schedule/list跨域就出现了。跨域问题的最终解药是网关统一处理。我在 Gateway 里配置了全局跨域过滤器允许来源、允许方法、允许携带凭证避免每个微服务单独解决CORS。同时路由规则要做好前后端路径对齐。约定所有前端请求统一走 /api 前缀网关按 /api/** 规则拆分转发spring: cloud: gateway: routes: - id: scheduling-service uri: lb://scheduling-service predicates: - Path/api/schedule/** filters: - StripPrefix1 - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1这个配置里 StripPrefix1 很关键它的作用是把请求路径里的第一层前缀 /api 去掉。也就是说前端请求 /api/schedule/list网关转发到后端服务时实际路径是 /schedule/list。如果不加这个过滤器后端每个 Controller 的 RequestMapping 都得带上 /api 前缀而且网关层做限流、鉴权时路径匹配还会乱掉。5.4 Vue打包与SpringBoot集成的几种部署选择这个项目的部署方式热词里专门提到了vue打包放进springboot中。确实有一种简单粗暴的部署方式把 Vue 工程 build 后的 dist 目录直接复制进 Spring Boot 的 src/main/resources/static 下然后跟随服务一起启动。这种方式的优点是只有一个进程部署简单适合毕设演示或小型内部系统缺点是前后端静态资源和后端服务耦合在一起每次前端改动都得重新打包后端违背了前后端分离的初衷。我的建议是线上正式环境用 Nginx 做前端静态资源服务反代 /api 到网关这样前后端可以独立发布server { listen 80; server_name renkang.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }Nginx 配置里 try_files 的最后一个参数 /index.html 是 Vue Router history 模式的关键。如果没有这一行刷新 /schedule/list 这类深层路径时会直接 404因为 Nginx 服务器上根本不存在这个物理文件。这个坑我遇到过好几次每回都是前端路由一切正常部署上去刷新页面就白屏。多环境构建时Jenkins 脚本里执行 npm install npm run build产物按环境归档到不同目录Nginx 指向当前生效目录。发布时先发布后端新版本再发布前端新版本这样服务端 API 兼容期不会造成页面不可用。6. 常见问题排查与性能优化实录6.1 微服务调用链路故障排查微服务排障的难度比单体高一个数量级。单体时代一个异常堆栈就能定位问题微服务时代一个请求可能要穿越网关、认证服务、订单服务、排班服务四五个进程日志散落在多个服务上。我见过不少人在微服务项目里排查一个问题要花半天就是没有链路追踪机制。我在仁康系统里引入了 Spring Cloud Sleuth 加 Zipkin虽然这套组合在 Spring Boot 3.x 里已经部分被 Micrometer Tracing 取代但原理是一样的为每个请求生成一个全局 traceId贯穿所有服务。每条日志都会带上 traceId排查时用 traceId 在 Kibana 或者直接在各服务日志文件里 grep就能把所有相关日志串起来。举一个实际排查案例用户反馈支付成功后仍然提示未预约成功。我用 traceId 串联日志发现请求链路是 支付回调 - 订单服务 - 排班服务。订单服务成功更新了状态但排班服务的号源锁定消息没有消费成功——排班服务对应的 MySQL 表刚好有锁等待导致消息消费超时重试。通过日志串起来后问题定位很快不用反复登录多个服务查日志。如果你不想引入 Zipkin 这种重量级链路组件至少要做到统一日志格式每个服务必须输出 traceId、业务流水号日志里禁止打印明文密码和敏感信息。这样即使没有可视化平台也能用 grep 快速理清调用链路。6.2 分布式锁失效的真实场景复盘分布式锁理论讲起来头头是道真实环境下失效的情况只多不少。我第一次把 Redisson 集成到项目里时测了个高并发场景500个线程同时抢50个号源跑完发现 Redis 里还剩8个号、MySQL里却有20条订单记录数据直接不一致。排查后发现原因在锁的 key 设计。我当时锁的 key 是 RedisKeyUtil.buildLockKey(scheduleId)但是每个服务节点的机器时钟不同步Redisson 判断锁是否过期时出现了偏差。后来我统一在 Nacos 配置了所有服务使用同一个 NTP 时间源才解决了这个问题。第二个坑是锁的 watch dog 没生效。Redisson 的看门狗默认只在锁未指定 leaseTime过期时间时启用。我当时加了 leaseTime 参数以为这样更保险结果看门狗反而失效了。如果业务执行超过 lock 方法传入的过期时间锁就直接被释放了。正确用法是 tryLock 时不传 leaseTime让看门狗全自动续期这样反而更安全。这个细节不实际踩坑很难意识到。第三个坑是锁被误删。网络抖动可能导致 Redis 连接断开然后锁自动失效。此时如果别的线程拿到锁前一个线程在 finally 里执行 unlock()就会把别人的锁释放掉。Redisson 的 unlock 内部会校验线程ID才能释放但如果序列化配置不一样这个校验也可能失效。我的建议是unlock 之前一定调用 lock.isHeldByCurrentThread() 做一次判断在代码里体现出来省得后续排查。6.3 压测性能数据与调优方向为了让系统性能有据可循我用 JMeter 对核心接口做了几轮压测。挂号接口的压测结果初版并不理想并发 300 时接口 P95 延迟是 620ms号源扣减成功率在 99.2%虽然不出错但延迟偏高用户体验会明显下降。通过 JProfiler 分析热点我发现锁等待占了大量耗时。Redisson 的 tryLock 在竞争激烈时会阻塞等待而排班号源服务的数据库连接池默认大小是 10锁释放后线程等待获取连接的时间很长。调优做三件事连接池从 10 调到 50并设置最大等待时间 2000ms。号源扣减操作从同步事务中拆出来用 Redis 原子自减完成数据库更新异步执行。网关层增加一个热门号源单独限流规则热点专家如主任医师的挂号接口限流阈值从500降到200防止热点请求挤占系统资源导致普通科室挂号全面超时。调优后同样压测300并发P95 延迟降到 180ms号源扣减成功率99.8%整体吞吐量提升接近3倍。这个过程给我的经验是性能不好先别盲目加机器先看锁的竞争率和连接池的等待队列很多时候小配置调整比横向扩容更有效。压测同时也暴露出一个数据一致性的隐患极端并发下Redis 扣减成功但 MySQL 异步落库失败。我为解决这个问题增加了一个对账任务每小时跑一次比对 Redis 号源余量和 MySQL 号源状态发现差异自动修正。对账这个兜底机制放在生产环境是必需品不是可选项。6.4 面试官高频追问与项目复盘很多做微服务项目的读者不只是为了完成毕设还希望拿它去面试。我复盘这个项目时整理了面试官最爱追问的几个问题方向分布式锁的原理和失效场景。一定要把 SETNX 方案和 Redisson 方案的差异讲清楚特别要强调看门狗机制避免了业务未执行完锁就过期的问题。还要能说出锁的粒度、误删锁、可重入这些衍生问题。这个追问链条非常深属于高频考点。分布式事务你们怎么解决。不要上来就背 Seata 三个模式而是讲业务场景挂号时哪些数据不能不一致为什么选择本地消息表消息重复了怎么幂等。把为什么不用两阶段提交讲出来面试官一般会认可你的理解深度。微服务拆分的原则和模块划分。重点解释你划分服务边界的依据是业务能力还是数据生命周期。为什么科室和医生不拆成两个服务以及拆分后牺牲了什么分布式事务复杂度逻辑要自洽。服务挂了怎么办。这个话题延伸到熔断、降级、限流三个概念。我举的是挂号的例子如果排班号源服务挂了网关对挂号接口限流降级前端提示当前服务繁忙而浏览科室列表这种只读操作完全不受影响用户可以继续浏览。网关做了一个初步的登录鉴权但不要所有服务都信任网关。对于订单、支付这类敏感业务服务内部还要二次校验当前登录用户与操作人是否一致防止水平越权。很多人忽略这个安全层面的细节面试官稍微追问就露馅。用真实业务论证方案比背概念有说服力得多。这也是我在最后复盘时才想明白的。7. 部署架构与上线实施记录7.1 环境规划与中间件部署仁康系统不是一个简单的单体部署涉及多个中间件。我最初是本地 docker-compose 拉起来一套开发环境所有中间件都跑在容器里包括 MySQL、Redis、RabbitMQ、Nacos、Sentinel、MinIO。这里我强烈建议如果你不是专门做运维不要在一开始就手动逐个安装这些中间件用 docker-compose 一条命令解决能省下大量时间。我的 docker-compose 核心配置片段大致是这样的version: 3.8 services: mysql: image: mysql:8.0 ports: - 3306:3306 environment: MYSQL_ROOT_PASSWORD: root123456 volumes: - mysql-data:/var/lib/mysql redis: image: redis:7.0 ports: - 6379:6379 nacos: image: nacos/nacos-server:v2.3.0 ports: - 8848:8848 environment: MODE: standalone rabbitmq: image: rabbitmq:3.12-management ports: - 5672:5672 - 15672:15672这里要特别注意 Nacos 的启动模式单机测试必须设置 MODEstandalone否则 Nacos 默认以集群模式启动会一直找集群节点控制台根本起不来。我最初没关注这个配置白白浪费了一个小时。生产环境我建议同一台云服务器上MySQL 和 Redis 用宿主机直接安装Nacos、Sentinel、RabbitMQ 用容器或进程守护。核心业务数据不放在容器里容器只托管无状态应用这样数据持久化和排查故障都简单不少。7.2 各服务部署端口与注册规则微服务部署时服务端口的规划直接关系到网关转发和运维排查。我统一按角色分配端口段方便一眼认出是哪个服务网关服务 gateway-service8000用户认证服务 auth-service8100科室与医生服务 doctor-service8200排班与号源服务 scheduling-service8300预约订单服务 order-service8400支付服务 payment-service8500消息通知服务 notifier-service8600系统管理服务 admin-service8700每个服务在 Nacos 里注册时服务名用小写中横线格式。OpenFeign 声明远程调用时直接用服务名比如 FeignClient(scheduling-service)Nacos 会自动根据服务名做负载均衡。部署顺序也有讲究。第一次全量启动时必须先启动 Nacos再启动网关然后是各业务服务。因为业务服务启动时会在 bootstrap.yml 里注册到 Nacos如果没有 Nacos服务和网关都会启动报错。后面的增量更新只重启对应服务就行不需要全量重启。所有服务的 jar 包启动命令我统一用 start.sh 脚本管理脚本里设置 JVM 参数-Xms512m -Xmx1024m -Dspring.profiles.activeprod。有条件的话再用 nohup 转后台进程。这套东西虽然不复杂但如果不脚本化八服务每次启动是纯手工活很容易漏一个。7.3 全链路监控与日志收集方案微服务上线以后没有监控等于裸奔。我给仁康系统做了三层监控第一层是虚拟机或云服务器的基础监控CPU、内存、磁盘、网络。告警阈值是 CPU 超过80%或内存使用率超过85%持续5分钟触发企业微信机器人告警。这一层用了云厂商自带的云监控没有额外搭 Prometheus。第二层是服务健康监控每个服务的 actuator 暴露 /actuator/health 端点然后用一个简单的定时脚本每分钟检查一次同时通过 Nacos 控制台查看每个服务的健康状态。让脚本自动拉取 Nacos 健康的服务列表一旦有服务注册信息异常立刻告警。第三层是 JVM 和 Redis 监控JVM 堆内存使用、GC 频率、线程池活跃线程数以及 Redis 内存和连接数。这一步我最初没做结果线上出现过一次内存泄漏服务每两天挂一次日志看了半天才发现是不小心在一个静态 Map 里存了用户会话对象没有清理。加上 JVM 监控后堆内存曲线能直观反映内存泄漏问题。日志收集方案我没有上 ELK而是简单地用 logback 按天输出到本地日志文件再用官方的 filebeat 收集到云日志服务。如果你的预算有限logback 云上日志检索也够用查询方式通过 traceId 检索准确率足够。7.4 双机部署与负载均衡单点故障在微服务架构下是不可接受的。Nacos 挂了整个新服务注册就断了MySQL 挂了所有查询都完蛋。针对关键节点我做了简单的一主一备高可用MySQL 用主从复制从库每天做全量备份主库故障时手动切换从库为临时主库。虽然做不到秒级自动切换但至少能保证半天内恢复服务。Nacos 集群部署两台一台 leader 一台 follower。注册中心的故障会导致服务发现不可用Nacos 集群模式能解决这个问题。Redis 开启 AOF 持久化并定期执行 RDB 快照备份故障时可以从备份恢复。双机部署的另一个重要点是对网关做负载均衡。我用 Nginx 挂了两个 gateway 节点由 Nginx 做四层或七层转发。这样即便一台 gateway 挂了另一端还能对外提供入口不会因为网关单点雪崩导致整个系统不可用。做双机部署时千万别忘了数据目录的共享。如果两台机器同时处理定时任务、同时去释放号源、同时去清理过期订单就会产生重复操作。我给所有定时任务加了一个分布式锁用 ShedLock 框架保证同一时刻只有一个实例执行任务。这个细节如果不做双机部署必出问题。7.5 项目实施过程中的工单记录项目上线后我记录了一个典型的高优先级工单可以当作一种参考模板。患者反馈专家号源明明显示剩余3个点击挂号却提示号源不足。前端和后端查不出明显的报错但现象持续了十几分钟。排查思路先看 Redis 号源 key 的剩余数量发现确实是3但 MySQL 中该排班号源状态的可用数量是0。这个差异说明 Redis 和 MySQL 的一致性被打破了。再查消息队列的死信队列发现排班号源服务里有一条号源锁定消息处理失败因为当时 MySQL 的该表正好有锁等待消息重试5次后进入死信队列导致 MySQL 里的号源数量没有随 Redis 扣减同步更新。处理方案人工执行一次对账任务把 Redis 的余量回写为 MySQL 的真实值然后修复排班号源服务里消息消费的代码增加重试间隔和死信监控提醒。修复后问题不再复现。这个工单给我的经验是Redis 和 MySQL 就像两个人的记账本靠 MQ 异步同步数据不是实时严格的强一致。这种方案在大多数场景下够用但必须有对账任务兜底。一旦兜底机制缺失像号源不足这种数据不一致问题就会直接暴露给患者信任度损失非常严重。8. 项目经验沉淀与二次扩展建议8.1 从毕设到企业级项目的差距在哪做完这个仁康系统我最强烈的感受是能跑通业务逻辑和能上线稳定运行是两码事。毕设或课程设计阶段重点通常放在功能实现——能注册、能挂号、能查询流程图一画论文一写答辩就过了。但企业级项目更看重稳定性、安全性、扩展性。举例说注册登录功能毕设做到 JWT BCrypt 加密就算完整。但企业级还要求图形验证码防刷、短信验证码防轰炸、登录失败次数限制、Token 刷新机制、横向越权校验、敏感操作二次鉴权。这些如果不做系统上线几分钟就会被脚本渗透。还有一个差距是工程化意识。代码规范、统一异常处理、统一日志切面、接口签名校验、参数校验注解这些看似不直接影响功能但直接影响可维护性和协作效率。我见过不少代码单个功能写得很漂亮但异常处理分散错误码七零八落加了新需求不敢动旧代码。所以如果你做类似项目我建议在业务跑通之后专门留一周时间做工程化打磨统一返回结构、统一错误码、统一分页模型、统一审计日志。这些沉淀下来远远比多写两个 CRUD 页面有价值。8.2 已有系统可以扩展的进阶方向仁康系统目前已经跑通了核心闭环但扩展空间还很大我梳理了几个可以做二期的方向排队叫号系统对接。目前系统只在线上完成预约到了医院以后怎么取号、怎么排队、诊室怎么叫号是线下流程。把这些流程数字化是一个自然延伸通过大屏或 WebSocket 推送叫号信息Cancel操作可以做到诊室和候诊区联动。前端可以用 Vue 做实时叫号面板后端用 WebSocket 或 SSE 推流。互联网医院在线问诊。挂号只是诊疗流程的开始真正把业务做大需要在线问诊、电子处方、药品配送。这需要增加医生工作台、会话 IM、处方单管理等模块。医生工作台可以复用现有管理系统框架消息模块从短信扩展到 IM 即时通讯。分布式配置的可视化管理。目前 Nacos 已经是配置中心了但能做到发布审核、灰度发布、配置回滚的还不多。可以把配置管理从手写 yml升级为控制台操作加一层配置变更审计日志。鸭绿江数据平台。挂号系统沉淀了科室热度、专家爽约率、号源利用率等数据。做成一个数据报表模块给医院运营人员看就是数据中台的雏形。这部分我用定时任务把核心指标汇总到一张报表表中前端用 ECharts 画趋势图扩展开来不算难。8.3 常见环境问题快速定位表我把开发过程中遇到的高频环境问题整理成一个速查表做同类项目时可以直接对照现象原因排查命令/方法服务启动即退出端口被占用lsof -i:8100注册不上Nacosnamespace没配对控制台查看日志捕获 ERROR网关404路由路径没匹配上确认Path与StripPrefix跨域报错网关未配置CORS浏览器Network看OPTIONS预检Redis连不上密码或 bind ip配置redis-cli -h ip -p 6379 ping前端白屏路由history模式/Nginx未配置try_files确认MQ消息堆积消费者线程池太小rabbitmq 控制台查看队列状态这张表的信息密度不至于替代你排查问题但能帮你在遇到常见报错时快速缩小范围。真实项目排查 80% 的时间不是在看代码而是在定位配置和环境问题这套表能省下不少时间。8.4 时间规划与开发顺序建议如果你是打算从零开始做同类项目开发顺序直接决定你后半程的效率。我的建议是倒着依赖链走第一阶段先搭建公共基础设施Nacos、MySQL、Redis 跑通创建多模块 Maven 工程把 common 模块返回结果封装、异常处理、工具类写好。这个阶段大约2到3天不写业务只搭骨架。第二阶段做认证服务。因为所有服务都要登录鉴权认证服务是其他服务的前置依赖。先把 JWT 的签发校验机制跑通再写注册登录。大约3天。第三阶段做科室与医生服务的 CRUD再把排班号源服务做出来。排班生成算法要提前设计我用了每个星期生成一次排班的策略医生设置好出诊时段后系统按规则批量生成号源这个阶段是系统核心。大约一周。第四阶段做预约订单服务和高并发方案。分布式锁、MQ 异步、定时释放号源都在这个阶段集中实现也是整个项目最需要调试的部分。大约一周。第五阶段做支付服务和通知服务打通完整挂号链路。大约3天。第六阶段做前端所有页面按后端 API 的进度依次实现。大约一周半。最后留一周做联调、压测、修 bug、部署上线。整体下来一个月到一个半月比较合理。如果你想压缩时间可以在第三和第四阶段并行但至少保底核心链路的完整。在这个开发顺序里我最想强调的还是那句老话先跑通一条主链路再逐步加分支。先把患者注册 - 医生排班 - 号源生成 - 预约挂号 - 支付成功这条主线端到端跑通后面再给每个环节加事务、幂等、缓存、熔断这些增强功能。先拿基础版验收再通过迭代打磨比一上来就想做完美系统要靠谱得多。
返回列表