首页 / 资讯中心 / 文章详情

Spring Boot+Vue全栈演出票务系统设计与高并发实践

Spring Boot+Vue全栈演出票务系统设计与高并发实践 ★ FEATURED ARTICLE
做文化艺术演出票务系统这个项目我前后折腾了大半年从零开始搭了一套基于Spring Boot Vue的全栈平台期间还融入了Node.js相关的工程化工具链。今天把这套系统的整体设计、核心技术点、实操过程中踩过的坑一次性梳理出来给有类似需求的朋友做个参考。这个项目最终交付的是一套包含用户端、管理员端、票务核心链路以及活动推广模块的完整系统。用户可以在线浏览演出、选座下单、支付购票管理员可以维护演出场次、管理座位图、处理订单、配置优惠活动。如果你正准备做演出票务、展览预约、影院选座这类带“场次库存座位”模型的项目这篇文章里的很多方案可以直接抄作业。1. 项目整体设计与技术选型思路1.1 为什么是Spring Boot VueNode.js在其中扮演什么角色先明确一个概念这个标题里出现的三者不是对等关系。Spring Boot是后端主框架负责业务逻辑、数据持久化、接口输出Vue负责前端界面渲染Node.js则贯穿在前端工程化链路里——npm、Vite、Webpack这些构建工具都跑在Node运行时上联调阶段的Mock服务、接口代理也是Node生态的常见玩法。选这套组合的核心原因是分工明确、资料多、招人成本低。Spring Boot在Java后端里属于“开箱即用”的类型内置Tomcat、自动配置、生态成熟适合快速搭建这种需要稳定事务处理能力的业务系统。Vue在中文社区里的普及率极高Element UI / Element Plus这类组件库直接解决后台管理的页面需求开发效率比传统模板引擎高一个量级。Node.js参与进来的位置我放在了三个层面一是前端工程化工具链。项目用Vite作为开发服务器所有依赖安装、本地启动、打包构建都通过npm脚本完成。Vite基于ESM冷启动秒级热更新体验比老一代Webpack舒服太多。二是开发期的Mock中间层。因为前端和后端是并行开发的后端接口还没就绪时我用Node.js起了一个轻量Mock服务按照约定好的接口文档返回模拟数据前端不阻塞。这个中间层在联调完成后直接下线不会污染生产代码。三是静态资源托管与部署。前端打包产物是纯静态文件我放在Node.js驱动的静态服务器里做内网测试外网正式环境则交给Nginx统一托管。这个细节在后续部署章节会细说。1.2 票务系统的核心业务流程拆解文化艺术演出票务系统表面看就是个“商品订单支付”的电商系统但它和普通电商有个明显差异票务强依赖场次和座位两个维度。普通卖货是“一件商品对应一个SKU库存就是数字”演出票务是“一个演出有多个场次一个场次有多个座位每个座位本身就是一个唯一库存单位”。这个差异决定了整条业务链路的复杂度。我把用户侧的核心流程拆成五步浏览演出列表查看详情页里的演出介绍、场次时间、场馆位置选择场次后进入选座页根据场馆座位图勾选座位提交订单系统锁定座位并生成待支付订单在支付倒计时内完成支付我设定15分钟有效支付成功生成电子票二维码演出当天扫码验票入场。管理侧流程相对集中演出方管理员创建演出项目 → 为项目配置场次 → 为场次导入或绘制座位图 → 设置票价规则 → 上架后用户可见。再加上订单管理、退款处理、优惠活动配置、验票核销几个模块。这套流程里最容易出问题的不是CRUD本身而是座位锁定的并发一致性、订单超时释放、支付回调幂等这三个点后面我会单独展开。1.3 活动推广模块的设计考量标题里把“活动推广”和“票务系统”并列说明这不是一个锦上添花的模块而是拉新促活的核心手段。我实现的推广能力包括五类优惠券满减券、折扣券、新人立减券支持限定适用范围和有效期限时秒杀指定场次在某个时间点开放低价座位限时限量分享裂变用户分享演出链接给好友好友购票成功后分享者获得奖励金或积分积分体系购票获得积分积分可抵现、可兑换周边场次预热未开售场次的“开售提醒”订阅到点推送通知。这个模块的设计重点不在于功能多而在于把推广行为埋进核心购票流程里。比如领取优惠券之后结算页要自动展示可用券分享链接要带上渠道参数秒杀结束前要有倒计时提示。所有推广功能都必须能追踪到最终订单否则做了一堆活动却衡量不了效果等于白做。2. 系统架构与核心数据模型设计2.1 前后端分离下的目录结构规划前后端分离最怕的就是“代码放得乱七八糟联调全靠问”。项目启动第一天我就定好了严格的目录规范后面所有功能开发都照着这个架子填。后端Spring Boot工程按模块分包src/main/java/com/example/ticket ├── controller # 接口层只做参数接收和结果封装 ├── service # 业务层事务边界在这里 ├── mapper # 数据访问层MyBatis-Plus ├── entity # 数据库实体 ├── dto # 接口出入参对象 ├── vo # 前端展示对象 ├── config # 配置类跨域、拦截器、Redis等 ├── common # 统一返回、异常处理、常量 └── utils # 工具类前端Vue工程src ├── api # 接口请求封装 ├── views # 页面组件 ├── router # 路由配置 ├── store # 全局状态管理 ├── components # 通用组件 └── utils # 前端工具这套结构没什么新奇但关键在于强约束。我在项目文档里明确写了controller里不允许写业务逻辑service里不允许直接操作HttpServletRequest所有接口出入参必须用DTO对象而不是Map。一开始大家觉得麻烦后期联调和排错时才知道这条规矩多值钱。2.2 数据库表设计与关键字段说明票务系统的数据表我总共设计了二十多张核心表有这么几张演出相关表名用途关键字段performance演出项目id, name, category, description, cover_urlsession演出场次id, performance_id, venue_id, start_time, end_time, statusvenue场馆id, name, address, seat_map_jsonseat座位id, venue_id, session_id, row_no, col_no, seat_type, priceseat_occupancy座位占用记录id, session_id, seat_id, order_id, status这里有一个设计取舍要说明座位的价格归属。同一场演出不同区域普通区、VIP区价格不同我把价格挂在seat表上而不是session表上因为座位一旦被“分段定价”价格就是座位的属性但同一场次如果临时调价就得批量更新seat表。另一种方案是搞price_rule表按区域映射动态性强一些项目初期为了简单我用了第一种。订单相关表名用途关键字段orders订单主表id, user_id, session_id, total_amount, pay_amount, status, expire_timeorder_item订单明细id, order_id, seat_id, original_price, pay_price, ticket_code订单状态我用整型枚举0待支付、1已支付、2已取消、3已退款、4已完成已验票、5已过期。订单主表和明细表分离方便一张订单买多张票也方便退单时按明细处理。推广相关表名用途关键字段coupon_template优惠券模板id, type, name, threshold, discount, total_count, limit_per_useruser_coupon用户券实例id, user_id, template_id, status, expire_timeshare_record分享记录id, user_id, session_id, share_code, invitee_id, commission_statuspoint_record积分流水id, user_id, change_value, reason, create_time推广表里最容易被忽略的是share_record它必须记录“分享人→被邀请人→是否购票→奖励是否发放”的完整链路否则推广奖励审计时根本说不清楚。我在share_record上加了一个source_order_id字段奖励只在被邀请人的首单成功后才触发发放。2.3 接口设计与权限模型接口设计上我遵循了RESTful风格但做了实际妥协。比如订单创建用的是POST /api/orders支付回调则是POST /api/payment/callback这是外部支付平台固定的路由没法强行按资源命名。权限模型用了简单的RBAC用户USER、管理员ADMIN、超管SUPER_ADMIN三种角色。用户端和管理员端的接口路由做了物理隔离管理后台所有接口统一挂在/api/admin前缀下通过Spring拦截器校验角色权限。用户端的身份认证用JWT Redis的方案拦截器解析token后从Redis读取用户信息避免每次请求都查数据库。这里有个小教训JWT本身是可以解密的别把敏感信息塞进token里。我只往token里放userId和角色其余信息一律从Redis或数据库获取。3. 核心功能实现与实操细节3.1 演出场次与座位管理座位图是票务系统里用户体验最直观的部分也是技术上最容易翻车的部分。我花了不少时间在座位图的渲染和交互上。座位数据模型上我选择了给每个场次单独存一份座位记录。场次创建时从场馆的静态seat_map_json生成该场次独立的seat记录列表。这么做的原因是不同场次可以有不同的开放区域、不同的价格配置如果所有场次共用一份座位表临时关闭某场次的某片区域就要额外加状态字段逻辑会绕很多弯。前端选座页我用的方案是Canvas绘制座位图交互数据来自后端接口// 座位数据结构 { sessionId: 1001, rows: [ { rowNo: 1, seats: [ { seatId: 1, colNo: 1, status: available, price: 180 }, { seatId: 2, colNo: 2, status: occupied, price: 180 } ] } ] }选座组件只维护一个“本地选中列表”用户每次点击座位时先判断状态available可以选中、occupied置灰不可点、selected表示已选中可取消。确认选座后调用后端锁定接口把选中的座位传到服务端做真正的一致性校验。这里重点说一下座位锁定的实现。多个用户同时选同一个座位后端必须保证“最后只有一个人锁座成功”。我用的是Redis Lua脚本做原子操作// 伪代码锁定座位的Redis原子操作 String luaScript for i1,#KEYS do if redis.call(setnx, KEYS[i], ARGV[1]) 0 then return 0 end end return 1;每个座位在Redis里的key设计为seat:lock:{sessionId}:{seatId}setnx成功表示锁座成功。加锁之后再把锁定信息写入MySQL的seat_occupancy表保持Redis和数据库的一致性。锁超时时间我设置的是15分钟和订单支付有效期保持一致到期后由定时任务清理释放。3.2 下单与支付流程的实现要点订单创建接口是整个系统最核心的一段逻辑我把它拆成了多个步骤并且全程包在一个事务里校验用户登录态校验场次是否还在售票状态校验传入的每个座位是否可售、价格是否正确重新确认座位锁防止前端绕过锁定直接下单计算优惠满减、积分抵扣、优惠券组合创建订单主表和明细表将座位占用状态置为“已锁定”发送订单超时延迟消息用于自动取消。前端的优惠计算只是个预览真正的优惠校验和金额计算必须以后端为准。我在开发初期就定了一条规矩前端传过来的任何金额字段后端都不直接信任所有金额都按订单明细里的座位价格重新累计算优惠券的使用条件也在后端校验。这个规矩帮我后续排查账目问题时省了一大堆事。订单超时释放我用的是延迟消息机制。下单成功后往消息队列发一条延迟15分钟的取消消息消息到了之后检查订单状态如果还是待支付就自动取消并释放座位如果已经支付则忽略。这套方案的优点是不会像定时扫表那样出现“明明超时了但订单还在”的窗口期。支付环节对接的是常见第三方支付平台的扫码支付和APP支付两套接口。支付回调的幂等处理是重中之重我用的方案是// 支付回调处理用订单状态做幂等 synchronized (orderId.intern()) { Orders order orderService.getById(orderId); if (order.getStatus() 1) { // 已支付过的订单直接返回成功不再重复处理 return success; } // 更新订单状态为已支付 // 生成电子票二维码 // 发放积分和推广佣金 }这里有个关键细节orderId.intern()只能保证单机内的互斥如果服务做了多实例部署就得换用Redis分布式锁。锁的key用pay:lock:{orderId}过期时间设置5秒防止极端情况下的死锁。3.3 活动推广模块优惠券、秒杀与分享裂变优惠券模块的难点不在发券而在防止超发和重复领取。超发问题发生在用户同时抢券的高并发场景。因为券模板有一个total_count发行总量如果直接用数据库的update coupon_template set issued_count issued_count 1 where id ? and issued_count total_count在事务并发下会有行锁竞争性能会急剧下降。我改成了Redis预扣减模式// 抢券时先用Redis扣减剩余量 Long remain redisTemplate.opsForValue() .decrement(coupon:stock: templateId); if (remain 0) { // 补偿回加返回库存不足 redisTemplate.opsForValue().increment(coupon:stock: templateId); throw new BizException(优惠券已被抢完); } // 扣减成功后再落库生成用户券记录这个先Redis后MySQL的模式把高并发的库存扣减从数据库压力转移到了RedisMySQL这边只需要保证最终一致性即可。如果扣减Redis失败或者后续落库失败需要通过对账任务把差额补回去。限时秒杀本质上是一种特殊场次我在session表上加了seckill_start_time、seckill_price、seckill_stock三个字段。秒杀场次的座位不参与普通购票流程只能通过秒杀接口购买。接口层面加了简单的频率限制同一个用户对同一个场次的秒杀请求30秒内只放行一次Redis里用INCR EXPIRE实现。分享裂变是最有意思的部分。每条分享链接会生成一个带shareCode的短链接用户打开链接后前端把这个shareCode存入localStorage注册或下单时带上这个参数。订单支付成功后后端根据shareCode查找到分享人发放奖励金。这个链路的关键是shareCode必须跟着首单走用户在分享链接里注册后过了三个月才买票奖励也得算到分享人头上去所以我用Redis把这些信息存了180天。3.4 前后端联调时的Node.js工具链配置联调效率直接决定项目进度这块我踩过不少坑。Vite开发服务器的代理配置是第一个要解决的问题// vite.config.js export default defineConfig({ server: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发时前端跑在8081后端Spring Boot跑在8080Vite把/api开头的请求全部代理到后端完美规避跨域问题。生产环境部署后前端静态文件和后端接口都在Nginx后面同样通过location规则把/api转发到后端服务。跨域这块我在Spring Boot侧同时加了CORS配置作为兜底防止某些浏览器环境下代理不生效的情况。Mock服务是另一个提升联调效率的利器。我写了一个简单的Node.js脚本读取一个JSON格式的接口约定文件自动生成路由返回mock数据。约定文件长这样{ /api/session/1001/seats: { method: get, response: { rows: [] } } }这个方案带来的效果是前端页面开发完全不依赖后端进度后端接口联调时前端只改一个baseURL的配置就能切回真实接口两边互不阻塞。4. 常见问题与排查实录4.1 超卖问题与库存扣减做秒杀活动时第一次压测就暴露了超卖问题。500个并发请求抢100张秒杀票最终成交了103单其中3单没有库存却能支付成功。排查过程是这样的先看订单表发现超出的订单座位状态都是“锁定”但MySQL锁定时有一批请求同时通过了“座位状态available”的条件判断出现了经典的check-then-act竞态条件。修复方案分两层底层加了Redis原子扣减作为前置防线——每个秒杀场次的Redis库存字段在活动开始前初始化抢票接口先DECR检查余量余量耗尽直接拒绝上层在MySQL里给座位占用记录加了唯一索引uk_session_seatsession_id, seat_id保证同一个座位无论如何都不可能产生两条有效的占用记录。唯一索引是兜底Redis扣减是主力两套都上超卖从根上断了。4.2 并发锁座的死锁与活锁锁座逻辑初期用MySQL悲观锁SELECT ... FOR UPDATE压测时发现了死锁。原因是用户同时选中了多个座位我在一个事务里按传入顺序逐条锁座位记录但两个请求锁座位的顺序正好相反就互相等待。解决方案有两个思路一是强制规定座位请求必须按seatId升序排列后再加锁二是改用Redis做锁。我最终采用了Redis方案因为座位锁的粒度本来就细、冲突概率高用独立Redis锁更容易控制超时时间。Redis锁成功之后MySQL侧只是记录占用结果不再承担锁竞争。4.3 支付回调重复通知与订单状态错乱支付平台的回调机制是“持续通知直到你返回成功”所以重复回调是必然的不是偶然事件。我第一版只做了简单的状态判断结果在一个退款场景里出了问题用户支付成功后立即申请退款退款回调先到了订单状态变成已退款随后支付成功的回调又到了因为判断逻辑是“非已支付就更新为已支付”订单又被改回了已支付——钱退了订单却显示已付款直接账目不平。后来我把回调处理改成事件驱动的状态机订单状态只允许按照合法路径流转待支付→已支付→已退款任何不合法跳转直接拒绝并记录告警日志。处理回调的第一步永远是查当前状态然后用分布式锁包住整个状态流转过程重复回调最多是幂等返回不会造成二次状态变更。4.4 前端座位图渲染性能与内存泄漏演出场馆大的时候一个场次有上千个座位Vue组件里如果用原生DOM渲染上千个座位元素选座交互会明显卡顿。我的优化方案是改用Canvas绘制座位图用requestAnimationFrame控制重绘频率座位点击用热区检测而不是DOM事件绑定。内存泄漏问题出在选座页组件销毁后定时器还在运行。用户在支付页待久了退回选座页前一页的锁座倒计时定时器没有清理导致页面内存持续上涨。排查后统一在组件onUnmounted生命周期里清除所有定时器和事件监听这个问题才解决。4.5 常见问题速查表现象可能原因解决方案下单后座位仍被其他人抢走座位锁未持久化到MySQL检查Redis锁和seat_occupancy记录是否双写成功优惠券领取数量超过发行量未做Redis预扣减加Redis库存预扣减逻辑支付成功但订单还是待支付回调URL配置错误或回调被拦截检查支付平台回调配置和后端日志用户重复支付了两笔前端重复提交订单下单接口做幂等同用户同座位短时间内去重部署后前端页面白屏路由为history模式但Nginx未配置try_filesNginx加上try_files $uri $uri/ /index.html;5. 实操心得与后续扩展建议这个项目做下来我最深的体会是票务系统的核心难点不在功能多少而在并发一致性和状态管理。所有业务规则都可以靠堆CRUD实现但“同一个座位只能卖给一个人”“同一个订单只能被支付一次”“优惠券不能超发”这些约束必须在设计阶段就考虑到并发场景而不是等功能上线后打补丁。几个具体的实操建议项目一开始就要区分“票务核心链路”和“周边功能”的优先级。第一步先打通演出管理、场次管理、选座、下单、支付这五个环节形成一个用户能完整走通的闭环优惠券、积分、秒杀、分享这些推广能力都应该在这个闭环稳定之后再加。我见过不少团队一上来就做一堆活动功能结果连票都卖不了完全是本末倒置。座位锁的TTL要和支付有效期严格一致。锁的时间太短用户付款慢一点座位就被释放太长恶意用户占着座位不付款会影响正常销售。我最后定的是15分钟用户下单后支付页有明确的倒计时倒计时结束订单自动取消、座位自动释放每一环都对得上。日志和监控要提前埋好。票务系统涉及钱和库存出了问题必须能回溯到具体请求。我后来给核心接口都加了操作日志记录用户ID、请求参数、响应结果、耗时支付回调做了专门的回调日志表。这个习惯在排查线上问题时帮了大忙。每场演出的座位图用的是Canvas方案后续如果要支持拖拽式座位编辑可以在这个基础上扩展这也是我准备做的方向之一。
阅读完成 · 觉得有帮助?
咨询建站