ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot的演唱会购票系统:选座与防超卖的并发设计

基于SpringBoot的演唱会购票系统:选座与防超卖的并发设计 做毕设选“演唱会线上购票系统”这个题目我第一反应是选对了。这类系统业务链路完整从场次管理、座位布局到订单支付、电子票核销每个环节都有足够的深度可以挖掘又不是那种烂大街到答辩老师看一眼就腻的纯CRUD。而且基于SpringBoot的技术栈既能在简历上写出东西又能在答辩时讲清楚设计思路性价比非常高。这篇我把自己带过的几个类似项目里踩过的坑、沉淀下来的方案完整梳理一遍从数据模型到选座逻辑从防超卖到延迟释放尽量让你能拿着思路直接开工。1. 项目整体设计与思路拆解1.1 核心业务需求解析演唱会线上购票系统表面上是“用户选票、付钱、拿票”但落到实际业务场景里需要拆成几个关键环节首先是演出内容管理主办方要发布演出信息包括艺人、时间、场馆、票价档位其次是座位资源管理每个场次对应一个座位图座位有区域、排号、座号、价格等属性而且不同场次的座位图可能完全不同然后是在线选座与下单用户按区域筛选座位、锁定座位、提交订单最后是订单与票务处理支付、出票、退票、验票。这四条链路里选座和防超卖是整个系统技术含量最高的地方也是答辩时最能打动老师的亮点。如果只是做增删改查那和“学生管理系统”没有本质区别但把“座位状态一致性”“订单超时释放”“分布式锁防并发”这几个点做扎实整个项目的技术深度立刻就不一样了。1.2 系统功能模块划分我把整个系统拆成前台用户端和后台管理端两块。用户端面向普通观众功能包括注册登录、浏览演出列表、查看演出详情与座位图、选座下单、在线支付毕设阶段可用模拟支付、查看电子票、申请退票。管理端面向运营人员功能包括演出场次管理、场馆与座位布局管理、票价设置、订单管理、票务核销、数据统计。这个模块划分是典型的电商前台后台模式既能覆盖完整业务闭环又不会把范围铺得太大导致做不完。实际操作中用户端和管理端复用同一套后端服务前端分两个入口即可没必要拆成两个独立项目否则工作量翻倍还不加分。1.3 技术选型为什么是SpringBoot选题名称里已经锁定了Java与SpringBoot这个组合在毕业设计里确实是性价比最高的选择。SpringBoot的核心价值是自动配置它把Spring生态里繁琐的XML配置全部封装掉了一个main方法就能启动一个可运行的Web服务这对毕设周期来说非常重要——你不用在环境搭建上耗费两周时间。SpringBoot在毕设场景里还有几个隐性优势第一社区资料极多遇到问题搜索一下基本都有答案第二招人方对这个技术栈的认可度极高毕设经验可以直接转化成面试素材第三SpringBoot天然整合了SpringMVC、MyBatis、Redis等常用组件后面想加功能、做优化都有清晰的扩展路径。配套组件方面我推荐持久层用MyBatis-Plus而不是纯MyBatis理由是MyBatis-Plus内置了单表CRUD方法能把常规的数据操作代码量缩减60%以上让你把精力集中在选座、订单这些核心业务上。缓存和分布式锁用Redis验证码用Hutool工具类JWT做登录鉴权Vue3Element Plus做管理端界面用户端可以用Vue3Vite。这套组合在毕设里属于“稳妥且完整”的标准答案。2. 数据库设计与座位状态管理的核心细节2.1 核心数据表结构与关系说明数据模型是整个系统的地基表结构设计是否合理直接决定了后续业务代码好不好写。我设计了一套六张核心表外加若干辅助表用户表user保存账号、密码BCrypt加密存储、昵称、手机号、注册时间。这张表没什么特殊之处但注意手机号要做唯一索引登录和后续电子票绑定都依赖它。演出场次表show这是业务的核心主表字段包括演出名称、艺人、场馆ID、演出时间、开售时间、停售时间、状态。注意“开售时间”和“演出时间”是两个完全不同的字段前者控制用户什么时候可以买票后者是观众什么时候到现场这两个字段搞混会导致业务逻辑完全错乱。座位表seat字段包括场次ID、区域、排号、座号、价格、状态。状态字段是选座系统的灵魂我用0/1/2三个值表示“可售/锁定/已售”。这张表的数据量随场次规模变化一个万人体育馆单场就有一万个座位记录所以必须加场次ID索引。订单表orders字段包括订单号、用户ID、场次ID、座位ID、总价、状态、创建时间、支付时间。订单号我建议用“时间戳随机数”的方式生成不要用自增ID因为订单号会暴露在URL和支付回调里自增ID容易被遍历抓取。电子票表ticket字段包括票号、订单ID、场次ID、座位ID、验票码、核销状态。电子票和订单是一对一关系但单独建表的好处是后续可以做转赠、验票记录回溯不会污染订单表。场次座位状态表show_seat_status这是我在实际项目中沉淀出来的设计。如果座位表直接存状态那么“同一个场馆不同场次的座位状态”就无法区分——A场次卖出去了B场次同一把椅子还是可售的。所以座位状态必须按“场次座位ID”维度存储。这里有两种方案方案一是每创建一个场次就为所有座位生成一批状态记录优点是查询快缺点是数据量大方案二是座位状态存在Redis里用场次ID座位ID做key优点是灵活缺点是宕机会丢状态。毕设阶段推荐方案一配合数据库事务逻辑最简单可靠。2.2 座位状态机与切换逻辑选座系统的核心是一个状态机座位初始状态是“可售”用户点击选座后座位先进入“锁定”状态锁定期间其他用户不可选用户支付成功后“锁定”变为“已售”如果锁定后超时未支付或者用户主动取消座位从“锁定”回到“可售”。“锁定”这个中间状态非常关键。如果没有锁定机制两个用户同时选中同一个座位支付时就会产生冲突如果一选中就直接扣库存改成“已售”那用户未支付期间座位就白白浪费了。所以常规做法是选中即锁定锁定设时限超时自动释放。锁定时限一般设置为10~15分钟。这个值的设定有讲究太短用户来不及支付太长座位资源利用率低。实际操作中我建议10分钟并给用户端一个倒计时提醒时间到了弹窗提示订单即将关闭。2.3 防超卖与并发控制的底层原理防超卖是所有票务系统的命门。想象这样一个场景某场演唱会开售瞬间一万个用户同时盯着内场A区1排1号这个座位如果没有并发控制这个座位可能被十几个用户同时下单成功这就是“超卖”。防超卖的第一道防线是数据库行锁。在更新座位状态时使用SELECT ... FOR UPDATE语句锁定该座位记录其他事务在此事务提交前必须等待。这是最朴素也最可靠的方案适合毕设和中小型系统。但注意行锁在高并发下会带来性能瓶颈——一万个用户抢同一个座位其他人都要排队等待锁释放。第二道防线是Redis分布式锁。用Redis的SETNX命令对某个座位的操作加锁拿到锁的线程才能执行状态更新其他线程直接返回“座位已被选中”。这种方式性能高但需要处理锁超时、优雅释放等问题复杂度更高。第三道防线是乐观锁。在座位表加一个version字段更新时检查版本号是否匹配UPDATE seat SET status 1, version version 1 WHERE id ? AND version ?。如果影响行数为0说明版本已经被其他事务修改本次更新失败。毕设阶段我最推荐的做法是数据库行锁 唯一约束兜底。行锁保证同一时刻只有一个请求能修改某个座位而订单表里对“场次ID座位ID”建唯一索引即使极端情况下出现并发漏洞数据库层面也会拒绝重复下单。这套组合实现简单、原理清晰答辩时能讲得头头是道。3. 实操过程与核心环节实现3.1 项目初始化与基础工程搭建我以一个实际的演唱会购票系统为例走一遍完整的搭建过程。首先在Spring Initializrstart.spring.io上生成基础工程选Java 17、SpringBoot 2.7.x依赖选Spring Web、MyBatis-Plus、MySQL Driver、Redis、Validation、JWT需手动引入jjwt。这里特别提醒SpringBoot 3.x和2.7.x在部分依赖的兼容性上差异很大很多教学视频还在用2.x如果你的环境是JDK 17建议直接锁定SpringBoot 2.7.18这是2.x系列最后一版稳定而且兼容所有主流教程。如果坚持用3.x注意javax.servlet已经改成jakarta.servletMyBatis-Plus也需要使用3.5.3以上的适配版本。项目结构上我习惯按功能分包而不是按层分包com.xxx.ticket ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── dto // 请求/响应对象 ├── config // 配置类Redis、拦截器等 ├── common // 通用工具、返回值封装 └── exception // 全局异常处理按功能分包的意思是比如订单相关的Controller、Service、Mapper可以放在order子包下这样业务内聚度高找代码效率高。对于毕设来说这种结构在答辩讲“项目架构”时也更好描述。3.2 选座接口与座位锁定的代码实现选座是系统最核心的接口。前端传来的参数包括场次ID、座位ID列表后端要做的事情是校验座位是否可售尝试锁定座位创建订单。下面这个是选座并锁定座位的关键代码片段Service public class SeatServiceImpl implements SeatService { Autowired private SeatStatusMapper seatStatusMapper; Autowired private OrderMapper orderMapper; Override Transactional(rollbackFor Exception.class) public LockResult lockSeats(Long showId, ListLong seatIds, Long userId) { // 1. 批量查询座位状态并加行锁防止并发修改 ListSeatStatus statusList seatStatusMapper.selectForUpdate(showId, seatIds); // 2. 校验所有座位是否都是可售状态 for (SeatStatus status : statusList) { if (status.getStatus() ! SEAT_AVAILABLE) { throw new BusinessException(座位已被他人选中请重新选择); } } // 3. 更新座位状态为锁定 int updateCount seatStatusMapper.lockSeats(showId, seatIds, SEAT_LOCKED); if (updateCount ! seatIds.size()) { throw new BusinessException(座位状态更新失败请重试); } // 4. 创建锁定状态的订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setShowId(showId); order.setUserId(userId); order.setStatus(ORDER_LOCKED); order.setExpireTime(LocalDateTime.now().plusMinutes(10)); orderMapper.insert(order); // 5. 创建订单与座位关联记录 orderMapper.insertOrderSeats(order.getId(), seatIds); return LockResult.success(order.getOrderNo(), order.getExpireTime()); } }注意几个细节。selectForUpdate对应Mapper里的SELECT ... FOR UPDATE语句必须在事务内执行否则锁不会生效。校验座位状态和更新状态这两个步骤之间因为有行锁保护不会出现“都通过了校验、然后一起更新”的情况。创建订单和更新座位状态在同一个事务里任何一步失败都会回滚不会出现“座位锁了但订单没建”的数据不一致。3.3 超时未支付订单的自动释放策略锁定的订单10分钟后未支付需要自动释放座位。这里有两种实现方案。方案一是定时任务扫描。启动一个Scheduled定时任务每分钟扫描一次订单表把所有status 锁定且expire_time now的订单找出来批量把座位状态改回可售、订单状态改为已取消。实现非常简单缺点是有延迟——用户可能在超时后一两分钟才看到座位被释放。方案二是延迟队列。用户创建订单时同时往Redis的延迟队列里放一个任务指定10分钟后执行释放操作。第三方库如Redisson提供了RDelayedQueue可以直接用。这个方案实时性好但需要额外引入依赖、处理Redis持久化问题毕设阶段不建议优先选择。我实际带项目时用的是方案一的改进版定时任务间隙设为30秒并加了一个“用户主动取消订单”的接口。用户点击取消时立即释放座位不需要等定时任务。这样既能保证最终一致性交互体验也过得去。释放座位的定时任务代码如下Component public class OrderExpireTask { Autowired private OrderMapper orderMapper; Autowired private SeatStatusMapper seatStatusMapper; Scheduled(fixedDelay 30000) public void releaseExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredLockedOrders(); for (Order order : expiredOrders) { // 手动控制事务边界避免大批量操作长时间占用事务 releaseOrderSeats(order); } } Transactional(rollbackFor Exception.class) public void releaseOrderSeats(Order order) { order.setStatus(ORDER_CANCELLED); orderMapper.updateById(order); ListLong seatIds orderMapper.selectSeatIdsByOrderId(order.getId()); seatStatusMapper.releaseSeats(order.getShowId(), seatIds); } }这里有个经验不要在定时任务方法上直接加Transactional注解对整批订单做事务控制否则处理50个订单时事务要挂50个订单的时间任何一个失败就全部回滚。最好是循环内单个订单依次提交即使个别订单处理失败也不影响其他订单的正常释放。3.4 前端选座交互与座位图渲染前端选座页面是整个项目最直观的“门面”做好了非常加分。座位图我用SVG方案实现——场馆的每个区域对应SVG里一个g标签每个座位是一个rect或circle元素颜色区分状态灰色不可售、绿色可售、红色锁定中、金色已售。后端返回数据时给前端提供一个接口返回某个场次的完整座位列表。前端拿到数据进行渲染// 伪代码座位图渲染核心逻辑 function renderSeatMap(showId) { const seats await fetch(/api/show/${showId}/seats); seats.forEach(seat { const rect createSeatElement(seat); rect.addEventListener(click, () { if (seat.status 0) { selectSeat(seat); // 点击可售座位 - 加入选中列表 } }); }); }选座状态管理要注意用户点了多个座位最后提交的是一整批座位ID所以前端要维护一个selectedSeats数组支持反选取消。同时要实时监听后端推送的座位状态变更——如果有其他用户比你更快锁定了某个座位你选择列表里的这个座位需要被自动移除并提示。毕设阶段做轮询即可每秒请求一次座位状态接口刷新已选座位的状态不要上WebSocket复杂度不划算。4. 常见问题与排查技巧实录4.1 并发下单超卖问题这是所有票务系统在高并发测试时最容易暴露的问题。现象是用JMeter模拟50个用户同时抢同一个座位结果数据库里出现了多条该座位的订单记录。排查思路如下首先检查座位状态更新语句是否有WHERE status 0条件如果没有两条事务读到同一个状态值然后都执行了更新必然超卖其次检查订单表唯一索引是否建好索引缺失会让数据库层面失去最后防线最后检查是否真的用了FOR UPDATE行锁用了行锁后两个事务必须串行更新同一个座位第二个事务读到的就是“已锁定”状态会直接抛出业务异常。我处理过的一个案例是代码里写了Transactional注解但Service方法被同类内部的另一个方法调用导致事务注解失效Spring代理机制问题。表现就是看似有事务实际行锁压根没生效。排查方法是看MyBatis的SQL日志确认更新语句执行时间和提交时间是否在同一个事务边界内。4.2 用户选座后服务宕机导致座位状态异常如果用户在锁定座位后、支付完成前服务突然宕机座位会一直停留在“锁定”状态直到定时任务把它释放。但如果宕机时间超过定时任务间隙订单没有释放用户再进来就会看到座位不可选实际上又不是真的被人买走了。应对方案是在系统启动时加一个恢复任务扫描所有状态为“锁定”但expire_time now的订单立即释放对应座位。同时把定时任务的释放逻辑和宕机恢复逻辑抽成同一个方法保证行为一致。另外上线前做一次完整的故障演练手动把某个订单的锁定时间改成过去重启服务观察座位是否被正确释放。4.3 JWT登录状态失效与接口鉴权遗漏很多毕设在接口鉴权上做得比较粗糙最典型的问题是只在拦截器里校验了“是否登录”但没校验“是否是本人”。比如选座接口只传了座位ID和场次ID后端从Session里取用户ID倒还好但如果前端把用户ID当成请求参数传上来攻击者就可以把用户ID改成别人的替别人下单或操作别人的订单。正确做法是所有涉及用户身份的操作一律从JWT或Session中解析用户ID绝不允许前端传入用户ID作为可信数据。拦截器里解析完Token后把用户信息放入ThreadLocal或RequestContextService层从上下文获取当前用户。JWT本身还有个常见坑密钥硬编码在代码里。虽然毕设阶段不至于被真正攻击但答辩时老师问“JWT密钥怎么管理”能答出“应该放到配置文件或环境变量避免提交到代码仓库”会是个加分项。4.4 座位图加载缓慢与数据量优化当场馆座位上万时一次性返回全部座位JSON数据前端渲染会出现明显卡顿。实测中一万个座位的数据量大约5~8MB网络传输和DOM渲染都是压力。优化方案有两个方向一是接口瘦身座位列表不需要返回全部字段只返回座位ID、区域、排座号和状态二是按区域分片加载用户放大或点击某个区域时才请求该区域的座位数据。实际操作中我通常两个方案一起上配合前端懒加载效果非常明显。4.5 常见问题速查表现象可能原因解决方案多个用户同时买到同一座位状态查询和更新非原子操作使用SELECT ... FOR UPDATE行锁或乐观锁订单支付成功后座位仍是锁定状态更新座位状态的SQL条件错误检查WHERE show_id AND seat_id条件是否正确定时释放订单时批量失败回滚事务粒度过大改为循环内单订单独立事务前端选座后状态不刷新接口未返回最新状态提交选座后主动请求一次座位状态接口管理端上传演出后用户端看不到缓存未刷新或状态字段未置为上线检查发布接口是否更新了状态字段与缓存支付回调重复执行导致重复出票回调接口缺少幂等校验根据订单号加唯一约束或状态判断已支付订单直接返回成功5. 项目深化与答辩亮点构建指南5.1 从普通CRUD到有亮点的系统如果一路做下来你的系统已经有了完整业务闭环但要在答辩中脱颖而出还需要几个别人没有的亮点。我个人推荐三个方向按性价比排序第一个是座位推荐功能。根据用户选择的票价档位系统自动推荐该区域内“最靠中间、最靠前”的空闲座位。实现逻辑不复杂按区域分组后计算每个座位的“中心距离”得分排序取最前面空闲座位即可。这个功能能直接展现你对业务场景的理解。第二个是热点场次的缓存优化。热门演唱会开售时演出详情页会被大量访问。把场次信息、座位剩余数量缓存到Redis设置过期时间缓存命中时直接返回大幅降低数据库压力。答辩时讲“缓存穿透、缓存击穿、缓存雪崩”三个概念的应对策略老师会眼前一亮。第三个是数据看板。管理端加一个简单的统计页面展示每日订单量、销售额Top3的场次、退票率等指标。数据源用订单表和场次表做聚合查询即可不需要引入大数据组件。这个功能能让系统看起来更完整也体现“数据驱动运营”的意识。5.2 答辩高频问题与作答思路毕设答辩问到系统大概率会集中在三个层面。业务层面老师会问“选座时两个人同时选同一座位怎么办”——顺着高并发处理思路把行锁、乐观锁、Redis分布式锁三个方案对比讲一遍即可技术层面老师会问“为什么用SpringBoot不用SSH/SSM”——回答自动配置简化开发、生态完善、内置容器易于部署三点就够了设计层面老师会问“座位状态为什么单独建表”——解释不同场次同一座位的状态独立以及MySQL单表数据量过大的隐患。还有一个容易翻车的点代码必须能当场跑起来。答辩现场演示环节网速不稳定、数据库连不上、前端白屏的情况我见过太多次了。提前准备一个本地环境的备用演示包前端打包成静态文件放进SpringBoot的static目录实现单进程启动就是完整系统这样就算现场断网演示也能正常走完。5.3 项目后续扩展的预留空间如果做完了还有余力或想在简历上写得更丰满可以考虑几个扩展方向。一是接入真实的支付网关SDK如支付宝沙箱环境把模拟支付替换成真实支付流程这个在简历上很有分量二是引入RabbitMQ做订单超时消息通知把定时任务升级成事件驱动架构三是增加Selenium自动化测试脚本对核心流程做端到端回归验证。不过我要提醒一句扩展功能不是越多越好。毕设的核心是把你做的东西讲清楚、说明白一两个亮点足够多了反而会分散答辩老师的注意力也可能因为某个功能没做完善而被追问得体无完肤。最后分享一个带项目过程中的真实体会很多学生一开始容易陷在“把页面做漂亮”里花大量时间调前端样式结果核心的选座并发问题反而没处理好。我的建议是先把后端核心链路打通再同步做前端。后端先把“选座锁定-支付出票-释放座位”这个闭环用Postman测通再去套页面整体节奏会从容很多。这个项目做到了什么程度其实取决于你在选座一致性上肯花多少心思——这恰恰是它与其他管理系统最大的分水岭。
返回列表