SpringBoot+Vue汽车票预订系统:并发防超卖与订单状态机实战

发布时间:2026/9/16 16:24:14
SpringBoot+Vue汽车票预订系统:并发防超卖与订单状态机实战 简介这是一套基于JAVASpringBootVueMySQL的汽车票网上预订系统属于高分毕业设计项目面向需要完成毕设、课程设计或期末大作业的在校生也适合前后端分离架构的实战学习者。系统覆盖用户注册登录、车次查询、在线选座购票、订单管理、支付结算及后台管理等完整业务闭环。资源包共742个文件约15.01MB包含105个java源码、42个vue组件、153个js脚本、44个css样式及162个svg图标同时提供1-install.bat、2-run.bat、3-build.bat等一键部署脚本和数据库SQL脚本便于本地快速搭建运行。项目已经严格调试下载后无需修改即可使用。目前已有56人学习/下载适合想深入理解SpringBootVue开发流程、或需要一个可直接运行的精美管理系统的开发者参考。1. 汽车票网上预订系统的技术栈与定位一套汽车票预订系统之所以能成为Java后端岗位面试和毕设选题里的常客本质上是因为它不是一个“增删改查”就能糊弄过去的玩具项目。它牵扯到车次库存的并发扣减、订单状态的多次流转、前后端分离下的接口权限控制以及MySQL事务隔离级别的实际落地。标题里这四件套——JAVA、SpringBoot、Vue、MySQL——正好对应了国内绝大多数中小型信息管理系统的标准组合把这套系统的骨架搭明白往后做任何“xx管理系统”都是在换皮。这篇博文打算顺着一个能跑通全流程的实现思路来展开先拆业务模型和数据表再写后端核心接口然后搭Vue前端页面最后说联调部署和那些新手必踩的坑。无论你是拿它做毕业设计需要源码和论文支撑还是想把SpringBootVue这套全栈链路从头到尾理一遍文章里给出的建表SQL、下单接口代码和前端路由配置都能直接复制改改就跑。需要提醒的是网上流传的所谓“高分毕设项目.zip”里源码质量参差不齐多数表结构经不起并发推敲与其花时间改别人的烂代码不如自己按文中的分层方式重新搭一遍工作量远没有想象中那么大。2. 业务模型拆解与MySQL表结构设计先想清楚再写代码2.1 汽车票预订的业务边界不是商城是带有强库存约束的票务系统汽车票预订和普通商品下单最大的区别在于“车次”这个资源是定量的一趟车固定40座或50座余票减到0就不能再出票。与此同时用户下单后通常有10到15分钟的支付窗口超时未支付要自动释放座位。这两条规则直接决定了数据表怎么设计、接口的事务边界划在哪里。先把系统的角色梳理清楚普通用户负责注册登录、查车次、下单、支付、退票管理员负责维护车次信息、查看订单统计、处理异常退票。系统里最核心的领域对象不是“汽车票”本身而是“车次排班”和“订单”——车次排班描述了某条线路在某个时间段发车订单则记录了一次购票行为。清晰了这个边界表结构就能自然推导出来实体关系上属于典型的一对多一个车次对应多条订单记录一个用户对应多个订单。2.2 核心表结构用户表、车次表、订单表和余票的快照策略设计上我一般会划出五张核心表用户表、线路表、车次排班表、订单表、乘客信息表。线路表放静态的出发城市、到达城市、里程车次排班表放具体某一天几点发车、票价、车型和总座位数中间用线路ID关联。订单表则冗余了车次ID、票价快照、下单用户ID、乘客姓名和证件号这么做的目的是出票后即使车次信息被修改订单里依然保留购票那一刻的完整快照。车次表是库存控制的关键字段上除了基础信息外必须单独留一个余票数字段并且配合一个version字段做乐观锁。不要试图在订单表里通过“count(*)”去统计已售座位再拿总座位数减因为这种读多写少的场景下统计查询会拖垮下单接口的响应时间。下面是简化但可直接落地的建表脚本CREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录名, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, phone varchar(20) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE t_schedule ( id bigint(20) NOT NULL AUTO_INCREMENT, line_name varchar(100) NOT NULL COMMENT 线路名称如 杭州-千岛湖, depart_time datetime NOT NULL, arrive_time datetime NOT NULL, ticket_price decimal(10,2) NOT NULL, total_seats int(11) NOT NULL COMMENT 总座位数, remain_seats int(11) NOT NULL COMMENT 当前余票, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_depart_time (depart_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车次排班表; CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务唯一, user_id bigint(20) NOT NULL, schedule_id bigint(20) NOT NULL, passenger_name varchar(50) NOT NULL, id_card_no varchar(30) NOT NULL, ticket_price decimal(10,2) NOT NULL COMMENT 下单时票价快照, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已取消 4已退票, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_schedule_id (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这段DDL里有几个值得注意的设计决策。order_no用业务唯一键而不是直接依赖自增主键是为了后续接支付系统时用订单号做幂等ticket_price在订单表里冗余了一份避免车次调价导致历史订单金额对不上账。remain_seats和version字段是防超卖的第一道闸门下面一节会给出配套的更新语句。索引方面depart_time加索引是因为高频查询是“某天从A到B的车次”按时间范围扫描user_id加索引支撑“我的订单”页面。2.3 余票扣减的并发控制乐观锁更新而不是先查后改防超卖是所有票务系统的命门。最常见的错误写法是先用select查出remain_seats在Java代码里判断大于0然后再update——两个请求同时进来时都查到了余票为1于是都执行了update最终超卖。正确做法是把判断条件并进update语句的where里利用MySQL行锁保证原子性UPDATE t_schedule SET remain_seats remain_seats - 1, version version 1 WHERE id ? AND remain_seats 0 AND version ?这条语句执行后通过affected rows返回值判断是否更新成功返回1说明扣减成功返回0说明余票已经被别的请求抢先扣完此时直接给前端返回“余票不足”。version字段的作用是防止ABA问题在车票场景中体现为两个请求同时读到version5第一个请求把version改成6后第二个请求的where条件version5就不再命中从而避免同一张票被扣两次。这种方式的优点是无需引入Redis分布式锁单库部署下性能完全够用缺点是每次更新都要带上上一次查到的version值多一次查询开销——但对于毕设和中小型系统来说收益远大于成本。车辆信息表、管理员表等周边表结构篇幅所限不再展开建表时保持InnoDB引擎、utf8mb4字符集、显式主键这三个基本原则就行。MySQL 8.x下如果使用默认的utf8mb4_0900_ai_ci排序规则注意和项目里可能用到的旧库做字符集统一否则关联查询会报排序规则冲突。3. SpringBoot后端实现下单事务、订单状态机与定时关单3.1 项目初始化与分层规划后端工程我习惯用SpringBoot 2.7.x系列配合JDK8原因很实际SpringBoot 3.x全面切换到Jakarta EE命名空间后网上大量资料还是基于javax包的新手照着旧教程抄代码容易出现包名不一致的诡异报错。如果机器上装的是JDK17以上建议直接用SpringBoot 3.2但所有import javax.servlet要换成import jakarta.servlet这是最容易踩的第一道坑。工程结构上按常见的controller、service、mapper、entity四层划分mapper层用MyBatis-Plus减少重复SQL量内置的BaseMapper就能覆盖大部分单表CRUD。涉及多表关联的复杂查询再手写XML比如“查询某条线路某天所有车次及余票”这类场景。pom.xml里核心依赖就四个spring-boot-starter-web、mybatis-plus-boot-starter版本用3.5.x、mysql-connector-j、lombok——其中mysql驱动在SpringBoot 2.7里默认引入的是8.0.33对应的driver-class-name要写成com.mysql.cj.jdbc.Driver老项目里常见的com.mysql.jdbc.Driver已经废弃了。3.2 下单接口的事务边界扣余票与创建订单必须同生共死下单是整个系统里事务要求最严格的接口。先扣余票、再创建订单记录、还要考虑锁车次行——这三步必须打包在同一个数据库事务里任何一步失败都要整体回滚。尤其要注意的是事务边界内不能调用外部接口或做耗时的网络操作否则长事务会导致连接池被占满。线上环境里MySQL默认的REPEATABLE_READ隔离级别和事务内的一致性读配合乐观锁使用没有逻辑问题反而比READ_COMMITTED更省心所以这里不需要刻意改动数据库隔离级别。核心实现代码如下注意Transactional注解加在Service方法上而不是Controller方法上Service public class OrderServiceImpl implements OrderService { Autowired private ScheduleMapper scheduleMapper; Autowired private OrderMapper orderMapper; Override Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateReq req) { // 1. 查询车次信息拿到当前版本号 Schedule schedule scheduleMapper.selectById(req.getScheduleId()); if (schedule null) { throw new BizException(车次不存在); } // 2. 乐观锁扣减余票影响行数为0说明余票不足或版本冲突 int rows scheduleMapper.deductSeats(schedule.getId(), schedule.getVersion()); if (rows 0) { throw new BizException(余票不足请刷新后重试); } // 3. 生成订单号并保存订单 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(req.getUserId()); order.setScheduleId(schedule.getId()); order.setPassengerName(req.getPassengerName()); order.setIdCardNo(req.getIdCardNo()); order.setTicketPrice(schedule.getTicketPrice()); order.setStatus(0); // 待支付 orderMapper.insert(order); // 注意这里不能有任何远程调用或Thread.sleep return orderNo; } }这段代码的逻辑链条是先读车次快照拿到version然后执行带条件的update根据返回行数判断是否继续。第3步生成订单号时建议用时间戳 随机数再经UUID去掉横线的组合保证32位长度且业务上可读不要直接用数据库自增ID当订单号暴露给前端容易被爬虫遍历。generateOrderNo()工具方法网上模板很多这里不啰嗦。需要额外留神的是同一个方法里如果有第二次selectById查询车次此时读到的是当前事务修改后的数据还是事务开始前的快照取决于隔离级别和是否走主键查询——写代码时务必用第1步查出来的schedule对象做后续计算不要重复查询。3.3 状态机设计订单生命周期里的四个流转入口订单状态不能只靠程序员自觉去赋值要用状态机收敛流转路径。汽车票订单的合理状态集合包括待支付、已支付、已出票、已取消、已退票外加一个超时自动取消的隐性动作。我一般会在OrderConstants接口里定义状态常量然后在Service层提供独立的cancel、pay、refund方法每个方法只允许特定前置状态做迁移避免出现“待支付直接变退票”这种脏数据。状态流转表如下动作前置状态后置状态关键操作用户支付待支付已支付回调校验金额、记录pay_time系统出票已支付已出票生成电子票号可异步执行用户取消待支付已取消释放余票remain_seats1用户退票已支付已退票按规则退费、释放余票超时关单待支付已取消定时任务批量处理支付这个动作在真实场景里要和第三方支付对接毕设项目里一般做成模拟支付——前端调一个/api/order/pay接口后端直接把状态改成已支付。即便只是模拟接口也要设计成幂等的第一次调用把状态置为已支付重复调用时如果查到已经是已支付就直接返回成功不要报错。退票和取消的区别在于是否已付款未付款只有取消已付款才走退票流程且可能需要扣手续费这两条分支用状态机约束后业务逻辑会清晰很多。3.4 定时任务清理超时未支付订单订单超时未支付是必测的功能点。用Spring自带的Scheduled做分钟级扫描就够了不需要上消息队列延迟消息那套重型方案。实现思路是每30秒扫描一次订单表把创建时间早于当前时间15分钟且状态为待支付的订单批量置为取消状态同时把对应车次的余票加回去。这里有个关键点定时任务和用户手动取消可能会同时操作同一张订单所以要在update语句里带上前置状态条件。Component public class OrderTimeoutTask { Autowired private OrderMapper orderMapper; Autowired private ScheduleMapper scheduleMapper; Scheduled(fixedDelay 30000) public void closeTimeoutOrders() { // 1. 查出所有超时且仍为待支付的订单 ListOrder timeoutOrders orderMapper.selectTimeoutOrders(15); for (Order order : timeoutOrders) { // 2. 尝试将状态从0改为3返回0说明已被其他请求处理 int rows orderMapper.compareAndSetStatus(order.getId(), 0, 3); if (rows 0) { // 3. 释放余票这里要传版本号走乐观锁 scheduleMapper.releaseSeat(order.getScheduleId()); } } } }compareAndSetStatus对应SQL为UPDATE t_order SET status 3 WHERE id ? AND status 0通过受影响行数天然避免重复关单。定时任务的fixedDelay表示上一次执行完后间隔30秒再执行下一次比fixedRate更适合数据库扫描类任务防止任务堆积重叠。订单量上来后这个方案的瓶颈在for循环逐条处理后续优化方向是改成批量update然后根据返回的订单集合去批量回补余票——对于毕设项目不必要。4. Vue前端实现路由参数传递、Axios封装与车次查询页4.1 Vue项目初始化与路由设计前端我选Vue3 Vite Element Plus组合Vue2官方已停止维护新项目没必要再用旧技术栈。用Vite创建项目比Vue CLI快很多命令是npm create vitelatest ticket-frontend -- --template vue然后npm install安装依赖。页面规划需要四个视图登录/注册页、车次查询页、订单确认页、个人中心含我的订单和退票入口。路由配置是本系统的骨架。车次查询页需要在点击某个车次后跳转到订单确认页并且把车次ID带过去——这就是“vue路由参数”的典型场景。有两种传参方式路径参数和query参数。路径参数更适合车次ID这种资源标识query参数适合搜索条件回显。具体配置如下// router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, redirect: /search }, { path: /search, component: () import(../views/SearchView.vue) }, { path: /order/confirm/:scheduleId, component: () import(../views/OrderConfirmView.vue), meta: { requiresAuth: true } }, { path: /orders, component: () import(../views/MyOrdersView.vue) }, { path: /login, component: () import(../views/LoginView.vue) } ] const router createRouter({ history: createWebHistory(), routes })/order/confirm/:scheduleId这种写法在组件里通过route.params.scheduleId取值页面刷新后参数依然在URL上不会丢失这是比store传参更健壮的做法。meta.requiresAuth配合全局前置守卫实现登录拦截用户未登录访问订单确认页时重定向到登录页登录成功后用redirect参数跳回原目标页面——这是几乎每个后台系统都要写的通用逻辑。跳转时用router.push({ path: /order/confirm/ id })不要用window.location.href否则整个应用会重新加载。4.2 Axios实例封装统一处理Token、错误码和加载态前后端分离后接口请求必须统一走一个Axios实例不能在每个组件里裸调axios.get。封装的目的有三个请求头自动携带token、响应拦截器统一处理登录过期跳转、错误提示统一用Element Plus的Message组件。下面是简化版封装// utils/request.js import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器附带token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) // 响应拦截器处理业务码和HTTP异常 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } else if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(登录已过期)) } else { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )后端接口统一返回{ code, message, data }这种结构code200表示业务成功code401必须是全局统一的未登录标识。这里有一个不少教程里忽略的细节baseURL配置成/api而不是完整的后端地址是为了配合开发环境的Vite代理解决跨域问题——前端跑在5173端口后端跑在8080端口浏览器直接请求会触发CORS而通过代理转发则完全绕开跨域限制。响应拦截器里把res.data直接返回给调用方业务代码里拿到的就是data部分不用每个页面都写res.data.data这种冗长链。4.3 车次查询页到订单确认页的数据流转车次查询页是系统的主入口核心逻辑是用户选择出发城市和日期点击查询后调后端接口拿到车次列表列表项里展示发车时间、到达时间、票价和余票数。查询条件通过query参数传给后端翻页和重新查询都走同一个接口。表格里每一行放置“预订”按钮点击后跳转到订单确认页并携带scheduleId。订单确认页则需要在onMounted生命周期里根据route.params.scheduleId重新查询车次详情再让用户填写乘客姓名和身份证号。前端校验乘客身份证号时因为身份证号码长度是18位输入框要限制maxlength18并且使用oninput过滤非数字和X字符。提交订单后拿到orderNo跳转到“待支付页面”展示订单号和模拟支付按钮支付完成后跳转到“我的订单”列表——这一串页面流转在本系统中占据了大概60%的前端工作量组件间共享的状态只有用户登录信息和订单号前者放localStorage后者用路由query传递不需要引入Pinia保持简单。4.4 Vite开发代理与生产环境接口地址切换开发环境跨域代理配置在vite.config.js里export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 后端Controller的RequestMapping若带/api前缀则不需要重写 // 若后端没有/api前缀则取消注释下面这行 // rewrite: path path.replace(/^\/api/, ) } } } })changeOrigin: true会让后端收到的请求头里的Host变成target地址避免部分后端框架对Host的校验报错。这里最常遇到的坑是/api前缀重复如果后端Controller写的是RequestMapping(/api/schedule)前端baseURL设/api后请求的是/api/api/schedule还是/api/schedule取决于代理里有没有rewrite。建议前后端约定俗成后端所有接口都以/api开头前端baseURL设为/api代理不做rewrite这样最直观且不会出歧义。生产环境下前端打包后的静态文件由Nginx托管Nginx里再单独配置location /api转发到后端服务这套部署方案在第5章展开。5. MySQL安装配置、SpringBoot版本冲突与部署验证中的高频坑5.1 MySQL安装和驱动连接的四个经典报错MySQL社区版下载安装本身不复杂但毕设项目里最常见的“查不到数据”或“服务起不来”往往出在配置细节上。Windows下安装MySQL 8.x时如果使用msi安装包安装过程会让你设置root密码和选择端口默认3306不用改解压版则需要在my.ini里配置basedir和datadir路径后用管理员权限执行mysqld --initialize-insecure初始化数据目录。初始化完成后net start mysql启动服务。连接层面最容易出问题的四个点# 查看MySQL服务状态Windows sc query mysql # 登录MySQL并查看字符集 mysql -uroot -p SHOW VARIABLES LIKE character_set_server; # 检查时区 SHOW VARIABLES LIKE %time_zone%;第一连接串必须带时区参数jdbc:mysql://localhost:3306/ticket?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8不带serverTimezone在MySQL 8.x下会直接报The server time zone value错误。第二驱动类名是com.mysql.cj.jdbc.Driver新版驱动里旧的类名还在但打了废弃标记。第三MySQL 8.x默认认证插件是caching_sha2_password如果项目里用了5.x时代的旧驱动5.1.49以下会报Unable to load authentication plugin升级驱动到8.0.33即可。第四建库时统一CREATE DATABASE ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;不要用默认的utf8mb4_0900_ai_ci因为老版本工具和部分连接框架对0900排序规则支持不完整。5.2 SpringBoot版本太高导致的依赖冲突处理很多新手直接在Spring Initializr上选最新版SpringBoot然后往pom里加网上找的旧版依赖坐标编译期没问题运行期疯狂报ClassNotFoundException或NoSuchMethodError。这里要明白版本管理机制SpringBoot的spring-boot-dependenciesBOM统一管控了所有全家桶依赖的版本只要pom里声明了spring-boot-starter-parent就不需要给spring-boot-starter-web、spring-boot-starter-validation这些写version。但MyBatis-Plus不在BOM里如果自己指定的版本和SpringBoot不兼容就会出问题——例如MyBatis-Plus 3.5.3以下版本在SpringBoot 3.x下会因无法识别mybatis-plus-spring-boot3-starter而直接启动失败。遇到依赖冲突时第一件事是看启动日志里最早的异常栈是Error creating bean with name还是NoClassDefFoundError。前者一般是组件扫描路径或配置类问题后者才是真正的jar包缺失。控制依赖树用命令mvn dependency:tree -Dincludesorg.mybatis能清晰看到当前生效的MyBatis版本。稳妥方案是SpringBoot 2.7.x就用mybatis-plus-boot-starter3.5.2SpringBoot 3.x就换用mybatis-plus-spring-boot3-starter3.5.5以上不要再踩老教程的坑。5.3 Nginx部署前端静态资源与后端接口分离部署阶段最省心的方案是后端打jar包直接java -jar ticket-server.jar运行前端npm run build生成dist目录交给Nginx托管Nginx按请求路径分流——静态请求走前端文件/api开头的请求反向代理到后端8080端口。Nginx配置核心片段server { listen 80; server_name your-domain.com; # 前端静态文件 root /opt/ticket-frontend/dist; index index.html; # Vue Router的history模式需要此配置防止刷新404 location / { try_files $uri $uri/ /index.html; } # API反向代理 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 $uri $uri/ /index.html;是Vue history路由必配项刷新/order/confirm/12时Nginx找不到真实文件就回退到index.html由前端路由接管。proxy_pass后面如果写http://127.0.0.1:8080会把原始的/api/xxx路径完整转发给后端如果写http://127.0.0.1:8080/带斜杠则会把/api前缀剥掉——两种都行但必须和后端Controller的RequestMapping前缀保持一致否则404。部署之后用curl -I http://localhost/api/schedule/list验证代理是否生效看返回的是200还是502502就检查后端进程是否还活着、端口是否真的在监听netstat -tlnp | grep 8080。5.4 下单并发与订单状态流转的验证清单系统联调完毕最后一步不是写论文而是验证核心业务闭环注册账号→登录→查车次→下单→支付→出票→取消/退票→余票回补。验证过程中用下面的清单逐项过能覆盖掉90%的功能缺陷验证项目操作方式预期结果超卖防护两个浏览器同时下最后一趟车次的最后一席位只有一个成功另一个提示余票不足超时关单下单后不支付等15分钟后查看订单列表订单状态变为已取消余票恢复重复支付支付成功后再次点击支付按钮接口幂等不产生重复出票权限拦截未登录直接访问/order/confirm/1跳转登录页登录后回到原页面订单快照修改车次票价后查看历史订单金额历史订单仍保留下单时票价并发测试不要打开两个页面手动点那根本测不出竞态条件——用JMeter开50个线程同时请求下单接口观察数据库里是否出现超卖记录。这类并发验证在面试里会被问到“你怎么证明你的系统没有超卖”能答出来而且能现场演示的和只贴代码说“我加了锁”的是两种评价。另外提醒一句写毕设论文时ER图、用例图、流程图这些素材在开发阶段就顺手从设计文档里截图保存等代码写完再补图会非常痛苦图里的表字段以你的实际代码为准不要照抄网上模板和数据库中不一致。本文还有配套的精品资源点击获取