ARTICLE DETAIL

资讯详情

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

大剧院订票选座系统:Java毕业设计中的锁座与并发控制

大剧院订票选座系统:Java毕业设计中的锁座与并发控制 简介这是一套面向高校计算机专业学生的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 QueryWrapperSeatStatus().eq(schedule_id, scheduleId)); if (count 0) return; Schedule schedule scheduleMapper.selectById(scheduleId); Hall hall hallMapper.selectById(schedule.getHallId()); ListSeatStatus 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, ListSeatPos seats, Long userId) { ListSeatPos 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就是上面那条条件更新 SQLunlockOne把status改回 0 并清空lock_timeLockRecord用来做超时释放定时任务扫描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、A3A1 锁成功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是否一致。这个习惯帮我省了很多答辩前的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表