ARTICLE DETAIL

资讯详情

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

从并发锁座到支付幂等:Java+Vue在线电影购票系统完整实践

从并发锁座到支付幂等:Java+Vue在线电影购票系统完整实践 做在线电影购票系统最容易被低估的其实是“看起来能跑”和“真正能扛住并发”之间的巨大差距。表面上看这就是一套注册登录、电影列表、座位选择、订单支付的标准CRUD但真把一个基于 Java Vue 的在线电影购票系统从建表设计做到部署上线你会发现座位状态在多人同时选座时怎么不错乱、订单超时怎么自动释放、支付回调重复通知怎么保证只处理一次随便拎一个出来都能让项目卡壳好几天。这篇内容我会完整复盘我实现这套在线电影购票系统时采用的方案总体功能怎么划分、数据库怎么建模、后端怎么用 Java 生态把核心购票链路写稳、前端 Vue 页面怎么跟接口做状态同步以及我在真实运行阶段踩过的高频问题。无论你是做课程设计、毕业设计还是想拿一个完整项目当简历亮点这篇文章里的思路和排错记录都能用得上。1. 系统全景拆解购票系统的核心功能与业务边界1.1 三类角色决定了功能模块的划分在线电影购票系统从业务上可以分为三类角色这是我当时梳理功能清单的出发点普通用户注册登录、浏览电影列表、查看电影详情与场次、选座、下单、支付、查看订单与电子票二维码。影院运营人员管理影厅座位布局、维护放映场次、设置场次价格、查看售卖统计。系统管理员用户管理、影片上下架、基础数据维护、退款审核。三类角色里普通用户端的链路是系统的生命线。运营端和管理端的很多功能其实是在为这条链路提供数据支撑。比如运营人员排了一个场次用户才能选择这个场次运营人员设置了某个时段的价格下单时才能计算出正确金额。1.2 四条必须优先保障的业务规则我开发过程中把下面四条规则视为整个系统的“物理定律”任何功能设计都不能和它们冲突一个座位在一个场次内同一时刻只能被一个订单锁定不能出现两个人同时买到同一个座位。座位锁定必须有时间限制用户锁座后迟迟不付款到期后座位必须重新变成可售状态。订单金额以下单那一刻的场次价格为准之后运营改价不能影响已经生成的订单。支付平台的回调通知可能到达多次系统必须保证订单状态只被更新一次出票逻辑只执行一次。如果你只是做一个演示用的系统规则可能感受不到它的分量。一旦系统要接入真实支付渠道或者同一场次有几百人同时抢票第1条和第4条出了问题就是生产事故。1.3 功能优先级先做能跑通的闭环再做锦上添花这里给个建议功能清单可以列得很长但开发顺序千万不要摊大饼。我按依赖关系把开发分成了三轮。第一轮只做核心闭环用户端从选电影到出票的全流程管理端只保留排片和座位管理。第二轮补齐支付回调、订单超时释放、历史订单查询。第三轮才做数据统计、优惠券、影评、会员等级这类扩展功能。这种顺序的好处是第一轮结束后项目就已经是一个能用的系统。后面每加一个功能都是在核心闭环上的增量不会出现“系统做了一半发现核心链路还不通”的尴尬局面。2. 技术选型Java Vue 各自承担什么职责2.1 后端选 Spring Boot MyBatis-Plus 的现实理由在线电影购票系统的后端我选用的是 Java 生态里的 Spring Boot。抛开“Java 岗位需求多”这类老生常谈单从这个项目的技术特征来看Spring Boot 也非常合适。系统的核心难点在事务边界。一个购票请求要同时处理座位状态变更、订单创建、库存扣减三个动作任何一个失败都必须整体回滚。Spring 声明式事务用Transactional就能把边界划干净配合 MySQL 行锁可以很好地解决座位超卖问题。如果换成非事务友好的技术栈你大概率要在业务代码里手动补偿代码复杂度会上一大截。持久层我选了 MyBatis-Plus。它给我最大的帮助是提供了乐观锁插件和逻辑删除功能。座位状态更新这种高频操作直接在上层用乐观锁版本号规避并发覆盖比每次都手写UPDATE ... WHERE status 0要省事得多。逻辑删除则让订单和场次的软撤销变得非常自然不用为“已退款订单还要保留记录”这件事额外建表。2.2 前端为什么用 Vue 3 Element Plus Vite前端选用 Vue 3核心原因是这个项目的界面交互复杂度集中在“选座”这个环节。选座页需要维护一个座位编号和座位状态组成的二维网格用户点击座位后需要实时联动右侧订单栏里的座位列表、总价。Vue 3 的组合式 API 让这个“座位状态 → 订单汇总”的派生关系非常直观我用一个ref保存场次座位列表用reactive维护当前已选座位集合再把选座数量、总价定义为computed。选座操作只是往集合里增删元素界面上所有关联区域自动更新。这种响应式心智模型比手动操作 DOM 的写法清爽太多。组件库用的 Element Plus表格、表单、弹窗、消息提示这类后台管理常用组件直接复用开发效率提升非常明显。构建工具用 Vite因为 Vue 3 生态对 Vite 的适配最顺滑开发时热更新几乎是秒级几百个组件页面切来切去也不会卡。2.3 前后端目录组织与协作约定项目采用完全的前后端分离Java 工程只出 RESTful APIVue 工程只做页面渲染和数据交互。接口统一走/api/**前缀返回结构固定为{ code: 200, message: success, data: {} }所有接口的时间字段统一返回毫秒时间戳前端负责格式化为本地时间。这个约定非常重要跨时区问题我们后面专门讲。前端工程用 Nginx 托管静态文件并配置/api前缀的请求反向代理到后端服务。这样本地开发和生产部署都不需要处理跨域接口路径保持一致。3. 数据库设计表结构、字段取舍与索引规划3.1 核心表结构说明整个系统我把数据模型拆成了十张左右的表。最核心的几张表如下用户表sys_user用户基础信息密码字段只存 BCrypt 加密后的哈希值不存明文。CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, phone varchar(20) DEFAULT NULL, role tinyint NOT NULL DEFAULT 0 COMMENT 0-用户 1-运营 2-管理员, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;影片表movie存储影片标题、海报、时长、上映状态等。这里要特别注意把“上映状态”和“删除状态”分开下架不代表删除运营数据后续还要用。影院与影厅表cinema/hall影院表管理多个影厅影厅表的核心字段是row_count和column_count也就是这个厅的座位网格有几个区每区几行几列。场次表schedule关联影片、影厅、开始时间、结束时间、基础票价。结束时间通常由影片时长加上影厅清扫时间自动计算不手动填。座位锁定表schedule_seat这是整个系统最容易设计歪的表。它记录的是“某场次的某个座位现在是什么状态”而不是直接去改seat表。原因稍后单独说。订单表order与订单明细表order_item订单头存用户、场次、总金额、订单状态、支付单号明细表记录这一次买了哪些座位一个订单可能包含多个座位。3.2 座位状态的核心设计场次座位表单独维护很多人第一版设计会把座位状态挂在seat表上也就是每个座位带一个status字段。这个设计在大规模数据下是灾难因为同一个实体座位会在不同场次出现它的状态必须按场次区分。正确做法是建一张schedule_seat表把“座位在场次中的状态”单独存CREATE TABLE schedule_seat ( id bigint NOT NULL AUTO_INCREMENT, schedule_id bigint NOT NULL, seat_id bigint NOT NULL, row_no int NOT NULL, column_no int NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0-可售 1-锁定 2-已售, ver int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_schedule_seat (schedule_id, seat_id) ) ENGINEInnoDB;每次创建场次时根据影厅的row_count、column_count批量生成场次座位记录。这样选座查询、座位状态锁定、售卖统计全部围绕schedule_id展开数据隔离非常干净。ver字段是乐观锁版本号。后面处理并发锁座时它就是防线之一。3.3 订单表快照设计与幂等字段订单设计有个很容易被忽视的点订单表不能只存 schedule_id 和座位 id 来反推金额。因为运营人员随时可能调整场次价格场次一旦改价历史订单的金额就跟着变了这会引发对账问题。所以我给订单表冗余了三个字段total_amount订单总金额、unit_price下单时场次单价、seat_desc下单时的座位文本描述比如“3排5座”。这三个字段在下单时刻写入之后无论场次怎么变订单展示的数据始终是当时的价格和座位信息。这就是数据快照思想。幂等字段方面我保留了两个order_no是业务订单号全局唯一payment_no是支付渠道返回的交易号默认允许为空。支付回调处理时payment_no就是天然幂等键防止同一笔支付被重复落库。3.4 索引规划的实战经验核心表索引我按高频查询设计schedule表(movie_id, start_time)组合索引支撑用户按影片查场次。schedule_seat表(schedule_id)单列索引配合唯一键使用。order表(user_id, create_time)组合索引支撑用户订单列表(payment_no)唯一索引幂等依赖它。索引不是越多越好。这个阶段你只需要覆盖当前查询等出现慢 SQL 再针对性加索引比一上来建一堆索引、拖慢插入更新速度要合理。4. 后端核心链路鉴权、锁座、超时释放与支付回调4.1 登录鉴权与接口权限控制在线电影购票系统的接口分为无需登录、用户权限、运营权限、管理员权限四档。我用 Spring Security JWT 实现。登录接口校验用户名密码成功后生成 JWT载荷里带userId和role。前端每个请求都带上Authorization: Bearer token头。后端通过自定义 Filter 解析 token把用户信息放进SecurityContext。权限控制我直接用了注解PreAuthorize(hasRole(USER)) GetMapping(/order/list) public R getUserOrders() { ... } PreAuthorize(hasRole(ADMIN)) PostMapping(/schedule) public R createSchedule(RequestBody ScheduleDTO dto) { ... }小项目里这种方式最直观不需要复杂的动态权限模型。4.2 座位锁定事务 悲观锁 乐观锁三重防线座位锁定是整个系统最核心的代码路径。一个用户的购票请求处理过程是校验场次合法性、是否允许售票。批量查询本次要锁的座位状态。如果有座位已被锁定或售出直接返回失败。如果全部可售执行座位状态变更并创建订单。事务提交返回订单号。关键在于第2步和第3步之间。如果两个用户同时请求同一场次的同一座位两个请求都查到“可售”然后都执行状态更新就会出现超卖。所以我用了数据库悲观锁Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateReq req) { ListScheduleSeat seats scheduleSeatMapper.selectForUpdate(req.getScheduleId(), req.getSeatIds()); for (ScheduleSeat seat : seats) { if (seat.getStatus() ! 0) { throw new BizException(座位已被选走请刷新后重试); } } // 批量更新状态为锁定 scheduleSeatMapper.batchLock(req.getScheduleId(), req.getSeatIds()); // 创建订单 ... }selectForUpdate对应的 SQL 是SELECT * FROM schedule_seat WHERE schedule_id #{scheduleId} AND id IN (#{seatIds}) AND status 0 FOR UPDATE;FOR UPDATE会在事务提交前锁住这些行第二个并发请求必须等第一个事务结束才能查询。我在压测时模拟 50 个并发抢同一个座位最终只有一条进入下一环节没有出现超卖。有人会问既然都用了悲观锁为什么还要加乐观锁ver我的回答是悲观锁解决的是同一时刻的写冲突但有一些场景不走这个事务路径比如运营后台手动释放座位、超时任务释放座位。乐观锁版本号在这两条路径和用户购票路径并发执行时能兜底防止“释放任务把刚新锁的座位又置回了可售”这类脏覆盖。4.3 订单超时释放的落地方式用户的座位锁定后订单状态是“待支付”。如果一直不支付座位不能永远被占着。我采用的方式是定时扫描 状态判断。系统中有一个 Spring Scheduled 定时任务每 30 秒扫描一次订单表Scheduled(fixedDelay 30000) Transactional(rollbackFor Exception.class) public void releaseTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListOrder expiredOrders orderMapper.selectTimeoutOrders(deadline); for (Order order : expiredOrders) { // 将订单标记为已取消 orderMapper.cancelOrder(order.getId()); // 释放该订单关联的场次座位 orderItemMapper.releaseSeatByOrderId(order.getId()); } }这里有一个细节很关键释放座位之前必须再次确认订单状态仍然是“待支付”。如果用户刚好在这一秒完成了支付定时任务就不能把订单改成取消否则会出现“用户付了钱座位却被释放”的事故。我在 SQL 里加上了状态条件UPDATE order SET status 4 WHERE id #{orderId} AND status 0如果更新行数为 0说明订单状态已经变化当前任务直接跳过座位释放逻辑。4.4 支付回调的幂等处理接入真实支付渠道后支付平台为了保证通知送达会多次回调同一个支付结果。处理回调的接口必须幂等。我的回调处理流程是验签确保请求来自支付平台。根据payment_no查询本地订单。如果没有该支付单说明是未知回调记录下来告警。如果订单状态已经是“已支付”直接返回成功不重复处理。如果订单是“待支付”更新订单状态为已支付更新payment_no然后执行出票逻辑。第4步就是幂等关键。我还给订单表设计了“已支付”状态的前置条件int count orderMapper.markPaid(orderNo, paymentNo); if (count 0) { // 说明订单已被处理过这里直接返回成功 return R.ok(); }因为markPaid的 SQL 带了WHERE status 0所以并发情况下只有一个请求能更新成功。更新成功的那一个继续走到出票逻辑其他请求什么都做不了只重复返回成功。5. Vue 前端选座组件的状态管理与下单闭环5.1 页面结构与路由规划前端页面围绕用户购票流程设计主要路由如下/home电影列表支持按热映、即将上映筛选。/movie/:id电影详情展示简介、演职人员、场次列表。/booking/:scheduleId选座页面展示座位图、当前选择、订单汇总。/order/confirm确认订单页面核对场次、座位、价格。/order/list订单列表。/order/detail/:orderNo订单详情与电子票。5.2 选座组件座位图渲染与选择逻辑选座页是前端最复杂的部分。我把它拆成了一个独立组件SeatMap.vue核心逻辑是用一个二维数组驱动页面渲染。后端返回的场次座位结构是扁平的列表前端首先要按行分组const seatRows computed(() { const rows {}; props.seats.forEach(seat { if (!rows[seat.rowNo]) rows[seat.rowNo] []; rows[seat.rowNo].push(seat); }); return Object.keys(rows).sort((a, b) a - b).map(row rows[row]); });每个座位渲染为一个可点击的方块根据状态显示不同颜色灰色不可点击已经售出。淡黄色当前场次空闲。蓝色带选中效果用户已选择。锁定中但未售出淡红并不可点击这个状态通常意味着该座位正在被其他用户占用。点击座位时直接更新已选集合function toggleSeat(seat) { if (seat.status ! 0) return; const index selectedSeats.value.findIndex(s s.id seat.id); if (index -1) { selectedSeats.value.splice(index, 1); } else { if (selectedSeats.value.length 6) { ElMessage.warning(单次最多选6个座位); return; } selectedSeats.value.push(seat); } }5.3 下单页与倒计时同步选座确认后进入订单确认页这里要展示当前订单的倒计时。倒计时的时间来源不是前端本地时间而是后端创建订单时返回的一个expireTime时间戳。前端用setInterval每秒计算剩余秒数到 0 时禁止提交订单并提示用户重新选座。这里还要处理一个问题定时器在页面不小心被销毁时会丢失所以我在组件卸载时清理定时器重新进入页面时根据后端返回的剩余时间重新计算。支付成功的处理是轮询订单详情接口。因为支付回调到后端更新订单有一个窗口期用户支付成功后需要等待几秒前端每 2 秒请求一次订单状态出现“已支付”状态就跳转到订单详情页展示电子票。5.4 前端和后端状态同步的细节选座页有一个很容易出问题的点用户停留在选座页超过 15 分钟此前查到的座位状态可能已经变化。我的解决方案比较务实用户点“提交订单”时后端会重新校验座位状态如果发现已被别人锁定接口会返回“座位状态已变化”的错误码前端收到错误后自动刷新座位图并提示用户重新选择。这个方案不需要前端做复杂的长轮询又能保证最终数据的正确性。6. 联调排错记录我在真实运行中踩过的高频问题6.1 并发锁座时的“超卖”第一次压测就发现了问题。我模拟了两个并发请求抢同一个座位结果两个请求都返回了下单成功。排查发现问题出在我第一版写的查询没有加FOR UPDATE只是先查后改两个事务同时读到 status0然后都执行了 UPDATE。加了悲观锁之后第二个事务在selectForUpdate处会一直等待等第一个事务提交后重新读取数据此时读到 status 已经是 1直接返回“座位已被选走”。这里要特别提醒FOR UPDATE必须和事务一起工作。如果你的 Service 方法没有加Transactional或者事务提前提交了锁就不生效。6.2 跨域与前后端联调前端本地开发环境通过 Vite 的 proxy 转发请求不会出现跨域问题。但有一次我直接把前端构建产物放到了 Nginx而后端是独立服务没有配置反向代理浏览器直接报跨域错误。最终解决方案是在 Nginx 配置里加了一段location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样外部浏览器只访问前端同源地址/apiNginx 再把请求转发给后端。生产环境我推荐这种方式比在后端硬编码跨域配置要干净得多。6.3 时间字段的时区坑系统一开始用本地开发环境所有时间字段都是东八区一切正常。部署到云服务器之后部分接口返回的时间比实际时间早了 8 小时。根因是服务器默认时区是 UTC而 Java 后端的 Jackson 在序列化LocalDateTime时没有指定时区。我的处理方法分两步第一步数据库连接串强制指定时区jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二步后端统一返回毫秒时间戳前端负责格式化成北京时间。这样彻底切断了数据库时区、服务器时区、浏览器时区之间的隐式依赖。6.4 支付回调的重复执行接入支付沙箱后我故意模拟了一次重复回调发现订单状态变成了“已支付”但电子票码生成了两遍。排查后定位到第一版回调代码只判断了“订单状态不等于已支付就执行出票”没有把支付单号更新和出票逻辑放进同一个事务。第二次回调进来时由于第一次的事务已经提交状态确实已经是“已支付”但代码又执行了一次出票逻辑。修复方案就是前面讲的markPaid带状态条件的幂等更新。只有真正把状态从“待支付”更新成“已支付”的那个请求才能继续执行后续出票逻辑。6.5 部署时的资源路径问题Vue 项目默认的build.publicPath是/如果部署在域名根路径下没问题但如果放在子路径下所有静态资源都会 404。我在项目里统一设置// vite.config.js export default defineConfig({ base: ./, // ... })这样构建出的index.html引用的 JS、CSS 全部使用相对路径无论部署在根路径还是子目录都能正常加载。写在最后如果再让我做一次我会在这些地方做得更好这套在线电影购票系统从数据库设计到前后端联调再到部署上线整个过程让我对“完整项目”的理解有了很大的变化。如果重新做一次我会在三个方面做调整。第一座位锁定的方案可以更轻。当前用的是数据库悲观锁在中小并发下表现很好但如果场次真的异常火爆连接池可能会成为瓶颈。更成熟的方案是用 Redis 分布式锁先挡住绝大部分并发再配合数据库行锁兜底这样性能和安全都能兼顾。第二我会从一开始就设计出票二维码的生成逻辑。现在电子票是后补的功能临时接了一个二维码生成工具库样式比较粗糙也没有做成独立的票务服务。如果提前从账单模型里把“票”抽象出来后续做退票、改签、入场验票都会轻松很多。第三监控日志应该更早接入。我在排查回调问题时靠的是打印日志人肉分析。后来才加上了接口访问日志和全局异常日志表。建议你在项目初期就把关键流程埋好日志链路尤其是锁座失败、订单取消、回调处理三个节点否则出问题时你只能在茫茫日志里大海捞针。最后再分享一个对新人特别有用的建议系统里所有金额字段数据库一律用DECIMAL千万别用FLOAT和DOUBLE浮点误差在支付场景里是绝对不可接受的。我早期在订单里用DECIMAL(10,2)已经够用你在自己做的时候记得加上这个约束省得后面对账到自己怀疑人生。
返回列表