资讯动态

Java全栈电影订票系统:高并发锁座与订单闭环实战

发布时间:2026/10/15 4:11:12 来源:尧图企业网站定制
简介这是一份面向具备一定Java基础、希望学习或从事电影行业订票管理系统开发人员的完整项目资料围绕Java与Spring Boot技术栈讲解电影在线订票选座系统的设计与实现。内容覆盖普通用户、会员用户与系统管理员三类角色的功能划分并涉及Spring Data JPA、Thymeleaf、MySQL等关键组件适合企业或创业者搭建在线票务订购服务平台时参考。资源包共1个docx文件约34KB以文档形式集中呈现系统设计思路与实现细节。文档中给出了数据模型构建、业务流程逻辑、核心代码模块示例以及编译运行所需步骤并配有系统操作演示与项目总结便于读者理解整体架构与关键实现。目前已有314人学习适合需要完整项目方案与排错思路的开发者参考。1. 电影在线订票系统从选座锁座到订单闭环Java 全栈怎么落地影院售票和普通电商最大的区别在于「座位是稀缺且实时变化的资源」。同一场次同一时刻可能有几百个用户盯着同一批座位谁先锁定谁付款锁了不付要释放付了不能退错。这套基于 Java 与 Spring Boot 的电影在线订票系统核心要解决的就是选座、锁座、下单、支付回调、出票这条链路在高并发下的正确性。它适合正在做课程设计、毕业设计或者想拿一个「有真实业务复杂度」的项目练手的后端开发者。相比增删改查的管理系统订票系统天然带并发、带状态机、带事务边界能逼着你把 Spring Boot 的很多能力真正用起来而不是停留在写 Controller 的层面。下面我按一个能跑通、能演示、能讲清楚原理的最小完整方案来讲包含建表、接口、锁座逻辑和排错。2. 需求拆解与数据模型先把座位和场次的关系理清楚很多人一上来就写代码结果表设计错了后面锁座逻辑怎么改都别扭。订票系统的数据模型有三个核心实体影片、场次、座位。关键在于「座位」不是一张全局表而是「某个场次的某个座位」才有意义——同一个物理座位在 10 点和 14 点两场里是两条独立记录状态互不影响。这一点想通了后面的锁座、释放、超时回滚全都顺了。2.1 五张核心表的设计与字段含义我一般会拆成五张表影片表、影厅表、场次表、场次座位表、订单表。场次座位表是整条链路的枢纽它把「座位」和「场次」绑定并携带状态字段。-- 影片表只存影片元信息和排片解耦 CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 影片名, duration INT NOT NULL COMMENT 时长(分钟), poster_url VARCHAR(255) COMMENT 海报地址, status TINYINT DEFAULT 1 COMMENT 1上架 0下架 ); -- 场次表一部影片在某个影厅的某段时间放映 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_id BIGINT NOT NULL COMMENT 影厅ID, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 票价, INDEX idx_movie_start (movie_id, start_time) ); -- 场次座位表核心枢纽每个场次的每个座位一行 CREATE TABLE schedule_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seat_row INT NOT NULL COMMENT 排号, seat_col INT NOT NULL COMMENT 列号, status TINYINT DEFAULT 0 COMMENT 0可售 1锁定 2已售, lock_user BIGINT DEFAULT NULL COMMENT 锁定用户, lock_time DATETIME DEFAULT NULL COMMENT 锁定时间, UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col) ); -- 订单表一次购票对应一条订单可含多个座位 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME COMMENT 支付截止时间 );字段设计上有两个点值得说。第一schedule_seat上的唯一索引uk_schedule_seat是防止重复插入座位的兜底排片生成时如果脚本跑重了数据库会直接拦下来。第二lock_time和订单的expire_time配合是超时释放锁的依据——锁座不是永久占用用户 15 分钟不付款就得把座位放回去否则热门场次会被恶意占座拖垮。2.2 状态流转座位和订单是两套独立状态机座位状态是0可售 → 1锁定 → 2已售订单状态是0待支付 → 1已支付 / 2已取消。这两套状态必须联动但不能耦合死。用户下单时座位从 0 变 1订单是 0支付成功回调时座位从 1 变 2订单从 0 变 1超时或用户取消时座位从 1 变回 0订单从 0 变 2。这里最容易翻车的是「支付回调」和「超时释放」同时发生用户在第 14 分 59 秒付款定时任务在第 15 分整释放座位两边都改状态最后可能座位被释放了但钱扣了。解决办法是状态变更全部走「带条件的 UPDATE」用受影响行数判断是否抢到而不是先查后改。这个思路在下一章的锁座实现里会具体展开。3. 用 Spring Boot 实现锁座与下单从接口到事务边界这一章是整套系统的技术核心。锁座本质是一个「并发抢占有限资源」的问题方案选型直接决定系统能不能扛住。常见做法有三种数据库悲观锁、数据库乐观锁条件更新、Redis 分布式锁。对于课程设计和中小规模场景我强烈建议先用「条件更新 唯一约束」这套纯数据库方案它不引入额外中间件逻辑清晰面试时也讲得明白。等真到了秒杀级别再上 Redis。3.1 锁座接口一条带条件的 UPDATE 解决并发先看核心的锁座 SQL 和对应的 Service 方法。思路是不查状态再改而是直接执行「只有当前是可售状态才更新为锁定」的语句靠数据库的行锁和 WHERE 条件保证原子性。// SeatService.java 核心锁座方法 Service public class SeatService { Autowired private ScheduleSeatMapper seatMapper; /** * 锁定指定场次的若干座位 * return 实际锁定成功的座位数等于请求数才算全部成功 */ Transactional(rollbackFor Exception.class) public int lockSeats(Long scheduleId, ListSeatPos seats, Long userId) { int locked 0; for (SeatPos pos : seats) { // 关键WHERE 里带 status0只有可售才能锁 int rows seatMapper.lockOneSeat( scheduleId, pos.getRow(), pos.getCol(), userId); if (rows 0) { // 有一个座位被别人抢了整体回滚 throw new SeatLockedException(座位已被占用: pos.getRow() 排 pos.getCol() 座); } locked rows; } return locked; } }!-- ScheduleSeatMapper.xml 对应的 SQL -- update idlockOneSeat UPDATE schedule_seat SET status 1, lock_user #{userId}, lock_time NOW() WHERE schedule_id #{scheduleId} AND seat_row #{row} AND seat_col #{col} AND status 0 /update逻辑说明lockOneSeat的 WHERE 条件里status 0是灵魂。两个线程同时执行这条 UPDATE数据库会对目标行加排他锁第一个执行完 status 变成 1第二个线程的 WHERE 条件不再满足受影响行数为 0于是抛异常回滚。整个过程不需要显式加锁也不需要先 SELECT 再判断避免了「查的时候可售、改的时候已被占」的竞态。参数说明Transactional的rollbackFor Exception.class很重要默认 Spring 只对运行时异常回滚自定义的SeatLockedException如果是受检异常就不会回滚座位会锁一半。scheduleId和座位坐标共同定位唯一行走的是uk_schedule_seat索引单次更新是毫秒级。3.2 下单与超时释放定时任务怎么不误伤已支付订单锁座成功后生成订单订单带一个expire_time一般设 15 分钟。释放逻辑用一个定时任务扫描过期未支付订单把座位放回可售。// OrderTimeoutTask.java 每 30 秒扫一次 Component public class OrderTimeoutTask { Autowired private TicketOrderMapper orderMapper; Autowired private ScheduleSeatMapper seatMapper; Scheduled(fixedDelay 30000) public void releaseExpiredOrders() { // 只查待支付且已过期的订单 ListTicketOrder expired orderMapper.findExpired( LocalDateTime.now()); for (TicketOrder order : expired) { // 先改订单状态带 status0 条件防止和支付回调抢 int updated orderMapper.cancelIfPending(order.getId()); if (updated 0) { continue; // 已被支付回调改过跳过 } // 订单确实被取消了再释放座位 seatMapper.releaseByOrder(order.getScheduleId(), order.getId()); } } }update idcancelIfPending UPDATE ticket_order SET status 2 WHERE id #{id} AND status 0 /update逻辑说明cancelIfPending同样用条件更新只有订单还是待支付status0才改成已取消。如果用户刚好在这一刻支付成功支付回调已经把 status 改成 1这里受影响行数为 0直接continue座位不会被误释放。这就是前面说的「用受影响行数判断谁抢到」比先查后改可靠得多。参数说明fixedDelay 30000表示上一次执行结束后再等 30 秒避免任务堆积。释放座位时按schedule_id批量更新把status从 1 改回 0同时清空lock_user和lock_time。注意释放条件里最好再带一次status 1防止把已售座位status2改回可售。3.3 支付回调的幂等处理支付回调可能被重复推送必须做幂等。做法是在回调入口先查订单状态或者直接用条件更新。public String handlePayCallback(String orderNo) { // 条件更新只有待支付才置为已支付天然幂等 int updated orderMapper.payIfPending(orderNo); if (updated 0) { return SUCCESS; // 已处理过直接返回成功避免重复通知 } // 座位状态 1 - 2 seatMapper.markSoldByOrder(orderNo); return SUCCESS; }payIfPending的 SQL 是UPDATE ticket_order SET status1 WHERE order_no#{orderNo} AND status0。重复回调时第二次受影响行数为 0直接返回成功不会重复出票。这套「条件更新 受影响行数」的模式贯穿整个系统是保证状态一致性的统一手法。4. 排片生成与座位初始化批量插入的坑和优化场次创建后要一次性生成整个影厅的座位记录一个 10 排 15 列的影厅就是 150 行。如果每行一条 INSERT150 次网络往返排片一多就慢得离谱。这一章讲批量插入怎么写以及初始化时容易踩的坑。4.1 用 MyBatis 批量插入生成座位// ScheduleService.java 创建场次并初始化座位 Transactional(rollbackFor Exception.class) public Long createSchedule(ScheduleDTO dto) { Schedule schedule new Schedule(); schedule.setMovieId(dto.getMovieId()); schedule.setHallId(dto.getHallId()); schedule.setStartTime(dto.getStartTime()); schedule.setEndTime(dto.getStartTime() .plusMinutes(dto.getDuration())); schedule.setPrice(dto.getPrice()); scheduleMapper.insert(schedule); // 根据影厅配置生成座位列表 ListScheduleSeat seats new ArrayList(); for (int row 1; row dto.getRowCount(); row) { for (int col 1; col dto.getColCount(); col) { ScheduleSeat seat new ScheduleSeat(); seat.setScheduleId(schedule.getId()); seat.setSeatRow(row); seat.setSeatCol(col); seat.setStatus(0); seats.add(seat); } } // 分批插入每批 500 条 seatMapper.batchInsert(seats); return schedule.getId(); }insert idbatchInsert INSERT INTO schedule_seat (schedule_id, seat_row, seat_col, status) VALUES foreach collectionlist items separator, (#{s.scheduleId}, #{s.seatRow}, #{s.seatCol}, #{s.status}) /foreach /insert逻辑说明MyBatis 的foreach把多条 VALUES 拼成一条 INSERT一次网络往返插入全部座位。150 条数据一条 SQL 就搞定比循环单插快一个数量级。参数说明批量插入要注意单条 SQL 的长度限制MySQL 默认max_allowed_packet是 4MB座位数特别多比如上千时要分批每批 500 到 1000 条比较稳妥。另外schedule_id依赖刚插入的场次主键所以必须在同一事务里且scheduleMapper.insert后要能拿到自增 IDMyBatis 的useGeneratedKeystrue。4.2 影厅座位布局的灵活配置真实影厅不是规整的矩形有走廊、有情侣座、有遮挡位。我一般会在影厅表里存一个座位布局的 JSON 或者用一张hall_seat模板表记录哪些坐标是有效座位。生成场次座位时只遍历有效坐标而不是无脑铺满矩形。这样过道位置就不会出现「可售座位」用户选座界面也不会显示不存在的座位。// 从影厅模板读取有效座位坐标 ListSeatPos validSeats hallSeatMapper.findByHallId(dto.getHallId()); for (SeatPos pos : validSeats) { ScheduleSeat seat new ScheduleSeat(); seat.setScheduleId(schedule.getId()); seat.setSeatRow(pos.getRow()); seat.setSeatCol(pos.getCol()); seat.setStatus(0); seats.add(seat); }这个改动看着小但直接影响选座页面的体验。如果座位表里全是矩形铺满前端渲染时还得再过滤一遍不如在后端生成时就只存有效座位前后端都省事。5. 避坑与排查订票系统最容易翻车的五个地方这套系统我见过太多人写完能跑一压测或者一演示就出问题。下面五条都是血泪经验每条按现象、原因、解决来讲。5.1 座位超卖两个人买到同一个座现象并发测试时同一个座位生成了两条已支付订单。原因锁座用了「先 SELECT 判断可售再 UPDATE」两个线程都查到可售都去更新。解决改成第 3 章的条件更新WHERE 里带status 0靠数据库行锁保证只有一个成功。如果已经上线加一个uk_schedule_seat唯一索引兜底重复插入直接报错。5.2 事务不回滚座位锁了但订单没生成现象锁座成功但生成订单时抛异常座位却一直是锁定状态。原因Transactional默认只对RuntimeException回滚自定义异常如果继承了Exception而不是RuntimeException事务不会回滚。解决注解上显式写rollbackFor Exception.class或者自定义异常继承RuntimeException。这个坑非常隐蔽因为不报错只是数据不一致。5.3 定时任务误释放用户付款了座位却被放走现象用户在支付截止前几秒付款结果座位被定时任务释放用户到场无座。原因释放任务和支付回调并发修改同一订单没有互斥。解决释放前先执行cancelIfPending带status0条件受影响行数为 0 说明已被支付直接跳过。核心思想还是条件更新不要先查后改。5.4 批量插入超时排片一多就卡死现象一次生成几十个场次的座位接口响应超过 30 秒甚至超时。原因循环单条 INSERT几千次数据库往返。解决用foreach批量插入并分批控制每批 500 到 1000 条。同时检查max_allowed_packetSQL 太长会被数据库拒绝。5.5 时间字段时区错乱场次时间对不上现象数据库里存的时间和页面显示差 8 小时。原因JDBC 连接串没配时区或者实体用了java.util.Date而数据库是DATETIME序列化时区不一致。解决连接串加serverTimezoneAsia/Shanghai实体统一用LocalDateTime前后端约定用时间戳或带时区的 ISO 字符串传输。这个坑在跨时区部署时尤其明显。6. 压测验证与一个提效技巧怎么证明你的锁座真的扛得住写完不等于对得用并发压测证明。我一般用 JMeter 或者一个简单的多线程脚本模拟 200 个用户同时抢同一场次的 50 个座位看最终「已售 锁定」的座位数是否恰好等于成功订单的座位数多一个少一个都说明有 bug。验证的核心指标有三个一是座位不超卖已售座位数等于支付成功订单的座位总数二是不漏锁没有出现「订单已支付但座位还是可售」三是超时释放准确过期订单的座位在 30 秒内回到可售。下面这段用 Java 并发工具快速验证锁座原子性比装 JMeter 轻量。// 并发锁座验证200 线程抢 50 个座位 public class LockSeatStressTest { public static void main(String[] args) throws Exception { int threads 200; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch start new CountDownLatch(1); AtomicInteger success new AtomicInteger(); AtomicInteger fail new AtomicInteger(); for (int i 0; i threads; i) { final long userId i; pool.submit(() - { try { start.await(); // 所有线程同时起跑 // 每个线程尝试锁 1 排 1 座 seatService.lockSeats(1001L, List.of(new SeatPos(1, 1)), userId); success.incrementAndGet(); } catch (SeatLockedException e) { fail.incrementAndGet(); } catch (Exception ignored) { } }); } start.countDown(); // 发令枪 pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); System.out.println(成功: success.get() 失败: fail.get()); // 正确结果成功恰好 1失败 199 } }逻辑说明CountDownLatch让 200 个线程在同一时刻发起锁座请求最大化并发冲突。正确结果是成功数恰好为 1其余全部抛SeatLockedException。如果成功数大于 1说明锁座逻辑有竞态回去检查 WHERE 条件是不是漏了status 0。参数说明线程数按需调整但要注意数据库连接池大小HikariCP 默认 10 个连接200 线程会排队压测时把maximumPoolSize调到 50 以上否则测的是连接池不是锁座逻辑。另外这个测试会真实写库跑完记得清理测试数据。一个提效技巧把「锁座 下单」合并成一个接口前端选完座直接调一次减少一次往返也避免锁座成功但下单请求丢失导致座位空锁。代价是接口职责变重但对订票这种强关联操作合并反而更安全。我自己做这类系统时习惯在锁座成功后立刻在同一事务里生成订单锁座和订单要么一起成功要么一起失败省掉了中间态。这个习惯帮我躲过了好几次「锁了座没订单座位一直占着」的线上问题。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑