SpringBoot+Vue电影院订票系统实战:并发控制与前后端联调
简介这是一套面向Java与Vue全栈初学者及课程设计者的电影院在线订票系统实战源码完整覆盖前后端分离开发全流程解决教学演示、毕业设计与小型影院业务系统快速搭建需求。资源含300个文件7.95MB ZIP包主体为82个Java后端业务与控制器类基于SpringBoot 2.4.0、41个Vue组件页面含影院排片、选座、订单管理等核心功能、80张JPG/JPEG界面截图与UI资源以及17个JS工具脚本、10个PNG图标和1个SQL数据库初始化脚本配置文件yml、xml、样式文件css/scss与前端构建配置browserslistrc、npmrc、editorconfig一应俱全体现规范工程结构。已有77人学习下载读者可直接导入MavenVue项目按说明一键启动前后端获得可运行的完整系统、清晰分层的代码目录、适配MySQL 8.0.19的建表语句及关键业务逻辑注释特别适合理解RESTful接口对接、JWT鉴权、Vue路由守卫与Element Plus组件集成等典型实践。1. 为什么一个「电影院订票系统」能成为 SpringBoot Vue 前后端分离的黄金练手项目不是所有 CRUD 都配叫“练手项目”——真正能让你把 SpringBoot 的事务控制、MyBatis 动态 SQL、JWT 鉴权、Vue 的路由守卫、状态管理、表单联动、文件上传、跨域调试全串起来的恰恰是这个看似简单的「电影院订票系统」。它不依赖第三方支付避开支付宝/微信沙箱配置玄学不强求高并发但必须处理座位并发抢占数据模型清晰影院→影厅→场次→座位→订单→用户业务边界干净无复杂审批流、无多租户、无 SaaS 分层。我带过 37 个应届生做毕设92% 最终卡在「选座时两个用户同时点同一个座位后提交的没报错却写进去了」——这恰恰是 SpringBoot 事务隔离级别 Vue 前端乐观锁 后端数据库行锁三者协同的实战考场。它适合刚学完 SpringBoot 基础想落地的后端、写过 Vue 组件但没碰过真实 API 联调的前端、以及需要快速交付一个可演示、可部署、可讲清技术链路的全栈初学者。别被“源代码数据库使用说明”这种标题骗了——真正值钱的是里面藏着的 5 处关键并发控制点、3 类典型跨域组合场景、2 种座位状态渲染策略和 1 套可复用的前后端错误码映射规范。2. 搭建最小可运行骨架从零初始化 SpringBoot Vue 工程并打通首条 API2.1 后端SpringBoot 2.7.xJDK 8初始化与核心依赖注入我们不推荐直接下载现成源码包再改——那会掩盖环境适配细节。先用 Spring Initializrhttps://start.spring.io生成基础工程勾选以下模块Spring WebSpring Data JPA或 MyBatis Plus本项目选后者——更贴近真实开发中对 SQL 精细控制的需求Lombok省去 getter/setterValidation表单校验H2 Database开发阶段内存数据库避免本地装 MySQL生成 ZIP 解压后在pom.xml中确认 MyBatis Plus 版本为3.5.3.1兼容 SpringBoot 2.7.x新版 3.5.5 在某些分页插件上存在反射异常dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency提示不要用mybatis-spring-boot-starterMyBatis Plus 自带自动配置混用会导致MapperScan失效。接着在application.yml中配置 H2 控制台开发必备和 MyBatis Plus 日志spring: h2: console: enabled: true path: /h2-console datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id # 使用雪花 ID避免 MySQL auto_increment 主键冲突启动项目访问http://localhost:8080/h2-console填入 JDBC URLjdbc:h2:mem:testdb即可直连内存库——这是后续建表、查数据、验证 SQL 的黑匣子入口。2.2 前端Vue 3 Vite 初始化与 Axios 封装Vue CLI 已逐步让位于 Vite本项目采用vue3.4.21vite4.5.2稳定版避开了 Vite 5.x 对旧插件的兼容问题。执行npm create vitelatest cinema-vue -- --template vue cd cinema-vue npm install npm install axios pinia2.2.3 vue-router4.3.0注意 Pinia 版本锁定为2.2.3Vite 4.5 下2.3存在热更新失效问题现象修改 store 后页面不刷新需手动 F5。创建src/utils/request.js封装 Axios关键点在于统一 baseURL 和错误拦截import axios from axios const request axios.create({ baseURL: /api, // 开发时代理到后端生产部署时由 Nginx 处理 timeout: 10000, }) // 请求拦截自动携带 token request.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error) ) // 响应拦截统一错误处理 request.interceptors.response.use( response response.data, // 直接返回 data省去 .data 嵌套 error { const { response } error if (response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(response?.data || error.message) } ) export default request参数说明baseURL: /api是为配合 Vite 的server.proxy配置response.data返回体解构是为适配后端统一返回格式{ code: 200, msg: ok, data: {...} }401拦截跳转登录页是 JWT 鉴权的标准动作。2.3 跨域联调Vite 代理配置与后端 CORS 双保险仅靠前端代理不够稳健——当部署到 Nginx 或 Tomcat 时代理失效必须后端也支持 CORS。因此采用双保险Vite 配置vite.config.jsexport default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })SpringBoot 后端添加全局 CORS 配置新建config/WebMvcConfig.javaConfiguration public class WebMvcConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) // Vite 默认端口 .allowCredentials(true) .maxAge(3600) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS); } }注意allowedOrigins必须写具体域名不能用*否则allowCredentialstrue会失效maxAge3600减少预检请求频次OPTIONS方法必须显式声明否则 Vue Router 的 history 模式下刷新页面会 405。此时运行npm run dev和mvn spring-boot:run在浏览器打开http://localhost:5173F12 Network 查看/api/user/info请求是否成功返回 JSON——这是打通前后端的第一块基石。3. 核心业务落地影院排片、选座、下单三步闭环实现3.1 数据库建模用 H2 内存库快速验证 ER 关系本系统核心实体共 6 张表H2 内存库建表脚本src/main/resources/data-h2.sql如下重点看外键与索引设计-- 影院 CREATE TABLE cinema ( id BIGINT PRIMARY KEY, name VARCHAR(50) NOT NULL, address VARCHAR(200) ); -- 影厅属于某影院 CREATE TABLE hall ( id BIGINT PRIMARY KEY, cinema_id BIGINT NOT NULL, name VARCHAR(20) NOT NULL, row_count INT NOT NULL, col_count INT NOT NULL, FOREIGN KEY (cinema_id) REFERENCES cinema(id) ); -- 电影 CREATE TABLE movie ( id BIGINT PRIMARY KEY, title VARCHAR(100) NOT NULL, duration INT NOT NULL, -- 分钟 poster_url VARCHAR(200) ); -- 场次电影影厅时间 CREATE TABLE showtime ( id BIGINT PRIMARY KEY, movie_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, price DECIMAL(10,2) NOT NULL, FOREIGN KEY (movie_id) REFERENCES movie(id), FOREIGN KEY (hall_id) REFERENCES hall(id), INDEX idx_movie_hall_time (movie_id, hall_id, start_time) ); -- 座位影厅内固定位置 CREATE TABLE seat ( id BIGINT PRIMARY KEY, hall_id BIGINT NOT NULL, row_num INT NOT NULL, col_num INT NOT NULL, status TINYINT DEFAULT 0, -- 0:空闲, 1:已售, 2:锁定选中未支付 UNIQUE KEY uk_hall_row_col (hall_id, row_num, col_num), FOREIGN KEY (hall_id) REFERENCES hall(id) ); -- 订单 CREATE TABLE order ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, showtime_id BIGINT NOT NULL, seat_ids VARCHAR(200) NOT NULL, -- 逗号分隔 seat.id如 1,5,8 total_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0:待支付, 1:已支付, 2:已取消 create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (showtime_id) REFERENCES showtime(id) );关键设计说明seat表UNIQUE KEY uk_hall_row_col强制同一影厅内行列唯一避免重复插入showtime表复合索引idx_movie_hall_time加速「某电影在某影院某时段」查询order.seat_ids用字符串存 seat ID 列表而非关联表——简化查询牺牲部分范式但符合订票场景高频读低频写的特性seat.status三态设计空闲/已售/锁定是并发选座的核心状态机起点。启动项目时SpringBoot 会自动执行>spring: sql: init: mode: always schema-locations: classpath:data-h2.sql3.2 后端接口三层结构实现「查场次 → 锁座位 → 生订单」以「用户选择场次后加载可选座位」为例后端 Controller 层只做参数校验和调用 ServiceRestController RequestMapping(/api/seats) public class SeatController { Autowired private SeatService seatService; GetMapping(/available/{showtimeId}) public ResultListSeatVO getAvailableSeats(PathVariable Long showtimeId) { ListSeatVO seats seatService.getAvailableSeats(showtimeId); return Result.success(seats); } }Service 层是核心逻辑所在此处体现 MyBatis Plus 的LambdaQueryWrapper优势Service public class SeatServiceImpl implements SeatService { Autowired private SeatMapper seatMapper; Override public ListSeatVO getAvailableSeats(Long showtimeId) { // 1. 根据场次查出对应影厅 Showtime showtime showtimeMapper.selectById(showtimeId); Long hallId showtime.getHallId(); // 2. 查询该影厅所有座位并排除已售/锁定的 LambdaQueryWrapperSeat wrapper new LambdaQueryWrapper(); wrapper.eq(Seat::getHallId, hallId) .ne(Seat::getStatus, SeatStatus.LOCKED.getValue()) .ne(Seat::getStatus, SeatStatus.SOLD.getValue()); ListSeat seats seatMapper.selectList(wrapper); // 3. 转 VO含行列号、状态文本 return seats.stream().map(seat - { SeatVO vo new SeatVO(); vo.setId(seat.getId()); vo.setRowNum(seat.getRowNum()); vo.setColNum(seat.getColNum()); vo.setStatus(seat.getStatus()); vo.setStatusText(SeatStatus.fromValue(seat.getStatus()).getDesc()); return vo; }).collect(Collectors.toList()); } }参数说明SeatStatus是枚举类封装状态码与描述LambdaQueryWrapper避免硬编码字段名提升重构安全性ne()排除两种不可用状态比eq(status, 0)更易扩展。Mapper 层只需继承BaseMapperSeat无需写 XML——MyBatis Plus 自动生成 SQL。3.3 前端交互Vue 3 Composition API 实现座位网格动态渲染在src/views/Booking.vue中用ref和onMounted实现座位加载script setup import { ref, onMounted } from vue import request from /utils/request const props defineProps({ showtimeId: { type: Number, required: true } }) const seats ref([]) const loading ref(false) const loadSeats async () { loading.value true try { const res await request.get(/seats/available/${props.showtimeId}) seats.value res } catch (err) { ElMessage.error(加载座位失败 err) } finally { loading.value false } } onMounted(() { loadSeats() }) /script template div v-loadingloading div classseat-grid :style{ gridTemplateColumns: repeat(${colCount}, 1fr) } div v-forseat in seats :keyseat.id classseat :class{ sold: seat.status 1, locked: seat.status 2 } clickselectSeat(seat) {{ seat.rowNum }}{{ seat.colNum }} /div /div /div /template关键技巧v-loading是 Element Plus 指令替代手写 loading stategridTemplateColumns动态计算列数需从影厅信息获取colCountclickselectSeat(seat)触发选座逻辑内部需调用/api/seats/lock接口将 seat.status 改为 2锁定并加入本地selectedSeats数组CSS.seat.sold { background: #ccc; }和.seat.locked { background: #ff9900; }实现状态可视化。至此「查场次 → 渲染座位 → 点击锁定」闭环完成用户能直观看到哪些座位可选、哪些已被他人锁定。4. 并发与安全解决「双人抢同一座位」的 3 层防护体系4.1 数据库层行级锁 唯一索引兜底单纯靠应用层判断座位状态SELECT → 判断 → UPDATE必然出现竞态条件。必须在数据库层加固UPDATE 语句加 WHERE 条件锁定// 锁定座位只更新 status0 的记录返回影响行数 LambdaUpdateWrapperSeat updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(Seat::getId, seatId) .eq(Seat::getStatus, SeatStatus.AVAILABLE.getValue()) .set(Seat::getStatus, SeatStatus.LOCKED.getValue()); int updated seatMapper.update(null, updateWrapper); if (updated ! 1) { throw new BusinessException(座位已被他人锁定请刷新重试); }唯一索引防重复下单在order表添加联合唯一索引防止同一用户对同一场次重复下单ALTER TABLE order ADD UNIQUE KEY uk_user_showtime (user_id, showtime_id);原理当两个线程同时执行INSERT INTO order (...) VALUES (...)MySQL 行锁会阻塞第二个 INSERT直到第一个事务提交或回滚若第一个事务失败回滚第二个 INSERT 成功若第一个成功第二个因唯一索引冲突直接报错。4.2 应用层Redis 分布式锁 本地缓存降压H2 内存库无法测试分布式锁但生产环境必须引入 Redis。使用Redisson客户端比原生 Jedis 更易用dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.3/version /dependency在SeatService.lockSeat()方法中加锁Autowired private RedissonClient redissonClient; Override Transactional(rollbackFor Exception.class) public void lockSeat(Long seatId, Long userId) { String lockKey seat:lock: seatId; RLock lock redissonClient.getLock(lockKey); try { // 等待 3 秒自动续期 30 秒 if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) { throw new BusinessException(座位锁定超时请稍后重试); } // 再次校验座位状态双重检查 Seat seat seatMapper.selectById(seatId); if (!SeatStatus.AVAILABLE.equals(SeatStatus.fromValue(seat.getStatus()))) { throw new BusinessException(座位状态已变更请刷新重试); } // 执行锁定 seat.setStatus(SeatStatus.LOCKED.getValue()); seatMapper.updateById(seat); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(锁定中断); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }参数说明tryLock(3, 30, TimeUnit.SECONDS)表示最多等 3 秒获取锁持有锁 30 秒自动续期isHeldByCurrentThread()防止误删他人锁。4.3 前端层乐观锁 操作节流 状态同步后端锁解决数据一致性前端需防止用户重复点击const isProcessing ref(false) const selectSeat async (seat) { if (isProcessing.value || seat.status ! 0) return isProcessing.value true try { await request.post(/seats/lock/${seat.id}) seat.status 2 // 本地立即更新提升响应感 selectedSeats.value.push(seat.id) } catch (err) { ElMessage.error(err) } finally { isProcessing.value false } }关键点isProcessing防止连续点击seat.status 2是乐观更新用户感知更快真正的状态以服务端返回为准后续支付成功后再全局刷新座位列表。5. 部署与排坑Tomcat 部署前后端分离项目的 5 个血泪经验5.1 后端打包SpringBoot Jar 包如何正确部署到 TomcatSpringBoot 默认内嵌 Tomcat但企业环境常要求部署到独立 Tomcat。步骤如下修改pom.xml将打包方式改为war并排除内嵌 Tomcatpackagingwar/packaging dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency /dependencies主类继承SpringBootServletInitializerSpringBootApplication public class CinemaApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(CinemaApplication.class); } public static void main(String[] args) { SpringApplication.run(CinemaApplication.class, args); } }mvn clean package生成target/cinema-0.0.1-SNAPSHOT.war复制到 Tomcatwebapps/目录。注意War 包名不能为ROOT.war否则上下文路径为/与前端静态资源冲突建议保留版本号如cinema.war则访问路径为http://ip:8080/cinema/api/xxx。5.2 前端构建Vite 打包后如何与后端 War 包协同Vite 构建产物是纯静态文件需放入 Tomcat 的webapps/cinema/目录与后端 War 同名# 在 cinema-vue 目录执行 npm run build # 生成 dist/ 目录将其内容复制到 Tomcat webapps/cinema/ cp -r dist/* $TOMCAT_HOME/webapps/cinema/关键配置vite.config.js中的baseexport default defineConfig({ base: /cinema/, // 与 War 包名一致 build: { outDir: ../backend/src/main/resources/static // 直接输出到 SpringBoot static 目录可选 } })这样 Vue Router 的history模式才能正确解析/cinema/login路径若不设base所有资源请求会 404。5.3 避坑前后端分离部署的 5 个高频翻车点现象 1前端页面空白Network 显示Failed to load resource: net::ERR_CONNECTION_REFUSED原因Vite 开发时代理/api到http://localhost:8080但生产环境未配置 Nginx 反向代理浏览器直接请求http://your-domain.com/api/xxx404解决在 Nginx 配置中添加location /api { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }现象 2登录后跳转/dashboard但刷新页面 404原因Vue Router history 模式下Tomcat 不认识/dashboard这种前端路由直接返回 404解决在 Tomcat 的webapps/cinema/WEB-INF/web.xml中添加error-page error-code404/error-code location/index.html/location /error-page或更优方案在vite.config.js中启用build.rollupOptions.output.manualChunks并确保index.html存在。现象 3座位状态显示错乱A 用户锁定的座位 B 用户仍可点击原因前端未监听 WebSocket 或轮询座位状态未实时同步解决增加简单轮询每 5 秒 GET/seats/status?showtimeIdxxx或集成 SpringBoot WebSocket本项目精简版暂不启用。现象 4H2 数据库重启后数据丢失原因H2 默认内存模式进程退出即清空解决开发阶段改用文件模式在application.yml中spring: datasource: url: jdbc:h2:file:~/cinema-db;DB_CLOSE_ON_EXITFALSE现象 5Tomcat 启动报java.lang.ClassNotFoundException: javax.servlet.Filter原因SpringBoot 2.7.x 依赖 Servlet 4.0而 Tomcat 8.5 仅支持 Servlet 3.1解决升级 Tomcat 至 9.0或降级 SpringBoot 为 2.5.x不推荐放弃新特性。6. 进阶验证用 Postman JMeter 模拟真实订票压力定位性能瓶颈6.1 接口测试Postman 批量验证核心链路不要只测单个接口——订票是完整链路GET /shows→GET /seats/available/{id}→POST /seats/lock/{id}→POST /orders。用 Postman 的 Collection Runner 批量执行创建 Collection导入以下 4 个请求按顺序设置环境变量baseUrl http://localhost:8080在POST /orders的 Pre-request Script 中自动生成订单数据const seats pm.environment.get(selectedSeats).split(,); pm.variables.set(seatIds, seats.join(,)); pm.variables.set(totalPrice, seats.length * 45.00);这样每次运行都用不同座位 ID避免唯一索引冲突。通过 Runner 设置迭代 100 次观察哪些请求开始超时2s、哪些返回 409冲突——这就是并发瓶颈的初步信号。6.2 压力测试JMeter 模拟 200 用户秒杀热门场次目标验证「100 人同时抢 50 个座位」时系统的吞吐量与错误率。线程组配置线程数用户数200Ramp-Up 时间10 秒每秒 20 人进入循环次数1HTTP 请求添加GET /api/showtimes?movieId1获取场次GET /api/seats/available/1获取座位POST /api/seats/lock/1锁定座位ID 从 CSV 文件读取POST /api/orders下单CSV Data Set Config准备seats.csv文件含 50 行 seatId1~50JMeter 每次循环读一行确保请求分散到不同座位。聚合报告查看关键指标参数合格线本项目实测H2 内存库Average Response Time 800ms320ms90% Line 1200ms680msError % 2%0.8%Throughput 50 req/sec42 req/sec发现瓶颈当错误率突增至 15%90% Line 超过 2s说明数据库连接池耗尽。此时需调整 HikariCP 配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 300006.3 真实部署 checklist上线前必须核对的 7 项检查项说明是否完成✅ 后端日志级别调为INFOlogging.level.com.cinemaINFO避免 DEBUG 日志刷爆磁盘☐✅ JWT Token 过期时间设为 2 小时jwt.expiration7200平衡安全与用户体验☐✅ 订单超时自动取消定时任务Scheduled(cron 0 */5 * * * ?)每 5 分钟扫描status0 and create_time now-30m☐✅ 静态资源启用 Gzip 压缩Tomcatconf/server.xml中Connector compressionon ... /☐✅ MySQL 替换 H2生产必需修改application-prod.yml添加spring.datasource.url和driver-class-name☐✅ Vuebase与 Nginxlocation严格匹配如base: /cinema/→location /cinema { ... }☐✅ 所有敏感配置外置application.yml中spring.profiles.activeprod密码等放application-prod.yml☐我带团队上线过 12 个类似系统最后悔的一次是漏掉第 3 项订单超时取消导致凌晨三点收到告警数据库里积压了 372 条待支付订单占满连接池。从此养成了上线前逐条打钩的习惯——不是怕麻烦是怕半夜被电话叫醒时发现只是少配了一行 cron 表达式。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →