资讯动态

Java电影售票系统实战:并发锁座与订单超时释放设计详解

发布时间:2026/10/22 15:30:55 来源:尧图企业网站定制
一个电影售票系统乍看像是典型的毕设CRUD项目但真正动手做起来你就会发现这里面藏着一套完整的电商交易模型商品电影票有场次、有座位、有库存、有超时未支付自动释放、有并发锁座。把这些捋清楚系统才算真正“立住”了。这篇博文我会从技术选型、数据库设计、核心业务实现到答辩避坑完整拆解我做这套Java电影售票系统的思路和代码细节。代码都是可直接复现的关键位置我会解释清楚为什么这么写而不是单纯堆砌代码。如果你是准备拿这类题目做毕设或者想练手Java Web全流程开发这篇文章应该能帮你省下不少瞎折腾的时间。1. 先想清楚售票系统的核心难点不是“增删改查”很多同学拿到“电影售票系统”这个题目第一反应就是用户表、电影表、订单表做几个页面能添加电影、能下单完事了。如果只是这个程度答辩时老师随便问两句“并发情况下库存怎么控制”“用户下单后一直不支付怎么办”基本就卡住了。实际动手前我花了两天时间把这套系统的业务链路完整捋了一遍。电影售票不同于普通商品销售它有三个非常典型的业务特征座位即库存且库存是二维的。一个影厅有若干排若干列每个座位在一场放映中只能被卖一次。这意味着库存管理不是简单的“剩几张”而是“哪个位子空了、哪个位子被占”。订单有时效性超时需释放座位。用户选座后如果一直不支付座位得能被释放出来给别人买否则热门场次会被“僵尸订单”占满。一场电影对应一个场次场次关联影厅和时段时间冲突、排片覆盖都是隐藏需求。想通了这三点整个系统的模块划分就清楚了用户模块、影片模块、场次排片模块、选座购票模块、订单支付模块、后台管理模块。其中选座购票和订单超时处理才是这套系统的灵魂也是论文里可以重点写的“难点与解决方案”。所以我的建议是动手写代码之前先花一天时间把业务流程图、状态流转图画清楚。别急着建工程磨刀不误砍柴工。2. 技术选型为什么我最终选了Spring Boot MyBatis-Plus MySQL这套组合毕设技术选型有个隐形原则不求最前沿但求最稳妥、最好讲。老师不一定要求你用多新的技术但你必须能把自己用的技术讲明白。我对比过几套方案也参考了某高校往届学长留下的一个卖火车票的小Demo最终锁定这套组合技术栈我选它的理由选型备注Spring Boot 2.x起步依赖一键集成内嵌Tomcat不用单独配服务器选2.x不选3.x因为网上资料和教程更全很多第三方配置演示也是基于2.xMyBatis-Plus单表CRUD完全不用写XML分页插件现成复杂查询自己写SQL自由度够比纯MyBatis省下大量重复写法比JPA更直观答辩时也好解释SQL逻辑MySQL 8.0用得最多面试和课程里也最常见注意8.0的驱动类名和时区配置跟5.x不同后面我会提坑Vue 2 Element UI前后端分离页面美观且Vue2的学习资料最多如果不想碰前端也可以用Thymeleaf模板渲染但视觉上会差不少Redis用于分布式锁和超时失效属于加分项毕设里不用Redis也能实现功能用时间戳判断但用了Redis会让项目档次明显不一样这里要特别说一句如果你对Redis不熟千万别硬塞进项目里。答辩的时候老师追问“Redis缓存和数据库一致性怎么保证”“锁的粒度怎么控制”答不上来反而扣分。我的做法是核心业务流程都用数据库事务解决Redis只用在“锁座防超卖”和“超时释放座位”两个场景而且这两个场景我有十足把握讲清楚才把它纳入技术栈。前端如果实在没精力可以直接用Bootstrap或者原生HTMLAjax做一个简洁版页面保证基本交互能跑通就行。毕设评分重点永远是在后端设计和业务逻辑页面只要干净、功能完整不扣分。3. 数据库设计订单表状态字段是我反复修改最多的部分数据库设计是这套系统的基础也直接决定了后续代码写起来顺不顺手。我总共设计了7张核心表用户表、电影表、影厅表、场次表、座位表物理座位、场次座位表某场次的座位状态、订单表。这里重点讲三张最容易出问题的表。3.1 场次表设计场次表是整个售票系统的“枢纽”它把电影、影厅、放映时间三者关联起来。核心字段如下CREATE TABLE session ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 场次ID, movie_id BIGINT NOT NULL COMMENT 影片ID, hall_id BIGINT NOT NULL COMMENT 影厅ID, start_time DATETIME NOT NULL COMMENT 开场时间, end_time DATETIME NOT NULL COMMENT 散场时间, price DECIMAL(10,2) NOT NULL COMMENT 票价, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态0-已取消 1-正常 2-已结束, INDEX idx_movie (movie_id), INDEX idx_start_time (start_time) ) COMMENT 场次表;设计时有个很容易忽略的点一定要存end_time散场时间。如果没有它后台做排片管理时没法快速判断“这个影厅某个时段能不能排新场次”需要动态算时长逻辑绕且容易出错。我当时是让管理员在后台创建场次时根据影片时长自动计算end_time再写一个排片冲突检测新场次的开始时间不能落在已存在场次的[start_time, end_time)区间内。3.2 场次座位表设计这是选座功能的核心。要注意它和物理座位表是分开的。物理座位表只描述影厅有哪些座位比如10排8列而场次座位表记录的是“这个场次的这个座位当前是什么状态”。CREATE TABLE session_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 座位状态0-可选 1-已锁定 2-已售出, order_id BIGINT DEFAULT NULL COMMENT 绑定订单ID释放座位时要用, lock_time DATETIME DEFAULT NULL COMMENT 锁定时间超时释放依据, UNIQUE KEY uk_session_seat (session_id, seat_row, seat_col) ) COMMENT 场次座位表;为什么单独建一张表而不是在物理座位上直接标记“已售出”因为同一影厅在不同场次中同一个座位的状态是完全独立的——上午10点场这个座位被买了下午2点场的它仍然是可选的。只有把座位状态跟场次绑定起来才是正确的数据模型。这里也埋了一个很关键的并发控制点选座时必须用数据库的行锁或乐观锁来保证同一个座位不会被两个人同时选中。我在后面的“超卖现象怎么避免”部分会专门讲。3.3 订单表设计订单表是业务流的终点也是我改动次数最多的表。它的状态字段直接映射了业务状态机CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0-待支付 1-已支付 2-已取消 3-已退款 4-已完成, create_time DATETIME NOT NULL, pay_time DATETIME DEFAULT NULL, cancel_time DATETIME DEFAULT NULL, expire_time DATETIME NOT NULL COMMENT 超时时间默认创建后15分钟 ) COMMENT 订单表;这里最核心的设计决策是订单状态必须和座位释放联动。我的状态机流转设计是用户选座创建订单 - 订单状态置为待支付座位状态同步置为已锁定用户支付成功 - 订单状态置为已支付座位状态置为已售出用户主动取消或超时未支付 - 订单状态置为已取消座位状态回滚为可选这个联动就是整个售票系统的“心脏”。一旦某个环节状态没同步比如订单取消了但座位仍锁定就会出现“显示有座但买不了”的故障。如果在论文的创新点里就写“我实现了订单状态与座位状态的一致性控制”这比单纯写“我做了个售票系统”要有分量得多。4. 核心业务实现锁座、支付、超时释放的完整代码逻辑这部分是整套系统的重点也是答辩时老师最可能深挖的地方。我分三个场景讲清楚并提供关键代码。4.1 选座逻辑怎么保证同一座位不被两个人同时抢到选座的流程是前端展示场次座位图用户点击座位后端接收一组座位编号校验座位状态如果全部可选则创建订单并锁定座位。第一步的校验很关键。如果你把座位查出来放进内存用Java代码判断“这排这列是否可选”再执行插入那在高并发下一定会出问题。因为判断和插入之间有个时间差两个请求可能同时读到同一个座位是“可选”状态然后都执行了插入。正确的做法是用UPDATE语句配合WHERE条件做原子操作Transactional public Order createOrder(Long userId, Long sessionId, ListSeatPosition seats) { // 1. 生成订单号 String orderNo generateOrderNo(); // 2. 创建订单状态为待支付 Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSessionId(sessionId); order.setStatus(0); order.setExpireTime(new Date(System.currentTimeMillis() 15 * 60 * 1000)); orderMapper.insert(order); // 3. 原子锁定座位只有当前状态为“可选”的座位才会被更新成功 for (SeatPosition pos : seats) { int updated sessionSeatMapper.lockSeat( sessionId, pos.getRow(), pos.getCol(), order.getId() ); if (updated 0) { // 有一个座位锁定失败则整个订单回滚 throw new BusinessException(座位已被占用请重新选择); } } // 4. 订单关联座位 orderSeatMapper.insertBatch(order.getId(), orderNo, seats); return order; }对应的SQL是这样update idlockSeat UPDATE session_seat SET status 1, order_id #{orderId}, lock_time NOW() WHERE session_id #{sessionId} AND seat_row #{row} AND seat_col #{col} AND status 0 /update这个方案的关键在于UPDATE语句本身是行级锁的。两个事务同时抢同一个座位只有一个事务的UPDATE能匹配到status0另一个匹配不到也就不会产生“两个订单都占用了同一座位”的情况。这就是最简单的乐观锁实现方式不需要引入Redis理解和讲解起来都很容易。4.2 超时释放定时器扫描 状态校验双保险用户下单后15分钟不支付座位必须释放。这里有两套方案方案A推荐使用Spring的定时任务每30秒扫描一次所有待支付且超时的订单逐单判断是否需要释放座位。这是我认为最直观、最容易在答辩时讲清楚的方案。Scheduled(fixedDelay 30000) public void releaseExpiredOrders() { // 找出所有超过expire_time且状态为0的订单 ListOrder expiredOrders orderMapper.selectExpiredOrders(new Date()); for (Order order : expiredOrders) { releaseOrderSeats(order.getId()); } } Transactional public void releaseOrderSeats(Long orderId) { Order order orderMapper.selectById(orderId); // 二次确认状态必须还是待支付防止用户刚好在支付 if (order null || order.getStatus() ! 0) { return; } // 释放该订单绑定的所有座位 sessionSeatMapper.releaseSeatsByOrderId(orderId); // 更新订单状态为已取消 order.setStatus(2); order.setCancelTime(new Date()); orderMapper.updateById(order); }这里有一个非常容易踩的坑用户可能在超时边界上刚发起支付而定时任务已经把他的订单判断为超时并释放了座位这时候支付结果回来就找不到对应订单或者出现座位被释放但用户已扣款的纠纷。我的处理方式是双保险在支付接口里先校验订单状态如果订单已经是“已取消”则不允许支付直接提示“订单已超时请重新下单”。为了防止“正在支付时被定时任务关单”我在支付接口和定时任务中都加了状态的前置检查谁先拿到状态修改权归谁。具体做法是先用UPDATE orders SET status1 WHERE id#{id} AND status0做一个条件更新如果更新影响行数为0说明这单已被别人处理直接失败返回。方案B如果项目里引入了Redis可以用Redis的过期key来触发回调释放座位。但分布式锁回调需要额外处理“事务已完成但回调丢了”的情况代码复杂度高不少。对于毕设我个人觉得方案A已经足够并且更好讲。如果你想让项目显得更有深度可以在论文里写一句“本系统实现了一种基于定时扫描与状态校验的兜底释放策略后续可优化为延迟消息触发”既体现了思考又不用冒Redis的复杂度风险。4.3 支付流程用状态机明确“待支付、已支付、已取消”之间的边界支付环节如果接入真实支付渠道比如支付宝沙箱流程会复杂很多。我当时的做法是模拟支付——在支付页面点击“确认支付”后端直接走支付成功逻辑但保留了一个支付回调接口的接口定义方便后续扩展。模拟支付的核心逻辑本质上就三步Transactional public boolean payOrder(Long userId, String orderNo) { // 1. 找到订单并校验归属 Order order orderMapper.selectByOrderNo(orderNo); if (order null || !order.getUserId().equals(userId)) { throw new BusinessException(订单不存在); } // 2. 用条件更新抢占状态防止和超时释放任务冲突 int updated orderMapper.updateStatus( order.getId(), 1, 0 // 把状态从0改成1只有待支付状态才能成功 ); if (updated 0) { throw new BusinessException(订单状态已变化请刷新确认); } // 3. 更新订单支付时间并同步锁定座位为已售出 order.setPayTime(new Date()); orderMapper.updateById(order); sessionSeatMapper.confirmSeatsByOrderId(order.getId()); return true; }这个状态机设计我画过一张图待支付 - 已支付/已取消已支付 - 已完成/已退款。所有状态变更都走“带条件的UPDATE”确保并发安全。答辩时如果你能画出这张状态图并给老师讲清楚“为什么不能直接UPDATE”基本就证明你是真的理解了这个业务。5. 开发期踩过的坑事务失效、时间格式、组件版本一个比一个隐蔽代码都写完不代表万事大吉我在整合调试阶段遇到过几个特别隐蔽的坑这里列出来希望你能跳过。5.1 同一个类里调用Transactional方法事务失效这是我第一次整合时遇到的最隐蔽的坑。我在SeatService里写了一个public方法内部直接调用了另一个被Transactional标注的方法结果事务压根没生效。座位锁定了订单创建失败的时候座位状态没有跟着回滚。原因是Spring事务基于代理只有通过代理对象调用被Transactional标注的方法事务才会生效。同类内部的直接调用走的是this对象绕过了代理。解决方案有两种// 方案一将需要事务的方法拆分到另一个Service类中通过注入调用 Autowired private OrderService orderService; public void createOrderWithSeats(...) { orderService.createOrderAndLockSeats(...); // 这个调用会走代理 } // 方案二自注入 Autowired private SeatService self; public void createOrderWithSeats(...) { self.createOrderAndLockSeats(...); // 自注入也是代理对象 }我在项目里用了方案一顺便也让代码结构更清晰OrderService负责订单数据SeatService负责座位状态两者通过事务边界协作。5.2 时间类型传给MySQL的时候时分秒被吞掉了我当时的实体类里场次开始时间用的是java.util.Date数据库字段是DATETIME。传参的时候没注意结果MySQL存进去的时间变成了2025-05-01 00:00:00——时分秒全丢了。查了好久才发现是JDBC连接串里少了时间参数。这个问题的根因是MySQL驱动在默认情况下如果日期参数类型不匹配会把时间部分置零。解决办法有两个// 方案一连接串里明确指定参数 jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai // 方案二实体类字段统一使用LocalDateTime配合MyBatis-Plus自动映射 private LocalDateTime startTime;我后来选了LocalDateTime因为LocalDateTime和MySQL的DATETIME天然匹配不需要额外转换这也是新项目的推荐做法。5.3 MyBatis-Plus分页插件不生效查出来还是全表这个坑极其常见。MyBatis-Plus的分页需要先配置分页插件不是加个Page参数就完事的。我刚开始没配结果分页查询返回的都是全部数据页数参数完全没起作用。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }另外要检查一个点如果使用了自定义XML里的SQL做分页SQL的写法必须满足分页插件解析要求比如不能有;结尾不能用SELECT DISTINCT加复杂聚合的极端情况。分页插件本质上是解析你的SQL拼接limit语句如果SQL本身就非法或写法特殊它会静默不处理只返回原结果。5.4 前后端联调时的跨域问题前端跑在8081端口后端跑在8080端口Ajax请求必然跨域。我需要加一个跨域配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns(*)和allowedOrigins(*)的区别allowCredentials(true)时allowedOrigins不能设为*必须用allowedOriginPatterns。这个细节在Spring Boot 2.4版本之后强制生效我用的是2.6一开始没注意前端带Cookie的请求一直失败。6. 答辩准备老师最爱追问的几个问题提前准备好答案做完项目只是第一步答辩把项目讲清楚才是拿高分的关键。下面这些问题是老师们最喜欢追问的建议提前准备好回答思路。6.1 “座位锁定为什么不用Redis而用数据库UPDATE”这是个高频问题。我的回答思路是数据库的UPDATE条件语句本身具备原子性和行锁能力UPDATE ... WHERE status0在并发下能保证只有一个请求匹配成功。在没有高并发压力的情况下这个方案简单可靠、不会引入额外组件而且事务边界更清晰。我还补充一句如果日活很高会引入Redis做分布式锁把锁粒度控制在“场次级别座位级别”实际上就是SETNX一个以sessionId:row:col为key的锁。这样既展示了你清楚两种方案的取舍又不会显得盲目炫技。6.2 “超时释放座位的过程中万一定时任务和用户支付撞车怎么办”这个问题我前面已经给出了答案核心是两层保障第一层支付和释放都使用条件UPDATE抢占状态谁先改状态谁成功第二层定时任务每次处理前都会二次读库确认状态而不是拿着扫描出来的旧数据直接操作。如果老师接着问“那用户刚好在释放前一刻点支付怎么办”你可以说支付流程的第一步就是更新订单状态如果释放任务先执行完成订单状态会从待支付变为已取消支付的条件更新影响行数为0接口直接返回“订单已超时”用户在页面上看到明确提示并重新下单。这个循环是自洽的。6.3 “如果座位很多同时创建订单时事务持有时间过长怎么办”老师们常问性能问题本质上想知道你有没有考虑过锁的粒度和范围。我的回答是目前每个座位一条UPDATE语句事务持有的是行级锁事务持续时间约为插入订单和锁定座位的时间如果用户一次性选择10个座位事务会持有10条行锁时间可控。如果遇到极端并发可以预扣库存、把分布式的锁范围控制在影厅维度但这个场景已经超出毕设范围我会在论文中作为展望提出。提前把这些追问捋顺了再上答辩场比临场“嗯嗯啊啊”要踏实太多。7. 写在最后这套项目还能怎么扩展让分数再上一个台阶如果时间和精力允许有几个低投入高回报的扩展方向毕业设计等级会明显不一样加入Redis缓存热门电影的场次列表减少数据库查询压力并在代码里处理缓存穿透问题。加入简单的推荐逻辑根据用户历史购票类型推荐同类型影片。用标签实现就行不用上算法。做一个简易的管理端统计报表用ECharts展示每日票房趋势图不用太复杂。如果论文需要“对比实验”可以在压测环境里模拟100个并发用户同时抢同一场次同一座位对比有无条件UPDATE的差异截图放进论文非常出效果。做毕设最重要的不是代码量有多大而是每个关键环节你都讲得出“为什么”。这套售票系统做下来我最大的收获不是掌握了Spring Boot的用法而是理解了一个业务从需求分析、数据建模到并发控制的完整闭环。在实际开发里遇到类似订票、预约、库存一类需求你会发现它们后面都是同一套交易模型这时你就能很自然地复用这些经验了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑