ARTICLE DETAIL

资讯详情

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

微服务架构疫苗预约系统实战:从Spring Boot到云部署全解析

微服务架构疫苗预约系统实战:从Spring Boot到云部署全解析 先交代一下这个项目的来源。上半年我帮一个社区接种点做信息化改造他们当时的预约方式是微信群接龙加现场排队每天早上八点半放号手机一响所有人同时点页面直接卡死。后来我以这个真实场景为蓝本用 Spring Boot 做了这套基于云平台的疫苗预约系统并在核心模块上采用微服务架构拆分整个系统覆盖了在线预约、时段放号、疫苗库存管理、接种记录追溯、消息通知等智慧医疗场景下的完整业务闭环。这篇文章会把项目的需求拆解、模块边界、核心并发方案、云端部署和压测优化一次性讲清楚适合正在做毕业设计的学生也适合想了解一个真实业务系统从设计到上线全过程的开发者参考。1. 项目定位从一个毕业设计题目到一个能上线跑的业务系统1.1 这个系统到底解决了什么问题很多人第一眼看到疫苗预约系统会觉得这就是个 CRUD无非是用户选个时间、填个身份信息、生成一条预约记录。但实际去社区接种点蹲两天就会发现真正痛苦的不是新增一条预约而是下面这些事放号瞬间大量用户同时点击系统能不能扛住。一个接种点每天就那么多疫苗不同时段能接待的人数也不一样怎么保证不超约。老人帮家属预约、家长给孩子预约账号和接种人并不是一个人身份关系怎么处理。接种完成后要给用户出凭证疫苗批次要能追溯到具体是哪一批、哪个厂家、哪个有效期。爽约的人占了号又不去号源怎么释放给后面的人。这些问题单独拎出来都不算难但放在一个系统里串起来对数据模型、接口设计和部署方案的要求就完全不一样了。我做的这套系统就是围绕这五个问题展开的核心目标只有一个让接种点的放号、预约、核销、追溯全流程线上化减少人工排队和纸质登记带来的混乱。1.2 Spring Boot 加微服务是不是过度设计选型的时候我确实纠结过。按社区接种点的规模单体应用加个 Redis 完全够用硬拆微服务反而会增加开发成本。但结合题目的要求以及这个系统未来要接多个接种点、要单独给卫生管理端做部署、要应对早高峰放号这种流量突刺微服务就不是纯摆设了。我的判断标准是下面三条有没有独立的伸缩需求。预约服务在放号时段流量最高但用户服务、通知服务相对平稳拆开后可以只给预约服务扩容。有没有独立的发布需求。管理端加一个疫苗批次字段不应该影响用户端正在跑的预约流程。有没有团队分工需求。毕业设计虽然是一个人写但演示和答辩时服务边界清晰本身就是很好的加分项。所以最终定下来的技术栈是这样的分层选型说明后端框架Spring Boot 2.7.x稳定版本生态最全微服务组件Spring Cloud AlibabaNacos 做注册中心和配置中心网关Spring Cloud Gateway统一鉴权、限流、路由转发ORMMyBatis-Plus单表 CRUD 效率高数据库MySQL 8.0业务数据持久化缓存与分布式锁Redis 6.x库存预扣、Token 存储、接口限流消息队列RabbitMQ异步通知、解耦文件存储MinIO接种凭证、公告图片部署Docker Docker Compose云主机一键编排用这套组合跑下来最大的感受是 Spring Cloud Alibaba 的 Nacos 把注册发现和配置管理合在一起对中小型项目非常友好不用额外引一堆组件。1.3 用户角色的划分系统里我设计了四类角色每一类对应不同的功能入口普通用户居民注册登录、添加家庭成员、选择接种点和疫苗、预约、查看预约记录、取消预约。接种点工作人员维护本接种点的疫苗库存、配置每日时段配额、扫码核销预约单。医生/护士录入接种记录、上报不良反应。系统管理员管理所有接种点、审核疫苗批次信息、查看全平台预约数据。角色权限我直接用 Spring Security 加 JWT 来做网关层统一校验 Token服务内部再用注解做细粒度权限控制。后面踩坑部分我会单独说这个环节的坑。2. 核心业务链路拆解从放号到接种追溯数据是怎么流的2.1 一条完整的预约链路我把一次正常预约拆成了十二个环节每个环节对应一个状态前端根据状态切换按钮用户登录 → 选择接种点 → 选择疫苗类型 → 选择接种日期 → 选择时间段 → 确认接种人 → 系统校验库存和配额 → 创建预约单 → 发送预约成功通知 → 现场出示预约码 → 工作人员核销 → 完成接种并生成接种记录。这里最关键的一步是校验库存和配额它不是查一次数据库那么简单。因为在放号那一刻可能有几百上千个请求同时进来如果每个请求都先 select 再 update库存就一定会超卖。这个问题的解法我在第四部分单独展开这里先记住一个结论预约系统真正的并发瓶颈不在 Tomcat 能扛多少连接而在库存扣减的原子性。2.2 疫苗库存和预约时段其实是两套数据很多初次做这个项目的人会把疫苗库存和预约名额混在一起这是一个很容易出错的点。我的设计里把它们分成了两套数据疫苗库存vaccine_stock表示接种点某种疫苗实际还有多少支由疫苗批次决定。时段配额slot_quota表示某个时间段最多能放多少个预约名额由接种点工作人员配置。为什么必须分开因为一支疫苗对应一个人但一个时段能接待多少人还取决于现场有几个接种台、几个医生。比如某天下午到货 200 支疫苗但接种点只有 2 个医生每个医生一小时最多打 8 个人那一小时最多放 16 个号而不是 200 个。如果只盯库存就会造成约了一大堆人但现场根本打不完。我用一张表维护值班医生的排班信息时段配额 医生数量 × 每小时接待能力 × 时段小时数。这个计算逻辑在管理端写成一个独立方法改排班后自动重新生成未来七天的配额。2.3 接种记录与批次追溯这是整个系统里最有医疗系统味道的部分。每次接种完成后系统会生成一条接种记录里面必须关联三样东西接种人 ID、预约单 ID、疫苗批次 ID。通过批次 ID 可以查到这批疫苗的厂家、批号、生产日期、有效期、入库时间和出库到哪个接种点。表关系大概是这样的CREATE TABLE vaccine_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vaccine_name VARCHAR(64) NOT NULL, manufacturer VARCHAR(128) NOT NULL, batch_no VARCHAR(64) NOT NULL UNIQUE, production_date DATE NOT NULL, expiry_date DATE NOT NULL, stock_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE vaccine_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, site_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, remaining_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_site_batch (site_id, batch_id) ); CREATE TABLE appointment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, site_id BIGINT NOT NULL, vaccine_batch_id BIGINT, slot_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT 0待核销 1已接种 2已取消 3已爽约, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE inoculation_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, appointment_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, site_id BIGINT NOT NULL, dose_no TINYINT NOT NULL, inoculate_time DATETIME NOT NULL, operator_id BIGINT NOT NULL );batch_no 字段我加了一个唯一索引因为实际业务里批号是厂家给的唯一编码后续要做批次追溯查询这个索引非常关键。接种记录表里也冗余了 site_id因为查询某个接种点打过哪些批次的疫苗是管理端最高频的报表查询之一冗余字段能避免一次大表 join。3. 微服务边界划分与数据库拆分3.1 服务拆了哪几个我最初画架构图的时候拆了 6 个服务后来砍掉一个支付服务才最终定了 5 个因为对公疫苗接种本身不涉及在线支付强行加支付反而是画蛇添足。最终的服务列表如下服务名示例端口核心职责gateway-service8080路由转发、统一鉴权、限流user-service8081用户注册、登录、家庭成员管理vaccine-service8082疫苗批次、库存、接种点管理appointment-service8083时段配额、预约单、核销record-service8084接种记录、批次追溯notify-service8085短信、站内信、公众号通知每个服务自治不直接访问其他服务的数据库。appointment-service 需要疫苗信息时通过 OpenFeign 调 vaccine-service 的接口需要通知用户时往 RabbitMQ 的 notify_exchange 发一条消息notify-service 异步消费。3.2 服务间通信与异步消息同步调用我用的是 OpenFeign 加 Nacos 的负载均衡这个组合在 Spring Cloud Alibaba 生态里非常成熟。但有一点要注意链路里不能有超过三层的同步调用。比如预约接口如果这样调就出问题了appointment-service → vaccine-service 查库存 → vaccine-service 又调 record-service 查上次接种记录 → 前端一直等着。这种链路里任何一个下游抖动整个预约接口就挂了。我的处理方式是预约时只同步查时段配额和疫苗批次是否存在这两个都是单点查询快。是否已经预约过上次接种时间这类校验放在本地数据库冗余字段里预约单创建时就把接种人基础信息快照进来。通知、凭证生成全部走 RabbitMQ 异步。3.3 数据库怎么拆拆库之前要先想清楚一个问题哪些数据天然属于同一个业务域。我的拆分原则是被修改的频率相近、被查询的场景相近的数据放一个库。实际落库分了四个数据库归属服务主要表db_useruser-serviceuser_account, family_memberdb_vaccinevaccine-servicevaccine_batch, vaccine_stock, site_info, slot_quotadb_appointmentappointment-serviceappointment_order, appointment_operate_logdb_recordrecord-serviceinoculation_record, adverse_eventslot_quota 我放在了 vaccine-service 而不是 appointment-service因为时段配额的产生依赖于疫苗库存和医生排班它是供给侧数据和疫苗库存的一致性要求更高。appointment-service 只保存预约单。这种拆法带来的问题和好处一样明显跨库查询几乎做不了。比如查某个用户在某接种点的所有接种记录需要先通过 user-service 拿到用户 ID再调 record-service。我更推荐的做法是给 record-service 的接种记录表里冗余 user_id 和 site_id让这类查询变成单表单条件查询避免跨服务调用。4. 预约并发与库存扣减整个项目最硬核的部分4.1 超卖的根因先看一段典型的错误写法// 错误示例先查再扣 AppointmentSlot slot slotMapper.selectById(slotId); if (slot.getRemainingCount() 0) { slot.setRemainingCount(slot.getRemainingCount() - 1); slotMapper.updateById(slot); // 创建预约单 }这段代码在并发量低的时候没任何问题但放号瞬间如果同时进来 200 个请求其中 150 个请求都查到 remainingCount 100然后各自减 1最终 update 的结果可能是 99、98而不是 0于是系统发出了 150 个预约成功通知实际只有 100 个名额超卖了。解决超卖本质上是解决判断和扣减之间不能被人插队。两个思路一个是数据库层面加条件更新一个是 Redis 层面用 Lua 脚本原子扣减。我最终用的是后者因为数据库行锁在极端并发下会变成热点行性能会掉得很快。4.2 Redis 加 Lua 原子扣减我让 vaccine-service 启动时把当天所有时段的剩余名额加载到 Rediskey 设计为slot:quota:{slotId}。放号接口进来后直接执行 Lua 脚本-- lua 脚本原子扣减时段配额 local key KEYS[1] local current tonumber(redis.call(GET, key) or -1) if current 0 then return -1 -- 时段不存在 end if current 0 then return 0 -- 已约满 end redis.call(DECR, key) return 1Spring Boot 这边用 RedisTemplate 执行脚本核心逻辑如下private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript( local key KEYS[1] local current tonumber(redis.call(GET, key) or -1) if current 0 then return -1 end if current 0 then return 0 end redis.call(DECR, key) return 1, Long.class ); public boolean tryDeductSlot(Long slotId) { Long result redisTemplate.execute( DEDUCT_SCRIPT, Collections.singletonList(slot:quota: slotId) ); return Long.valueOf(1).equals(result); }扣减成功后再把预约单插入数据库。为了保险数据库更新也用乐观锁兜底int rows appointmentSlotMapper.deductWithVersion(slotId); // UPDATE appointment_slot SET remaining_count remaining_count - 1, // version version 1 WHERE id ? AND version ? AND remaining_count 0 if (rows 0) { // 释放 Redis 中刚扣掉的名额 redisTemplate.opsForValue().increment(slot:quota: slotId); throw new BizException(手速慢了该时段已被约满); }这里有个小细节容易被忽略如果数据库扣减失败一定记得把 Redis 里的名额加回去否则会出现数据库没扣成、Redis 却少了的内存泄漏。我一开始没做回补压测时发现跑了一小时后 Redis 里的剩余名额和数据库对不上排查了很久才找到原因。4.3 过期释放与幂等防重用户预约成功后如果不去接种号源就浪费了。我的做法是三重释放用户在截止时间前主动取消同步释放 Redis 配额和数据库配额。预约成功后超过预约开始时间未核销定时任务扫描超过 30 分钟未核销的预约单标记为爽约并回收配额。放号当天的号源如果第二天凌晨还有剩余自动滚动释放到当天的候补池。第一重和第二重是必需业务第三重是给接种点的运营配置加了一个开关默认关闭因为有些疫苗有严格的冷链存储要求并不希望把所有库存都放给当天临时预约。幂等防重是另一个容易翻车的地方。用户在 App 里连续点了两次提交预约如果接口没有幂等处理就会产生两条预约单。我的处理方式很简单前端在进入预约确认页时向后端申请一个 requestId提交预约时携带这个 requestId后端在 appointment_order 表加了一个 request_id 唯一索引重复插入会直接抛 DuplicateKeyException捕获后返回请勿重复提交。5. 云平台部署开发机跑通不算完上云才算交付5.1 用 Docker 统一整套环境项目在本地跑的时候MySQL、Redis、RabbitMQ、Nacos 一个都不能少每换一台电脑都要重新配一遍环境特别耽误时间。后来我写了一套 docker-compose把中间件全部容器化服务本身也打成镜像整个部署变成三步上传代码、构建镜像、启动容器。先看单个服务的 DockerfileFROM maven:3.8-openjdk-8 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests FROM openjdk:8-jre WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar ENV SPRING_PROFILES_ACTIVEprod EXPOSE 8083 ENTRYPOINT [java, -jar, app.jar]多阶段构建的好处是最终镜像里没有 Maven 和源码体积能小一半以上。appointment-service 打出来约 180MB如果不用多阶段构建直接拿带 Maven 的镜像跑体积能到 400MB。中间件和服务的编排文件类似这样version: 3.8 services: mysql: image: mysql:8.0 container_name: vaccine-mysql environment: - MYSQL_ROOT_PASSWORDyour_password - MYSQL_DATABASEdb_appointment ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql redis: image: redis:6.2 container_name: vaccine-redis ports: - 6379:6379 rabbitmq: image: rabbitmq:3.9-management container_name: vaccine-rabbitmq environment: - RABBITMQ_DEFAULT_USERadmin - RABBITMQ_DEFAULT_PASSyour_password ports: - 5672:5672 - 15672:15672 nacos: image: nacos/nacos-server:v2.2.3 container_name: vaccine-nacos environment: - MODEstandalone ports: - 8848:8848 - 9848:9848 appointment-service: build: ./appointment-service container_name: vaccine-appointment depends_on: - mysql - redis - nacos ports: - 8083:8083 environment: - SPRING_PROFILES_ACTIVEprod volumes: mysql_data:5.2 云上资源怎么规划部署用的是一台 4 核 8G 的云主机。这个配置对中小型系统来说够用但要注意几个细节云主机的安全组只放行需要对外开放的端口比如 80、443、前端静态资源的端口MySQL、Redis、RabbitMQ 这些中间件端口不要暴露到公网。服务之间通过 Docker 内网网络通信链路更安全。Nacos 和 RabbitMQ 的管理台端口如果是用于开发调试建议改复杂密码或者干脆只在本地开云上只开业务端口。如果以后用户量上来云主机配置不够优先给 appointment-service 加实例再在前面挂一层负载均衡而不是盲目升配置。这就是当初拆微服务最直接的收益。数据库连接参数、Redis 地址、Nacos 地址这些环境相关的配置我放在 Nacos 配置中心里管理。每个服务在 bootstrap.yml 里只写 Nacos 的地址和环境名具体参数全部从 Nacos 拉取这样云上和本地只需要切换 profile 就行不需要改代码重新打包。5.3 部署后的验证清单每次部署完我会按顺序过一遍下面的清单避免上线后才发现低级问题Nacos 控制台里能否看到所有服务均在线。前端页面能否正常登录登录请求是否经过网关并正确鉴权。选一个时段用在线压测工具发 50 个并发请求确认 Redis 配额扣减正常。手动把 Redis 里的配额删掉确认接口返回已约满数据库库存不变。预约成功后RabbitMQ 消费端是否收到消息通知是否触达。检查日志中出现 ERROR 的数量重点看数据库连接池是否爆掉。6. 压测与优化1000 人同时抢号时系统还稳吗6.1 压测方案与初始结果放号场景是最典型的流量突刺。我用 JMeter 模拟 1000 个用户同时请求预约接口每个用户只请求一次跑 300 秒统计吞吐量和错误率。压测机放在同一内网排除公网带宽的影响。第一轮压测结果不太理想指标初始结果优化后结果并发数10001000平均响应时间820ms96ms99 分位响应时间2.4s210ms错误率8.7%0.1%QPS约 230约 850初始结果的错误主要来自数据库连接池耗尽和接口超时。当时连接池默认配置是 101000 个请求一进来直接打满后面的请求排队等到超时。6.2 三个关键优化优化集中在三个方面第一个是数据库连接池参数。HikariCP 最大连接数调到 50minimum-idle 调到 20同时把连接超时时间从 30 秒降为 3 秒。这个调整立竿见影错误率从 8.7% 降到 2% 左右。第二个是网关限流。预约接口在网关层配置了令牌桶限流每秒允许 500 个请求通过多余的请求直接返回当前预约人数过多请稍后再试而不是继续往里冲。可能有人觉得直接拒绝用户不好但真实场景中与其让 1000 个人全部卡在数据库层不如放进来 500 个真正能处理完的剩下的人几秒后重试用户感知反而更好。第三个是预约接口只做必要操作。我仔细过了一遍代码把发送通知从同步流程挪到了 RabbitMQ把生成接种凭证改成核销时再生成预约请求链路里只剩下 Redis 扣减、数据库插单、返回结果三步。6.3 上线后的监控监控这块我没有用特别复杂的链路追踪而是结合 Spring Boot Actuator 和 Micrometer把核心指标打到 Prometheus再用 Grafana 看面板。真正每天盯的指标就四个QPS 和平均响应时间主要看预约接口。Redis 内存使用率防止存储 Key 过期堆积。RabbitMQ 队列积压数量通知积压超过 1000 就要告警。数据库慢查询日志每天扫描一次超过 1 秒的 SQL。另外我在预约 service 里埋了业务日志记录每次放号时 Redis 扣减成功数、数据库插入成功数、失败原因。这样如果线上出现Redis 扣了但数据库没插成功的问题不用靠用户投诉翻日志就能定位。7. 开发中踩过的坑和最终心得7.1 JWT 登录态与网关鉴权第一版我是让每个服务自己解析 Token代码很快变得很啰嗦而且每个服务都得维护一份密钥。后来我把鉴权统一放到网关层用户在 user-service 登录成功拿到 JWT后续所有请求先过网关网关解析 Token 并把用户 ID 放到请求头里下游服务直接信任请求头里的 userId。这里有个坑如果网关鉴权失败下游服务拿到的请求头里没有 userId很容易出现空指针。我在网关过滤器中统一处理了未登录的返回并且下游服务在读取 userId 时做了一次兜底校验如果为空直接抛 401。另外一个容易被忽略的点是 Token 过期策略。普通用户预约时操作时间可能比较长一个 Token 只给 30 分钟很容易中途掉线。我做成每次请求都校验刷新时间超过 7 天强制重新登录7 天内滚动续期。7.2 消息发送的丢失与重复用 RabbitMQ 做异步通知最怕两件事消息丢了消息重复消费。消息丢失的根源通常是生产者发送后没确认或者消费者处理失败后直接确认了。我的做法是生产端开启 publisher-confirm消费端关闭自动 ACK业务处理成功后再手动 ACK。一旦处理失败消息会进入重试队列重试三次仍然失败则进入死信队列由定时任务捞出来人工处理。消息重复消费的坑更隐蔽。notify-service 消费预约成功通知时如果短信服务超时但实际已经下发手动 ACK 失败了消息就会重新投递用户就会收到两条短信。解决办法是加消费幂等表每一条通知消息都带一个 messageId消费前先查 notify_log 表如果已经处理过就直接跳过。7.3 分布式事务的务实解法预约链路里涉及两个库的写操作appointment-service 写入预约单vaccine-service 扣减配额。如果扣减配额成功了但预约单插入失败就会造成号源丢失。一开始我想用 Seata 做全局事务后来发现对这个项目来说太重了而且 Seata 在高并发下的性能损耗也不小。我最终用的是本地消息表加定时对账的最终一致性方案预约单插入成功后立刻在同一本地事务里写入一条待对账记录定时任务每分钟扫一次把成功的预约单和 vaccine-service 的配额扣减记录做对比发现不一致就触发补偿。这个方案虽然没有强一致但实际运行中完全够用而且逻辑非常好讲清楚。答辩或者做技术分享的时候能说明白为什么不用 Seata而选择最终一致性比单纯说我会用 Seata 更能体现对业务的理解。最后再说一个实际的体会。做完这套系统我最大的收获不是学会了多少框架而是意识到设计这件事远比编码重要。疫苗预约系统的业务本质是资源调度疫苗数量、医生接待能力、时段配额、用户爽约概率这些因素交织在一起不动脑子直接写 CRUD做出来的东西大概率是能跑但不敢上线。如果你也在做类似的项目建议先花一周时间把业务流程画清楚把什么数据先写、什么数据后写、失败了怎么补偿这些问题想清楚再动手写代码后面返工的成本会低非常多。
返回列表