拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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做登录鉴权,Vue3+Element Plus做管理端界面,用户端可以用Vue3+Vite。这套组合在毕设里属于“稳妥且完整”的标准答案。

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 Initializr(start.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.servlet,MyBatis-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, List<Long> seatIds, Long userId) { // 1. 批量查询座位状态并加行锁,防止并发修改 List<SeatStatus> 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() { List<Order> 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); List<Long> 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或RequestContext,Service层从上下文获取当前用户。

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测通,再去套页面,整体节奏会从容很多。这个项目做到了什么程度,其实取决于你在选座一致性上肯花多少心思——这恰恰是它与其他管理系统最大的分水岭。

返回列表