☰
Java大剧院订票选座系统:数据库设计与并发锁座实战
2026/10/7 5:56:32 网站建设 项目流程

简介:本资源为基于Java的大剧院订票选座管理系统毕业设计完整资料包,面向计算机相关专业需要完成毕业设计或课程设计的学生,以及希望积累B/S架构项目实战经验的开发者。系统采用Java语言与MySQL数据库,分为前台与后台:前台支持会员注册登录、浏览戏剧戏曲歌舞舞蹈音乐曲艺杂技马戏等节目、在线预订生成订单及个人信息维护;后台由管理员审核订单、管理节目分类与用户信息、发布公告推送。压缩包共1619个文件,约78.06MB,涵盖130个java源码、129个class、96个vue组件、86个html页面、88个css样式、306个js脚本,以及sql建库脚本、xml配置、mp4演示视频和说明文档,前端资源含svg、gif、png、jpg等图片素材,结构完整便于二次开发。目前已有179人学习下载,适合需要完整赛题方案、可运行源码、数据库脚本与操作录屏的读者参考,帮助快速理解订票选座业务逻辑与前后端交互流程。

1. 从一张剧院座位图说起:Java 订票选座系统到底在解决什么问题

很多人第一次做「基于 Java 的大剧院订票选座管理系统」时,脑子里想的是 CRUD:用户表、演出表、订单表,增删改查一套就完事。真上手才发现,最难的从来不是增删改查,而是选座——同一排相邻两个座位怎么算、已经被锁的座位怎么实时同步、两个人同时点同一个座位谁赢。这才是这个毕业设计真正的技术含量所在,也是答辩老师最爱追问的地方。

这个系统面向的场景很具体:大剧院有若干演出场次,每个场次对应一张座位图,观众登录后挑场次、点座位、下单支付,后台管理员维护演出和座位状态。它适合正在做 Java 方向毕业设计的同学,也适合想练手一个「有并发、有状态、有前后端交互」的完整项目的开发者。数据库设计、座位状态机、并发锁座这三块,是决定这个项目能不能拿高分、能不能真正跑起来的关键。下面我按自己带过几届毕设的经验,把选型、建库、锁座、避坑一条条讲清楚。

2. 技术选型与数据库设计:为什么用 Spring Boot + MyBatis + MySQL

2.1 技术栈怎么选才不给自己挖坑

毕业设计最忌讳技术栈堆得太花。我见过有人上来就微服务、Redis 集群、消息队列全套,结果连座位状态都没跑通,答辩时被问「你这个分布式锁解决什么问题」直接卡壳。常见且稳妥的做法是Spring Boot + MyBatis + MySQL + Thymeleaf 或 Vue,理由很实在:

  • Spring Boot 把 Tomcat、依赖注入、事务管理都封装好了,@Transactional一行注解就能控制订单和座位状态的一致性,省掉大量配置。
  • MyBatis 的 XML 映射对毕业设计友好,SQL 写得清楚,答辩时能指着 SQL 讲业务逻辑,比 JPA 自动生成更可控。
  • MySQL 免费、资料多,事务和行锁机制成熟,锁座场景天然适配。
  • 前端用 Thymeleaf 服务端渲染最省事,想加分就用 Vue 前后端分离,但要注意跨域和登录态传递会多花两三天。

选型的核心判断标准只有一个:你能不能把每一层为什么用它讲明白。讲不明白的技术,加了就是负债。

2.2 数据库表设计:五张核心表与座位状态字段

座位图是这个系统的灵魂,表设计直接决定后面好不好写。核心表我一般设计成五张:

表名作用关键字段
user用户(观众+管理员)id, username, password, role
show_info演出场次id, title, hall_id, show_time, price
seat座位静态信息id, hall_id, row_num, col_num, seat_type
seat_status座位动态状态id, show_id, seat_id, status, order_id
order订单id, user_id, show_id, total_price, status

这里有个关键设计决策:座位静态信息和动态状态必须分表。seat存的是「这个厅第 3 排第 5 列有个座位」,是物理事实,一个厅一份;seat_status存的是「某场演出的这个座位现在卖没卖」,每场演出一份。如果合成一张表,每加一场演出就要复制一遍座位数据,冗余且难维护。

seat_status.status用整数表示状态机:0可选、1已锁定(待支付)、2已售出。这个状态机是后面所有逻辑的基础,建表 SQL 如下:

CREATE TABLE seat_status ( id BIGINT PRIMARY KEY AUTO_INCREMENT, show_id BIGINT NOT NULL COMMENT '演出场次ID', seat_id BIGINT NOT NULL COMMENT '座位ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0可选 1锁定 2已售', order_id BIGINT DEFAULT NULL COMMENT '关联订单', lock_time DATETIME DEFAULT NULL COMMENT '锁定时间,用于超时释放', UNIQUE KEY uk_show_seat (show_id, seat_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_show_seat这个唯一索引是防重复售座的第一道防线,后面讲并发时会用到。lock_time字段是为「下单后 15 分钟未支付自动释放」准备的,没有它,用户点了座位不付款,这个座位就永久锁死了,这是新手最容易漏的字段。

2.3 用 SQL 初始化一个剧院厅的座位数据

座位数据不用手敲,用存储过程或脚本批量生成。假设一个厅 10 排、每排 20 列,中间过道留空,生成脚本这样写:

-- 生成座位静态数据,hall_id=1 DELIMITER $$ CREATE PROCEDURE gen_seats(IN p_hall BIGINT, IN p_rows INT, IN p_cols INT) BEGIN DECLARE r INT DEFAULT 1; DECLARE c INT DEFAULT 1; WHILE r <= p_rows DO SET c = 1; WHILE c <= p_cols DO -- 第 10、11 列之间是过道,跳过 IF c <> 10 AND c <> 11 THEN INSERT INTO seat(hall_id, row_num, col_num, seat_type) VALUES(p_hall, r, c, IF(r <= 3, 2, 1)); -- 前3排为VIP END IF; SET c = c + 1; END WHILE; SET r = r + 1; END WHILE; END$$ DELIMITER ; CALL gen_seats(1, 10, 20);

逻辑说明:外层循环排、内层循环列,IF c <> 10 AND c <> 11跳过过道位置,seat_type用IF判断前 3 排为 VIP(值 2),其余普通座(值 1)。参数p_rows、p_cols按实际厅大小改,p_hall对应演出厅 ID。跑完这张表就有 180 条座位记录(10×20 减去 20 个过道位)。这一步做完,前端画座位图就有数据源了,用row_num和col_num定位每个座位的坐标。

3. 选座与锁座的核心实现:把并发问题摁死在数据库层

3.1 座位状态机与下单流程

先把业务流程理清楚,代码才不会乱。用户选座的完整链路是:

  1. 进入某场演出的选座页,查询seat_status中该show_id下所有座位状态,渲染座位图。
  2. 用户点击座位,前端把seat_id发给后端,后端尝试把状态从0改为1,写入order_id和lock_time。
  3. 用户确认下单,生成订单,状态从1改为2。
  4. 若 15 分钟内未支付,定时任务把超时的1改回0,清空order_id。

这个流程里,第 2 步是并发重灾区。两个人同时点同一个座位,如果处理不当,会双双锁定成功,最后卖出两个订单——这就是典型的超卖。

3.2 用乐观锁 + 唯一索引解决并发抢座

解决并发锁座,我一般用「数据库唯一索引兜底 + 条件更新判断影响行数」的组合,不引入 Redis 也能扛住毕设级别的并发。核心 SQL 是这样:

UPDATE seat_status SET status = 1, order_id = #{orderId}, lock_time = NOW() WHERE show_id = #{showId} AND seat_id = #{seatId} AND status = 0;

对应的 Mapper 方法返回int,表示影响行数:

@Update("UPDATE seat_status SET status=1, order_id=#{orderId}, lock_time=NOW() " + "WHERE show_id=#{showId} AND seat_id=#{seatId} AND status=0") int lockSeat(@Param("showId") Long showId, @Param("seatId") Long seatId, @Param("orderId") Long orderId);

Service 层判断返回值:

@Transactional public boolean selectSeat(Long showId, Long seatId, Long orderId) { int rows = seatStatusMapper.lockSeat(showId, seatId, orderId); if (rows == 0) { // 影响行数为0,说明座位已被别人锁定或已售出 throw new BizException("座位已被抢占,请重新选择"); } return true; }

逻辑说明:WHERE status = 0是乐观锁的精髓——只有当座位当前是「可选」时才更新。数据库对同一行的 UPDATE 会加行锁,两个并发请求进来,第一个先执行把status改成 1,第二个执行时status已经不是 0,WHERE不匹配,影响行数为 0,直接失败。这样不需要额外加锁,靠数据库自身的行锁和条件判断就实现了互斥。

参数说明:showId和seatId联合定位座位,orderId用于后续关联订单,status=0是前置条件。注意@Transactional必须加,否则更新和后续订单插入不在一个事务里,中途失败会留下脏数据。

3.3 超时未支付自动释放座位

用户锁了座不付款,座位不能一直占着。用 Spring 的定时任务扫超时记录:

@Scheduled(fixedRate = 60000) // 每分钟执行一次 public void releaseTimeoutSeats() { // 锁定超过15分钟且仍未支付的座位,释放回可选状态 int released = seatStatusMapper.releaseTimeout( LocalDateTime.now().minusMinutes(15)); log.info("释放超时座位 {} 个", released); }

对应 SQL:

UPDATE seat_status SET status = 0, order_id = NULL, lock_time = NULL WHERE status = 1 AND lock_time < #{deadline};

逻辑说明:fixedRate = 60000表示每 60 秒跑一次,deadline是当前时间减 15 分钟。凡是status=1且锁定时间早于这个阈值的,说明用户超时未付,释放回0。参数 15 分钟按业务定,剧院场景一般 10 到 15 分钟合理。这个定时任务要在启动类加@EnableScheduling才生效,很多人忘了加,任务静默不执行,排查半天。

4. 前后端联调与座位图渲染:把数据变成能点的图

4.1 后端接口设计:查询座位状态

前端要画座位图,得先拿到某场演出的全部座位及状态。接口设计成一个查询:

@GetMapping("/seats/{showId}") public Result<List<SeatVO>> listSeats(@PathVariable Long showId) { // 关联seat和seat_status,返回座位坐标+状态 List<SeatVO> seats = seatService.listByShow(showId); return Result.ok(seats); }

SeatVO至少包含seatId、rowNum、colNum、seatType、status五个字段。SQL 用seat左连seat_status:

SELECT s.id AS seatId, s.row_num, s.col_num, s.seat_type, IFNULL(ss.status, 0) AS status FROM seat s LEFT JOIN seat_status ss ON s.id = ss.seat_id AND ss.show_id = #{showId} WHERE s.hall_id = (SELECT hall_id FROM show_info WHERE id = #{showId}) ORDER BY s.row_num, s.col_num;

逻辑说明:用LEFT JOIN保证即使某座位在seat_status里还没记录(新演出首次访问),也能返回,IFNULL兜底为可选状态。hall_id通过子查询从演出信息里取,保证只查这个厅的座位。参数showId是路径变量,直接对应演出场次。

4.2 前端座位图渲染与点击交互

座位图本质是个二维网格,用 CSS Grid 或绝对定位都行。核心是把后端返回的列表按rowNum、colNum铺成矩阵:

// 假设 seats 是后端返回的座位数组 const grid = document.getElementById('seatGrid'); seats.forEach(seat => { const div = document.createElement('div'); div.className = 'seat ' + statusClass(seat.status); div.dataset.seatId = seat.seatId; // 用行列号定位,行号做纵坐标,列号做横坐标 div.style.gridRow = seat.rowNum; div.style.gridColumn = seat.colNum; div.onclick = () => toggleSeat(div, seat); grid.appendChild(div); }); function statusClass(status) { // 0可选 1锁定 2已售 return status === 0 ? 'available' : (status === 1 ? 'locked' : 'sold'); }

逻辑说明:gridRow和gridColumn直接映射座位的物理行列,过道位置因为数据库里没数据,自然留空。statusClass把状态映射成不同 CSS 类,可选绿色、锁定黄色、已售灰色。toggleSeat负责选中/取消选中,选中时把seatId收集到一个数组,下单时一起提交。参数seat.status来自后端,前端只做展示,真正的状态判断以后端为准,防止用户改前端状态骗过系统。

4.3 下单接口与座位状态二次校验

用户选完座点下单,后端不能信任前端传来的座位状态,必须再查一次:

@Transactional public Long createOrder(Long userId, Long showId, List<Long> seatIds) { // 二次校验:确认这些座位当前都是可选状态 int available = seatStatusMapper.countAvailable(showId, seatIds); if (available != seatIds.size()) { throw new BizException("部分座位已被选走,请重新选择"); } // 逐个锁定座位 for (Long seatId : seatIds) { int rows = seatStatusMapper.lockSeat(showId, seatId, null); if (rows == 0) { throw new BizException("座位" + seatId + "锁定失败"); } } // 生成订单... }

逻辑说明:countAvailable先统计这批座位里还有几个是可选的,数量对不上说明有人抢先,直接抛异常。然后逐个lockSeat,任何一个失败就回滚整个事务。参数seatIds是用户选中的座位列表,@Transactional保证要么全成功要么全回滚,不会出现锁了一半的脏状态。这一步是防超卖的最后一道闸,别省。

5. 避坑与排查:那些让毕设翻车的细节

5.1 座位状态不同步,刷新后全变回可选

现象:用户锁了座,页面刷新后座位又变绿了,好像没锁住。

原因:lockSeat的 UPDATE 没提交事务,或者前端查询接口没关联seat_status表,只查了seat静态表。

解决:确认 Service 方法有@Transactional,确认查询 SQL 用了LEFT JOIN seat_status。用SELECT * FROM seat_status WHERE show_id=? AND status=1直接查库验证数据到底写没写进去,先分清是写的问题还是读的问题。

5.2 并发测试时出现同一座位卖出两单

现象:用 JMeter 或两个浏览器同时抢同一座位,两个订单都成功了。

原因:lockSeat的WHERE条件漏了status=0,或者用了先查后改的写法(先SELECT判断再UPDATE),两步之间有时间窗口。

解决:必须用「条件更新 + 判断影响行数」的原子写法,WHERE status=0不能少。另外确认seat_status表有uk_show_seat唯一索引,双保险。测试时把线程数调到 50 并发压一下,看是否只有一个成功。

5.3 定时释放任务不执行

现象:超时座位一直不释放,lock_time早就过了 15 分钟。

原因:启动类忘了加@EnableScheduling,或者定时方法所在的类没被 Spring 扫描到(没加@Component)。

解决:检查启动类注解,检查定时任务类是否在@SpringBootApplication的扫描路径下。加一行日志在任务开头,启动后看控制台有没有打印,没有就是没生效。

5.4 座位图过道位置显示异常

现象:座位图中间该空的地方出现了座位,或者行列错位。

原因:生成座位数据时没跳过过道列,或者前端gridColumn从 0 开始而数据库列号从 1 开始,差一位。

解决:核对gen_seats里的IF c <> 10 AND c <> 11逻辑,核对前端定位用的是colNum而不是数组下标。CSS Grid 的行列号从 1 开始,和数据库row_num、col_num对齐,别混用。

5.5 订单金额和座位价格对不上

现象:VIP 座和普通座价格不同,但订单总价按统一价算了。

原因:下单时只取了演出基础价,没按seat_type区分。

解决:在seat表加price字段,或在show_info里存 VIP 价和普通价两个字段,下单时按座位类型累加。别在代码里写死价格,价格要能从数据库配。

6. 进阶技巧:把座位图做成可配置的,以及怎么验证系统真的可靠

带过几届毕设后我发现,能拿高分的系统都有一个共同点:座位图不是写死的,而是可配置的。普通做法是每个厅的座位布局在代码里定死,换个厅就得改代码。进阶做法是把厅的布局参数(排数、列数、过道位置、VIP 区域)存成 JSON 配置,生成座位时读配置动态生成。

// hall_layout 表存布局JSON,如 {"rows":10,"cols":20,"aisles":[10,11],"vipRows":3} String layoutJson = hallMapper.getLayout(hallId); HallLayout layout = JSON.parseObject(layoutJson, HallLayout.class); // 按配置生成座位,过道位置跳过 for (int r = 1; r <= layout.getRows(); r++) { for (int c = 1; c <= layout.getCols(); c++) { if (layout.getAisles().contains(c)) continue; // 插入座位... } }

这样管理员在后台改个 JSON 就能新增一个厅,不用动代码,答辩时这是实打实的加分项。参数aisles是过道列号列表,vipRows是 VIP 排数,都从配置读,灵活。

验证系统可靠性,我一般做三件事。第一,用 JMeter 对锁座接口做 100 并发压测,看成功数是否等于座位数,多一个就是超卖。第二,手动制造超时订单,等 15 分钟看定时任务是否释放。第三,把数据库里seat_status的状态和前端座位图逐个比对,确认状态一致。这三步做完,系统基本就稳了。

说个我自己的教训:早年做这类系统,我图省事把座位状态和座位信息合成一张表,结果每加一场演出就复制几千条座位数据,数据库越跑越慢,后来重构分表花了两天。座位静态信息和动态状态一定要分表,这个习惯我保持到现在。希望帮到你。

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

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

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

立即咨询