☰
疫苗预约系统核心设计:并发控制与状态流转的Spring Boot实践
2026/10/9 8:06:31 网站建设 项目流程

1. 这个题目不是"又一个CRUD":预约场景背后的三个硬核知识点

1.1 为什么疫苗预约系统是毕业设计的"甜点级"选题

疫苗预约系统这个题目,在计算机毕业设计里出现频率极高,尤其是Spring Boot技术栈的版本,几乎每年都有不少同学在选。说实话,这个题目之所以受欢迎,是因为它看起来"够熟悉"——人人都打过疫苗,需求不用别人解释,场景天然成立。但真正把它做深了以后你会发现,这个系统背后压着三个不太好糊弄的知识点:并发控制、状态流转、多角色权限。这三个点恰好是很多普通CRUD项目里最薄弱、最容易被答辩老师追问的地方。

我见过不少同学拿到这个题目之后,第一反应是先把用户表、疫苗表、预约表建出来,然后就开始写增删改查。等写到"预约"这个核心动作的时候,发现逻辑并不像想象中那么简单:一个时段最多放100个号,第101个人来预约怎么办?两个人同时点预约,数据库里会不会多出一条记录?取消预约之后,名额怎么归还给后来的用户?这些问题本质上都是在问同一件事:你对业务状态的理解够不够细,对并发场景有没有处理意识。

这也是我推荐这个题目的原因。它有足够的业务复杂度来展示工程能力,又不至于像电商秒杀系统那样需要分布式事务、消息队列这类重型组件。一个Spring Boot项目,配合MySQL和Redis,就能把核心问题讲得清清楚楚。对毕业设计来说,这是性价比非常高的选题,做完之后的收获密度也远高于做一个纯后台管理系统。

1.2 技术栈选型:Spring Boot为主干,Redis和MySQL各司其职

我的技术栈是这样定的:

  • 后端:Spring Boot 2.7.x,配套Spring MVC、MyBatis-Plus、Lombok
  • 数据库:MySQL 8.0,InnoDB引擎,负责核心业务数据的落盘
  • 缓存与并发控制:Redis,承担库存预扣和热点数据缓存
  • 认证与权限:Spring Security + JWT,也可以简化成拦截器加Token,看你想在答辩时展示到什么程度
  • 前端:Vue 3 + Element Plus + Axios,不想写前端的话用Thymeleaf做服务端渲染也行,但展示效果会差一些

每个选型都有它对应的理由。Spring Boot不用多说,毕业设计的主流框架,生态成熟,网上资料多到看不完。MyBatis-Plus相比原生MyBatis,最大的好处是单表CRUD不用写SQL,可以把精力集中在预约、库存这些核心业务逻辑上。MySQL作为业务数据库,所有数据最终都要在这里落盘,事务能力是它不可替代的底气。Redis在这套系统里不是摆设,它承担了预约时段的库存预扣和热点数据的缓存,是后面讲到的并发控制方案中很关键的一环。

至于为什么不引入更重的中间件,原因很简单:毕业设计的核心是展示你对业务和技术的理解是否成体系,而不是堆砌组件。把Spring Boot、MySQL、Redis这三件套用到位,已经足够撑起一个完整且有深度的项目。组件越多,出问题的概率越大,答辩时被问倒的风险也越高。

2. 先把业务闭环画清楚:从疫苗入库到接种完成的完整流转

2.1 三类角色和一个核心流程

写代码之前,我习惯先把业务闭环画出来。这个系统的核心角色有三类:系统管理员、医护人员(接种点工作人员)、居民(预约用户)。整个业务主轴是一条完整的链路:

管理员维护疫苗类型和批次信息,创建接种计划,也就是选择某个疫苗批次、指定日期和时段、设定预约名额。居民登录后查看可预约的时段,选择合适的日期和时段提交预约。预约成功后,居民按时间到接种点,医护人员核对身份和预约码,完成核销确认。接种完成后,系统生成接种记录,如果是多剂次疫苗,还需要自动提醒用户下一剂的预约时间。

这个流程看起来简单,但每个环节都有细节。比如创建接种计划的时候,不是只填一个日期就完了,而是要把一天拆成若干个时段,比如上午8:30到9:30、9:30到10:30这样,每个时段单独设置名额上限。这样设计的好处是避免所有人扎堆同一个时间点,也方便医护人员安排工作节奏。再比如多剂次疫苗(例如需要打两针或三针的),系统要记录当前是第几剂,预约时要限制用户只能预约与当前接种进度匹配的剂次,不能打完第一针就直接约第三针。

2.2 预约环节的关键状态:可用、已约满、已取消、已完成

整个系统的"魂"在状态管理上。我梳理下来,最少需要两组状态。

第一组是接种时段(预约计划)的状态。一个时段创建出来之后是"启用"状态,可以接受预约;当预约人数达到名额上限时,自动变为"已约满",前端不再展示预约按钮;管理员可以手动关闭某个时段,让它变为"停用"。这个状态字段要冗余在时段表里,因为查询可预约列表时需要频繁过滤。你总不能每次都在内存里算一遍剩余名额再决定显不显示。

第二组是预约记录的状态。用户提交预约后,记录是"待接种";用户主动取消,变为"已取消";医护人员核销并完成接种,变为"已完成";如果预约时间过了但用户没来,需要有一个定时任务把超时未核销的记录标记为"爽约"。这里要注意,"爽约"和"已取消"虽然结果类似,但业务含义完全不同,一个是被动失约,一个是主动放弃,如果后续要做用户信誉统计,这两个状态必须分开存。

状态流转规则必须提前写清楚,这是后期写service层逻辑的依据。我当时整理了一张状态流转表,每个状态变更都对应一个服务方法,比如cancelAppointment()、completeAppointment()、markNoShow()。每个方法里第一件事就是校验当前状态是否允许做这个变更,避免出现"已完成"的记录又被取消之类的事。这个小习惯帮我在后期省了很多debug时间。

2.3 数据表设计:六张核心表各自存在的理由

表结构设计我遵循一个原则:每张表都必须有它存在的理由,每个字段都必须能回答"谁在什么时候怎么用"这个问题。核心表我设计了六张:

表名职责关键字段
user用户信息与角色id, username, password, role, id_card, phone
vaccine_type疫苗类型字典id, name, manufacturer, dose_count, interval_days
vaccine_batch批次库存id, type_id, batch_no, stock, remaining, expiry_date
schedule接种时段计划id, type_id, batch_id, date, time_slot, total_slots, booked_slots, status
appointment预约记录id, user_id, schedule_id, dose_number, status, code
inoculation_record接种记录id, appointment_id, user_id, batch_no, dose_number, inoculate_time, operator_id

拿schedule表举个例子,它能说明一个很容易被忽略的设计点:booked_slots(已预约数)这个字段看起来是冗余的,因为理论上可以通过count预约表来统计。但在后面讲到的并发控制中,它要作为原子更新SQL的判断条件参与扣减,必须冗余在时段表里。这就是"字段设计服务于业务逻辑"的体现,也是很多课程里没讲到、但实际项目中很常见的建模思路。

疫苗批次和疫苗类型为什么要拆成两张表?因为一个类型的疫苗可能有多个批次,每个批次的库存、有效期、到货时间都不一样,预约时必须明确用户约的是哪一批。如果不拆表,批次信息就只能堆在类型表里,既没法管理多批次,也没法在核销时准确扣减对应批次的库存。预约表里我还加了一个code字段,保存六位随机短码,用于线下核销场景:用户到接种点报预约码或出示二维码,医护人员输入即可完成核销,比翻通讯录找用户名高效得多。

3. 并发预约与库存扣减:系统能不能扛住"秒杀"就看这里

3.1 超卖的根源:先查再改的竞态窗口

预约系统最核心的技术难点,就是并发下的库存扣减。假设一个时段放了100个号,现在有10个人同时提交预约,如果代码这样写:

// 错误的示范:先查询再更新 Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule.getBookedSlots() < schedule.getTotalSlots()) { schedule.setBookedSlots(schedule.getBookedSlots() + 1); scheduleMapper.updateById(schedule); // 插入预约记录... }

表面上看逻辑没问题:先查一下有没有名额,有名额就加一。但并发场景下,两个请求可能同时读到booked_slots = 99,都判断还有名额,然后都执行加一,最终booked_slots变成101,超卖了。这就是经典的"先查再改"竞态窗口。数据库默认的隔离级别下,普通select并不会加锁,并发请求读到的都是同一个旧值,谁先update谁后update完全取决于时序,但他们都已通过了名额判断。

解决思路无非两条:让"检查并更新"变成一个原子操作,或者给数据行加锁。下面我分别说一下我在这个项目里采用的两种方案,它们对应的代码量和工作量差异不大,但解决问题的层次不一样。

3.2 方案一:数据库原子更新,一条SQL守住预约上限

第一种方案最简单直接:把"判断名额加扣减"合并成一条原子SQL。MySQL的UPDATE语句命中行时会加行级锁,多个并发请求到达后会串行执行,不会出现两个事务同时读到旧值的情况。

UPDATE schedule SET booked_slots = booked_slots + 1 WHERE id = #{scheduleId} AND booked_slots < total_slots AND status = 'ENABLED'

用MyBatis-Plus执行这条更新,根据返回的受影响行数做判断。如果返回1,说明扣减成功,继续插入预约记录;如果返回0,说明名额已满或者时段停用,直接提示"该时段已约满"。这个方案的优点是逻辑简单、可靠,只需要一个事务就能同时完成扣减和订单插入,不需要额外的中间件。缺点是高并发下数据库压力集中在schedule表上,但对于一个社区级预约场景来说完全够用。

配套的事务写法要特别注意:扣减库存和插入预约记录必须在同一个事务里,要么都成功,要么都失败。否则会出现库存扣了但预约记录没生成,或者预约记录生成了但库存没扣的数据不一致。在service方法上加上@Transactional注解,然后把这两个操作放在同一个方法里,是最省心的做法。

3.3 方案二:Redis预扣库存,把压力挡在数据库前面

如果想把并发能力做得更漂亮,可以用Redis预扣方案。在时段创建或者每天排期生成时,把剩余名额同步到Redis:

SET schedule:15:remaining 100

预约时先用Redis的原子命令扣减:

Long remaining = redisTemplate.opsForValue().decrement("schedule:15:remaining"); if (remaining < 0) { // 名额不足,回补后提示已约满 redisTemplate.opsForValue().increment("schedule:15:remaining"); throw new BusinessException("该时段已约满"); }

这个方案的思路是先把并发压力在Redis这一层消化掉,只有扣减成功的请求才继续走数据库插入预约记录。Redis的DECR命令天然是原子的,不存在竞态问题。预约记录落库之后,再同步更新数据库里的booked_slots,把最终数据持久化下来。

这个方案我在答辩时讲出来效果很好,因为它展示了分层处理的思路——Redis挡流量,MySQL做持久化。但要提醒一句:预扣成功但落库失败的场景,必须做好补偿。我之前为了省事,没有把"Redis扣减成功、数据库插入失败"的情况处理干净,测试时发现Redis里名额已经扣了,数据库里预约记录却没有,两边对不上。最后补了一个简单的策略:数据库插入失败时,立即对Redis执行increment回补,同时记录一条错误日志。更严谨的做法是用定时任务做对账,但毕业设计做到"失败即回补、日志可追踪"这个程度,已经能说明问题了。

注意:Redis不是必需的。如果你的环境里跑不起Redis,直接使用3.2的数据库原子更新方案完全够用。Redis方案的意义在于展示你做技术选型和分层设计的思考,而不是为了炫技。

3.4 取消预约与重新放号:库存回补的幂等处理

有扣减就有回补。用户取消预约时,需要把时段的名额加回来。这里最容易忽略的是幂等性:同一个取消请求如果被重复提交,或者前端因为网络抖动重试,可能会把名额加两次。名额越加越多,系统就会出现"幽灵名额"——显示有名额,但实际预约记录对不上。

我当时做了这样几层防护。预约状态从"待接种"到"已取消"的变更,使用UPDATE appointment SET status = 'CANCELLED' WHERE id = ? AND status = 'BOOKED',只有返回1才执行后续的名额回补。如果返回0,说明这条预约已经被处理过,直接忽略。数据库名额回补同样用带条件的更新:UPDATE schedule SET booked_slots = booked_slots - 1 WHERE id = ? AND booked_slots > 0,用受影响行数判断是否真的执行了扣减。如果用了Redis方案,回补Redis时也要判断流程是否整体成功,避免重复执行increment。

这套"状态判断先行,数据更新在后"的模式,在预约、取消、核销、完成所有涉及状态变更的操作里都要贯彻。我的习惯是写一个统一的状态变更方法,前置校验当前状态,再执行变更,这样能最大程度降低出错概率。等你写完整个项目回头再看,会发现绝大多数诡异的bug,都出在没有做好状态前置校验的地方。

4. 三端协同与权限设计:管理员、医护人员、居民各看各的

4.1 居民端:从选疫苗到查看接种记录的全流程

居民端的功能,我按一个普通用户的使用路径来拆:注册登录、查看疫苗列表、选择时段、提交预约、取消预约、查看接种记录。

注册登录是入口,我用Spring Security加JWT做了一套无状态认证方案。用户注册时要校验身份证号是否合法、手机号格式对不对,这些校验必须放在后端,不能只靠前端拦截。JWT生成后返回给前端,前端存在本地,之后每次请求把Token放在请求头里,后端通过过滤器统一解析用户身份。这里有一个安全细节:预约提交时,后端要从Token里解析出用户id,绝对不要信任前端传过来的用户id参数,这是很多新手容易忽略的点。

疫苗列表页要展示当前可预约的疫苗类型、批次剩余量、剂次进度等信息。这里涉及一个查询性能优化的小点:如果批次和排期多了,每次都去count预约记录来算剩余名额,页面会越用越慢。我的做法是直接把booked_slots字段查出来,前端用total_slots减booked_slots算出剩余名额,避免额外统计查询。

"我的预约"页面除了展示预约记录,还有一个重要的交互是取消预约。我加了一个限制:距离预约时段开始不足两小时的预约不能取消,避免用户临时放鸽子导致名额浪费。这个限制逻辑放在后端,前端只是隐藏按钮,真正拦在服务层。预约成功后返回预约码和时段信息,前端跳转到预约详情页,展示一个提示信息,这些交互功能虽然不难,但能明显提升项目完整度。

4.2 医护端:到场核销与接种登记

医护端的核心功能是核销和登记。我设计的交互是:用户到接种点出示预约码,医护人员在医护端核销页面输入或扫描预约码,系统验证券码有效且该预约处于"待接种"状态,然后进入接种登记页。

登记页要填写的信息包括:实际接种的疫苗批次号、接种部位、接种人员签名等。如果该疫苗是多剂次的,系统会自动判断这是第几剂,并更新用户的接种进度。核销完成后,预约状态变为"已完成",同时插入一条接种记录。接种记录表和预约记录表为什么分开?因为预约可能被取消,但接种记录一旦产生就是既成事实,它代表的是"实际发生了的医疗服务",两者生命周期不同,不能混在一张表里。

这里有一个业务细节值得说说:预约时用户约的是某个时段,但实际到场时间和预约时段很可能不一致,这是真实接种点每天都会发生的事。所以核销逻辑不应该校验"当前时间是否在预约时段内",而应该只校验"预约是否有效"。迟到的用户也应该能正常核销,这是合理的业务设计,也能避免演示时因为系统时间不准出现尴尬报错。

医护端还有一个实用功能是今日接种列表:按日期查询当天所有预约记录,按时段分组显示,标注已核销和未核销。这个功能实现不复杂,就是带条件的联表查询,但它能直观展示"预约到核销"的完整闭环,答辩演示时非常好用,老师一眼就能看懂系统在真实业务中怎么运转。

4.3 管理端:批次入库、时段排期与数据统计

管理端承担的是"配置和监控"的职责,我把它分成四块:疫苗类型管理、批次库存管理、预约排期管理、数据统计。

疫苗类型管理表面看就是增删改查,但有一个约束必须处理:已经被预约或接种记录引用的类型不能删除,只能停用。这个约束我在service层写了一个引用检查方法,删除前先判断有没有关联数据。这个细节看似不起眼,但体现了数据完整性的意识,答辩时值得提一句。

批次库存管理要处理疫苗有效期。批次入库时录入生产日期和有效期,列表页要显示"即将到期"的批次,用醒目标记提示管理员优先安排接种。这个功能只是简单的日期比较,但抓住了疫苗管理的真实痛点——过期疫苗是绝对不能被接种的。你做出来之后,不用解释,老师也能看出你对业务场景有思考。

预约排期管理是管理端最核心的功能。管理员选择一个疫苗类型和批次,设置日期、时段、每时段名额,一键生成一周或一个月的排期。生成排期的同时,要把排期同步到Redis(如果用了预扣方案的话)。这里我踩过一个坑:生成排期时没有判断日期是否重复,导致同一天同一个时段生成了两条计划,前端出现重复的预约入口。后来在生成前先查一次数据库做排重,才解决这个问题。

数据统计部分,我用MyBatis-Plus的聚合查询做了几个图表数据接口:每日预约量、各疫苗预约占比、各时段预约热度。前端用ECharts画折线图和饼图,后端返回聚合好的JSON。这个模块代码量不大,但对展示项目完整性帮助很大,也能体现你前后端联调的能力。

权限控制方面,我用Spring Security的注解方式,在Controller方法上标注角色要求:

@PreAuthorize("hasRole('ADMIN')") @PostMapping("/api/admin/schedule") public Result<?> createSchedule(@RequestBody ScheduleRequest request) { // ... }

居民端接口需要登录但任何角色都能访问,医护端接口需要DOCTOR或ADMIN角色,管理端接口只允许ADMIN。Token里放角色信息,过滤器解析出来,Spring Security根据注解做校验。整体逻辑清晰,讲起来也顺畅。如果你不希望引入Spring Security的复杂度,用拦截器校验角色也能实现同样的效果,只是需要自己处理更多边界情况。

5. 我从头做一遍这个项目的复盘:联调、演示与坑

5.1 环境准备:版本匹配是第一个隐形坑

很多同学第一步就卡在环境上。Spring Boot 2.7.x对应Java 8或Java 11,MyBatis-Plus 3.5.x搭配Spring Boot 2.x没有问题,但如果用了Spring Boot 3.x,注意它基于Jakarta命名空间,很多旧依赖要换版本。我建议直接使用Spring Boot 2.7.x,这个组合的资料最丰富,踩坑的人最少。

建议优先使用 Spring Boot 2.7.x 搭配 Java 8/11,不仅资料多,而且很多第三方组件的兼容性适配都是围绕这个版本做的,能帮你省下大量排查环境问题的时间。

还有一个高频坑是MySQL 8.0的时区问题。JDBC连接串里必须加上serverTimezone=Asia/Shanghai,否则日期和时间字段会出现8小时偏移。这个坑看着小,但一旦出现,排期和预约记录整体错乱,排查起来相当费时间。Redis本地跑不起来的话,可以先把并发控制切到数据库原子更新方案,核心业务不依赖本地环境能跑,本身也是工程能力的体现。

5.2 演示前的数据准备与并发自测

毕业设计答辩看的是演示效果,数据准备比代码本身更能体现你的用心。我第一次演示就翻车了:演示时数据库是空的,现场创建排期、预约、核销,整个流程走了五分钟,老师看得兴致全无。后来我学乖了,提前准备了三套固定数据:

  • 一个已经满员的时段,用来演示"该时段已约满"的提示;
  • 一个还剩少量名额的时段,用来演示预约成功和名额实时变化;
  • 几条已完成接种的记录,用于演示接种记录查询。

另外,演示时建议准备一个"预约码核销"的固定数据,比如一条待接种状态的预约记录,预约码预先记好,演示时直接输入这个码,三步完成核销,节奏快很多。

并发这块怎么自测?我的做法简单粗暴:写一个临时测试类,用Java线程池模拟50个线程同时对一个时段发预约请求,然后看数据库里booked_slots的最终值是多少,预约记录条数是多少,两者必须一致。这个测试不用写得很正规,但能第一时间暴露出超卖问题。如果你会用JMeter或者Postman的runner功能,也可以直接压一下接口。重点观察两个数字:预约记录总数是否等于时段容量,已预约数是否等于预约记录总数。两个对上,说明并发控制基本没问题;对不上,恭喜你,你发现了bug,而且这个bug大概率出在状态判断或事务边界上。

5.3 高频故障排查清单

做这个项目的过程中,有几个问题反复出现,我整理成一张清单,遇到类似情况可以直接按这个思路查:

现象可能原因排查方向
预约后名额没变扣减SQL没生效检查WHERE条件里的状态和名额判断是否被误伤
同一个时段出现重复排期生成排期前没做排重查询当天该时段是否已有记录
前端请求报401Token过期或未携带检查拦截器放行路径和Token解析逻辑
预约成功但列表查不到事务未提交检查service方法是否有@Transactional
核销时报状态错误状态判断顺序不对先校验状态再更新,用UPDATE带状态条件
剩余名额显示为负数取消回补重复执行检查取消接口是否做了幂等判断

这张表不是通用答案,但排查思路是通用的:先看SQL受影响行数,再看事务边界,最后看状态前置条件是否满足。能按顺序查完这三个点,绝大多数问题都能定位。我自己Debug时最大的体会是:不要凭感觉改代码,先复现、再排查、最后动手,一步步缩小问题范围,比瞎试高效得多。

6. 答辩与收尾:老师最常问的问题和我的一点建议

6.1 几句话讲清楚你的项目亮点

答辩时老师不一定有耐心看完整个系统演示,你要能用三句话讲清楚项目为什么值得做、难点在哪、你怎么解决的。我的准备思路是这样:

第一句讲业务价值:"这是一个面向社区的多角色疫苗预约平台,覆盖了从批次管理、时段排期、在线预约、到场核销到接种记录的全流程,解决了传统线下排队效率低、信息不透明的问题。"

第二句讲技术难点:"最核心的难点是预约并发控制,我实现了两个层次的方案:数据库原子更新加事务保证不超卖,Redis预扣加失败回补支撑更高并发。"

第三句讲工程规范:"整个项目采用Spring Boot加MyBatis-Plus加MySQL加Redis的分层架构,JWT认证配合Spring Security做三端权限控制,关键流程全部走状态机,保证数据一致性。"

这三句话不用背,理解背后的逻辑,现场临场组织就行。重要的是你真的做过,而不是背台词。

6.2 有哪些细节值得继续打磨

最后说几个我做完之后觉得还能再深入的细节。一是消息通知:预约成功、接种提醒、疫苗到货,都可以对接站内信或短信通道,把通知模块补上是很大的加分项。二是定时任务:用Spring的@Scheduled做一个每天凌晨的定时任务,自动把过期未核销的预约标记为爽约,同时生成当天的接种统计,这也是真实业务里一定会有的功能。三是多剂次疫苗的预约联动:打完第一针后,系统自动推荐第二针的预约时段并推送提醒,这个逻辑写出来,业务完整度立刻上一个台阶。

如果你正在做这个题目,我的建议是:别急着写代码,先把状态流转表和并发方案想清楚。这两件事想通了,后面基本是一马平川。做完之后回头再看,你会发现这个项目带给你的不只是毕业设计和几个技术点,还有一套面对不确定业务需求时如何拆解和建模的思考方式。这个东西,比项目本身更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询