做计算机毕业设计的时候,我选了一个特别能体现工程能力的题目:基于微信小程序的HPV疫苗预约与抢苗系统。Java后端 + SpringBoot框架 + 微信小程序前端,这套组合在答辩里非常能打,因为它天然覆盖了从普通CRUD到高并发扣库存的技术层次。
HPV疫苗预约和普通挂号系统不一样,它有两个完全不同的业务场景:一个是常规的预约排号,另一个是高并发下的“抢苗”。抢过九价的人都知道,放苗那一刻,系统卡顿、转圈、提示“已约满”是常态。很多同学会把这种系统做成一个普通的增删改查,答辩时被老师问一句“抢苗瞬间你是怎么保证不超卖的”就答不上来。这篇文章把我当时从需求分析到数据库设计、后端接口、小程序端实现,再到压测踩坑的完整过程写出来,给正在做类似选题的同学一个可以直接参考的路线。
1. 为什么HPV疫苗预约系统比普通挂号系统难做
1.1 九价疫苗的供需矛盾带来的业务场景
先理清业务背景。HPV疫苗分二价、四价、九价,其中九价适用年龄是16到26岁女性,供应量长期紧张。很多城市的社区医院放苗周期不固定,每月放一次,每次几十到几百支,但排队的人有几万。这个供需矛盾决定了系统不能只做“预约”,还要做“抢苗”。
抢苗意味着什么?意味着某个时间点(比如上午10点整)大量用户同时点击“立即预约”,后端在同一时刻收到几千上万个请求。如果还是普通挂号那种“先查库存,再插入订单”的写法,数据库很快就会被打崩,而且会出现超卖——库存只剩1支,但10个人都预约成功了。
我一开始也天真地觉得,毕设嘛,把页面做出来,能操作就行。后来整理需求的时候才意识到,预约和抢苗是两种截然不同的业务模型,需要分开设计。普通预约可以允许用户选择时间段、提前几天约;抢苗则是“到点开放、先到先得、瞬间并发”。
1.2 预约与抢苗是两种截然不同的业务模型
普通预约的核心是“资源编排”,关注的是时间段的冲突检测,比如一个接种点一天开放100个号,上下午各50个,你不能重复预约同一个时间段。抢苗的核心则是“库存竞争”,关注的是扣减的原子性和并发控制,你不能把最后一个名额同时给两个人。
这个区别直接影响技术方案。我做的时候把两个流程拆开了:
- 普通预约:走数据库事务,先查疫苗批次剩余数量,大于0就插入预约记录并扣减库存,用乐观锁控制并发。
- 抢苗:走Redis预扣库存 + 异步落库,用Lua脚本保证“判断库存 + 扣减”的原子性,避免超卖。
具体怎么实现,后面章节会详细讲。先记住一个结论:HPV疫苗预约系统的核心难点不在CRUD,而在库存扣减的并发安全。 这个点抓住了,论文的核心创新点、答辩的亮点就都有了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与整体架构:从毕业设计答辩角度反推
2.1 为什么是SpringBoot + 微信小程序
先说为什么选微信小程序。HPV疫苗接种群体以年轻女性为主,微信公众号里预约、扫码、消息提醒都是刚需,小程序天然免安装、用完即走。从毕设角度讲,小程序端也是一个完整的独立前端项目,比纯网页后端要丰富很多,开题和答辩都有东西可展示。
后端选SpringBoot理由就很简单了:Java生态成熟,SpringBoot自动配置省去一堆XML配置,内嵌Tomcat一键启动,非常适合毕设阶段快速交付。而且Java在后端招聘市场占有率一直很高,做完这个项目写进简历,面试官问到高并发、缓存、事务这些点,你都有实际场景可以聊。
我用的具体版本是:
- JDK 1.8(兼容性最好,答辩演示不出问题)
- SpringBoot 2.7.x
- MyBatis-Plus 3.5.x
- Redis 6.x(Windows本地测试可以用,演示建议用Linux环境)
- MySQL 5.7 / 8.0 都可以
- 微信小程序原生开发(不引入uniapp,减少不必要的复杂度)
- 若依框架(可以选择不用,自己搭简单权限就行)
提示:如果你的电脑内存只有8G,建议JDK1.8 + SpringBoot 2.7.6,别追最新版,SpringBoot 3.x必须配JDK17,有些老版本的MyBatis-Plus插件不兼容,查起问题来很耽误时间。
2.2 系统整体架构与模块划分
这个项目虽然是毕设,但我还是按生产环境的分层思想去设计的。整体分为四层:
| 层级 | 技术载体 | 职责 |
|---|---|---|
| 小程序端 | WXML / WXSS / JS | 用户登录、疫苗浏览、预约操作、抢苗页面、订单管理 |
| 接入层 | SpringBoot Controller | 参数校验、鉴权、接口路由 |
| 业务层 | SpringBoot Service | 预约逻辑、库存扣减、消息通知、事务控制 |
| 数据层 | MySQL + Redis | 用户数据、疫苗库存、预约订单、缓存数据 |
模块拆分上,我的包结构是:
code复制com.hpv.vaccine
├── controller // 接口层
├── service // 业务层
│ ├── impl
├── mapper // MyBatis-Plus Mapper
├── entity // 数据库实体
├── dto // 前端入参出参对象
├── config // 微信配置、Redis配置、拦截器
├── utils // 工具类
└── common // 统一返回结果、异常处理
这套结构答辩的时候很好讲:每一层的职责是什么、为什么这样分层、层与层之间怎么交互,都是标准的软件工程套路。
2.3 环境准备与项目初始化
SpringBoot项目初始化我用的是Spring Initializr,勾选Web、MyBatis、MySQL Driver、Redis、Lombok这几个依赖。需要注意,MyBatis-Plus不是Spring Initializr自带的,要单独在pom.xml里引入:
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.2</version>
</dependency>
Redis连接配置也简单,application.yml里写:
yaml复制spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
环境这块最容易踩的坑是Redis没启动就启动项目,控制台报一堆连接异常。我习惯写一个启动自检:项目启动后先尝试连接Redis,连不上就打印一个醒目的警告日志,这样演示的时候不会尴尬。
3. 数据库设计:把“库存-预约-接种者”三者关系理清楚
3.1 核心表结构设计
数据库设计是整个系统的地基。我当时画ER图的时候,核心就是“疫苗批次”和“预约订单”两张表,用户表作为外围支撑。把疫苗批次单独拆出来而不是直接在疫苗表上写库存,是因为每个批次的到货时间、有效期、适用年龄都不一样。
用户表 vaccine_user:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| openid | varchar(64) | 微信唯一标识,唯一索引 |
| phone | varchar(20) | 手机号 |
| id_card | varchar(18) | 身份证号,唯一索引 |
| real_name | varchar(20) | 真实姓名 |
| gender | tinyint | 性别 0女 1男 |
| birthday | date | 出生日期 |
| create_time | datetime | 注册时间 |
疫苗批次表 vaccine_batch:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| vaccine_type | varchar(10) | 二价/四价/九价 |
| batch_no | varchar(32) | 批次号 |
| hospital_id | bigint | 接种点ID |
| total_stock | int | 总库存 |
| remain_stock | int | 剩余库存 |
| start_time | datetime | 放苗时间 |
| end_time | datetime | 预约截止时间 |
| age_min / age_max | int | 适用年龄范围 |
| status | tinyint | 0未开始 1抢苗中 2预约中 3已结束 |
预约订单表 vaccine_appointment:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 用户ID |
| batch_id | bigint | 疫苗批次ID |
| appointment_time | datetime | 预约接种时间段 |
| status | tinyint | 0已预约 1已接种 2已取消 3已过期 |
| create_time | datetime | 下单时间 |
| cancel_time | datetime | 取消时间 |
用户表和订单表之间的核心约束是:同一个用户、同一批次、状态为0(已预约)的订单只能有一条。 这个我是通过数据库唯一索引 + 业务层双重校验实现的。如果只靠代码判断,高并发下两个请求同时查到“无记录”,就会同时插入成功,造成一人约两针。
提示:MySQL唯一索引可以这样加:ALTER TABLE vaccine_appointment ADD UNIQUE INDEX uk_user_batch (user_id, batch_id, status); 但注意status会变成0,所以唯一索引要把status设计成“0-有效,其他-无效”这种逻辑,或者用user_id + batch_id + is_valid三个字段。
3.2 库存扣减与订单状态设计
库存扣减这里我踩过一个特别典型的坑:先查余量、再判断、再更新。代码看起来没问题,但并发下就是会超卖。
java复制// 错误示范
Integer remain = vaccineBatchMapper.selectById(batchId).getRemainStock();
if (remain > 0) {
// 执行插入订单
// 执行扣减
}
两个线程同时读到 remain=1,都满足条件,都去插入订单,最终剩库存变成-1。这个问题的本质是“检查”和“扣减”不是原子操作。
正确做法是用条件更新来判断是否扣减成功:
sql复制UPDATE vaccine_batch
SET remain_stock = remain_stock - 1
WHERE id = #{batchId} AND remain_stock > 0
这个SQL只有一行,但它是原子的。数据库行锁保证同一时刻只有一个事务能更新这一行,另一个事务进来后发现 remain_stock > 0 不成立了,更新影响行数为0,就知道库存已经被抢完了。
MyBatis-Plus代码写出来是这样:
java复制boolean success = vaccineBatchMapper.deductStock(batchId);
if (success) {
// 扣减成功,创建预约订单
appointmentMapper.insert(appointment);
} else {
// 扣减失败,返回“已约满”
}
对应的Mapper方法:
java复制@Update("UPDATE vaccine_batch SET remain_stock = remain_stock - 1 " +
"WHERE id = #{batchId} AND remain_stock > 0")
int deductStock(@Param("batchId") Long batchId);
这个方案在单个MySQL实例下完全够用,也是我在答辩时重点讲的一个点:用数据库的行锁保证并发扣减的原子性,而不是用Java层的synchronized。因为synchronized只能锁住单台机器,如果以后部署多台服务器,它就失效了;而且synchronized锁的是整个JVM,抢苗这种热点请求全挤到一个锁上,吞吐量很低。
4. 后端核心功能实现:预约、放号与抢苗
4.1 普通预约流程实现
普通预约的场景是:疫苗有库存、放苗时间已过、用户选择时间段然后确认预约。流程是:
- 用户登录,后端通过openid解析用户信息
- 前端选择接种点和疫苗类型
- 后端查询对应批次的余量、适用年龄、时间限制
- 校验年龄:九价要求16-26岁,根据birthday计算
- 校验是否已有未完成订单
- 扣减库存(条件更新)
- 插入预约订单
- 返回结果,小程序端弹出预约成功提示
这里每步都不能省。年龄校验我当时用的是Java 8的Period类:
java复制int age = Period.between(user.getBirthday().toLocalDate(), LocalDate.now()).getYears();
if (age < 16 || age > 26) {
throw new ServiceException("九价HPV疫苗适用年龄为16-26岁");
}
这个校验一定要放在库存扣减之前,避免把宝贵的库存浪费在一个不符合条件的人身上。
4.2 抢苗模块:Redis预扣库存与Lua脚本防超卖
抢苗场景比普通预约复杂得多。放苗时间是固定的,比如“明天上午10:00开抢”,用户会在9:59:59疯狂点击按钮。如果所有请求直接打MySQL,数据库大概率扛不住。
我当时采用的方案是:Redis预减库存 + Lua脚本保证原子性 + 异步线程落库。
先把初始库存提前加载到Redis中:
java复制stringRedisTemplate.opsForValue().set("vaccine:stock:" + batchId, String.valueOf(stock));
抢苗接口接收到请求后,先执行一个Lua脚本:
lua复制local stock = redis.call('get', KEYS[1])
if tonumber(stock) <= 0 then
return 0
end
redis.call('decrby', KEYS[1], 1)
return 1
用Redis自带的单线程模型保证这个脚本的原子性。多个请求同时进来,Redis会一个一个执行,不会出现两个请求同时扣减同一个库存的情况。
库存扣减成功之后,再向一个消息队列表里插入一条待处理的记录,后台线程池异步消费,把预约订单写入MySQL。这样MySQL的压力只有最终抢到的人,而不是所有点击的人。
Lua脚本执行代码:
java复制DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText("local stock = redis.call('get', KEYS[1]) ...");
redisScript.setResultType(Long.class);
Long result = stringRedisTemplate.execute(redisScript,
Collections.singletonList("vaccine:stock:" + batchId));
4.3 排队与限流:避免瞬时流量打垮系统
光有预扣库存还不够。如果10:00整10000个人同时请求,后端服务本身的连接池、线程池也会被打满。我加了三道防护:
第一道是前端节流。小程序端抢苗按钮在倒计时结束前是置灰状态,倒计时结束后的1秒内只允许点击一次,点击后按钮变为“排队中”,防止用户狂点产生大量无效请求。
第二道是接口限流。用Guava RateLimiter做一个简单的令牌桶,对抢苗接口的QPS限制在200左右:
java复制private final RateLimiter rateLimiter = RateLimiter.create(200);
public boolean tryAcquire() {
return rateLimiter.tryAcquire();
}
超过QPS的请求直接返回“当前人数过多,请稍后重试”。
第三道是Redis记录用户参与状态。同一个用户30秒内不允许重复抢苗:
java复制Boolean absent = stringRedisTemplate.opsForValue()
.setIfAbsent("vaccine:user:" + userId, "1", Duration.ofSeconds(30));
if (Boolean.FALSE.equals(absent)) {
throw new ServiceException("您操作太快了,请稍后再试");
}
这套组合下来,抢苗高峰期的系统表现会非常稳。答辩时你可以主动把这个设计讲出来,面试官和老师都会觉得你是真的考虑过生产环境的问题。
5. 微信小程序端的实现细节
5.1 登录与实名绑定
小程序端登录流程是:wx.login 获取code,后端用code换openid,再把openid和用户手机号绑定。
换openid的核心代码:
java复制String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId +
"&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code";
RestTemplate restTemplate = new RestTemplate();
String response = restTemplate.getForObject(url, String.class);
JSONObject json = JSONObject.parseObject(response);
String openid = json.getString("openid");
注意:小程序端拿到的code是一次性的,5分钟内有效,而且只能用一次。如果后端处理失败导致重复调用,会报 invalid code。我当时就是一个请求被浏览器缓存了,导致二次提交时报错,排查了半天。
5.2 首页疫苗信息与预约入口
小程序首页我会展示三个卡片:二价、四价、九价疫苗,点击进去能看到适用年龄、接种剂次、当前剩余库存、放苗倒计时。这个信息是从后端接口实时拉取的:
code复制GET /api/vaccine/list
GET /api/vaccine/detail?id=xxx
GET /api/vaccine/countdown?id=xxx
剩余库存我建议直接返回Redis中实时的预减后的数字,而不是MySQL里的值,这样用户看到的库存更准确。
5.3 抢苗倒计时与状态同步
抢苗页面是整个小程序最核心的页面。倒计时的实现要注意:不要用客户端本地时间,要以后端返回的服务器时间为准。 用户的手机时间可能不准,如果比北京时间快几秒,倒计时提前结束,点进去后端还没放号,会一直提示“未开始”;如果慢几秒,等你反应过来苗已经被抢完了。
我的做法是请求后端获取当前服务器时间,再计算出距离放苗时间的毫秒数:
javascript复制const serverTime = res.data.currentTime; // 后端返回时间戳
const startTime = res.data.startTime;
const remaining = Math.max(0, startTime - serverTime);
that.setData({
countdown: remaining
});
倒计时范围用setInterval每秒刷新一次,到0时把按钮从“即将开始”改成“立即抢苗”。
抢苗成功的瞬间,后端回传订单ID和提示信息,小程序端进入待支付或待确认状态,并展示疫苗批次和接种点地址。抢苗失败则展示“手速慢了,下次加油”这类提示,引导用户关注下一次放苗时间。
6. 我在开发中踩过的坑与优化建议
6.1 行锁等待超时与连接池耗尽
有一次我用JMeter做压测,起的线程数是200,结果跑了一轮后系统大量报错:
Lock wait timeout exceeded; try restarting transaction
原因是慢查询占用了数据库连接。每条抢苗请求都去执行UPDATE语句,而MySQL默认的innodb_lock_wait_timeout是50秒,如果前面的事务一直没提交,后面的UPDATE就会一直等,把连接池的50个连接全部占满,导致其他请求连数据库的资格都没有了。
解决思路是:缩短事务时间,把耗时的非数据库操作(比如微信消息推送、日志记录)放到事务外面执行;另外更新库存的SQL尽量用简单的条件更新,不要在大事务里做太多查询。
另一个技巧是给热点表加一个 LIMIT 1,让MySQL在命中行锁后更快返回,不过这个是优化细节,毕设阶段不必太深入。
6.2 小程序端时间与服务端时间不一致
我前面提到倒计时用服务端时间,这个坑我是实际踩过的。当时我直接用 new Date().getTime() 来算剩余时间,测试时手机时间比标准时间慢了30秒,导致倒计时还没走完,后端已经开抢了。用户界面上仍然显示“即将开始”,但其实苗已经没了。
解决方法是加一个 /api/vaccine/server-time 接口,每次进入抢苗页面都重新拉取服务器时间。抢苗开始时,后端还会额外校验一遍当前时间是否到达放苗时间,双保险。
6.3 关于“防黄牛”策略的思考
这个项目还有一个容易被忽视的点:防黄牛。疫苗预约领域黄牛问题很严重,所以我在设计时做了几道限制:
- 一个手机号只能绑定一个微信openid
- 一个身份证只能存在一条未完成的有效预约
- 预约成功后如果要取消,当日取消次数不能超过2次
- 同一IP在1分钟内对抢苗接口的访问次数不能超过100次
这些规则不需要一开始全部实现,但至少要预留接口和表结构。答辩时能说出“考虑到黄牛刷单,我在用户维度做了防刷限制”这句话,就是加分项。实际的实现我建议按“用户身份唯一 + 频率限制 + 黑名单”三个维度来落地,不用做太复杂,能把场景讲清楚就行。
6.4 关于毕设交付的一点个人体会
最后想聊聊我做这个项目的整体感受。HPV疫苗预约与抢苗系统这个选题,看上去是“预约系统”,但本质上是一个高并发库存系统的小型实践。它不像电商那种超大规模,但麻雀虽小五脏俱全,涉及到了用户鉴权、库存扣减、并发控制、小程序端交互、消息推送等多个方面。做完之后能讲的东西非常多,写论文的时候也容易展开。
如果你正在做这个题目,我的建议是:先把普通预约流程做成一个完整闭环,再去碰抢苗高并发部分。普通预约保证系统“能用”,抢苗部分保证系统“有亮点”。一个稳妥的毕设项目,应该是能用 + 有亮点,而不是只追求某个花哨功能而忽略了基础的完整性。
抢苗的Redis方案如果时间来不及,也可以用数据库条件更新来撑。我当时是先做了数据库扣减版本,本地压测200并发不超卖,再升级到Redis版本,性能提升非常明显。一步一个脚印来,你的答辩会非常顺。
