Spring Boot+Vue疫苗接种预约平台:防超卖与并发控制实战 想搞清楚Spring Boot Vue怎么做预约平台的人多半是遇到了同样的问题业务看着简单不就是“选日期、选时段、点预约”嘛但真动起手来疫苗库存怎么扣、号源怎么放、怎么防止同一个号被两个人抢到、前端日历怎么联动后端数据这些细节一个比一个磨人。这篇博客就拿“Spring Boot疫苗接种预约平台-Vue”这个项目当例子把从数据库设计到接口联调、从并发防超卖到定时放号的全过程拆开讲清楚。适合正在做课程设计、毕业设计或者想在公司内部快速搭一个预约演示系统的开发者参考前端后端都兼顾照着做就能跑出一个能演示、能压测的完整版本。1. 需求与设计拆解预约平台背后的隐性逻辑1.1 表面上做预约实际上做的是“资源分配”疫苗预约和普通电商下单有个本质区别电商超卖最多赔个货预约超卖会直接导致接种点现场排长队、有人白跑一趟。所以这个项目的第一优先级不是“功能好看”而是“资源分配要准”。一个接种点的可预约数量由什么决定疫苗库存、接种台数量、医生工作时间、每个时段的接待能力几个因素叠在一起才得到一个“可放号数”。我见过很多新手直接把“疫苗库存”当“可预约数”用结果疫苗明明还剩100支一个时段放出去200个号现场全乱套。正确的做法是把“库存库存表”和“可预约额度号源表”拆开库存只管疫苗实物还有多少号源表才管某个接种点在某个时段能约多少人。每天定时根据库存、人员排班、已预约量重新生成未来几天的号源这才是预约平台的正确打开方式。1.2 为什么选Spring Boot Vue这套组合用Spring Boot做后端接口、Vue做前端页面本质上是在做前后端分离。你要是不分离用Thymeleaf模板一把梭确实也能做出预约功能但到了后面加小程序端、加管理后台、加数据大屏的时候每加一个端就要改一遍后端页面维护成本直接翻倍。Spring Boot的优势在于生态成熟JWT做登录、MyBatis-Plus做数据操作、Spring Security做权限、Redis做分布式锁和缓存都是现成的轮子。Vue这边我建议直接用Vue 3 Vite Element Plus组件全、文档多路由守卫做权限拦截也方便。这套组合在校园项目和企业内部系统里都非常主流遇到问题随便一搜就是解决方案比各种小众框架稳妥得多。1.3 三种角色三种完全不同的使用路径这个平台至少有三种角色权限必须分开角色核心诉求主要功能普通用户快速预约、查询记录浏览接种点、选择日期时段、提交预约、取消预约、查看接种记录接种点管理员管理号源和现场核销配置放号规则、查看当日预约名单、核销预约码、统计接种数据系统管理员运维和全局配置管理接种点、管理疫苗信息、查看所有平台数据、处理异常订单很多项目一开始不分角色所有接口混在一起后台管理页直接把用户表拉出来让人改。等到你要做接口权限控制的时候发现每个接口都得重写等于返工。我的建议是先设计一张简单的角色表用户表里加role字段也行然后用拦截器或注解把管理接口保护起来一开始麻烦后面省事得多。1.4 技术选型和环境版本建议具体版本我建议这样选比较稳后端Spring Boot 2.7.18 JDK 8或11 MyBatis-Plus 3.5.x MySQL 8.0 Redis 6.x Maven前端Vue 3.4 Vite 5 Element Plus 2.x Axios Pinia Vue Router 4鉴权JWTio.jsonwebtoken 0.11.5接口文档SpringDoc OpenAPISwagger UI交互页Node.js版本要注意Vite 5要求Node 18太老的Node版本启动会直接报错。JDK别上来就用21有些依赖还没适配JDK 8或者11配合Spring Boot 2.7.18是当前兼容性最好的组合。2. 后端核心预约锁号与防超卖设计2.1 号源生成让“放号”变成系统行为而不是人工操作号源是预约的基础没有号源表用户约什么号源表的核心字段包括接种点ID、疫苗ID、预约日期、开始时间、结束时间、总号源数、已预约数、状态。其中状态用于区分“可预约、已约满、已过期、已停用”。放号策略我采用的是“每日定时生成未来N天号源”。比如每天凌晨0点生成未来7天的号源这样既保证用户有充足时间预约又不会一次性生成太多导致数据量膨胀。假设一个接种点每天开放3个时段上午8:30-11:00、下午14:00-16:30、晚间17:30-20:00每个时段放50个号那每天就是150个号源记录未来7天也才1050条数据量完全可控。放号的定时任务要注意一个坑如果项目以后部署了多个实例多台服务器Scheduled会每台都执行一遍导致号源重复生成。解决思路是用Redis分布式锁包裹整个生成逻辑只有拿到锁的实例才执行任务。这个点在第6章问题排查里会详细讲。2.2 防超卖数据库唯一约束是底线Redis锁是闸门预约防超卖是这类系统最关键的技术点。一个时段只剩10个号100个人同时抢怎么保证只成功10个我采用的办法是“Redis预减 数据库唯一约束兜底”双保险第一层Redis锁。用户点击“提交预约”时先尝试获取某个时段锁获取不到直接提示“当前预约人数过多”。伪代码如下String lockKey appointment:lock: slotId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, userId, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(预约人数过多请稍后再试); } try { // 检查号源余量 AppointmentSlot slot slotMapper.selectById(slotId); if (slot.getAppointedCount() slot.getTotalCount()) { throw new BusinessException(该时段已约满); } // 创建预约订单 appointmentService.createOrder(userId, slotId); } finally { redisTemplate.delete(lockKey); }第二层数据库唯一索引。Redis锁在极端情况下可能因过期时间太短而失效所以数据库层面必须加最后一道防线。预约订单表里加一个唯一索引(user_id, slot_date, slot_time)意思是同一个用户同一天同一个时段只能有一条预约记录另外还要加上版本号或乐观锁控制号源表的更新。这里我特别想强调一个新手常犯的错误select余量后再update中间完全没有锁并发一高就超卖。正确做法是直接把余量判断放进SQL里UPDATE appointment_slot SET appointed_count appointed_count 1 WHERE id #{slotId} AND appointed_count total_count;如果这条SQL影响行数为0说明没抢到号直接回滚。把判断交给数据库的原子性比在Java代码里做判断可靠得多。2.3 订单状态机预约不是一步到位要能取消、能过期预约订单不能只有“已预约”和“已完成”两个状态我把状态定义为状态值含义流转方向0待接种初始状态1已接种接种点管理员核销后2已取消用户取消或超时未核销3已爽约到预约时间未到现场爽约状态怎么判定最简单的方案是当appointment_date end_time小于当前时间并且订单状态还是“待接种”时在定时任务里统一改成“爽约”。这样能倒逼用户认真对待预约也能让管理者看到真实的接种率而不是所有预约都算“成功预约”。取消预约的逻辑也要严谨用户取消后必须释放号源额度也就是把appointed_count减回去。这里同样要用上面的原子SQL操作只是把加号换成减号。2.4 事务边界不是所有方法都要加Transactional用Spring Boot做这类业务事务用得好能省很多事用不好会带来怪问题。我的原则是只有跨表、跨实体的写操作才加事务。比如创建预约订单写订单表 更新号源count 写预约操作日志这三步必须在一个事务里但生成号源的定时任务就不需要长事务一条条插入反而更安全。还有一个细节事务里查出来的对象如果被修改了但没有调用更新方法事务提交时MyBatis-Plus不会自动帮你更新必须显式调用updateById。有些框架会自动管理脏数据但MyBatis-Plus不会别把习惯搞混。3. 前端Vue从路由守卫到预约页面的完整实现3.1 初始化项目Vite创建 目录结构规划用Vite创建Vue3项目非常快npm create vitelatest vaccination-frontend -- --template vue cd vaccination-frontend npm install npm install element-plus axios pinia vue-router npm install echarts --save目录结构建议直接按功能划分别把所有组件都堆在components里src/ api/ // 按模块封装的请求接口 assets/ // 静态资源 components/ // 公共组件 router/ // 路由配置与守卫 store/ // Pinia状态管理 views/ home/ // 首页、接种点列表 appointment/ // 预约流程页面 order/ // 记录与订单详情 admin/ // 后台管理 utils/ // axios实例、工具函数3.2 路由守卫前端权限拦截的正确姿势前后端分离项目里前端路由守卫做的是“体验优化”真正的安全校验永远在后端。前端拦截的意义在于——没有token的用户压根看不到预约页哪怕他猜到了路由地址也会被弹回登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (token to.path /login) { next(/) return } next() })管理端的路由可以加meta: { roles: [admin] }再在守卫里校验一下用户角色是否匹配这样用户看到的管理菜单和路由入口是一致的不会出现“菜单藏着但能手动输URL进去”的问题。3.3 预约首页日历面板 时段余量预约页面的核心交互是左边一个日期选择器右边显示这一天每个时段的余号。数据来自什么接口我建议后端提供一个聚合接口一次返回指定接种点未来7天每天每个时段的余量{ code: 200, data: [ { date: 2025-05-20, slots: [ { id: 101, timeRange: 08:30-11:00, left: 23, status: 1 }, { id: 102, timeRange: 14:00-16:30, left: 0, status: 2 } ] } ] }前端拿到数据后余量为0或者状态为“已约满”的时段按钮直接置灰。这个细节非常重要让用户从一开始就无法点击约满的时段而不是等他提交了再告诉他“约满了”体验差距非常大。3.4 axios封装统一错误处理别让每个页面都写catch我见过不少项目的每个页面都单独写一遍“请求失败弹出提示”代码重复严重后来统一封装了个axios实例几百行重复代码瞬间没了const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.response?.data?.message || 网络异常请稍后重试) return Promise.reject(error) } )这里要特别提醒401的处理一定要放在响应拦截器里统一做否则用户登录过期以后每个接口都会弹一个错误框非常烦人。统一跳转登录页才是用户能接受的处理方式。3.5 预约倒计时别用本地时间要用服务端时间差预约页面常常要展示“订单保留15分钟请在15分钟内确认信息”这个倒计时不能直接用前端new Date()因为用户电脑时间可能不准甚至有人会为了占号故意改时间。正确做法是后端在下单接口返回两个字段——expireTime和serverTime前端用expireTime - serverTime计算出剩余毫秒数再用setInterval每秒更新。这样即使本地时间错了倒计时依然准确。const remainMs expireTime - serverTime const timer setInterval(() { remainMs - 1000 if (remainMs 0) { clearInterval(timer) ElMessage.warning(预约已超时请重新选择时段) location.reload() } }, 1000)4. 数据库设计五张核心表与索引规划4.1 核心表结构整个平台的数据库核心是五张表。用户表userid主键、username、passwordBCrypt加密后存储、real_name、phone、id_card接种需要实名、role0普通用户、1管理员、2接种点管理员、create_time。疫苗表vaccineid、name如“流感疫苗”、manufacturer、batch_no、stock_count、inventory_warning低库存预警阈值、status。接种点表vaccination_pointid、name、address、daily_capacity每日最大接种量、work_start_time、work_end_time、status。号源表appointment_slotid、point_id、vaccine_id、slot_date、start_time、end_time、total_count、appointed_count、status。其中slot_date字段类型必须用DATE而不是DATETIME否则日期范围查询会出各种边界bug。预约订单表appointment_orderid、order_no业务流水号、user_id、point_id、vaccine_id、slot_id、appointment_date、time_range、status、qr_code预约凭证、create_time、update_time。4.2 索引怎么加不能有索引就乱加索引不是越多越好每个索引都影响写入速度。我的建议是最少加这几个表索引用途appointment_orderuk_user_slot (user_id, slot_id)唯一约束防止同一用户重复预约同一时段appointment_orderidx_user_id (user_id)查询“我的预约记录”appointment_orderidx_slot_id (slot_id)管理员查某个时段的预约名单appointment_slotidx_point_date (point_id, slot_date)前端查某接种点某天可约时段appointment_orderidx_status_date (status, appointment_date)定时任务查爽约订单唯一索引uk_user_slot是这个系统防超卖的数据库底线。哪怕Redis锁因为极端原因失效数据库这一层也会让第二个重复预约直接报“Duplicate entry”配合业务代码的异常捕获最终订单数不会超过号源数。4.3 统计SQL接种率与趋势分析管理后台通常需要一个“近7天接种趋势”的折线图SQL这样写比较直观SELECT appointment_date AS date, COUNT(*) AS total_orders, SUM(CASE WHEN status 1 THEN 1 ELSE 0 END) AS vaccinated_count, ROUND(SUM(CASE WHEN status 1 THEN 1 ELSE 0 END) / COUNT(*), 2) AS rate FROM appointment_order WHERE appointment_date BETWEEN #{startDate} AND #{endDate} GROUP BY appointment_date ORDER BY appointment_date;这种SQL在数据量小的时候直接查表完全没问题。如果以后预约量到了几十万条再考虑加一层汇总表比如每天凌晨算好前一天的数据存起来查询时直接读汇总不要一开始就上大宽表过度设计没必要。5. 实操过程三天搭出可演示版本5.1 第一天后端脚手架与登录鉴权先用Spring Initializr生成工程核心依赖选这几个Spring Web、MySQL Driver、Lombok、Spring Security如果你不想被Security繁琐的配置折磨可以先用Sa-Token或者手写拦截器这里我选择手写JWT拦截器更可控。application.yml的关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/vaccination?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl有一个细节几乎是必踩的坑JDBC连接串一定要加serverTimezoneAsia/Shanghai不然日期字段查出来会差8个小时前端显示的时间跟你数据库里存的不一致排查起来极其隐蔽。登录取数流程用户提交用户名密码后端用BCrypt校验密码通过后签发JWT返回token和用户基础信息。我习惯把token有效期设成2小时加上一个rememberMe参数如果选了记住我有效期延长到7天。String token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() expireTime)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();5.2 第二天预约核心流程与事务预约接口的完整流程是这样的校验用户登录状态和接种点、疫苗参数加Redis锁键为appointment:lock:{slotId}查询号源状态如果已约满返回错误生成订单号插入订单表状态为“待接种”执行原子更新UPDATE appointment_slot SET appointed_count appointed_count 1 WHERE id ? AND appointed_count total_count如果影响行数为0抛出异常回滚事务删除Redis锁返回订单详情和预约二维码。这里把第4步和第5步的顺序这么安排是有讲究的先插入订单再扣减号源。有人会反问如果先扣号源再插订单订单插入失败的时候回滚不也一样吗逻辑上一样但插入订单需要拿到订单号而订单号可以通过Redis自增或雪花算法在事务外生成顺序上不影响。我更倾向先插订单后扣号源是因为订单是核心业务实体扣号源只影响一个数字出了问题人工修数据也容易定位。5.3 第三天前端联调、跨域与打包部署前后端联调阶段最常见的就是跨域问题。本地开发时前端跑在5173端口后端跑在8080端口直接fetch必然跨域。最简单的方案是在vite.config.js里配置代理export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端代码里请求/api/appointment/listVite会把请求转发到http://localhost:8080/api/appointment/list浏览器视角下不存在跨域。上线部署时同样的问题用Nginx解决server { listen 80; server_name your-domain.com; location / { root /var/www/vaccination-frontend/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意try_files配置是必须的。Vue Router开启了history模式后刷新/appointment页面时Nginx会返回404加上try_files保持前后端路由的一致性让所有路径都回到index.html再由前端路由解析。打包命令就是常规的npm run build产物在dist目录。后端打包是mvn clean package -DskipTests生成target目录下的jar包。部署时先确保MySQL和Redis起来了再用java -jar启动后端Nginx托管前端静态文件并反向代理API。6. 常见问题与排查实录6.1 明明扣了库存前端却提示“余号不足”这种情况八成不是因为号源真的不够而是事务没提交。Spring的Transactional默认只在抛出RuntimeException时回滚如果你在事务方法里自己catch了异常并且没往外抛事务照样会提交。而号源扣减和订单插入如果不在同一事务里极可能出现“订单插入失败库存已扣减”的脏数据。排查方法打开MyBatis-Plus的SQL日志看提交顺序或者在事务方法里加断点确认异常是否真的抛出。修整方向是把订单插入和号源扣减放在同一个事务方法里并且自定义业务异常要继承RuntimeException。6.2 预约成功但前端一直卡在加载中这个问题九成是跨域或token问题。预约成功意味着后端接口确实返回了200但前端axios封装的响应拦截器判断res.code ! 200时弹了个错误然后再Promise.reject页面代码忘记处理reject状态loading就永远不消失。另一个可能预约接口返回的qr_code是个很长的字符串JSON解析没问题但如果接口返回了BigInt类型ID而前端JavaScript无法精确表示会导致字段错乱。解决方案给主键加JsonSerialize(using ToStringSerializer.class)把Long类型的ID序列化成字符串。6.3 定时任务重复生成号源生产环境多实例部署后Scheduled默认每台机器都会执行导致号源翻倍。我当时踩过这个坑排查了半小时才意识到是负载均衡后面挂了2台应用。解决思路是给定时任务加分布式锁用Redis的setIfAbsent实现String lockKey scheduler:generateSlot; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.MINUTES); if (locked) { try { generateNextSevenDaysSlots(); } finally { redisTemplate.delete(lockKey); } }锁的过期时间一定要大于任务最大执行时间否则任务没执行完锁就过期了第二个实例也会进来又变成重复执行。6.4 并发压测200线程涌入超卖10个号我在本地用JMeter模拟过一个号源80的时段并发200个线程同时提交结果产生了90个成功订单。排查过程是这样的第一检查Redis锁的过期时间。我原来设置了10秒过期但事务方法里查询和更新之间执行了外部API调用耗时超过10秒锁提前释放第二个请求进来了。第二检查唯一索引。我那版的唯一索引只建在了(user_id, slot_id)上但压测时我是用200个不同用户去抢同一个slot唯一索引压根拦不住。第三检查号源扣减SQL。发现问题根本不是超卖而是订单重复——同一个用户在极短时间内点了几次提交按钮前端没做防重复提交。加上按钮loading态和redisTemplate.delete(lockKey)确保只删自己的锁之后再压测成功订单数精确等于80。现象可能原因排查方向成功订单数 号源数Redis锁提前失效 / 扣减SQL非原子检查锁过期时间、验证SQL影响行数同用户重复预约成功前端未防重复提交 / 唯一索引缺失加按钮disabled、补唯一索引库存减了订单没生成事务边界不对 / 异常被吞检查Transactional、抛异常方式用户取消后号源没释放取消逻辑忘记更新count取消接口里加原子更新语句6.5 账号登录后马上跳回登录页这个问题通常出在JWT的密钥或有效期配置上。确认启动配置文件里secret不是默认值、有效期不是0前端axios请求头里Authorization前缀是Bearer注意有个空格后端解析时也是同样规则两端不一致就会一直401。还有一种情况是后端返回了401响应拦截器清了token但问题是接口路径本身写错了比如多了一个斜杠走了一个不存在的接口拦截器把它当成鉴权失败处理。排查时先看浏览器Network面板看请求路径是否真的是/api/user/info别一上来就怀疑token逻辑。写在最后的几点个人体会真正把这个项目从零搭完跑了压测之后我最大的体会是预约平台的核心从来不是“能预约”而是“预约得准”。一个看似简单的appointed_count 1背后是并发控制、事务边界、唯一约束三层保障叠起来才能不出问题。建议你自己动手的时候先拿JMeter或Postman把并发测试跑通亲眼看到“200个人抢80个号最终80个成功”这个结果再去做前端页面心里会踏实很多。另外还有个小经验想分享开发阶段记得把所有默认密码、JWT密钥、数据库账号都换成随机生成的强密码宁可麻烦一点也别图省事用admin/admin——这类系统一旦上线马上会被扫描器盯上弱口令漏洞是安全事故里最高发的一种。项目后续还可以往几个方向扩展对接微信公众号或小程序预约、增加疫苗库存消息推送、加一个爽约三次自动拉黑的规则后端和前端都不用大改只需要在现有表结构上增加字段和定时任务整体架构完全撑得住。