疫苗预约系统核心设计:并发控制与状态流转的Spring Boot实践

📅 发布时间:2026/10/9 8:06:45
疫苗预约系统核心设计:并发控制与状态流转的Spring Boot实践
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.0InnoDB引擎负责核心业务数据的落盘缓存与并发控制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, phonevaccine_type疫苗类型字典id, name, manufacturer, dose_count, interval_daysvaccine_batch批次库存id, type_id, batch_no, stock, remaining, expiry_dateschedule接种时段计划id, type_id, batch_id, date, time_slot, total_slots, booked_slots, statusappointment预约记录id, user_id, schedule_id, dose_number, status, codeinoculation_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预扣方案。在时段创建或者每天排期生成时把剩余名额同步到RedisSET 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 11MyBatis-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连接串里必须加上serverTimezoneAsia/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做一个每天凌晨的定时任务自动把过期未核销的预约标记为爽约同时生成当天的接种统计这也是真实业务里一定会有的功能。三是多剂次疫苗的预约联动打完第一针后系统自动推荐第二针的预约时段并推送提醒这个逻辑写出来业务完整度立刻上一个台阶。如果你正在做这个题目我的建议是别急着写代码先把状态流转表和并发方案想清楚。这两件事想通了后面基本是一马平川。做完之后回头再看你会发现这个项目带给你的不只是毕业设计和几个技术点还有一套面对不确定业务需求时如何拆解和建模的思考方式。这个东西比项目本身更值钱。