ARTICLE DETAIL

资讯详情

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

Java大剧院订票选座系统:高并发锁座与出票全链路实战

Java大剧院订票选座系统:高并发锁座与出票全链路实战 简介这是一套面向高校计算机专业学生的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 画交互逻辑简单答辩时也容易讲清楚。数据库连接池用 HikariCPSpring 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句里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 锁座的核心 SQLFOR 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 XMLselect idselectForUpdate resultTypeSessionSeat SELECT id, session_id, seat_id, status, lock_time FROM session_seat WHERE session_id #{sessionId} AND seat_id #{seatId} FOR UPDATE /select update idlockSeat 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, ListLong 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(targettry_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 UPDATEtyperefkeyuk_session_seat当前锁等待SELECT * FROM performance_schema.data_lock_waits无记录或极少死锁历史SHOW ENGINE INNODB STATUS最近无死锁连接池状态HikariCP 日志active 连接数远小于 max5.3 我踩过的坑不要用SELECT再UPDATE的分离写法早期我图省事先SELECT查状态Java 里判断再UPDATE。结果并发下两个线程都查到 status0都执行 UPDATE虽然最后 status 是 1但两个订单都关联了同一个座位。血泪经验判断和更新必须在同一条 SQL 或同一个锁事务里。后来改成UPDATE ... WHERE status 0并检查 affected rows才彻底解决。这个习惯我保持到现在任何状态流转都先想“判断和更新是不是原子的”。希望帮到你。本文还有配套的精品资源点击获取
返回列表