☰
Java大剧院订票选座系统:高并发锁座与出票全链路实战
2026/10/1 5:47:56 网站建设 项目流程

简介:这是一套面向高校计算机专业学生的Java毕业设计完整项目包,主题为大剧院订票选座管理系统,采用B/S架构与MySQL数据库,适合作为毕业设计、课程设计或项目实战参考。系统分为前后台:前台支持会员注册登录、浏览戏剧戏曲歌舞舞蹈音乐曲艺杂技马戏等节目、在线预订生成订单及个人信息维护;后台由管理员审核订单、管理节目分类与用户信息、发布公告推送。压缩包共1619个文件,约78.06MB,包含130个Java源文件、129个class、96个Vue组件、86个HTML页面、88个CSS样式、306个JS脚本及1个SQL数据库脚本,另有svg、gif、png、jpg等图片素材与mp4演示视频,覆盖源码、说明文档、数据库与录屏。目前已有179人学习下载。读者可据此获得完整可运行的赛题方案、清晰的目录结构、数据库建表脚本与操作演示,便于快速理解订票选座业务逻辑并完成二次开发或答辩准备。

1. 大剧院订票选座系统:从“锁座”到“出票”的完整链路

大剧院订票选座管理系统,核心难点从来不是“把座位画出来”,而是同一时刻几百个人点同一个座位时,系统怎么保证不超卖、不重复出票。很多同学做毕业设计时,前端座位图做得漂漂亮亮,结果一压测就翻车:两个人同时下单,同一个座位出了两张票。这个标题对应的,就是一套基于 Java 的、能真正跑通“场次管理 → 座位锁定 → 下单支付 → 出票核销”全链路的系统,配套源码、数据库脚本和演示视频。它适合正在做计算机毕业设计、课程设计,或者想拿一个完整 Java Web 项目练手的人。你拿到手要做的不是照抄,而是理解锁座那几行 SQL 为什么必须那么写。下面我按实际落地顺序,把选型、建库、锁座、避坑、验证一层层拆开。

2. 技术选型与数据库设计:为什么用行锁而不是乐观锁

2.1 选型理由:Spring Boot + MyBatis + MySQL 是毕业设计最稳的组合

做这类系统,技术栈选择直接决定你后面调试的难度。我一般会推荐Spring Boot 2.x + MyBatis + MySQL 8 + Thymeleaf 或 Vue的组合,原因很实际:Spring Boot 把 Tomcat、数据源、事务管理都自动配好了,你不需要在 web.xml 和一堆 XML 里耗时间;MyBatis 对 SQL 的控制力比 JPA 强,锁座这种需要精确写SELECT ... FOR UPDATE的场景,用 MyBatis 更顺手;MySQL 的 InnoDB 支持行级锁和事务,是保证不超卖的基础。

如果你用 JPA,写行锁要绕@Lock注解,遇到复杂查询容易失控。用 MyBatis 你直接写 SQL,锁没锁住一眼就能看出来。前端用 Thymeleaf 服务端渲染,座位图用 Canvas 或 SVG 画,交互逻辑简单,答辩时也容易讲清楚。数据库连接池用 HikariCP,Spring Boot 默认自带,不用额外配。

提示:不要为了“显得高级”上 Redis 分布式锁。单机 MySQL 行锁足够撑住毕业设计演示和几百并发,引入 Redis 只会让你多一个要装的环境和一堆连接超时问题。

2.2 数据库表设计:五张核心表撑起整个系统

数据库设计是这套系统的地基。我一般会建五张核心表:venue(场馆)、show_session(场次)、seat(座位)、order(订单)、order_seat(订单座位关联)。座位表不要每个场次复制一份,而是用seat存物理座位,用session_seat存每个场次的座位状态,这样场次多了也不会爆炸。

-- 场次座位状态表:核心是 status 和 version 两个字段 CREATE TABLE `session_seat` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `session_id` BIGINT NOT NULL COMMENT '场次ID', `seat_id` BIGINT NOT NULL COMMENT '物理座位ID', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售', `lock_time` DATETIME DEFAULT NULL COMMENT '锁定时间,用于超时释放', `order_id` BIGINT DEFAULT NULL COMMENT '关联订单', PRIMARY KEY (`id`), UNIQUE KEY `uk_session_seat` (`session_id`,`seat_id`), KEY `idx_status_lock` (`status`,`lock_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段建表语句里,uk_session_seat唯一索引是关键——它从数据库层面保证同一个场次同一个座位只有一条记录,任何重复插入都会直接报错。status用 0/1/2 表示可售、锁定、已售,lock_time用来做超时释放:用户锁定座位后 15 分钟没支付,定时任务把status改回 0。idx_status_lock索引让定时任务扫描过期锁定时不用全表扫。

参数说明:status不要用字符串,用 TINYINT 省空间且比较快;lock_time用 DATETIME 而不是 TIMESTAMP,避免时区转换的玄学问题。订单表里要有order_no(唯一)、total_amount、pay_status、create_time,订单座位关联表存order_id和session_seat_id,出票时按这个关联查座位。

2.3 锁座的核心 SQL:FOR UPDATE到底锁住了什么

锁座是整个系统最容易翻车的地方。常见做法是:用户点座位 → 后端开事务 →SELECT ... FOR UPDATE查这个座位 → 判断 status 是否为 0 → 是则 UPDATE 为 1 并写 lock_time → 提交事务。这里FOR UPDATE加的是行级排他锁,在事务提交前,其他事务查同一行会被阻塞,直到锁释放。

-- 锁座:必须在事务中执行,且 session_id + seat_id 要走唯一索引 SELECT id, status FROM session_seat WHERE session_id = #{sessionId} AND seat_id = #{seatId} FOR UPDATE; -- 如果上一步查出来 status = 0,执行更新 UPDATE session_seat SET status = 1, lock_time = NOW(), order_id = #{orderId} WHERE id = #{id} AND status = 0;

逻辑说明:第一条 SQL 用FOR UPDATE锁住这一行,第二条 UPDATE 的WHERE status = 0是双重保险——即使锁没生效,status 条件也能防止把已售座位改成锁定。参数上,sessionId和seatId必须走uk_session_seat唯一索引,否则FOR UPDATE可能升级成表锁,整个场次都卡住。失败时看什么:如果并发下单出现“死锁”报错,检查多个座位锁定顺序是否一致,按seat_id排序后再锁能避免大部分死锁。

3. 从选座到出票:完整代码实现与参数配置

3.1 座位图渲染:用 Canvas 画座位并绑定点击事件

前端座位图不用搞太复杂,用 Canvas 画矩形代表座位,不同颜色表示状态。关键是点击座位时把sessionId和seatId发给后端,后端返回锁定结果后再刷新颜色。下面是一段简化的 Canvas 绘制和点击逻辑。

// 初始化座位图,seats 是从后端拿到的座位列表 const canvas = document.getElementById('seatMap'); const ctx = canvas.getContext('2d'); const seatSize = 30, gap = 8; let selectedSeats = []; function drawSeats(seats) { ctx.clearRect(0, 0, canvas.width, canvas.height); seats.forEach((seat, index) => { const row = Math.floor(index / 20); const col = index % 20; const x = col * (seatSize + gap) + 20; const y = row * (seatSize + gap) + 20; // 根据状态设置颜色:0可售绿色,1锁定灰色,2已售红色 ctx.fillStyle = seat.status === 0 ? '#4CAF50' : (seat.status === 1 ? '#9E9E9E' : '#F44336'); ctx.fillRect(x, y, seatSize, seatSize); ctx.fillStyle = '#fff'; ctx.font = '12px Arial'; ctx.fillText(seat.seatNo, x + 8, y + 20); // 存储座位坐标用于点击检测 seat.rect = { x, y, w: seatSize, h: seatSize }; }); } canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; // 遍历座位判断点击位置 seats.forEach(seat => { const r = seat.rect; if (clickX >= r.x && clickX <= r.x + r.w && clickY >= r.y && clickY <= r.y + r.h) { if (seat.status !== 0) return; // 不可售直接忽略 // 发送锁定请求 fetch('/api/seat/lock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: seat.sessionId, seatId: seat.id }) }).then(res => res.json()).then(data => { if (data.success) { seat.status = 1; selectedSeats.push(seat.id); drawSeats(seats); // 重绘 } else { alert('该座位已被锁定,请选择其他座位'); } }); } }); });

逻辑说明:drawSeats根据seat.status决定颜色,点击时先判断status !== 0直接返回,避免无效请求。fetch发到/api/seat/lock,后端返回success才把本地状态改成 1 并重绘。参数上,seatSize和gap根据场馆实际座位数调整,20 列是常见剧院布局。注意:前端状态只是展示,真正锁没锁住以后端事务为准,所以每次锁定后最好重新拉一次座位列表。

3.2 后端锁座接口:事务边界和超时释放怎么写

后端锁座接口是整个系统的核心。我一般会写一个SeatService.lockSeat(sessionId, seatId, userId)方法,用@Transactional开事务,内部先SELECT ... FOR UPDATE,再判断再更新。事务边界要包住查询和更新,不能查完就提交。

@Service public class SeatService { @Autowired private SessionSeatMapper sessionSeatMapper; @Transactional(rollbackFor = Exception.class) public boolean lockSeat(Long sessionId, Long seatId, Long userId) { // 1. 行锁查询 SessionSeat seat = sessionSeatMapper.selectForUpdate(sessionId, seatId); if (seat == null || seat.getStatus() != 0) { return false; // 不存在或已被锁/已售 } // 2. 更新为锁定状态 int rows = sessionSeatMapper.lockSeat(seat.getId(), userId); if (rows == 0) { throw new RuntimeException("锁座失败,可能已被抢占"); } // 3. 写入锁定记录(可选,用于超时释放) return true; } }

对应的 MyBatis Mapper XML:

<select id="selectForUpdate" resultType="SessionSeat"> SELECT id, session_id, seat_id, status, lock_time FROM session_seat WHERE session_id = #{sessionId} AND seat_id = #{seatId} FOR UPDATE </select> <update id="lockSeat"> UPDATE session_seat SET status = 1, lock_time = NOW(), order_id = #{orderId} WHERE id = #{id} AND status = 0 </update>

逻辑说明:@Transactional保证查询和更新在同一个事务里,FOR UPDATE锁住的行在事务提交前不会被其他事务修改。lockSeat的WHERE status = 0是乐观检查,防止锁等待期间状态被改。参数上,rollbackFor = Exception.class确保任何异常都回滚,避免锁没释放。超时释放用一个@Scheduled定时任务,每 5 分钟扫一次status = 1 AND lock_time < NOW() - INTERVAL 15 MINUTE的记录,改回 0。

注意:定时任务和锁座事务可能并发操作同一行,定时任务的 UPDATE 也要加WHERE status = 1条件,避免把刚支付成功的订单又释放掉。

3.3 下单与出票:订单号生成和座位状态流转

锁座成功后,用户下单支付,订单状态从“待支付”变“已支付”,座位状态从 1 变 2。订单号我一般用“时间戳 + 用户ID后四位 + 随机数”,保证唯一且可读。出票就是根据订单查order_seat关联,生成票面信息。

public String createOrder(Long userId, List<Long> sessionSeatIds) { // 生成订单号:yyyyMMddHHmmss + userId后4位 + 3位随机 String orderNo = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + String.format("%04d", userId % 10000) + String.format("%03d", new Random().nextInt(1000)); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setPayStatus(0); // 待支付 order.setTotalAmount(calculateAmount(sessionSeatIds)); orderMapper.insert(order); // 关联座位 for (Long seatId : sessionSeatIds) { OrderSeat os = new OrderSeat(); os.setOrderId(order.getId()); os.setSessionSeatId(seatId); orderSeatMapper.insert(os); } return orderNo; }

逻辑说明:订单号里带时间戳保证趋势递增,用户ID后四位方便排查,随机数防并发碰撞。payStatus用 0/1/2 表示待支付、已支付、已取消。支付回调里把session_seat.status改成 2,并记录支付时间。参数上,totalAmount用BigDecimal计算,不要用 double,避免金额精度问题。

4. 避坑与排查:锁座系统最常见的五个翻车现场

4.1 座位超卖:FOR UPDATE没走索引变成表锁

现象:压测时两个人同时下单,同一个座位出了两张票,或者系统直接卡死。原因:SELECT ... FOR UPDATE的WHERE条件没有走唯一索引,MySQL 退化成表锁,所有锁座请求串行,超时后部分请求拿到旧数据。解决:确认session_seat表有uk_session_seat(session_id, seat_id)唯一索引,且查询条件顺序和索引一致。用EXPLAIN看type是不是ref或eq_ref,如果是ALL就说明没走索引。

4.2 锁超时释放把已支付订单误释放

现象:用户支付成功,但座位又被释放,别人能再次购买。原因:定时任务释放锁定座位时,只判断了lock_time超时,没判断订单是否已支付。解决:释放前先查关联订单的pay_status,只有待支付且超时的才释放。或者更简单:支付成功时把session_seat.status改成 2,定时任务只扫status = 1的记录,天然不会碰已售座位。

4.3 事务未提交导致锁一直不释放

现象:某个座位锁定后,其他人一直提示“已被锁定”,但数据库里status还是 0。原因:锁座方法里开了事务,但中间调用了外部 HTTP 接口或做了耗时操作,事务迟迟不提交,行锁一直持有。解决:锁座事务里只做数据库操作,不要调支付、发短信等外部服务。把外部调用放到事务提交后,用TransactionSynchronizationManager的afterCommit回调。

4.4 前端重复点击导致同一用户锁多个座位

现象:用户快速点同一个座位两次,后端收到两个请求,第二个请求阻塞后返回失败,但前端已经显示选中。原因:前端没做防抖,按钮没禁用。解决:点击后立即把按钮置灰,或者用loading状态锁住。后端也要幂等:同一个用户对同一个座位重复锁定,直接返回成功,不要报错。

4.5 数据库连接池耗尽导致锁座接口超时

现象:并发一高,接口大量超时,日志里出现Connection is not available。原因:锁座事务持有连接时间长,HikariCP 默认最大连接 10,不够用。解决:把spring.datasource.hikari.maximum-pool-size调到 20-30,同时设置connection-timeout为 3000ms,避免请求无限等待。但根本还是要缩短事务时间,锁座事务里不要做无关操作。

5. 验证锁座是否真的生效:一个可复现的并发测试脚本

5.1 用 JMeter 或 Python 脚本模拟并发抢座

光看代码看不出锁有没有生效,必须压测。我一般用 Python 的threading写一个简单并发脚本,模拟 50 个人同时抢同一个座位,看最终成功几个。

import threading import requests url = "http://localhost:8080/api/seat/lock" session_id = 1 seat_id = 100 success_count = 0 lock = threading.Lock() def try_lock(): global success_count resp = requests.post(url, json={"sessionId": session_id, "seatId": seat_id}) if resp.json().get("success"): with lock: success_count += 1 threads = [threading.Thread(target=try_lock) for _ in range(50)] for t in threads: t.start() for t in threads: t.join() print(f"成功锁定次数: {success_count}") # 正确结果应该是 1

逻辑说明:50 个线程同时发锁座请求,如果锁座逻辑正确,只有 1 个成功,其余返回失败。参数上,session_id和seat_id换成你数据库里真实存在的可售座位。如果success_count大于 1,说明锁没生效,回去检查FOR UPDATE和索引。这个脚本跑通,答辩时直接演示,比任何 PPT 都有说服力。

5.2 看数据库锁等待和死锁日志

MySQL 里用SHOW ENGINE INNODB STATUS看最近的死锁信息,INNODB_LOCKS和INNODB_LOCK_WAITS表(MySQL 8 里是performance_schema.data_locks)能看当前锁等待。如果发现大量LOCK WAIT,说明锁竞争激烈,考虑按seat_id排序后批量锁定,减少交叉等待。

检查项命令正常表现
索引是否生效EXPLAIN SELECT ... FOR UPDATEtype=ref,key=uk_session_seat
当前锁等待SELECT * FROM performance_schema.data_lock_waits无记录或极少
死锁历史SHOW ENGINE INNODB STATUS最近无死锁
连接池状态HikariCP 日志active 连接数远小于 max

5.3 我踩过的坑:不要用SELECT再UPDATE的分离写法

早期我图省事,先SELECT查状态,Java 里判断,再UPDATE。结果并发下两个线程都查到 status=0,都执行 UPDATE,虽然最后 status 是 1,但两个订单都关联了同一个座位。血泪经验:判断和更新必须在同一条 SQL 或同一个锁事务里。后来改成UPDATE ... WHERE status = 0并检查 affected rows,才彻底解决。这个习惯我保持到现在,任何状态流转都先想“判断和更新是不是原子的”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询