ARTICLE DETAIL

资讯详情

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

基于SpringBoot的演唱会购票系统:座位锁定与并发控制实战

基于SpringBoot的演唱会购票系统:座位锁定与并发控制实战 在各大毕业设计选题库里基于 Java 的演唱会线上购票系统算是热门常住户SpringBoot 版本更是一抓一大把。同一个题目有人三天拉一个现成源码换层皮交差有人却能把它做成简历上的硬技能、答辩现场被追问十分钟也不怕的项目。差别往往就藏在几个看似不起眼的问题里一场演唱会到底怎么建模座位什么时候算被锁定用户支付超时了座位怎么释放电子票怎么保证不能重复入场这篇文章我会把一套基于 SpringBoot 的演唱会线上购票系统从需求分析、表结构设计、在线选座、票务管理到电子票验票的完整思路拆开讲一遍。适合做毕业设计、想积累一个完整 Java 项目经验、或者正在准备笔试面试的朋友参考。系统不算大技术栈也不算前沿但如果能把里面的状态流转和并发控制理清楚它的含金量不输那些模板化的管理系统。1. 这类购票系统的真实业务边界不只是增删改查1.1 题目背后到底在考察什么先说一个我观察到的现象很多人拿到演唱会线上购票系统这个题目之后第一反应是列功能清单——用户注册、登录、浏览演出、选座、下单、支付、后台管理。功能清单没问题但这种线性罗列恰恰漏掉了这个题目的灵魂座位和票是有状态的而且会随着时间变化。一场演唱会可以拆成 show演出、session场次、seat座位、order订单、ticket电子票五件事。演出是静态的场次是排期的座位是有状态的空闲、锁定、已售订单是有状态的待支付、已支付、已取消、已退票电子票也是有状态的未入场、已入场。如果只是把表建出来用增删改查把数据塞进去那和普通的管理系统没有本质区别。真正的考察点是你能不能把这些状态的变化规则理清楚并且在并发场景下保证状态不混乱。1.2 用户端、管理端和通用服务的三块划分我当时把系统拆成了三块前端用户门户、后台管理端、通用基础服务。前端用户门户负责用户注册登录、演出列表与详情、场次选择、在线选座、订单确认、支付回调后的电子票展示、个人订单中心。后台管理端负责演出和场次管理、座位区域和票价设置、订单查询与退款处理、二维码验票入口。通用基础服务在初期其实不需要单独拆微服务但我会在工程结构上把几个容易被忽视的横切点提前预留好统一的登录认证我用的是 Sa-Token比 Spring Security 配置轻很多、统一的接口返回结构、全局异常处理、Redis 缓存工具类。这些不是一个可见页面但答辩时能体现你对工程完整性的思考。1.3 数据库设计把演出-场次-座位-订单-票串成一条线核心表我列了七张表名职责关键字段show演出信息id, title, artist, cover_url, descriptionsession场次信息id, show_id, session_time, venue_id, statusseat座位id, session_id, area_id, row_no, col_no, statusorders订单id, order_no, user_id, session_id, total_amount, statusorder_seat订单座位明细id, order_id, seat_id, priceticket电子票id, ticket_no, order_id, session_id, entry_statusvenue场馆id, name, address, seat_map_json建表时我要特别强调三个设计点。第一座位表必须独立成表并且直接挂在 session 下面。因为同一个场馆不同场次可以有不同的可用座位范围而且同一种座位在不同场次可以设置不同价格。把 row_no、col_no 直接塞进订单表里看起来省事但后期做退票、换座、运营统计都会被坑。第二order_seat 明细表要建联合唯一索引(session_id, seat_id)。这是防超卖的一道数据库底线同一场次同一个座位只允许出现在一个有效订单里。第三座位状态字段不要用字符串满天飞我用 int 类型0 表示空闲、1 表示锁定、2 表示已售配合后台座位图很直观。订单状态我反而用语义化字符串比如 PENDING、PAID、CANCELLED、REFUNDED因为订单状态读代码时更容易识别。下面给一个简化的场次座位表结构CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, area_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1锁定 2已售, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_session_seat (session_id, row_no, col_no), KEY idx_session_status (session_id, status) );注意那个 version 字段后面做乐观锁就是靠它。学校项目里很多人不建 version但等你真正遇到超卖问题就知道这个字段有多重要。2. 技术选型为什么是SpringBoot哪些细节决定项目成败2.1 后端主框架与依赖选择后端框架我基本没有犹豫SpringBoot 2.7.x JDK 8。为什么不选 Spring Boot 3因为项目定位是毕业设计和简历项目JDK 17 的模块化改动和 Jakarta EE 迁移容易踩坑而 Spring Boot 2.7 时代的社区资料、问答、第三方中间件兼容性都是最稳的。等你把 2.7 玩熟了再迁移到 3.x 其实也不难。持久层用的 MyBatis-Plus理由很简单单表 CRUD 不用写 SQL分页插件现成开发节奏快的时候很省时间。但要注意MyBatis-Plus 只是帮你少写简单 SQL复杂的座位状态更新、订单和座位的事务联动还是得自己写 XML 里的原生 SQL。我习惯在 service 层把数据访问封装好controller 不直接调 mapper。认证我选的是 Sa-Token。很多人问我为什么不用 Spring Security我的理由是项目里只需要登录态校验这一个核心需求Spring Security 那套过滤器链和配置对新手太重Sa-Token 的 login、checkLogin 几下就完事理解成本低很多。当然如果你的目标是给面试官讲安全框架原理Spring Security 的名字更好听这个自己权衡。工程结构我按这种方式分包com.example.ticket ├── controller ├── service ├── mapper ├── entity ├── dto ├── config ├── common2.2 前端与联调方案前端用的是 Vue 3 Element Plus Vite。选 Vue 不是因为炫技而是前后端分离后接口边界非常清楚。答辩时你可以演示前端页面也可以切到接口文档去讲设计比 JSP 那种服务端渲染页面更能体现工程能力。联调阶段我建议在 vite.config.js 里配置代理把/api的请求转发到本机 8080server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样开发时不需要处理跨域部署时再用 Nginx 统一代理。前端项目里注意一个常见问题接口返回结构必须统一我用{ code, message, data }这个结构后端写一个 Result 类前端封装 axios 拦截器统一处理 code 非 200 的情况。这个细节看着小实际联调能帮你省大量时间。2.3 Redis 在座位预占和订单倒计时里的定位Redis 在这个系统里不是装饰品它承担了三件事。一是演出详情缓存。一个热门演唱会的详情页请求量会远高于下单量把 show 信息和场次列表缓存到 Rediskey 设计为cache:show:detail:{showId}过期时间 30 分钟后台修改演出信息时主动删除缓存。二是座位状态缓存。座位图加载时优先从 Redis 读seat:session:{sessionId}减少数据库压力。但这里有一致性问题我后面会专门讲。三是锁座位的分布式锁和订单倒计时。这是最重要的一项。用户锁定座位后我在 Redis 里放一个座位锁和订单到期时间保证同一个座位同一时间只有一个用户能锁。为什么需要 Redis 而不是数据库直接改因为数据库 update 也能改状态但如果两个请求同时读到座位状态是空闲直接 update 就可能出现超卖。Redis 的 SETNX 天然适合做互斥锁比数据库悲观锁性能好而且能配合 TTL 处理用户掉线、支付超时的情况。3. 在线选座模块从座位图到锁定座位的完整链路3.1 座位数据如何组织一个场馆的座位不是简单的矩形网格通常有内场、看台内场是编号座位看台分区域。所以我抽象了 area区域的概念每个区域有独立的名称、价格、行数、列范围。前端画座位图时不同区域用不同颜色渲染。后端给前端的座位图接口返回的数据大致是这样{ sessionId: 1001, showName: 某歌手巡回演唱会-上海站, areas: [ { areaId: 1, areaName: 内场A区, price: 1280, rows: [ { rowNo: 1, cols: [{ colNo: 1, status: 0 }] } ] } ] }前端拿到这个结构后根据 status 渲染颜色空闲可点、锁定灰色、已售深色。有个小优化接口可以只返回每排座位中状态连续变化的断点而不是每个座位都逐条传遇到几千个座位的大场次能省不少流量。我第一次实现时图省事没做后来数据量上来确实卡这个我们在最后一章细说。3.2 选座交互与座位预占用户点击座位前端先不急着请求接口而是把选中的座位集合暂存在本地提供一个已选座位面板。点击立即购买时一次性把 sessionId 和 seatId 列表提交到后端接口POST /api/order/lockSeats。后端这个方法我拆成了几步参数校验seatId 列表非空、长度不超过单笔限购数。对每个 seatId 加分布式锁。在锁内查询座位当前状态。全部座位都空闲则统一改为锁定。创建订单订单过期时间设为 15 分钟。写入 Redis 座位锁和订单倒计时。分布式锁部分项目里用了 Redisson 的话直接RLock lock redissonClient.getLock(lock:seat: seatId)就行它自带看门狗续期。如果不依赖 Redisson用 Redis 原生命令也能实现SET lock:seat:{seatId} {requestId} NX EX 60requestId 用 UUID释放锁时要用 Lua 脚本判断是不是自己的锁再删除防止误删别人的锁。我用 Redisson 是为了省心但还是建议理解一下 SETNX Lua 的原理面试官真的爱问这个。3.3 并发抢座场景下的锁定策略这是全项目最核心的一段逻辑我把核心思路写出来public boolean lockSeats(Long sessionId, ListLong seatIds, Long userId) { String lockKey lock:order: sessionId : userId; RLock orderLock redissonClient.getLock(lockKey); orderLock.lock(10, TimeUnit.SECONDS); try { for (Long seatId : seatIds) { RLock seatLock redissonClient.getLock(lock:seat: seatId); boolean locked seatLock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(座位已被他人抢占请重新选择); } try { int rows seatMapper.lockSeatIfFree(sessionId, seatId); if (rows 0) { throw new BizException(座位已被锁定请重新选择); } } finally { seatLock.unlock(); } } // 创建订单并插入明细 } finally { orderLock.unlock(); } }这里的lockSeatIfFree对应 XML 里的条件更新 SQLUPDATE seat SET status 1, version version 1 WHERE session_id #{sessionId} AND id #{seatId} AND status 0这个方法没有直接返回 boolean而是返回影响行数。0 表示条件不满足也就是座位已经不是空闲状态了。先加 Redis 锁再用条件 UPDATE 做数据库兜底两道关卡同时守住才能保证不超卖。锁定的座位如果用户一直不支付不能永远占着。我的做法是订单表记录 expire_time同时给 Redis 里的订单 key 设置 15 分钟过期。后台有一个定时任务每分钟扫一次待支付订单发现过期就把订单置为 CANCELLED并把对应座位恢复为空闲。用户刷新座位图时座位就会重新出现。4. 票务管理订单状态机与防超卖设计4.1 订单状态机的定义订单状态我定义成四种PENDING待支付、PAID已支付、CANCELLED已取消、REFUNDED已退票。有人会把已出票单独拆一个状态我觉得在这个系统里没必要。支付成功、座位状态更新为已售、电子票生成三件事绑定在一起PAID 本身就是出票完成的标志。状态流转规则如下当前状态触发事件目标状态伴随操作PENDING用户取消CANCELLED释放座位PENDING倒计时到期CANCELLED释放座位删除Redis锁PENDING支付成功回调PAID座位置为已售生成电子票PAID用户申请退票REFUNDED座位回到空闲电子票失效所有状态变更我都要求带条件更新UPDATE orders SET status PAID WHERE order_no ? AND status PENDING影响行数为 0 就不能继续往下走。这是状态机在数据库层的落地比先查再改安全得多。4.2 锁座和下单的先后关系很多初学者容易把两步混在一起先创建订单再改座位状态。问题在于如果订单创建成功但座位状态修改失败就会出现订单存在但没座位的脏数据。正确的顺序是先锁座位再下单锁座失败整个请求直接返回错误根本不会产生订单。反过来如果先改座位状态再创建订单订单失败后还得记得把座位状态回滚。我当时为了让回滚少出问题把锁座和下单放在同一个事务方法里Transactional(rollbackFor Exception.class) public Long createOrderAndLockSeats(...) { // 1. 条件更新座位状态 // 2. 插入订单 // 3. 插入 order_seat 明细 }座位状态更新失败抛出异常后整个事务回滚订单和明细都不会落库。这里有一个隐蔽的坑是事务方法内部自调用会导致注解失效我后面会在踩坑章节专门说。4.3 支付回调幂等为什么状态判断不能省对接模拟支付平台或真实支付渠道的沙箱环境时回调通知并不是只发一次。网络抖动、渠道重试机制都可能导致同一个支付结果通知被重复送到。如果不做幂等处理电子票可能生成两遍座位状态也可能被重复更新。我的处理流程很直接public PayResult handlePayCallback(PayNotify notify) { // 1. 幂等判断 Order order orderMapper.selectByOrderNo(notify.getOrderNo()); if (order null) { return PayResult.fail(订单不存在); } if (PAID.equals(order.getStatus())) { return PayResult.success(重复通知已处理); } // 2. 事务操作更新订单状态、座位状态、生成电子票 }先查订单状态如果已经是 PAID直接返回成功不执行后续逻辑。有人会问两个回调同时进来都查到 PENDING 怎么办所以在更新订单状态时还要再用条件 UPDATE把 status PENDING 作为 WHERE 条件保证只有一个回调能成功把状态改成 PAID。这层兜底加上后重复回调的问题就彻底解决了。支付回调里的整个操作——更新订单为 PAID、把座位从锁定改为已售、生成电子票记录——必须放在一个事务里。任何一步失败事务回滚支付平台会继续重试回调直到所有数据一致。5. 电子票与验票票面数据和入场流程怎么对上5.1 票号生成与二维码电子票的 ticket_no 我一开始直接用 UUID结果票号又长又难记。后来改成品类前缀 日期 随机码的组合比如TIX20250101123456A7B8生成逻辑是String ticketNo TIX yyyyMMdd randomCode(6);随机码用随机数生成存放在 ticket 表里加唯一索引。这个票号同时是验票的唯一凭证所以不可重复是硬要求。入库前先查一次冲突就重新生成概率极小不需要过度设计。二维码用 ZXing 生成内容就是 ticket_no 字符串用户在自己的订单详情页能看到。注意二维码内容不要拼业务 URL因为如果系统部署域名变了历史二维码会全部失效直接存票号字符串最稳妥。5.2 验票接口的状态判断验票入口在后台管理端工作人员扫码后把票号传给接口POST /api/verify/ticketbody 里带 ticketNo。接口逻辑if (ticket null) return 无效票; if (ticket.entryStatus 1) return 该票已入场; Order order selectByOrderNo(ticket.orderNo); if (!PAID.equals(order.getStatus())) return 订单未支付; // 校验场次、演出是否匹配 int rows ticketMapper.markEntered(ticketNo); if (rows 0) return 入场失败; return 验票通过;核心是markEntered的条件更新UPDATE ticket SET entry_status 1, entry_time NOW() WHERE ticket_no #{ticketNo} AND entry_status 0这个 UPDATE 保证了同一张票即使被并发扫码两次也只有第一次能成功把 entry_status 从 0 改成 1第二次影响行数为 0直接返回已入场。思路和订单幂等完全一致——条件更新是防止重复操作最朴素也最可靠的手段。5.3 一场演出结束后的数据回流我一开始只把这套系统当成开发完、演示完、答辩完就结束的项目直到设计统计功能时才发现ticket 表里的入场时间本身就是一份很有价值的运营数据。系统可以顺带做几个简单统计每个场次出票数、入场数、各票价档位销售占比、退票数量。这些统计不需要单独建表SQL 聚合就能出来但对项目完整度的提升很直观。比如查一场演出每个区域卖了多少票SELECT os.area_id, COUNT(*) AS sold_count, SUM(os.price) AS sold_amount FROM order_seat os JOIN orders o ON os.order_id o.id WHERE os.session_id #{sessionId} AND o.status PAID GROUP BY os.area_id;这个查询思路不难但答辩时能说明你考虑了数据回流和运营分析印象分会好很多。6. 实际开发和答辩中暴露的问题写代码时最容易翻车的四个地方6.1 一次性提交多个座位的超卖事故我早期版本是让前端把选中的座位一次性拼成一个数组后端一次性把这个数组里的座位状态全部改成锁定。看起来没什么问题但用并发压测时两个用户同时选同一排相邻的两个座位就出现了同一个座位被同时锁定的情况。原因是一个批量 UPDATE 语句内部不加行级锁判断两个连接同时写最后提交的覆盖了先前的状态又没有 version 之类的条件结果两边都认为锁座成功。后来改成逐座条件更新、单座失败整体回滚问题才消失。这个教训说明一个原理批量接口必须逐条校验状态单条失败就不能继续提交成功。简单粗暴但管用。6.2 事务自调用失效与缓存一致性我在锁座方法里遇到一个很隐蔽的 bug把 createOrder 和 lockSeats 写在同一个类里lockSeats 方法内部直接调用 this.createOrder结果 createOrder 上的事务注解根本没生效。Spring 的事务基于 AOP 代理this 调用走的是原始对象不是代理对象Transactional 自然被绕过。解决方法是按职责拆 Bean让事务方法跨 Bean 调用代理链才能生效。缓存一致性方面我也踩过两个坑。第一个是修改演出信息后先更新数据库再更新 Redis结果更新 Redis 失败缓存里残留旧数据。后来改成先更新数据库再删除缓存下次请求回源数据库重新加载。第二个是座位状态变化后缓存里的座位图还是旧的用户明明看到座位空闲点进去却提示已被锁定。我的处理是锁座成功、支付成功、订单取消三处都主动删除场次座位缓存用删除缓存而不是更新缓存这个策略最简单也最不容易出错。6.3 前端座位图渲染卡顿座位数量几百个时没什么感觉到了几千个座位用 Vue 的 v-for 渲染几千个 div每点击一个座位还要触发响应式更新页面肉眼可见地卡。我后来改成用 Canvas 绘制座位图座位数据一次性绘制成纯图形点击时通过坐标换算判断命中哪个座位只有点击点才更新局部。效果非常明显UI 线程基本不卡了。如果不想把 Canvas 写得太复杂也可以用虚拟滚动加分组渲染把每一排视为一组只渲染可视区域内的排。但对比下来Canvas 对几千个矩形格子的绘制性能是碾压级的项目里直接用它最稳。6.4 答辩追问环节我自己准备好的三个问题答辩老师大概率会问这些问题我在写项目时就已经把答案准备好了。第一个问题同一时间大量用户抢同一个座位你怎么保证不会超卖我的回答思路分三层Redis 分布式锁保证同一座位同一时刻只有一个用户进入锁定流程数据库条件 UPDATEstatus 0作为第二道防线order_seat 表的联合唯一索引保证一个座位不可能出现在两个有效订单里。三层任一层生效都不会超卖。第二个问题用户锁定座位后一直不支付怎么办订单有 15 分钟过期时间定时任务扫描 PENDING 订单超过 expire_time 的订单置为 CANCELLED同时把座位恢复为空闲并删除对应 Redis 缓存。这样锁座不会被永久占用资源能及时回收。第三个问题支付成功回调如何保证数据一致性回调处理的核心是幂等先查订单状态PAID 则直接返回再用条件 UPDATE status PENDING 尝试更新更新成功后才在同一事务里生成电子票和更新座位状态。这样即使回调重发也不会重复出票。这三个问题的思路其实在锁座和下单的先后关系以及支付回调幂等两节里都讲透了答辩时只需要串起来说。就我个人实操经验来看这类系统真正拉开差距的地方从来不是功能列表长短而是你有没有想清楚座位、订单、票这三个对象之间的状态流转规则。只要把这一条主线理明白剩下的注册登录、列表展示、后台管理都是水到渠成的事。
返回列表