先交代一下这个项目的来源。上半年我帮一个社区接种点做信息化改造,他们当时的预约方式是微信群接龙加现场排队,每天早上八点半放号,手机一响所有人同时点,页面直接卡死。后来我以这个真实场景为蓝本,用 Spring Boot 做了这套基于云平台的疫苗预约系统,并在核心模块上采用微服务架构拆分,整个系统覆盖了在线预约、时段放号、疫苗库存管理、接种记录追溯、消息通知等智慧医疗场景下的完整业务闭环。这篇文章会把项目的需求拆解、模块边界、核心并发方案、云端部署和压测优化一次性讲清楚,适合正在做毕业设计的学生,也适合想了解一个真实业务系统从设计到上线全过程的开发者参考。
1. 项目定位:从一个毕业设计题目到一个能上线跑的业务系统
1.1 这个系统到底解决了什么问题
很多人第一眼看到"疫苗预约系统"会觉得这就是个 CRUD,无非是用户选个时间、填个身份信息、生成一条预约记录。但实际去社区接种点蹲两天就会发现,真正痛苦的不是"新增一条预约",而是下面这些事:
- 放号瞬间大量用户同时点击,系统能不能扛住。
- 一个接种点每天就那么多疫苗,不同时段能接待的人数也不一样,怎么保证不超约。
- 老人帮家属预约、家长给孩子预约,账号和接种人并不是一个人,身份关系怎么处理。
- 接种完成后要给用户出凭证,疫苗批次要能追溯到具体是哪一批、哪个厂家、哪个有效期。
- 爽约的人占了号又不去,号源怎么释放给后面的人。
这些问题单独拎出来都不算难,但放在一个系统里串起来,对数据模型、接口设计和部署方案的要求就完全不一样了。我做的这套系统就是围绕这五个问题展开的,核心目标只有一个:让接种点的放号、预约、核销、追溯全流程线上化,减少人工排队和纸质登记带来的混乱。
1.2 Spring Boot 加微服务是不是过度设计
选型的时候我确实纠结过。按社区接种点的规模,单体应用加个 Redis 完全够用,硬拆微服务反而会增加开发成本。但结合题目的要求,以及这个系统未来要接多个接种点、要单独给卫生管理端做部署、要应对早高峰放号这种流量突刺,微服务就不是纯摆设了。
我的判断标准是下面三条:
- 有没有独立的伸缩需求。预约服务在放号时段流量最高,但用户服务、通知服务相对平稳,拆开后可以只给预约服务扩容。
- 有没有独立的发布需求。管理端加一个疫苗批次字段,不应该影响用户端正在跑的预约流程。
- 有没有团队分工需求。毕业设计虽然是一个人写,但演示和答辩时,"服务边界清晰"本身就是很好的加分项。
所以最终定下来的技术栈是这样的:
| 分层 | 选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 稳定版本,生态最全 |
| 微服务组件 | Spring Cloud Alibaba | Nacos 做注册中心和配置中心 |
| 网关 | Spring Cloud Gateway | 统一鉴权、限流、路由转发 |
| ORM | MyBatis-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-service | 8080 | 路由转发、统一鉴权、限流 |
| user-service | 8081 | 用户注册、登录、家庭成员管理 |
| vaccine-service | 8082 | 疫苗批次、库存、接种点管理 |
| appointment-service | 8083 | 时段配额、预约单、核销 |
| record-service | 8084 | 接种记录、批次追溯 |
| notify-service | 8085 | 短信、站内信、公众号通知 |
每个服务自治,不直接访问其他服务的数据库。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_user | user-service | user_account, family_member |
| db_vaccine | vaccine-service | vaccine_batch, vaccine_stock, site_info, slot_quota |
| db_appointment | appointment-service | appointment_order, appointment_operate_log |
| db_record | record-service | inoculation_record, adverse_event |
slot_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 启动时把当天所有时段的剩余名额加载到 Redis,key 设计为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 DefaultRedisScript<Long> 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,把中间件全部容器化,服务本身也打成镜像,整个部署变成三步:上传代码、构建镜像、启动容器。
先看单个服务的 Dockerfile:
FROM 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 --from=builder /build/target/*.jar app.jar ENV SPRING_PROFILES_ACTIVE=prod 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_PASSWORD=your_password - MYSQL_DATABASE=db_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_USER=admin - RABBITMQ_DEFAULT_PASS=your_password ports: - "5672:5672" - "15672:15672" nacos: image: nacos/nacos-server:v2.2.3 container_name: vaccine-nacos environment: - MODE=standalone 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_ACTIVE=prod 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 秒,统计吞吐量和错误率。压测机放在同一内网,排除公网带宽的影响。
第一轮压测结果不太理想:
| 指标 | 初始结果 | 优化后结果 |
|---|---|---|
| 并发数 | 1000 | 1000 |
| 平均响应时间 | 820ms | 96ms |
| 99 分位响应时间 | 2.4s | 210ms |
| 错误率 | 8.7% | 0.1% |
| QPS | 约 230 | 约 850 |
初始结果的错误主要来自数据库连接池耗尽和接口超时。当时连接池默认配置是 10,1000 个请求一进来直接打满,后面的请求排队等到超时。
6.2 三个关键优化
优化集中在三个方面:
第一个是数据库连接池参数。HikariCP 最大连接数调到 50,minimum-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,做出来的东西大概率是能跑但不敢上线。如果你也在做类似的项目,建议先花一周时间把业务流程画清楚,把"什么数据先写、什么数据后写、失败了怎么补偿"这些问题想清楚,再动手写代码,后面返工的成本会低非常多。