ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue影院选座系统:并发锁座与订单状态流转实战

SpringBoot+Vue影院选座系统:并发锁座与订单状态流转实战 简介这是一套面向Java Web全栈学习者的电影售票及影院管理系统完整源码基于SpringBoot与Vue.js前后端分离架构实现适合课程设计、毕业设计或技术进阶练习。系统覆盖用户注册登录、电影信息维护、影院与放映厅管理、排片场次设定、可视化选座购票、订单支付与电子票生成以及管理员权限下的订单与用户管理并涉及Spring Security权限控制、Redis缓存优化与前端懒加载等实践点。压缩包共314个文件约16.14MB以81个Java后端源码、41个Vue组件、15个XML配置、15个JavaScript脚本及102张图片资源为主另含SQL建表脚本与项目说明文档目录结构清晰便于按模块阅读与二次开发。目前已有71人学习下载读者可借此完整梳理前后端接口设计、组件化开发与数据库建模思路快速搭建可运行的售票管理平台。1. 电影售票系统为什么总在选座那一步翻车影院售票系统的核心链路其实很短排片、选座、下单、支付、出票。但真正做过的人都知道最容易出问题的不是支付回调而是选座。一个座位被两个人同时点中后台没锁住订单就重复了锁住了但没设过期时间用户关掉页面座位就永远灰着。这类问题在单体架构里靠数据库行锁能扛一阵但一旦前后端分离、并发上来就必须在 SpringBoot 层做显式控制。这个标题指向的是一套典型的 SpringBoot Vue 前后端分离影院管理系统覆盖影片管理、影厅排片、在线选座、订单支付、后台统计这几块。它适合两类人一是想拿一个完整业务闭环练手前后端分离的开发者二是需要一套可跑通的影院业务骨架做二次开发的人。下面我按实际落地顺序把选型理由、建表、接口、锁座、前端渲染和踩坑一条条拆开讲。2. 技术选型与数据库设计为什么是 SpringBoot Vue 而不是别的2.1 后端选 SpringBoot 的四个现实理由影院系统的业务复杂度中等但模块边界清晰用户、影片、影厅、排片、订单、支付。这种场景下 SpringBoot 的优势很直接。第一起步依赖把 Web、MyBatis、Redis、事务管理一次性拉齐不用在 XML 里堆配置。第二内嵌 Tomcat 让部署变成一条java -jar对个人开发者和中小团队足够。第三生态成熟分页插件、连接池、缓存这些常见需求都有稳定方案。第四前后端分离后后端只负责 JSON 接口SpringBoot 的RestController写起来很顺。我一般会把项目拆成controller、service、mapper、entity、dto、config、common这几层。common里放统一返回体和全局异常处理这是后面所有接口的基础。// common/Result.java public class ResultT { private Integer code; // 200 成功500 失败401 未登录 private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg success; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } // getter/setter 省略 }这段代码的关键在于统一返回结构。前端拿到code就能判断成败不用每个接口写一套解析逻辑。code的取值建议固定200 成功、401 未登录、403 无权限、500 业务失败。参数说明上data用泛型是为了让不同接口复用同一个包装类避免为每个返回值单独建类。2.2 前端选 Vue 的取舍Vue 在这个项目里的价值集中在选座页。选座是一个典型的状态密集型界面几十上百个座位每个座位有可选、已售、选中三种状态还要实时反映库存。用 Vue 的响应式数据配合v-for渲染座位矩阵比手写 DOM 操作省太多事。路由用vue-router管理从影片列表到排片详情再到选座页参数通过路由传递。// router/index.js 片段 const routes [ { path: /movies, component: () import(/views/MovieList.vue) }, { path: /schedule/:movieId, component: () import(/views/ScheduleList.vue) }, { path: /seat/:scheduleId, component: () import(/views/SeatSelect.vue) } ]路由参数用:scheduleId这种动态段选座页通过this.$route.params.scheduleId拿到排片 ID再请求座位数据。这里要注意动态路由参数在组件复用时不会自动触发重新请求需要在watch里监听$route变化否则从 A 场次切到 B 场次会显示旧座位。2.3 数据库表设计六张核心表影院系统的表不用多但字段要想清楚。下面是我常用的六张核心表结构。表名作用关键字段user用户id, username, password, phone, rolemovie影片id, name, duration, poster, statushall影厅id, name, row_count, col_countschedule排片id, movie_id, hall_id, start_time, priceseat座位库存id, schedule_id, row_num, col_num, statusorder订单id, user_id, schedule_id, seats, amount, status座位库存表是重点。常见做法是排片创建时批量生成该场次的所有座位记录status用 0 表示可售、1 表示锁定、2 表示已售。这样选座时查的是seat表而不是实时计算性能可控。CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_num INT NOT NULL, col_num INT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0可售 1锁定 2已售, lock_time DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, row_num, col_num) );uk_schedule_seat这个唯一索引是防重复的底线。即使应用层锁失效数据库也会拦住同一场次同一座位的重复插入。lock_time用来做锁过期判断后面会讲。3. 选座与订单接口把并发锁座这件事做对3.1 选座接口的锁座逻辑选座的核心矛盾是用户点中座位到支付完成之间座位必须被占住但不能永久占住。我的做法是「先锁后付」锁定时长 15 分钟超时自动释放。// service/SeatService.java public ResultString lockSeats(Long scheduleId, ListString seats, Long userId) { // 1. 先清理该场次过期的锁 seatMapper.releaseExpired(scheduleId, LocalDateTime.now().minusMinutes(15)); // 2. 逐个尝试锁定 for (String seat : seats) { String[] rc seat.split(-); int row Integer.parseInt(rc[0]); int col Integer.parseInt(rc[1]); int updated seatMapper.lockOne(scheduleId, row, col, userId); if (updated 0) { // 有一个座位被占回滚已锁的 seatMapper.unlockByUser(scheduleId, userId); return Result.fail(座位 seat 已被占用); } } return Result.ok(锁定成功请在15分钟内支付); }对应的 Mapper 用条件更新保证原子性update idlockOne UPDATE seat SET status 1, lock_time NOW(), user_id #{userId} WHERE schedule_id #{scheduleId} AND row_num #{row} AND col_num #{col} AND status 0 /update逻辑说明lockOne的WHERE status 0是关键只有当前可售的座位才能被更新返回影响行数为 0 就说明被别人抢先了。参数上scheduleId定位场次row、col定位座位userId记录是谁锁的方便回滚。这种「条件更新 影响行数判断」比先查再改可靠避免了查询和更新之间的时间窗口。3.2 订单创建与支付回调锁座成功后创建订单状态为「待支付」。支付回调里把订单改为「已支付」同时把座位状态从 1 改成 2。Transactional public ResultString paySuccess(String orderNo) { Order order orderMapper.findByNo(orderNo); if (order null || order.getStatus() ! 0) { return Result.fail(订单状态异常); } orderMapper.updateStatus(orderNo, 1); seatMapper.confirmSold(order.getScheduleId(), order.getSeats()); return Result.ok(支付成功); }Transactional保证订单状态和座位状态一起变不会出现订单已付但座位没确认的情况。confirmSold把对应座位的status置为 2并清空lock_time。3.3 前端选座页的渲染与交互选座页拿到座位列表后按行列渲染成矩阵。每个座位根据status绑定不同样式点击时切换选中态。// SeatSelect.vue 片段 data() { return { seats: [], // 后端返回的座位列表 selected: [] // 用户选中的座位 } }, methods: { toggleSeat(seat) { if (seat.status ! 0) return; // 已售或锁定不可选 const key seat.rowNum - seat.colNum; const idx this.selected.indexOf(key); if (idx -1) { this.selected.splice(idx, 1); } else { if (this.selected.length 4) { alert(一次最多选4个座位); return; } this.selected.push(key); } } }逻辑上status ! 0直接拦截不可选座位避免无效请求。selected存的是行-列字符串提交时直接传给后端。限制最多 4 个座位是业务约束防止恶意占座。参数上seats来自接口selected是本地状态提交订单时把selected和scheduleId一起发出去。4. 排片管理与后台统计让运营能自己改数据4.1 排片冲突检测排片最容易出的问题是同一影厅同一时间段排了两场电影。解决思路是在插入前查重叠。public boolean hasConflict(Long hallId, LocalDateTime start, LocalDateTime end) { int count scheduleMapper.countConflict(hallId, start, end); return count 0; }select idcountConflict resultTypeint SELECT COUNT(*) FROM schedule WHERE hall_id #{hallId} AND start_time lt; #{end} AND DATE_ADD(start_time, INTERVAL duration MINUTE) gt; #{start} /select这里用影片时长算出结束时间做区间重叠判断。参数start、end是新排片的起止时间hallId锁定影厅。注意duration存在movie表里实际查询要 join这里简化了写法。冲突检测放在 service 层插入前调用返回 true 就拒绝。4.2 后台统计接口运营需要看每日票房、上座率、热门影片。这些用聚合查询直接出。-- 每日票房 SELECT DATE(create_time) AS day, SUM(amount) AS total FROM order WHERE status 1 GROUP BY DATE(create_time) ORDER BY day DESC;上座率则是已售座位数除以总座位数按场次分组。这类统计接口建议加缓存因为数据变化不频繁每次实时聚合在数据量大时会拖慢后台。常见做法是用 Redis 缓存 5 分钟或者定时任务预计算。4.3 权限控制后台接口必须区分普通用户和管理员。用拦截器校验 token 里的角色。public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String token req.getHeader(Authorization); if (token null || !jwtUtil.validate(token)) { resp.setStatus(401); return false; } String role jwtUtil.getRole(token); if (req.getRequestURI().startsWith(/admin) !ADMIN.equals(role)) { resp.setStatus(403); return false; } return true; }逻辑是先验 token 有效性再判断路径是否属于后台最后比对角色。参数上Authorization头携带 JWTrole从 token 载荷里取。这样普通用户即使拿到后台 URL 也进不去。5. 避坑与排查那些让我加班到凌晨的问题5.1 座位锁了但用户没支付座位一直灰着现象是用户选座后关闭页面座位状态停在 1别人选不了。原因是锁没有过期释放机制。解决是加定时任务或每次选座前清理过期锁。我一般两者都做选座接口入口先调releaseExpired再配一个每分钟跑一次的定时任务兜底。Scheduled(fixedRate 60000) public void releaseExpiredLocks() { seatMapper.releaseExpiredAll(LocalDateTime.now().minusMinutes(15)); }5.2 前端路由参数变了但页面没刷新现象是从一个场次切到另一个场次座位图还是旧的。原因是 Vue 复用组件实例created不再触发。解决是在watch里监听$route.params.scheduleId变化时重新拉数据。watch: { $route.params.scheduleId(newId) { if (newId) this.loadSeats(newId); } }5.3 支付回调重复执行导致座位状态错乱现象是支付平台重试回调订单被处理两次。原因是回调没有幂等控制。解决是在paySuccess里先判断订单状态只有「待支付」才处理已支付直接返回成功。这就是 3.2 里order.getStatus() ! 0判断的作用。5.4 跨域请求被浏览器拦截现象是前端调后端接口报 CORS 错误。原因是前后端不同端口。解决是在 SpringBoot 加全局跨域配置而不是在每个 Controller 上写CrossOrigin。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns和allowCredentials(true)同时用时不能用allowedOrigins(*)否则浏览器会拒绝。5.5 分页插件失效导致全表查询现象是影片列表接口返回了全部数据分页参数没生效。原因是 MyBatis 分页插件没配置或配置顺序不对。解决是在配置类里注册PageInterceptor并确认它排在 SQL 拦截器之前。这个坑很隐蔽因为接口不报错只是慢。6. 把选座并发压到 500 并发的验证方法系统能跑通不等于能扛住。影院售票的高峰是热门影片开售那几分钟选座接口会瞬间承压。我一般用 JMeter 或 wrk 对锁座接口做压测重点看两个指标重复锁定率和响应时间。验证重复锁定可以写一个并发测试100 个线程同时锁同一个座位正确结果是只有 1 个成功其余 99 个返回失败。如果成功数大于 1说明锁有问题。Test public void testConcurrentLock() throws Exception { int threads 100; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(0); for (int i 0; i threads; i) { final long userId i; pool.submit(() - { try { latch.await(); ResultString r seatService.lockSeats(1L, Arrays.asList(3-5), userId); if (r.getCode() 200) success.incrementAndGet(); } catch (Exception e) { // 忽略 } }); latch.countDown(); } pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); System.out.println(成功锁定数 success.get()); // 期望为 1 }这段测试的关键是CountDownLatch让所有线程同时发起请求模拟真实并发。success期望值为 1大于 1 就说明条件更新没起作用要回去检查 SQL 的WHERE status 0是否被优化掉或者事务隔离级别是否被改过。压测时还要注意连接池大小。SpringBoot 默认 HikariCP 最大连接数是 10500 并发下会大量等待。我一般把maximum-pool-size调到 50 左右同时确认数据库的max_connections够用。响应时间方面锁座接口在正常负载下应该在 50ms 以内超过 200ms 就要查慢 SQL。另一个容易忽略的点是座位数据的加载方式。选座页一次性拉整场座位如果影厅有 300 个座位每次请求返回 300 条记录并发高时网络和序列化都是负担。我的习惯是座位数据加一层 Redis 缓存key 用seat:schedule:{id}锁座成功后主动删缓存下次请求重建。这样读多写少的选座场景能明显降负载。最后说个我自己的习惯任何涉及库存和状态的接口我都会先写并发测试再写业务代码。因为这类 bug 在功能测试里几乎测不出来只有并发才暴露。血泪经验是选座系统的坑不在功能多而在状态流转的每一个边界。把锁、过期、幂等这三件事做扎实这套 SpringBoot Vue 的影院系统就能真正拿去用而不是只停在演示。希望帮到你。本文还有配套的精品资源点击获取
返回列表