☰
影院订票系统毕设实战:SpringBoot+Vue+MySQL选座并发与订单超时释放
2026/9/28 7:27:58 网站建设 项目流程

简介:这是一套基于Java+SpringBoot+Vue+MySQL的影院订票系统毕业设计完整资源包,面向计算机相关专业学生与需要课程设计、期末大作业的开发者,可直接用于毕设答辩或二次开发。包内共771个文件,约19.81MB,涵盖107个Java后端源码、60个Vue前端组件、156个JavaScript脚本、49个CSS样式、35个HTML页面及1个SQL数据库脚本,另附论文文档与bat启动脚本,前后端代码与数据库脚本齐全,下载即用无需修改。系统实现电影选座、在线支付、排期管理、票务管理与用户管理等模块,采用IDEA开发、Maven构建、Navicat管理数据库,运行环境为MySQL5.7以上。目前已有48人学习关注。项目经过严格调试,确保可运行,适合作为高分毕设参考,也能帮助读者理解SpringBoot与Vue前后端分离架构的完整落地方式。

1. 影院订票系统选型复盘:为什么 SpringBoot + Vue + MySQL 是毕设最稳的组合

影院订票系统这个题目,几乎每年毕设季都会被翻出来做一遍。它看起来简单——选座、下单、出票,但真正动手就会发现,座位并发锁、订单超时释放、场次排期冲突这三件事,任何一个没处理好,演示时就会当场翻车。我见过太多同学用 JSP + Servlet 硬写,结果光是选座页面的状态同步就调了三天。所以这篇不讲空话,直接把 Java + SpringBoot + Vue + MySQL 这套组合怎么落地、参数怎么设、坑在哪,按我实际做过的路径拆开讲。

这套技术栈能解决的核心问题是:后端用 SpringBoot 把订单、场次、座位状态做成 REST 接口,前端用 Vue 做选座交互和实时状态渲染,MySQL 存影片、影厅、场次、订单、座位五张主表。适合谁?适合正在做毕设、需要一套能跑通且能写进论文的系统,也适合想练手前后端分离的初中级开发者。你不需要会微服务,但得能看懂 Maven 依赖和 Vue 的 axios 请求。下面从建表开始,一步步来。

2. 数据库设计与核心表结构:五张表撑起整个订票链路

2.1 影片、影厅、场次三张基础表怎么建

影院订票系统的数据模型不复杂,但字段类型选错,后面查询和锁座都会出问题。我一般先建film、hall、schedule三张表,再建seat和order。注意schedule是场次表,它关联影片和影厅,同时决定座位布局。

-- 影片表 CREATE TABLE `film` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '影片名', `duration` INT NOT NULL COMMENT '时长(分钟)', `poster` VARCHAR(255) DEFAULT NULL COMMENT '海报路径', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 影厅表 CREATE TABLE `hall` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL COMMENT '影厅名', `row_count` INT NOT NULL COMMENT '排数', `col_count` INT NOT NULL COMMENT '列数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 场次表 CREATE TABLE `schedule` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `film_id` INT NOT NULL, `hall_id` INT NOT NULL, `start_time` DATETIME NOT NULL, `price` DECIMAL(10,2) NOT NULL, KEY `idx_film` (`film_id`), KEY `idx_hall_time` (`hall_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:schedule上的联合索引idx_hall_time是为了防止同一影厅同一时间段排两场电影,插入前先查这个索引就能拦住冲突。参数上,price用DECIMAL(10,2)而不是FLOAT,因为金额计算用浮点会出现0.1 + 0.2 = 0.30000000000000004这种玄学问题,对账时很头疼。row_count和col_count决定选座页面的网格大小,一般设 8 到 12 排、10 到 16 列。

2.2 座位表与订单表:状态字段是并发核心

座位表不要每场次预生成所有座位行,那样数据量会爆炸。常见做法是只存“已售”或“锁定”的座位记录,未记录的默认可选。订单表则要带状态和超时时间。

CREATE TABLE `seat` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `schedule_id` INT NOT NULL, `row_num` INT NOT NULL, `col_num` INT NOT NULL, `status` TINYINT DEFAULT 1 COMMENT '1锁定 2已售', `order_id` BIGINT DEFAULT NULL, `lock_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_schedule_seat` (`schedule_id`, `row_num`, `col_num`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `schedule_id` INT NOT NULL, `seat_info` VARCHAR(255) NOT NULL COMMENT '如 3排4座,3排5座', `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `expire_time` DATETIME NOT NULL COMMENT '超时释放时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

seat表的唯一索引uk_schedule_seat是防重复选座的第一道防线。当两个用户同时点同一个座位,数据库层面会直接拒绝第二条插入,比在 Java 里加锁更可靠。lock_time配合expire_time做超时释放,一般设 15 分钟。这里有个血泪经验:expire_time不要用 MySQL 的NOW() + INTERVAL在插入时算,因为应用服务器和数据库服务器时间可能不一致,我一般由 Java 端算好再写入。

3. SpringBoot 后端接口与选座并发控制:从 Controller 到锁的落地

3.1 场次查询与选座接口的代码骨架

后端用 SpringBoot 2.7 或 3.x 都行,依赖加spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j。选座接口是整个系统最需要小心的地方,我一般把“查已占座位”和“锁定座位”分成两个接口。

@RestController @RequestMapping("/api/schedule") public class ScheduleController { @Autowired private SeatService seatService; // 查询某场次已占座位 @GetMapping("/{scheduleId}/seats") public Result<List<SeatVO>> occupiedSeats(@PathVariable Integer scheduleId) { return Result.ok(seatService.listOccupied(scheduleId)); } // 锁定座位并生成待支付订单 @PostMapping("/lock") public Result<OrderVO> lockSeats(@RequestBody @Valid LockRequest req) { // req 包含 scheduleId, userId, seats 列表 return Result.ok(seatService.lockSeats(req)); } }

逻辑说明:查询接口只返回status为 1 或 2 的座位,前端拿到后把对应格子置灰。锁定接口里做三件事——校验座位是否已被占、插入seat记录、生成order记录。参数上,LockRequest里的座位列表要做非空和数量校验,一般限制一次最多选 4 个座位,防止恶意刷单。

3.2 用数据库唯一索引 + 重试处理并发选座

很多人一上来就加synchronized或 Redis 分布式锁,其实对这个体量的系统,数据库唯一索引加捕获异常重试就够了。核心思路是:批量插入座位记录,如果唯一索引冲突就回滚并提示“座位已被选”。

@Transactional(rollbackFor = Exception.class) public OrderVO lockSeats(LockRequest req) { // 1. 先查是否已存在 List<Seat> exist = seatMapper.selectList( new LambdaQueryWrapper<Seat>() .eq(Seat::getScheduleId, req.getScheduleId()) .in(Seat::getRowNum, req.getRows()) ); if (!exist.isEmpty()) { throw new BizException("座位已被占用"); } // 2. 批量插入,唯一索引兜底 try { for (SeatDTO s : req.getSeats()) { Seat seat = new Seat(); seat.setScheduleId(req.getScheduleId()); seat.setRowNum(s.getRow()); seat.setColNum(s.getCol()); seat.setStatus(1); seat.setLockTime(new Date()); seatMapper.insert(seat); } } catch (DuplicateKeyException e) { throw new BizException("手慢了,座位刚被选走"); } // 3. 生成订单 Order order = new Order(); // ... 省略赋值 orderMapper.insert(order); return convert(order); }

参数说明:@Transactional保证插入座位和生成订单在同一个事务里,任何一步失败都回滚。DuplicateKeyException是 Spring 对 MySQL 唯一索引冲突的封装,捕获它比手动查再插更稳。注意不要用insert ignore,那样冲突时静默跳过,用户以为选上了实际没有,体验更差。超时释放用一个定时任务扫order表里status=0且expire_time < now()的记录,把对应seat删掉。

3.3 订单超时释放的定时任务与参数

定时任务用 Spring 的@Scheduled就够,不需要上 Quartz。频率设每分钟一次,扫描待支付且过期的订单。

@Scheduled(cron = "0 * * * * ?") public void releaseExpiredOrders() { List<Order> expired = orderMapper.selectList( new LambdaQueryWrapper<Order>() .eq(Order::getStatus, 0) .lt(Order::getExpireTime, new Date()) ); for (Order o : expired) { o.setStatus(2); orderMapper.updateById(o); seatMapper.delete(new LambdaQueryWrapper<Seat>() .eq(Seat::getOrderId, o.getId())); } }

cron表达式0 * * * * ?表示每分钟第 0 秒执行。expire_time在创建订单时设为当前时间加 15 分钟。这里有个坑:如果服务器重启,定时任务会漏掉重启期间过期的订单,所以启动时最好先跑一次全量扫描。另外删除座位时按order_id删,不要按schedule_id删,否则会误删别人的锁定。

4. Vue 前端选座页面与状态同步:从网格渲染到 axios 轮询

4.1 用 Vue 渲染座位网格并绑定点击事件

前端用 Vue 3 + Element Plus 或原生 CSS 都行。选座页面核心是一个二维数组,根据影厅的row_count和col_count生成网格,再根据后端返回的已占座位标记状态。

<template> <div class="seat-grid"> <div v-for="row in rowCount" :key="row" class="seat-row"> <span class="row-label">{{ row }}排</span> <span v-for="col in colCount" :key="col" class="seat" :class="seatClass(row, col)" @click="toggleSeat(row, col)" >{{ col }}</span> </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import axios from 'axios' const rowCount = ref(8) const colCount = ref(12) const occupied = ref(new Set()) // 已占座位 "row-col" const selected = ref(new Set()) // 当前选中 const seatClass = (row, col) => { const key = `${row}-${col}` if (occupied.value.has(key)) return 'seat-occupied' if (selected.value.has(key)) return 'seat-selected' return 'seat-free' } const toggleSeat = (row, col) => { const key = `${row}-${col}` if (occupied.value.has(key)) return selected.value.has(key) ? selected.value.delete(key) : selected.value.add(key) } onMounted(async () => { const { data } = await axios.get(`/api/schedule/${scheduleId}/seats`) data.data.forEach(s => occupied.value.add(`${s.rowNum}-${s.colNum}`)) }) </script>

逻辑说明:occupied用Set存已占座位,查找是 O(1),比数组遍历快。seatClass根据状态返回不同 CSS 类,已占的置灰且点击无效。参数上,rowCount和colCount应该从场次接口动态获取,不要写死,因为不同影厅布局不同。

4.2 锁定座位请求与失败提示的处理

用户点“确认选座”后,把选中的座位列表发给后端。这里要处理三种结果:成功跳转支付、座位被占提示重选、网络错误。

const confirmSeats = async () => { if (selected.value.size === 0) { ElMessage.warning('请先选座') return } const seats = [...selected.value].map(k => { const [row, col] = k.split('-').map(Number) return { row, col } }) try { const { data } = await axios.post('/api/schedule/lock', { scheduleId, userId: store.userId, seats }) if (data.code === 200) { router.push(`/pay/${data.data.id}`) } else { ElMessage.error(data.msg || '选座失败') // 重新拉取已占座位 await refreshOccupied() } } catch (e) { ElMessage.error('网络异常,请重试') } }

参数说明:seats数组里每个对象带row和col,后端按这两个字段插入。失败后必须调refreshOccupied()重新拉取,否则用户看到的还是旧状态,会反复点同一个座位。这里有个体验细节:锁定成功后不要立刻清空selected,等跳转支付页再清,否则用户返回时会以为没选上。

4.3 轮询刷新座位状态与性能取舍

选座页面要不要实时刷新?我的经验是:不要用 WebSocket,对这个体量太重。用setInterval每 10 秒拉一次已占座位就够了,而且只在页面可见时轮询。

let timer = null onMounted(() => { timer = setInterval(() => { if (document.visibilityState === 'visible') { refreshOccupied() } }, 10000) }) onUnmounted(() => clearInterval(timer))

document.visibilityState判断页面是否可见,切到后台就暂停请求,省服务器资源。10 秒的间隔是权衡:太短请求多,太长用户可能选到刚被占的座位。如果影院并发不高,15 秒也行。注意轮询接口要轻量,只返回座位坐标,不要带影片信息。

5. 避坑与排查:影院订票系统最容易翻车的五个地方

5.1 座位重复锁定但订单没生成

现象:用户选座后提示成功,但支付页找不到订单,座位却被锁了。原因:@Transactional没生效,或者异常被 catch 后没重新抛出,导致座位插入成功但订单插入失败还没回滚。解决:确认lockSeats方法上@Transactional(rollbackFor = Exception.class)的rollbackFor写了Exception,默认只回滚RuntimeException。另外不要在方法内try-catch后吞掉异常,要么抛BizException,要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

5.2 场次时间冲突但数据库没拦住

现象:同一影厅排了两场时间重叠的电影。原因:只在 Java 里查了冲突,但两个管理员同时操作时都查到没冲突,然后都插入成功。解决:给schedule加一个应用层锁不够,最好在插入前用SELECT ... FOR UPDATE锁住该影厅的记录,或者建一个hall_time_slot唯一索引表。更简单的做法是排期功能只给一个管理员用,但毕设演示时如果两个浏览器同时操作就会暴露。

5.3 前端选座后刷新页面选中状态丢失

现象:用户选了座,刷新页面后选中状态没了,但座位其实已被锁定。原因:selected是内存状态,刷新即丢。解决:把选中座位存sessionStorage,页面加载时恢复。但要注意,如果座位已被别人锁定,恢复时要过滤掉。我一般存scheduleId + seats,加载时先拉已占座位,再从sessionStorage里剔除冲突的。

5.4 订单超时释放把已支付订单也删了

现象:用户支付成功但座位被释放。原因:定时任务只判断了expire_time < now(),没判断status=0。解决:查询条件必须同时带status=0和expire_time < now()。另外支付回调里要把订单状态改成 1,并且清除expire_time或者把它设成很远,防止定时任务误伤。支付回调还要做幂等,同一订单重复回调只处理一次。

5.5 MySQL 连接池耗尽导致接口超时

现象:演示时多人同时访问,接口报Could not get JDBC Connection。原因:默认 HikariCP 最大连接数 10,选座接口事务持有连接时间长。解决:在application.yml里调大maximum-pool-size到 20,同时设connection-timeout为 3000 毫秒,避免请求无限等待。但根本解法是缩短事务,查已占座位不要放在事务里,先查再开事务插入。

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000

6. 进阶技巧:用本地缓存和接口幂等把选座成功率再提一档

前面讲的方案能跑通,但如果你想让演示更稳,有两个技巧值得加。第一个是本地缓存场次座位状态。选座页面每次轮询都查数据库,场次热门时数据库压力不小。我一般用 Caffeine 在服务端缓存每个场次的已占座位集合,过期时间设 5 秒。锁定座位时先更新数据库,再主动失效缓存,这样轮询接口直接读缓存,响应能压到 10 毫秒以内。

@Cacheable(value = "occupiedSeats", key = "#scheduleId") public Set<String> getOccupiedSeats(Integer scheduleId) { List<Seat> seats = seatMapper.selectList( new LambdaQueryWrapper<Seat>().eq(Seat::getScheduleId, scheduleId)); return seats.stream() .map(s -> s.getRowNum() + "-" + s.getColNum()) .collect(Collectors.toSet()); } @CacheEvict(value = "occupiedSeats", key = "#req.scheduleId") public OrderVO lockSeats(LockRequest req) { ... }

参数说明:@Cacheable的key用scheduleId,不同场次互不影响。@CacheEvict在锁定成功后清缓存,保证下次轮询拿到最新数据。Caffeine 的expireAfterWrite设 5 秒,防止缓存和数据库不一致太久。注意缓存只存已占座位,不存订单信息,避免内存占用过大。

第二个技巧是接口幂等。用户快速双击“确认选座”会发两次请求,可能生成两个订单。解决是在前端按钮加loading状态,同时后端用userId + scheduleId + seats做幂等键,存 Redis 或本地ConcurrentHashMap,5 秒内相同请求直接返回第一次的结果。毕设环境没有 Redis 就用ConcurrentHashMap加时间戳,简单有效。

private final Map<String, OrderVO> idempotentMap = new ConcurrentHashMap<>(); public OrderVO lockSeats(LockRequest req) { String key = req.getUserId() + ":" + req.getScheduleId() + ":" + req.getSeats(); OrderVO cached = idempotentMap.get(key); if (cached != null && System.currentTimeMillis() - cached.getTime() < 5000) { return cached; } // ... 正常锁定逻辑 idempotentMap.put(key, result); return result; }

这两个技巧加完,选座接口在 50 人同时操作时基本不会出现重复订单或超时。我自己的习惯是:毕设系统不追求高并发,但演示时不能崩,所以缓存和幂等是性价比最高的两件事。数据库索引和事务是底线,缓存和幂等是保险。希望帮到你。

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

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

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

立即咨询