资讯动态

演出购票系统实战:SpringBoot+Vue高并发库存扣减与订单超时处理

发布时间:2026/9/9 15:04:30 来源:尧图企业网站定制
1. 购票系统的项目边界与前期设计思路1.1 这个系统到底在解决什么问题先说结论演出购票系统是一个典型的前后端分离 高并发库存扣减 订单状态机综合体它和普通的管理后台根本不是一回事。很多人把它当 CRUD 去做数据库表一建、页面一通渲染就算完事可真到联调和压测阶段问题全出来了。我们先还原一下业务流程。用户打开系统看到演出列表演唱会、话剧、音乐节点进详情选择场次进入选座页面锁定座位提交订单支付最后通过订单拿到电子票。后台这边管理员维护演出信息、场次座位、上架下架运营能看到售票数据。如果只是写个“平台”这套东西确实不难但购票的核心从来不在展示而在交易链路的正确性和并发场景下的数据一致性。我在实际做这个项目的时候最先想清楚的是三件事一是座位到底怎么建模二是库存扣减怎么做才不超卖三是订单超时未支付后座位怎么释放。这三个问题想不清楚后面全是打补丁。1.2 技术选型SpringBoot Vue 是不是最优解先说框架。SpringBoot做后端Vue做前端这个组合在目前的 Java 全栈项目里基本就是标准答案。SpringBoot 解决了 Spring 早期 XML 配置地狱的问题内嵌 Tomcat、自动装配、starter 一键引入依赖几分钟就能把一个可运行的工程拉起来。Vue 的好处是组件化 响应式选座页面的座位状态变化、购物车式的订单确认、支付倒计时这类交互用 Vue 写起来很顺手不像 jQuery 时代那样得手动操作 DOM。有人会问那我用 JSP Servlet 不行吗可以但你在面试和实际协作中会非常吃亏。前后端分离意味着前端可以独立部署到 Nginx后端只提供 JSON 接口分工明确、扩展方便而且 Vue 生态里有 Element Plus、Ant Design Vue 这种现成组件库后台管理页面半天就能搭出来效率完全不是一个级别。需要提醒一句技术选型不要盲目追新。SpringBoot 3.x Vue 3 的组合没问题但如果你是参考旧教程或者要用一些老的轮子建议选 SpringBoot 2.7.x 和 Vue 2/3 都行。稳定优先于版本新潮这是我踩过坑后最深的体会。2. 后端核心模块拆解表结构、配置与接口落地2.1 数据库设计别把“票”设计成一张表这是整个项目成败的关键我要多说几句。很多新手拿到购票系统第一反应是建一张 ticket 表带个库存字段卖一张减一。这个思路对简单商品可以但对演出场景是错的。原因在于演出票是有“座位”属性的商品你要回答的不是“还剩多少张”而是“剩下的是哪几个座位”。把座位和票混在一张表里订单关联会变得又乱又难扩展。我实际用的核心表结构是这样的表名关键字段作用t_showid、name、poster、description、status演出基本信息t_sessionid、show_id、hall_id、start_time、end_time、price、total_stock、stock某一场次库存挂在场次上t_seatid、session_id、row_no、col_no、status0可用/1锁定/2售出座位维度绑定场次t_orderid、order_no、user_id、session_id、total_amount、status0待支付/1已支付/2已取消/3已退款、expire_time订单主表t_order_itemid、order_id、seat_id、seat_row、seat_col订单和座位的关联t_userid、username、password、phone、nickname前端登录用户这里有两个设计要点值得展开。第一个是座位数据放在场次下还是演出下。我选了场次级即每创建一个场次就批量生成这个场次对应厅的座位记录。好处是隔离性好一个场次卖完了不影响另一个场次坏处是座席多的时候数据量大。不过对中等规模的演出几千座完全够用批量插入很快。第二个是库存字段要不要冗余。t_session 里的 stock 是冗余字段因为理论上可以从 t_seat 统计出来。但为什么不直接 count因为购票接口要判断“还有没有票”数据库里 count 全表然后判断性能差而且座位状态有“锁定”这种中间态统计口径容易乱。冗余一个 stock 字段用原子更新扣减查询和更新都高效。订单表也要单独说。order_no 尽量用时间戳 随机数生成或者干脆用雪花 ID别用自增 ID 对外暴露容易被人遍历出业务量。订单和座位的关系是一对多一个人可以一次买多张票所以要有订单明细表。退款、改签的时候明细表的作用就体现出来了。2.2 SpringBoot 工程结构与关键配置工程结构我用的是经典的四层controller → service → mapper → entity额外加一个 config 包放配置类一个 common 包放统一返回和异常处理。这种分层在毕业设计和实际团队里都通用不要搞花活。启动类、pom.xml 这些不细说重点聊几个配置上的坑。第一个是yml 里的敏感信息。数据库密码、JWT 密钥直接写在 application.yml 里项目一传到 Git 上就相当于裸奔。我后来用了 Jasypt 做配置项加密密钥通过环境变量传入。用法很简单pom 里加依赖然后用工具类将明文密码加密成密文yml 里写ENC(xxx)。spring: datasource: url: jdbc:mysql://localhost:3306/ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: ENC(加密后的密文) redis: host: localhost port: 6379 servlet: multipart: max-file-size: 100MB max-request-size: 100MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第二个是启动时自动建表。用 MyBatis-Plus 的话可以自己写一个数据库初始化组件在项目启动时执行建表 SQL。注意 MySQL 的高版本对 utf8mb4、时区要求比较严格连接串里一定要带serverTimezoneAsia/Shanghai否则插入时间数据会报错。这个坑我帮别人排查过很多次每次都是时区问题。第三个是Java 版本和 SpringBoot 版本匹配。如果你本机是 Java 17就不要硬去用 SpringBoot 2.x 的老版本虽然能跑但是会有一些兼容性警告。反之如果跟着教程用 SpringBoot 2.7.x装了 Java 11也不要硬换 Java 17。版本不匹配导致启动失败的案例太多了。2.3 登录鉴权与接口统一规范购票系统的接口基本都要求登录后才能操作尤其是下订单。这个不用搞复杂的 OAuthJWT 无状态鉴权就够了。流程很简单用户登录成功后后端生成一个 token包含用户 ID、过期时间前端存到 localStorage 里每次请求在 header 里带上Authorization: Bearer token后端拦截器校验 token 合法性并把用户信息放入 ThreadLocal 或请求上下文。我这里贴一个拦截器核心逻辑Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 if (request.getRequestURI().contains(/api/user/login) || request.getRequestURI().contains(/api/user/register)) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); // 解析 token失败会抛异常 Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } // 未认证统一返回 401 response.setStatus(401); return false; } }还有一点接口返回结构一定要统一。我定义了一个ResultT类包含 code、message、data 三个字段所有 controller 方法都返回这个结构。这样前端 axios 封装里可以统一处理错误码不用每个接口单独判断。很多人忽略这个小细节结果联调时前端被各种嵌套 JSON 结构折磨到崩溃。2.4 购票核心接口库存扣减与订单生成购票接口是整个系统里逻辑最复杂、也是最能说清楚项目含金量的部分。我先给一个出错版本的设计思路很多人在这一步就栽了。最直观的想法是先查库存大于 0 就执行更新然后把订单插进去。伪码长这样// 错误示例切勿照抄 int stock sessionService.getStock(sessionId); if (stock 0) { sessionService.reduceStock(sessionId); orderService.createOrder(...); }问题在哪里在并发场景下“查库存”和“减库存”不是原子操作。两个线程同时查到库存为 1都认为能买结果都把库存减了库存变负数订单却生成了两条。这就是典型的超卖。直接能想到的改进是加同步锁或者用数据库乐观锁。同步锁在单机下有效一旦上多台服务器部署就失效乐观锁的话SQL 改成这样UPDATE t_session SET stock stock - 1 WHERE id ? AND stock 0这条 SQL 是原子的数据库层面保证同一条记录只有一个线程能更新成功。返回影响行数如果是 1说明扣减成功如果是 0说明库存卖完了。这个方法简单可靠是目前绝大多数购票系统的兜底方案。但纯靠这一条 SQL还解决不了“选座”场景的问题。因为座位不是数量而是具体到某个位置。两个人同时选中 A 座下单库存减了没问题但 A 座只能属于一个人。所以我在座位表里加了 status 字段锁座的时候也要用原子的 UPDATEUPDATE t_seat SET status 1 WHERE id ? AND status 0更新影响行数为 1表示这个座位抢到了为 0表示这个座位已经被人锁定或售出。这一步执行成功的座位才进入后续创建订单的流程。座位状态的原子化更新是整个购票系统防超卖的第二道防线。3. 前端 Vue 项目的实现细节与联调要点3.1 安装环境与初始化项目很多人在这一步就卡住了。我见过太多同学从网上抄了一堆命令然后对着屏幕报错发愣。这里认真说一遍最稳妥的流程。先装Node.js版本建议 LTS长期支持版比如 18 或 20。装完在终端输入node -v和npm -v能看到版本号就说明装对了。Node 自带 npm但国内网络环境下很多时候下载依赖慢甚至失败建议把 npm 源切成淘宝源npm config set registry https://registry.npmmirror.com然后创建 Vue 项目。我用的是官方脚手架 create-vue它比 Vue CLI 更轻快默认就是 Vite 构建。执行npm create vuelatest它会问你项目名、要不要 TypeScript、要不要 Vue Router、Pinia 等按需选择。注意不要一上来就装一堆依赖等基础项目能跑起来再按功能慢慢加。依赖安装我建议用 pnpm比 npm 快很多还能省磁盘空间。如果你用 npm那就在项目目录执行npm install遇到版本冲突就检查 package.json 里的依赖版本热门组件库之间的版本打架是最常见的问题。3.2 路由、状态管理与请求封装Vue 项目的骨架里我要认真讲的是三块路由、状态管理、axios 封装。这三块是前后端分离项目的神经中枢。路由方面购票系统页面层级不多但要注意两点一是路由守卫未登录用户不能进入下单页面二是路由传参比如从演出详情页跳转到选座页要带 sessionId有两种方式query 参数和 params 参数。query 会出现在 URL 上刷新不丢失params 要配合路由配置里动态路径:sessionId否则刷新后参数会丢失。我实际项目里选座页跳转用的是动态路由 params语义更清晰。// 路由配置 { path: /select-seat/:sessionId, name: SelectSeat, component: () import(/views/SelectSeat.vue), meta: { requiresAuth: true } } // 跳转 router.push({ name: SelectSeat, params: { sessionId: row.sessionId } })状态管理用的是 PiniaVue 3 推荐。注意不要把整个用户信息乱塞只存 token 和用户 ID其余个人信息如昵称、头像用的时候再去查。因为登录态下一步要用于 axios 请求头这样处理最干净。请求封装是重点。axios 如果直接裸调每个页面都要处理 token 和错误码代码会非常冗长。我封装了一个全局实例import axios from axios import { useUserStore } from /stores/user import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { // 跳转登录页 } ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )走到这一步前后端接口联调就算打通了一大半。3.3 选座页面和订单确认的交互细节选座页是这个项目交互难度最高的页面。核心逻辑是渲染一个二维数组行是排列是座位号。每个座位根据后端传过来的状态显示成不同的颜色可选、已锁、已售。用户点击选中再次点击取消前端维护一份“本次选择座位”的数组最后带着座位 ID 列表去下单。需要特别说明的是锁定策略为了体验好一点我做的方案是用户点击“提交订单”时才去后端锁座因为用户选座过程中反复点击如果每个动作都请求后端锁座数据库压力会很大。如果用户锁座成功后一直没有支付系统会在超时后自动释放。这里有个小细节容易被忽略前端选座数组和后端座位的对应关系。座位 ID 是后端生成的前端拿到座位数据后要保留 ID不能只存行列号。否则提交订单时你发现你根本没有座位 ID只能再查一遍多一次交互多一份出错概率。订票数量方面我在前端限制单笔订单最多买 6 张票。这不是后端硬性规定而是业务场景需要演唱会通常有连坐需求但又不希望你一个人把一个区域买干净。4. 并发、超卖与订单超时的处理方案4.1 为什么普通写法一定会超卖我在上面说了简单的超卖场景这里再往深一层挖。假设库存是 5 张票同时来了 10 个请求。如果采用“查库存-判断-更新”的流程即使你把判断逻辑写成一整块 synchronized一旦系统部署成多实例比如两台服务器nginx 负载均衡锁只对单台机器有效两台机器同时查到库存 5同时各自减一结果库存变 3可是有两个人买到了同一张票。这就是分布式场景下的并发问题。所以并发控制的本质是把“检查 更新”做成一个原子操作。数据库层面用条件更新可以做到Redis 层面的 Lua 脚本也可以做到。两者各有利弊下面展开说。4.2 基于 Redis 预扣 数据库兜底的方案在实际项目中我在购票接口上做了一层 Redis 库存预扣目的不是炫技而是挡住瞬时高并发对数据库的直接冲击。思路是这样的在演出场次上架时把场次剩余库存同步到 Rediskey 用stock:{sessionId}。购票请求进来先执行一段 Lua 脚本-- 判断库存是否大于 0是则减一 if redis.call(get, KEYS[1]) and tonumber(redis.call(get, KEYS[1])) 0 then return redis.call(decr, KEYS[1]) end return -1脚本返回大于等于 0 的值说明预扣成功继续走后续创建订单逻辑返回 -1说明没票了直接返回。Lua 脚本是原子执行的Redis 单线程模型保证同一时刻只有一个请求能成功扣减这里不会出现超卖。注意Redis 预扣成功并不代表数据库扣减成功。你在 Redis 扣了真正落库的时候数据库的 t_session.stock 仍然要执行那条UPDATE ... WHERE stock 0的条件更新。也就是说Redis 是第一道快速校验和流量缓冲数据库是最终一致性兜底。两道都过了才真正把订单创建出来。还有一个关键点如果用户下单后取消或超时未支付要把 Redis 和数据库的库存都加回来否则库存会被慢慢扣光。这会和下面的订单超时处理联在一起做。4.3 订单超时未支付自动取消购票系统必须处理“锁座后不支付”的问题否则座位全被锁死其他人买不了。最常见的实现有两种。第一种定时任务扫描。每隔 30 秒查一次订单表把超过支付时限比如 15 分钟且状态还是待支付的订单修改为已取消同时释放关联座位回补库存。优点是不需要额外中间件实现简单缺点是有延迟最坏情况快 30 秒才释放数据量大时数据库压力不小。第二种RabbitMQ 延迟队列。创建订单时发送一条延迟消息15 分钟后投递给消费者消费者去检查订单状态如果还是待支付就执行取消和回滚。优点是很精准几乎无延迟缺点是要多引入 MQ对部署环境要求更高。我在毕设级别和真实小项目中推荐先用定时任务。因为购票系统压力一般没有大到连 30 秒一次扫描都扛不住而且定时任务排查问题更容易不需要理解消息队列的复杂概念。等到有真实并发需求了再迁移到延迟队列也不迟接口层面不用大改。一个实操细节扫描数据库时不能一条一条更新要写一条批量 SQL比如UPDATE t_order SET status 2 WHERE status 0 AND expire_time NOW()。别忘了同时把座位状态回滚并且把 Redis 库存也加回去。这三个动作最好放在一个事务里保证原子性。5. 常见问题与排查技巧实录5.1 跨域、端口与联调时的老大难前后端分离项目第一个拦路虎永远是跨域。Vue 开发服务器默认跑在 5173后端在 8080浏览器直接发请求会被 CORS 拦截。最快的解决方案是在 Vite 配置里做代理// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里请求/api/xxx时开发服务器会把请求转发到http://localhost:8080/api/xxx浏览器看到的是同源请求就绕开了 CORS。注意部署到生产环境时通常用 Nginx 做同类型代理而不是在后端开启 CrossOrigin 一放了之。端口冲突也是高频问题最常见的是 8080 被占。在 Windows 上用netstat -ano | findstr 8080查到占用进程然后任务管理器杀掉简单粗暴但很有效。还有一个跟 IDEA 相关的坑IDEA 创建 SpringBoot 项目时卡住或报超时通常是因为访问 Spring Initializr 默认地址https://start.spring.io被网络拦住。解决办法是把初始化服务的 URL 改成阿里云镜像https://start.aliyun.com。改完秒建项目。5.2 数据一致性相关的诡异问题排查这里我把实际遇到过的几个诡异问题整理一下未必每个项目都会遇到但遇到了真的会卡很久。现象一明明库存没扣页面却显示已售罄。排查方向Redis 里stock:{id}是不是被扣成了 0我说过 Redis 预扣和数据库更新是两层如果 Redis 扣了但后续数据库事务失败了Redis 没有回滚就会出现这种不一致。解决思路在事务回滚的 catch 块里把 Redis 库存加回来。如果项目里有分布式事务中间件可以用那就更稳但学生项目用不上。现象二订单已取消但座位仍然显示已锁定。排查方向定时任务取消订单的 SQL 是否真的执行了座位释放逻辑是否在同一个事务里。我见过有人把释放座位的代码写在校验条件之外结果 SQL 执行了座位释放逻辑被跳过这种代码 review 都不容易看出来。现象三前端启动后 browser 显示 Network 不可用。这个和网络环境、代理工具有关但也可能是浏览器安全策略拦截了。只访问本机开发地址时最简单是清空浏览器缓存和 LocalStorage再重启开发服务器。定位方法打开浏览器开发者工具看一下 Network 面板里具体哪个请求失败了再顺藤摸瓜。现象四演出详情页要播放预告片video 标签放 m3u8 流播不了。这是 H5 原生视频标签的限制需要用 hls.js 或 video.js 来解析 m3u8再喂给 video 标签。这个需求和购票系统本身没有必然关联但如果你加了演出视频模块这是绕不开的。5.3 项目部署与打包时的细节前端打包是npm run build产物在 dist 目录。后端是mvn clean package产物是 jar 包。生产环境我用 Nginx 部署前端反向代理/api到后端地址server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index 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; } # history 路由刷新 404 修复 location / { try_files $uri $uri/ /index.html; } }这里最容易踩的坑就是try_files这一行。如果你的 Vue 路由用了 history 模式刷新/select-seat/123这个地址时 Nginx 会去找该路径下的真实文件找不到就 404。加上try_files ... /index.html后所有前端路由都回退到 index.html由 Vue Router 自己处理。如果你用 hash 模式则不需要这一行但 URL 会带#不太好看。后端 jar 包启动也别直接用java -jar裸启动用nohup java -jar app.jar logs/app.log 21 拉起来落日志也方便排查这是我常用的生产启动方式。6. 写在最后的个人体会做这个演出购票系统最值得的不是你学会了 SpringBoot 几个注解、Vue 的几个生命周期函数而是你接触到了真实业务系统里最核心的交易与一致性问题。CRUD 练习做一百个不如把一个购票流程从头到尾跑通畅一次这中间涉及的数据库设计、并发控制、订单超时处理、前后端联调、部署上线任何一环出问题都要花时间排查而这些排查经验才是面试时能讲出来的真东西。我个人体会最深的一点是不要在一开始就把方案设计得过于复杂。Redis 预扣、延迟队列、分布式事务这些名词听起来很高级但如果你的业务量根本没有到那个程度它们只会增加你的调试成本。先把最简单可靠的方案跑通——数据库条件更新防超卖、定时任务关单、Nginx 代理部署——然后在面试或项目总结时把“如果量大了我会怎么演进”的思路讲出来这才是务实的学习路径。最后分享一个很小但特别实用的技巧开发联调阶段把后端接口的访问日志和前端 axios 的请求日志同时打开出问题时两侧对照着看会比对着页面猜来猜去快十倍。购票系统的业务链路长从选座到支付涉及四五次请求日志就是你的眼睛。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价