简介:这是一套面向高校计算机专业学生的Java毕业设计完整项目包,主题为大剧院订票选座管理系统,采用B/S架构与MySQL数据库,适合正在准备毕业设计或课程设计、需要真实项目练手的开发者参考。压缩包共1619个文件,约78.06MB,涵盖Java源码、class编译文件、Vue前端组件、HTML与CSS页面、JavaScript脚本、SQL建库脚本及mp4演示视频等,前后端与数据库资源齐备。系统分前台与后台:会员注册登录后可浏览戏剧、戏曲、歌舞、舞蹈、音乐、曲艺、杂技、马戏等节目并在线预订生成订单,支持个人信息修改;管理员负责订单审核、节目分类维护、用户管理与公告推送。资源还附带说明文档、数据库文件与操作演示视频,便于快速理解项目结构、还原运行环境并梳理业务逻辑。目前已有179人学习下载,可作为毕业设计选题落地与功能扩展的实用参考。
1. 大剧院订票选座系统:从锁座到出票,Java 毕业设计里最容易被答辩老师追问的三个点
大剧院订票选座管理系统,核心不是“卖票”,而是“在并发下把同一个座位只卖给一个人”。很多同学做毕业设计时,把重点放在页面好不好看、后台能不能增删改查,结果答辩时老师一句“两个人同时点同一个座位怎么办”就直接卡住。这个标题背后其实藏着三个硬骨头:座位状态实时同步、订单与座位的强一致性、以及演出场次与票价区域的动态绑定。它适合正在做 Java 毕业设计、想选一个“有业务深度但又不至于无从下手”题目的同学,也适合已经写了半截 CRUD 想加亮点的开发者。源码、数据库、说明文档和演示视频只是交付形式,真正决定你能不能过答辩的,是锁座逻辑和数据库事务边界有没有讲清楚。
2. 先想清楚座位怎么存:数据库表结构决定后面好不好改
2.1 演出、场次、座位、订单四张核心表怎么拆
很多同学一上来就建一张seat表,里面塞status、user_id、order_id,结果发现同一个座位在不同演出场次的状态根本没法区分。大剧院和电影院不同,同一个物理座位在不同日期、不同演出里是独立售卖的。所以第一层拆分是:hall(演出厅)定义物理座位布局,show(演出)定义演什么,schedule(场次)定义哪天几点演,seat_status定义某个场次下某个座位的售卖状态。
我一般会这样建表:
-- 演出厅表:只存物理布局 CREATE TABLE hall ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, row_count INT NOT NULL, col_count INT NOT NULL ); -- 场次表:一场演出对应一个厅和一个时间 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, show_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, price_config JSON NOT NULL -- 按区域存票价,如 {"A": 380, "B": 280} ); -- 座位状态表:核心,按场次隔离 CREATE TABLE seat_status ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT DEFAULT 0, -- 0可售 1锁定 2已售 order_id BIGINT DEFAULT NULL, lock_time DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no) );这里最关键的是seat_status上的唯一索引uk_schedule_seat。没有这个索引,后面用INSERT ... ON DUPLICATE KEY UPDATE做锁座时会插入重复行,导致一个座位出现两条状态记录。price_config用 JSON 类型是为了避免再拆一张票价表,毕业设计里够用,但如果你要按座位精确调价,还是单独建price表更规范。
2.2 为什么不用一张表存所有场次座位
有同学问:我直接建一张seat表,每个场次生成一批座位记录行不行?行,但数据量会爆炸。一个 1000 座的剧院,一年 300 场演出,就是 30 万行座位状态。如果再加历史场次,很快上百万。更麻烦的是,每次新增场次都要批量插入座位,维护成本高。用seat_status按场次懒生成——用户第一次查询某场次座位时,如果发现该场次还没有座位记录,就从hall的row_count和col_count批量生成。这样新场次零成本,历史场次也不占额外空间。
// 懒生成座位状态 public void initSeatStatusIfAbsent(Long scheduleId) { Long count = seatStatusMapper.selectCount( new QueryWrapper<SeatStatus>().eq("schedule_id", scheduleId)); if (count > 0) return; Schedule schedule = scheduleMapper.selectById(scheduleId); Hall hall = hallMapper.selectById(schedule.getHallId()); List<SeatStatus> list = new ArrayList<>(); for (int r = 1; r <= hall.getRowCount(); r++) { for (int c = 1; c <= hall.getColCount(); c++) { SeatStatus s = new SeatStatus(); s.setScheduleId(scheduleId); s.setRowNo(r); s.setColNo(c); s.setStatus(0); list.add(s); } } seatStatusMapper.batchInsert(list); }这段代码的坑在于:如果两个用户同时第一次访问同一个新场次,可能同时触发批量插入,导致唯一索引冲突。解决办法是在schedule表上加一个seat_initialized标记,或者用分布式锁。毕业设计里最简单的做法是给schedule_id加唯一约束,插入冲突时捕获异常忽略即可。
3. 锁座与下单:用数据库事务把并发问题摁住
3.1 乐观锁还是悲观锁,毕业设计选哪个
锁座本质是“检查座位可售,然后把它改成锁定”。两个用户同时读到“可售”,然后都去改,就超卖了。常见做法有两种:悲观锁SELECT ... FOR UPDATE,在事务里锁住行;乐观锁用版本号或状态条件更新。我一般推荐毕业设计用“条件更新 + 影响行数判断”,因为它不依赖数据库锁等待,代码也好讲。
UPDATE seat_status SET status = 1, lock_time = NOW() WHERE schedule_id = ? AND row_no = ? AND col_no = ? AND status = 0;执行后看返回的affectedRows。如果是 1,说明锁座成功;如果是 0,说明座位已经被别人锁了或已售。这个方案的好处是:不需要显式开事务锁,一条 SQL 就完成了“检查 + 更新”的原子操作。但注意,它只能保证单座位锁定的原子性,如果你一次选多个座位,需要循环执行,并且要处理部分成功的情况。
3.2 多座位锁定的回滚与超时释放
用户一次选三个座位,第一个锁成功,第二个失败,怎么办?必须把第一个也释放掉,否则座位就被白白占住。我一般会写一个lockSeats方法,循环调用单座位锁定,一旦有失败就回滚已锁的座位。
@Transactional(rollbackFor = Exception.class) public LockResult lockSeats(Long scheduleId, List<SeatPos> seats, Long userId) { List<SeatPos> locked = new ArrayList<>(); for (SeatPos pos : seats) { int rows = seatStatusMapper.lockOne(scheduleId, pos.getRow(), pos.getCol()); if (rows == 0) { // 回滚已锁座位 for (SeatPos l : locked) { seatStatusMapper.unlockOne(scheduleId, l.getRow(), l.getCol()); } return LockResult.fail("座位 " + pos + " 已被占用"); } locked.add(pos); } // 写入锁定记录,设置过期时间 lockRecordMapper.insert(new LockRecord(userId, scheduleId, seats, LocalDateTime.now().plusMinutes(15))); return LockResult.success(); }参数说明:lockOne就是上面那条条件更新 SQL;unlockOne把status改回 0 并清空lock_time;LockRecord用来做超时释放,定时任务扫描lock_time超过 15 分钟的记录,把对应座位状态改回可售。15 分钟是常见值,太短用户来不及支付,太长座位被占死。演示视频里最好把这个超时释放也录进去,答辩老师很吃这一套。
3.3 订单落库与座位状态终态
锁座成功后,用户进入支付页。支付回调里要做两件事:把seat_status的status从 1 改成 2,并写入order_id;同时生成订单记录。这两步必须在同一个事务里,否则会出现“座位已售但订单没生成”或“订单生成了但座位还是锁定态”。
@Transactional(rollbackFor = Exception.class) public void confirmOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != OrderStatus.PENDING) return; // 更新座位为已售 int rows = seatStatusMapper.sellSeats(order.getScheduleId(), order.getSeats(), orderId); if (rows != order.getSeats().size()) { throw new BizException("座位状态异常,出票失败"); } order.setStatus(OrderStatus.PAID); orderMapper.updateById(order); }这里sellSeats的 SQL 要带上status = 1条件,确保只有锁定态才能变成已售。如果返回行数不对,说明座位状态被其他流程改了,直接抛异常回滚。这个校验是很多同学漏掉的,答辩时被问到“如果支付回调重复调用怎么办”就能用上——重复调用时status已经是 2,条件不满足,不会重复出票。
4. 避坑与排查:锁座系统最容易翻车的五个地方
4.1 座位状态懒生成时并发插入冲突
现象:两个用户同时打开同一个新场次的选座页,后台日志报Duplicate entry唯一索引冲突。原因:两边都发现没有座位记录,同时批量插入。解决:在schedule表加seat_initialized字段,用UPDATE schedule SET seat_initialized = 1 WHERE id = ? AND seat_initialized = 0的返回行数判断谁负责初始化,抢到的才执行批量插入,另一个等待后直接查询。
4.2 锁座后用户直接关页面,座位被占死
现象:座位显示锁定,但订单列表里没有对应订单,15 分钟后也没释放。原因:锁座记录写入了,但定时任务没跑,或者lock_time用的是数据库时间而定时任务用的是应用时间,时区不一致。解决:统一用数据库NOW()写入lock_time,定时任务也用NOW()比较;同时在选座页加一个“释放座位”按钮,用户主动取消时立即释放。
4.3 支付回调里座位状态更新影响行数为 0
现象:支付成功但座位没变成已售,订单状态却改了。原因:sellSeats的 SQL 条件里status = 1,但座位可能因为超时释放已经变回 0。解决:支付回调里先查订单关联的座位状态,如果发现座位已释放,要么重新锁座(如果还没被别人买),要么给用户退款。毕业设计里可以简化为:支付回调时如果座位状态不是 1,记录异常日志并人工处理,但答辩时要能说出这个边界。
4.4 多座位锁定部分成功导致数据不一致
现象:用户选了 A1、A2、A3,A1 锁成功,A2 失败,但 A1 没有释放。原因:lockSeats方法没有加@Transactional,或者异常被 catch 后没有重新抛出。解决:确保方法级事务注解生效,回滚逻辑放在 catch 里手动执行,并且 catch 后要throw让事务回滚。注意:如果用的是try-catch吞掉异常,Spring 事务不会回滚。
4.5 座位图前端渲染与后端状态不同步
现象:用户看到 A1 是绿色可售,点下去提示已被占用。原因:前端座位图是页面加载时一次性拉取的,没有轮询或 WebSocket 推送。解决:毕业设计里最简单的是加一个 30 秒轮询接口,只返回状态变化的座位;进阶一点用 WebSocket 推送。演示视频里可以展示两个浏览器窗口,一个锁座后另一个刷新能看到状态变化。
5. 让答辩老师眼前一亮的两个进阶技巧
5.1 用 Redis 预扣减座位库存做第一层过滤
数据库条件更新虽然可靠,但并发高时全部打到数据库,响应会变慢。我一般会在锁座前加一层 Redis 过滤:用SETNX给每个座位加一个带过期时间的 key,比如seat:lock:{scheduleId}:{row}:{col},设置 15 分钟过期。只有SETNX成功的请求才去走数据库条件更新。这样大部分重复点击在 Redis 层就被拦住了,数据库压力小很多。
public boolean tryLockInRedis(Long scheduleId, int row, int col) { String key = String.format("seat:lock:%d:%d:%d", scheduleId, row, col); Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, "1", 15, TimeUnit.MINUTES); return Boolean.TRUE.equals(ok); }注意:Redis 锁只是过滤,不能替代数据库条件更新。因为 Redis 可能丢数据,或者过期时间到了但数据库事务还没提交。所以最终一致性还是靠数据库的status = 0条件更新来保证。这个双层设计在答辩时讲出来,老师会觉得你考虑得比较周全。
5.2 用数据库定时事件自动释放超时锁定
除了应用层定时任务,MySQL 本身也支持定时事件。可以在数据库里创建一个事件,每分钟扫描一次seat_status,把lock_time超过 15 分钟且status = 1的记录改回 0。
CREATE EVENT ev_release_expired_lock ON SCHEDULE EVERY 1 MINUTE DO UPDATE seat_status SET status = 0, lock_time = NULL WHERE status = 1 AND lock_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE);这个方案的好处是不依赖应用是否运行,数据库自己就能清理。但要注意:MySQL 事件调度器默认是关闭的,需要SET GLOBAL event_scheduler = ON;。另外,生产环境用事件要谨慎,毕业设计里作为亮点讲可以,但别把它当成唯一释放手段,应用层定时任务还是要保留。
5.3 验证锁座逻辑是否真的生效
写完代码后,怎么验证?我一般会写一个简单的并发测试,用CountDownLatch模拟 10 个线程同时抢同一个座位。
@Test public void testConcurrentLock() throws Exception { int threads = 10; CountDownLatch latch = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(0); for (int i = 0; i < threads; i++) { new Thread(() -> { try { latch.await(); boolean ok = seatService.lockSeat(1L, 1, 1, 100L); if (ok) success.incrementAndGet(); } catch (Exception e) { // 忽略 } finally { latch.countDown(); } }).start(); } latch.await(); assertEquals(1, success.get()); // 只能有一个成功 }这个测试跑通,基本能说明锁座逻辑在单机环境下没问题。如果要做分布式锁,再考虑 Redis 或 ZooKeeper,但毕业设计里单机数据库条件更新已经够用。我自己的习惯是:每次改完锁座 SQL,先跑这个并发测试,再看数据库里seat_status的status和order_id是否一致。这个习惯帮我省了很多答辩前的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取