简介:这是一份面向具备一定Java基础、希望学习或从事电影票务系统开发人员的完整项目资料,围绕Java与Spring Boot技术栈,讲解电影在线订票选座系统的设计与实现。内容覆盖普通用户、会员与管理员三类角色的功能划分,并涉及Spring Data JPA、Thymeleaf、MySQL及IntelliJ IDEA开发环境配置等关键环节,适合企业或创业者搭建在线票务平台时参考。资源包共1个docx文件,约34KB,以文档形式集中呈现系统设计思路、数据模型构建、业务流程逻辑与核心代码示例,便于按章节查阅。目前已有314人学习下载。读者可从中获取完整的功能清单、技术选型说明、开发步骤与编译运行指引,以及用户注册登录、电影信息查询等模块的代码片段,为课程设计、毕业设计或实际项目开发提供可复用的参考方案。
1. 电影在线订票系统:从选座锁座到订单超时,一个 Java 全栈项目能踩多少坑
影院售票和普通电商最大的区别在于:座位是有限且不可分割的库存。同一场次同一个座位,在任意时刻只能卖给一个人。很多开发者第一次做电影在线订票系统时,会把它当成“带座位号的商品下单”来处理,结果一上并发就出现超卖、锁座不释放、订单和支付状态对不上。基于 Java 与 Spring Boot 的电影在线订票系统,核心要解决的就是三件事:场次与座位的映射关系、选座时的并发控制、订单从创建到核销的完整状态机。这套系统适合有 Java Web 基础、想完整走一遍“真实业务闭环”的开发者,也适合需要交付课程设计或模拟项目的同学。下面按“能跑起来、能扛住并发、能查得清问题”的顺序,把每个环节拆开讲。
2. 先把数据模型立住:影片、场次、影厅、座位怎么关联
2.1 为什么不能把座位直接挂在影片上
影片是内容维度,场次是时间维度,影厅是空间维度,座位是空间里的最小单位。四者关系如果压成一张表,后面加“同一影厅不同场次座位状态不同”时就会崩。常见做法是拆成五张核心表:影片表、影厅表、场次表、座位表、场次座位关联表。其中场次座位关联表是整套系统的枢纽,它记录“某场次的某个座位当前是什么状态”。
-- 影片表:只存内容信息,不涉及排期 CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, duration_minutes INT NOT NULL, release_date DATE, status TINYINT DEFAULT 1 COMMENT '1上架 0下架' ); -- 影厅表:物理空间,座位行列为后续生成座位做准备 CREATE TABLE hall ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, row_count INT NOT NULL, col_count INT NOT NULL ); -- 场次表:影片在某影厅某时间段的放映 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, base_price DECIMAL(10,2) NOT NULL, 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, seat_col INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售', lock_order_no VARCHAR(64) DEFAULT NULL, lock_expire_time DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col) );逻辑说明:schedule_seat上的唯一索引uk_schedule_seat是防超卖的第一道防线,同一场次同一行列只能有一行记录。status用三个状态而不是布尔值,是因为“锁定”和“已售”在业务上完全不同——锁定可以超时释放,已售不能。lock_order_no和lock_expire_time让锁座可追溯、可自动回收。
参数说明:base_price放在场次而不是影片上,因为同一影片不同时段价格不同。row_count和col_count决定生成多少条schedule_seat,一个 10 排 15 列的影厅就是 150 条,排片时批量插入。
2.2 场次创建时批量生成座位
排片接口不能只插一条schedule就完事,必须同时把该影厅所有座位展开成schedule_seat。这一步用批量插入,避免循环单条 insert。
@Service public class ScheduleService { @Autowired private ScheduleMapper scheduleMapper; @Autowired private HallMapper hallMapper; @Autowired private ScheduleSeatMapper scheduleSeatMapper; @Transactional(rollbackFor = Exception.class) public Long createSchedule(ScheduleCreateDTO dto) { Hall hall = hallMapper.selectById(dto.getHallId()); if (hall == null) { throw new BizException("影厅不存在"); } Schedule schedule = new Schedule(); schedule.setMovieId(dto.getMovieId()); schedule.setHallId(dto.getHallId()); schedule.setStartTime(dto.getStartTime()); // 结束时间 = 开始时间 + 影片时长 + 15分钟清场 schedule.setEndTime(dto.getStartTime().plusMinutes(dto.getDuration() + 15)); schedule.setBasePrice(dto.getBasePrice()); scheduleMapper.insert(schedule); // 批量生成座位,状态默认0可售 List<ScheduleSeat> seats = new ArrayList<>(); for (int r = 1; r <= hall.getRowCount(); r++) { for (int c = 1; c <= hall.getColCount(); c++) { ScheduleSeat seat = new ScheduleSeat(); seat.setScheduleId(schedule.getId()); seat.setSeatRow(r); seat.setSeatCol(c); seat.setStatus(0); seats.add(seat); } } scheduleSeatMapper.batchInsert(seats); return schedule.getId(); } }逻辑说明:整个方法在一个事务里,排片和座位生成要么都成功要么都回滚。结束时间自动加 15 分钟清场时间,这是影院排片的常见做法,避免两场之间没有间隔。
参数说明:batchInsert的 SQL 用INSERT INTO ... VALUES (...),(...),(...)拼接,一次插入 150 到 300 条比较合适,太多会撑爆max_allowed_packet。如果影厅超过 500 座,建议分批,每批 200 条。
3. 选座锁座:并发下怎么保证一个座位只卖一次
3.1 乐观锁还是悲观锁,先看冲突概率
选座场景的冲突概率取决于热度。冷门场次可能一整天没人抢同一个座位,热门场次开场前十分钟可能几十个人抢同一排。常见做法是数据库行锁 + 状态条件更新,不引入 Redis 分布式锁也能扛住中小规模。核心 SQL 只有一句:
UPDATE schedule_seat SET status = 1, lock_order_no = #{orderNo}, lock_expire_time = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE schedule_id = #{scheduleId} AND seat_row = #{row} AND seat_col = #{col} AND status = 0;逻辑说明:WHERE status = 0是条件更新的关键。如果两个请求同时到达,数据库行锁会让它们串行执行,第一个把status改成 1,第二个的WHERE条件不再满足,affected rows返回 0,业务层据此判断锁座失败。这比先SELECT再UPDATE可靠得多,后者在两次操作之间有窗口期。
参数说明:lock_expire_time设为 15 分钟,和订单超时时间保持一致。如果用户下单后 15 分钟未支付,定时任务会把status从 1 改回 0,并清空lock_order_no。
3.2 锁座接口的完整写法与失败处理
@RestController @RequestMapping("/api/seat") public class SeatController { @Autowired private ScheduleSeatMapper scheduleSeatMapper; @PostMapping("/lock") public Result lockSeats(@RequestBody LockSeatDTO dto) { // 一次最多锁6个座位,防止恶意占座 if (dto.getSeats().size() > 6) { return Result.fail("一次最多选择6个座位"); } String orderNo = generateOrderNo(); List<String> failedSeats = new ArrayList<>(); for (SeatPos pos : dto.getSeats()) { int affected = scheduleSeatMapper.lockSeat( dto.getScheduleId(), pos.getRow(), pos.getCol(), orderNo); if (affected == 0) { failedSeats.add(pos.getRow() + "排" + pos.getCol() + "座"); } } if (!failedSeats.isEmpty()) { // 有座位锁失败,回滚已锁的座位 scheduleSeatMapper.releaseByOrderNo(orderNo); return Result.fail("座位已被占用:" + String.join("、", failedSeats)); } return Result.ok(orderNo); } }逻辑说明:循环锁座时只要有一个失败,就把这个订单号已锁的座位全部释放,保证原子性。这里没有用数据库事务包住整个循环,是因为锁座本身是逐条条件更新,失败回滚用订单号批量释放更直接。
参数说明:generateOrderNo建议用时间戳 + 用户 ID 后四位 + 随机数,保证全局唯一且可读。一次最多 6 个座位是业务限制,防止黄牛批量占座,具体数字可按影院规则调整。
3.3 订单超时释放:定时任务怎么写才不翻车
锁座不释放是这类系统最常见的线上问题。用户锁了座不支付,座位一直显示灰色,别人买不了。常见做法是 Spring 的@Scheduled定时扫描过期锁。
@Component public class SeatLockCleanTask { @Autowired private ScheduleSeatMapper scheduleSeatMapper; // 每分钟执行一次,释放过期锁座 @Scheduled(cron = "0 * * * * ?") public void releaseExpiredLocks() { int released = scheduleSeatMapper.releaseExpired(); if (released > 0) { log.info("释放过期锁座 {} 个", released); } } }对应的 SQL:
UPDATE schedule_seat SET status = 0, lock_order_no = NULL, lock_expire_time = NULL WHERE status = 1 AND lock_expire_time < NOW();逻辑说明:定时任务只处理status = 1且已过期的记录,不会误伤已售座位。每分钟执行一次,最坏情况下座位多锁一分钟,对业务影响可接受。
参数说明:cron表达式0 * * * * ?表示每分钟第 0 秒触发。如果系统并发高,可以改成每 30 秒,但要注意加分布式锁避免多实例重复执行。单实例部署不用考虑这个问题。
4. 订单与支付状态机:别让订单和座位状态各说各话
4.1 订单状态流转的四个关键节点
订单从创建到完成,状态必须和座位状态联动。常见状态有:待支付、已支付、已取消、已核销。每个状态变更都要同时更新订单表和座位表,且在一个事务里。
| 订单状态 | 座位状态 | 触发动作 | 可逆性 |
|---|---|---|---|
| 待支付 | 锁定 | 用户锁座成功 | 可取消 |
| 已支付 | 已售 | 支付回调成功 | 不可逆 |
| 已取消 | 可售 | 用户取消或超时 | 不可逆 |
| 已核销 | 已售 | 影院检票 | 不可逆 |
4.2 支付回调的幂等处理
支付回调可能重复到达,必须做幂等。常见做法是用订单号 + 支付流水号做唯一约束,或者先查订单状态再更新。
@Transactional(rollbackFor = Exception.class) public void handlePayCallback(String orderNo, String payTradeNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null) { throw new BizException("订单不存在"); } // 幂等:已支付直接返回 if (order.getStatus() == OrderStatus.PAID.getCode()) { return; } if (order.getStatus() != OrderStatus.UNPAID.getCode()) { throw new BizException("订单状态异常"); } // 更新订单 orderMapper.updateStatus(orderNo, OrderStatus.PAID.getCode(), payTradeNo); // 座位从锁定改为已售 scheduleSeatMapper.confirmSold(orderNo); }逻辑说明:先查状态,已支付直接返回,保证重复回调不会重复处理。订单更新和座位确认在同一个事务里,避免订单已支付但座位还是锁定状态。
参数说明:payTradeNo是支付平台返回的流水号,存下来用于对账。confirmSold的 SQL 是UPDATE schedule_seat SET status = 2 WHERE lock_order_no = #{orderNo} AND status = 1,只更新锁定状态的座位。
5. 避坑与排查:这五个问题我几乎每次都能遇到
5.1 座位图渲染出来全是灰色,但数据库里明明是 0
现象:前端调座位列表接口,返回的status都是 0,但页面渲染出来全是不可选。
原因:前端把status当布尔值判断,0在 JavaScript 里是 falsy,直接走了不可选分支。
解决:前端判断用status === 0而不是if (status),或者后端返回时把状态映射成字符串AVAILABLE、LOCKED、SOLD。
5.2 锁座接口偶尔返回成功,但座位实际没锁上
现象:用户反馈锁座成功,进入支付页后座位又被别人买走。
原因:锁座 SQL 的affected rows判断写成了>= 0,或者用了updateById没带状态条件。
解决:必须用条件更新并严格判断affected == 1。任何updateById直接改状态的写法都要改掉。
5.3 定时任务释放了已支付订单的座位
现象:用户支付成功,但座位被定时任务改回可售,导致重卖。
原因:释放 SQL 只判断了lock_expire_time < NOW(),没判断status = 1,把已售的status = 2也扫进去了。
解决:释放条件必须同时满足status = 1 AND lock_expire_time < NOW()。已售座位的lock_expire_time应该在支付成功时清空。
5.4 同一场次两个用户同时锁相邻座位,一个成功一个失败
现象:用户 A 锁 5 排 6 座成功,用户 B 锁 5 排 7 座失败,提示已被占用。
原因:锁座循环里用了同一个订单号,但失败回滚时把整个订单号的座位都释放了,包括 A 的。
解决:每个用户每次锁座生成独立订单号,回滚只释放当前订单号下的座位。不要复用订单号。
5.5 订单超时时间到了但座位没释放,查日志发现定时任务没执行
现象:@Scheduled注解加了但任务不跑。
原因:启动类没加@EnableScheduling,或者任务方法所在的类没有被 Spring 管理。
解决:启动类加@EnableScheduling,任务类加@Component。如果用了多实例,加分布式锁或改用数据库悲观锁控制只有一个实例执行。
6. 进阶技巧:用状态机 + 对账把订单异常率压到最低
订单状态机如果只用 if-else 散落在各个 Service 里,后期加“退款”“改签”会非常痛苦。我一般会抽一个轻量的状态机配置,把允许的流转写在一张表里,代码只做查表和校验。
public enum OrderStatus { UNPAID(0, "待支付"), PAID(1, "已支付"), CANCELLED(2, "已取消"), VERIFIED(3, "已核销"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } // 允许的流转:当前状态 -> 可流转到的状态集合 public static boolean canTransfer(int from, int to) { if (from == UNPAID.code && (to == PAID.code || to == CANCELLED.code)) return true; if (from == PAID.code && to == VERIFIED.code) return true; return false; } }逻辑说明:canTransfer把合法流转集中在一个地方,任何状态变更前先调这个方法,不合法直接抛异常。这样加新状态时只改这一个方法,不会漏掉某个分支。
参数说明:状态码用整数存数据库,枚举里同时保留 code 和 desc,前端展示用 desc,逻辑判断用 code。
对账是另一个容易被忽略的环节。每天凌晨跑一次对账任务,检查三件事:订单状态为已支付但座位状态不是已售的、座位状态为已售但没有对应已支付订单的、锁定超过 24 小时还没释放的。前两类直接告警人工介入,第三类自动释放。对账 SQL 不复杂,但能兜住绝大多数状态不一致的问题。
-- 对账1:已支付订单但座位未确认 SELECT o.order_no, o.status, s.status AS seat_status FROM `order` o JOIN schedule_seat s ON o.order_no = s.lock_order_no WHERE o.status = 1 AND s.status != 2; -- 对账2:已售座位但无有效订单 SELECT s.id, s.schedule_id, s.seat_row, s.seat_col, s.lock_order_no FROM schedule_seat s LEFT JOIN `order` o ON s.lock_order_no = o.order_no WHERE s.status = 2 AND (o.id IS NULL OR o.status != 1);逻辑说明:对账 SQL 只查不写,结果输出到日志或告警群,由人工确认后再决定是否自动修复。自动修复有风险,比如用户正在支付中,自动释放会导致支付成功但座位没了。
参数说明:对账任务建议在凌晨 3 点到 5 点之间执行,避开业务高峰。查询加LIMIT防止全表扫描拖垮数据库,比如每次查 1000 条,分页处理。
这套系统我前后改过三版,第一版超卖,第二版锁座不释放,第三版才把状态机和对账补上。血泪经验是:座位状态和订单状态必须在一个事务里改,任何异步补偿都要有对账兜底。不要相信“消息队列一定不丢”,也不要相信“定时任务一定准时”。把对账做好,出了问题能查、能修,比追求零故障更现实。希望帮到你。
本文还有配套的精品资源,点击获取