☰
SpringBoot电影票预订系统实战:锁座、并发控制与支付回调设计
2026/10/7 14:52:31 网站建设 项目流程

简介:这是基于SpringBoot框架的电影票预订系统完整设计资料,面向Java方向课程设计与毕业设计场景,提供从功能设计到代码落地的全套方案参考。系统前后台功能齐全:前台支持用户注册登录、影片列表与详情查看、在线选座购票、在线支付、根据观影喜好进行电影推荐,以及观影后的评价与论坛交流;后台涵盖管理员信息管理、注册用户管理、电影信息与推荐管理、评论与论坛管理,并打通订票、购票、支付等订单全流程。压缩包共1229个文件、约18.36MB,以Java源码及class文件、Vue组件、HTML/JS/CSS前端页面为主,辅以数据库SQL脚本、论文文档、开题报告与答辩PPT,可支撑课程验收与毕业答辩。资源已有59人学习,对正在完成同类课题的开发者有直接参考价值,尤其适合研究前后端交互、订单流转与权限设计。

1. 电影票预订系统,SpringBoot 落地时最值得较真的不是业务代码

如果说图书借阅、二手交易这类管理系统练的是 CRUD 基本功,那电影票预订系统就是第一道值得认真对待的“有状态业务”关卡——它要求你同时处理座位状态、场次时间、订单和支付回调,还会在并发下暴露出一堆平时写接口根本碰不到的脏数据问题。拿 SpringBoot 来做这套系统,选型上确实比 SSM 省心,自动配置和 starter 机制能让你把注意力放在排片、选座、锁座、出票这条主链路上,而不是天天调 XML 映射和事务代理配置。

这套系统适合两类人:一类是拿它做毕业设计,需要“论文 + 开题 + PPT”一套完整交付物;另一类是工作两三年的 Java 工程师,想找个业务闭环够完整的练手项目,把事务、缓存、并发控制串起来。它解决的核心问题也很朴素:让用户能查影片、看场次、选座位、下单支付,让管理员能排片和统计票房。听起来不复杂,但真把所有边界情况跑一遍,你会发现最花时间的地方全在“座位别卖重、订单别丢、票款对得上账”这三件事上。

2. 技术选型和工程结构:先想清楚 SpringBoot 要替你扛多少事

2.1 选型逻辑:为什么不从 Spring MVC 手写,也不直接上微服务

很多毕设选题第一反应是“用 SSM 够传统,好过查重”,但我的判断恰恰相反:电影票预订系统的业务复杂度不高,真正难的是状态一致性和接口边界,SpringBoot 的自动装配和 starter 能砍掉大量环境配置类代码,让你把论文篇幅留给业务设计本身。常见做法是 SpringBoot 2.x + MyBatis-Plus + MySQL + Redis,这套组合在课程设计和中小企业内部系统中都非常常见,资料多、踩坑记录全,适合一个人独立完成。

选 MyBatis-Plus 而不是 JPA,是因为电影票业务的 SQL 里有很多条件拼接场景——查影片、筛场次、统计上座率,MP 的条件构造器比 JPA 的 Specification 直观得多,而且代码生成器能直接把表结构反向生成实体和 Mapper,写论文时画 ER 图也更省事。Redis 在这里不是花架子,它的核心用途是两处:一是存座位维度的锁标记,二是做场次热门影片的缓存,降低对 MySQL 的查询压力。至于为什么不上微服务,原因很简单——单机事务能解决的事,拆成多个服务只会给自己增加分布式事务的负担,论文答辩时反而容易被问倒。

2.2 工程目录:按业务边界分包,而不是按技术层次分包

我看到很多毕设代码喜欢把 controller、service、mapper 各放一个包,然后所有业务类全堆进去。这种结构写小 demo 没问题,但电影票系统里“选座”和“订单”是两条独立链路,混在一起会导致一个改动牵动全部代码。我一般会这样组织:

src/main/java/com/example/movie/ ├── common/ # 统一返回、异常、常量、枚举 ├── config/ # Redis、MyBatis-Plus、拦截器配置 ├── module/ │ ├── user/ # 用户注册登录 │ ├── film/ # 影片管理 │ ├── schedule/ # 场次排片 │ ├── seat/ # 座位图与锁座 │ ├── order/ # 订单与支付回调 │ └── admin/ # 后台统计 └── utils/ # 日期、随机数、座位号工具

这种按业务模块分包的思路,对应的就是论文里的功能模块划分,写章节时可以直接把包结构映射到功能设计图上,答辩时讲起来也顺。controller 层只做参数接收和结果包装,业务判断全部下沉到 service 层,事务注解加在 service 实现类上,这是保证事务边界正确的基本前提——很多人喜欢在 controller 方法上加 @Transactional,这属于极其容易翻车的误用,事务根本不会按预期工作,因为 Spring 的代理机制拦的是外部调用。

3. 数据库设计是这套系统的真正分水岭:五张核心表与两处冗余

3.1 核心表结构:从普通 CRUD 到关系建模

电影票预订系统的数据库设计,核心就五张表:用户表、影片表、场次表、座位表、订单表。这五张表之外,我还强烈建议加一张排片操作日志表,后台改场次时留痕,写论文时能多一个“系统安全性设计”的素材。以下是我常用的建表 SQL 核心片段:

CREATE TABLE `film` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '影片名称', `duration` INT NOT NULL COMMENT '时长(分钟)', `release_date` DATE NOT NULL COMMENT '上映日期', `cover_url` VARCHAR(500) DEFAULT '' COMMENT '海报地址', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `schedule` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `film_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 DEFAULT 1 COMMENT '1有效 0已取消' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段类型上我吃过不少亏。price 一定用 DECIMAL(10,2) 而不是 DOUBLE,浮点数做金额计算会出现 0.1 + 0.2 = 0.30000000000000004 这种问题,虽然 Java 端用 BigDecimal 也能兜住,但 MySQL 里存 DOUBLE 本身就是错的建模。时间字段用 DATETIME 而不是 TIMESTAMP,是因为 TIMESTAMP 的范围到 2038 年,而且 DATETIME 不受时区影响,对本地部署的毕设项目更稳。

座位表这里有个设计决策:是每个座位一行数据,还是用一个 JSON 字段存整张座位图?我的建议是拆成独立表,但只存“被锁定/已售出”的座位。理由是:一个标准影厅 100 个座位,一天 5 个场次就是 500 行,一个月数据量也才一万多行,MySQL 完全扛得住;而拆行之后,锁座可以用 UPDATE 语句带条件来原子操作,比读整张图再在内存里做判断要可靠得多。座位表字段至少要有 schedule_id、seat_row、seat_col、status、lock_time。

3.2 订单与场次的冗余:如何让统计数据不把 MySQL 跑垮

订单表是唯一涉及金额的核心表,字段需要包括订单号、用户 ID、场次 ID、座位 ID 集合、总价、状态(待支付/已支付/已取消/已退款)、支付流水号、创建时间。这里的关键决策是“座位 ID 集合”的存储方式:常见做法是用逗号分隔的字符串存“1,2,3”或者 JSON 数组。虽然这不符合第三范式,但真实项目里订单快照就是这么设计的——下单之后座位不可变,用户查看订单详情时需要立刻展示座位号,不能去关联实时座位表,否则将来座位数据被清理就查不到了。

场次表到影片表之间是 N:1 关系,但我在 schedule 表里冗余了一个 film_title 字段。原因很简单:后台列表页和数据大屏都要显示场次对应的影片名,不做冗余就得每次 JOIN 影片表,而影片改名、下架都不影响已开场次的历史展示。这个冗余在论文里可以写成“查询性能优化设计”,答辩老师基本不会挑毛病,因为它确实是实战中很常见的取舍。

另一个容易被忽略的设计是订单号。不要用自增 ID 直接当订单号暴露给用户,应该生成形如“yyyyMMddHHmmss + 随机 6 位数字”的业务订单号,这样既能在日志里快速定位,也能避免用户通过改 ID 越权访问他人订单。支付流水号则必须单独存,作为对账的唯一条目,一个订单可能对应多次支付尝试,但只有一条成功的支付流水。

4. 从选座到订单的核心链路:锁座、超时释放、防超卖一次说清

4.1 选座接口的并发控制:数据库行锁是底线,Redis 是加速器

电影票预订系统最容易翻车的地方是选座。两个用户同时点同一个座位,如果接口逻辑是“先查 status 再 update”,并发下必然超卖。最基础的防线是 SQL 层面的条件更新:

@Update("UPDATE seat SET status = 1, lock_time = NOW(), user_id = #{userId} " + "WHERE schedule_id = #{scheduleId} AND seat_row = #{row} AND seat_col = #{col} " + "AND status = 0") int lockSeat(@Param("scheduleId") Long scheduleId, @Param("row") Integer row, @Param("col") Integer col, @Param("userId") Long userId);

这段 SQL 的巧妙之处在于把“查询空闲”和“更新为已锁”合并成一个原子操作——UPDATE 语句在 InnoDB 里会对命中的行加行锁,同一时刻两个事务同时执行这条语句时,只有一个能影响一行,另一个的 affected rows 为 0。Java 层拿到返回值后判断是否为 1,是 1 才继续创建订单,否则直接返回“座位已被选”。

但只靠这条 UPDATE 还不够。用户从选座到提交订单通常要花一两分钟,座位的“已锁”状态需要有时间边界,否则用户选完座不付款,座位一直被占着,其他人就不能买了。我一般会在选座时同时往 Redis 写一条带过期时间的记录:

Boolean absent = redisTemplate.opsForValue() .setIfAbsent("seat:lock:" + scheduleId + ":" + row + ":" + col, userId.toString(), 2, TimeUnit.MINUTES);

setIfAbsent 是原子操作,底层对应 Redis 的 SETNX,能保证并发时只有一个请求能成功占位。这里 Redis 和 MySQL 是双写的关系:Redis 负责快速拒绝和过期提示,MySQL 是最终一致性的兜底。用户提交订单时校验 Redis 里的 value 是否等于自己的 userId,等于才允许创建订单——这个校验成本极低,能挡住大部分“抢到锁但乱提交”的异常请求。

4.2 订单状态机与超时取消:定时任务别用 sleep 循环

订单创建后是“待支付”状态,用户必须在 15 分钟内完成支付,否则订单自动取消、座位释放。这个“自动取消”功能是电影票系统里必须有的闭环,否则座位会被死锁占满。实现方式最忌讳的是在 Java 里写一个 while(true) 循环每隔几秒扫描全表——不仅锁表风险高,而且论文答辩时会被追问“并发量大了怎么办”。

我常用的方案是延迟队列思维落地:订单创建时把订单号放入 Redis ZSet,score 设为过期时间戳,另起一个定时任务每秒取一次当前时间之前的数据:

// 订单创建时:zSet.add("order:delay", orderId, expireTimeStamp); // 定时取消任务,每秒执行 Set<String> expiredOrders = redisTemplate.opsForZSet() .rangeByScore("order:delay", 0, System.currentTimeMillis()); for (String orderId : expiredOrders) { boolean removed = redisTemplate.opsForZSet() .remove("order:delay", orderId) > 0; if (removed) { cancelOrderIfNotPaid(orderId); // 先查订单状态,待支付才取消 } }

这段代码的逻辑要点是:用 ZSet 的 score 天然支持按时间排序,rangeByScore 一次取回所有已到期的订单号;remove 操作保证同一订单不会被多个线程重复处理,这是一个轻量级的分布式锁替代方案。cancelOrderIfNotPaid 方法里要再次查一次订单状态,只有“待支付”才执行取消——这就是典型的“先标记后处理”模式,避免定时任务重复执行时把已取消的订单再扫一遍。

这里有一个容易踩坑的细节:Redis ZSet 的 score 是 double 类型,时间戳 13 位数字转 double 会有精度损失,但毫秒级时间戳转 double 后误差在 1 毫秒内,对订单过期这种分钟级精度的需求完全不影响,不需要做特殊处理。

5. 支付回调与状态同步:写论文时最容易被问倒的环节

5.1 回调接口的幂等设计:重复通知不能生成两条流水

支付回调是这个系统里最讲究“健壮性”的一环。常见的模拟支付会提供一个支付页面,用户点击“模拟支付”后系统回调自己项目里的 /api/pay/notify 接口。但真实支付平台的回调机制是“多次通知、直到成功响应”,模拟环境下虽然只调一次,但代码必须按“可能调多次”来写——这就是幂等性的意义。

我的回调处理逻辑是三步走:第一步,根据支付流水号查询是否已处理过;第二步,如果未处理,校验订单金额是否与回调金额一致;第三步,更新订单状态为已支付,同时更新座位状态为已售出。核心代码片段如下:

@Transactional public boolean handlePayNotify(String orderNo, String paySerialNo, BigDecimal payAmount) { // 1. 按支付流水号查处理记录,已存在则直接返回成功 PayRecord record = payRecordMapper.selectBySerialNo(paySerialNo); if (record != null) { return true; } // 2. 查订单并校验金额 Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getAmount().compareTo(payAmount) != 0) { throw new BizException("订单金额校验失败"); } // 3. 更新订单状态,同时插入支付记录 int updated = orderMapper.updateStatusByOrderNo(orderNo, "PAID", "PENDING"); if (updated == 1) { payRecordMapper.insert(paySerialNo, orderNo, payAmount); } return updated == 1; }

这个设计的核心是第 3 步的“状态条件更新 + 支付记录插入”在同一个事务里。如果回调重复执行,第一步就能拦住;如果两个回调同时执行,第二步的 updateStatusByOrderNo 带条件 “WHERE status = 'PENDING'”,只有一个事务能更新成功,另一个影响行数为 0,不会产生两条支付记录。

5.2 座位状态的二次更新:出票时不能依赖内存状态

支付成功后,座位状态需要从“已锁定”变成“已售出”。这个操作不能直接 UPDATE seat SET status = 2,因为中间如果有后台管理员手动释放了某个座位(比如用户投诉误锁),状态已经不是 1 了。所以需要带状态的条件更新:

int updated = seatMapper.updateStatus( scheduleId, row, col, 1, // 期望当前状态:已锁定 2, // 目标状态:已售出 orderId // 绑定订单号 ); if (updated != 1) { throw new BizException("座位状态异常,请人工处理"); }

这里 MySQL 返回的影响行数就是最终判断依据,任何应用层的判断都可能有时间窗口,但数据库行锁能保证这一行的状态迁移是原子的。这个设计写进论文的“系统可靠性设计”章节非常加分——它体现了“状态机变迁必须带条件”的工程意识,比单纯贴代码讲 CRUD 高一个层次。

5.3 对账与统计:后台报表的 SQL 写法

后台管理需要一个票房统计页面。常见的指标是:某部影片的总票房、某天的订单量、某场次的上座率。上座率这个指标最容易算错——分母是影厅总座位数,分子是已售出座位数,但“已售出”和“已锁定”必须区分,锁座未支付的座位不能算上座。统计 SQL 通常是:

SELECT s.film_id, f.title, COUNT(o.id) AS order_count, COALESCE(SUM(o.total_amount), 0) AS box_office FROM schedule s LEFT JOIN orders o ON o.schedule_id = s.id AND o.status = 'PAID' LEFT JOIN film f ON f.id = s.film_id WHERE s.start_time BETWEEN #{start} AND #{end} GROUP BY s.film_id, f.title ORDER BY box_office DESC;

这里有一个大家很容易翻车的点:LEFT JOIN 时,关联条件 o.status = 'PAID' 必须写在 ON 子句里,不能写在 WHERE 里。如果写在 WHERE,LEFT JOIN 就会被过滤成 INNER JOIN,未售出的场次会整行消失,统计结果直接错误。这个细节在论文里不需要写,但答辩时如果老师追问“为什么用 LEFT JOIN 而不是 JOIN”,你能答出来就很加分。

6. 避坑:电影票预订系统从开发到答辩的七个真实翻车现场

6.1 LocalDateTime 在 JSON 序列化时变成时间戳,前端解析直接报错

现象:后端返回的场次时间变成一串数字,前端页面显示异常,或者干脆报解析错误。

原因:SpringBoot 默认用 Jackson 序列化,Java 8 的 LocalDateTime 如果没有配置格式,默认序列化为数组或时间戳——这取决于 Jackson 版本和配置。

解决:在 application.yml 里做全局配置,指定格式和时区:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 serialization: write-dates-as-timestamps: false

注意这个配置只对 java.util.Date 生效,对 LocalDateTime 要在实体字段上加 @JsonFormat 注解,或者写一个全局的 Jackson 自定义序列化器。我一般习惯在实体字段上直接加:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime;

6.2 MyBatis-Plus 的 @TableField 自动填充与 insert 冲突

现象:创建时间字段明明设置了自动填充,但插入数据后 create_time 是 NULL,或者 UPDATE 时 update_time 不更新。

原因:用了 @TableField(fill = FieldFill.INSERT) 但没实现 MetaObjectHandler 接口,或者实现了但没注册为 Spring Bean。

解决:写一个 MetaObjectHandler 的实现类,并确保被 Spring 扫描到。另外要注意,如果实体里的 createTime 字段手动 set 了值,自动填充会覆盖掉——这在批量导入数据时是个坑,需要根据业务决定是让自动填充优先还是手动值优先。

6.3 事务不生效:同一个类里 this 调用导致 @Transactional 失效

现象:取消订单的方法里调用了同一个类的扣减库存方法,扣减方法上的事务不生效,抛异常后数据还是被改了一半。

原因:Spring 事务基于代理,this 调用不走代理,@Transactional 注解被跳过。

解决:把需要事务的方法拆到另一个 Service 类里,通过注入的 Bean 调用;或者使用 AopContext.currentProxy() 强制走代理。我一般会选择拆类,因为代码结构更清晰,而且答辩时好解释。

6.4 Redis 缓存与数据库不一致:改场次价格后,用户看到的还是旧价

现象:后台改了一场电影的价格,前端小程序或网页上还是显示旧价格,过一段时间才更新。

原因:场次列表接口加了 Redis 缓存,但后台修改时只更新了 MySQL,没有删缓存。

解决:所有后台写操作必须同步处理缓存,最简单的方式是修改后直接删除对应缓存 key,下次查询自动回填。同时给缓存设置过期时间,兜底防止缓存与数据库长期不一致。不要用“先更新数据库再更新缓存”的做法,并发下极易出现旧值覆盖新值。

6.5 座位图 JSON 乱码与前后端状态码不统一

现象:前端拿到的座位状态字段是 0、1、2,但不知道每个数字代表什么;后端返回的错误信息是一段英文异常堆栈。

原因:枚举状态没有统一管理,异常处理没有全局拦截。

解决:定义一个 SeatStatus 枚举类(AVAILABLE、LOCKED、SOLD),后端统一封装 ResponseResult,全局异常处理器用 @RestControllerAdvice 捕获业务异常并返回友好提示。这个在论文的“系统实现”里是可以直接写成一节的,而且代码量不大、看起来又很专业。

6.6 定时取消订单任务与用户支付同时发生

现象:用户在最后几秒完成支付,但定时任务先执行把订单取消了,导致用户付了钱但票被释放;或者订单取消后,支付回调发过来,系统把已取消的订单再次更新为已支付。

原因:取消逻辑和支付回调之间有竞态条件,两个操作没有互斥。

解决:取消订单时用条件更新,只取消状态为“待支付”的订单;支付回调也做同样的条件判断,只更新“待支付”的订单。两道防线加上之后,要么取消成功、要么支付成功,不可能出现两边都成功。这是一次血泪教训换来的经验,做这个系统时一定要先写测试用例验证这个场景。

6.7 论文里的 ER 图与代码表结构不一致

现象:论文里画了 6 张表,代码里有 8 张;论文里字段名是下划线风格,代码里实体用的是驼峰。

原因:写论文时表结构还没定稿,后来加表加字段没有同步更新文档。

解决:先建表,表结构完全冻结后再画 ER 图和写数据字典;实体类统一用 MyBatis-Plus 的驼峰映射,数据库用下划线命名,两者可以自动转换。PPT 里的功能架构图也要用最终版的包结构,不要用早期版本。

7. 最后一章:从可运行到可答辩,用一套 SQL 脚本和数据把项目盘活

系统跑通之后,真正拉开差距的是数据准备和演示脚本。我见过太多毕设项目代码没问题,但演示时打开页面是空的,老师问“有没有测试数据”时支支吾吾。所以我的习惯是:建一个 data.sql 或单独的初始化脚本,把影片、场次、影厅座位一次性铺好,确保打开系统就能看到内容。

具体做法是写一个初始化 SQL,插入 6 部左右的影片,每部影片配上 2 个场次,每个场次生成一个影厅的座位数据。座位数据可以用一段存储过程来批量生成,而不必手工写 100 行 INSERT。代码逻辑大概是这样:

-- 为指定影厅生成 8 排 12 列,共 96 个座位 INSERT INTO seat (hall_id, seat_row, seat_col, status) SELECT 1, r.n, c.n, 0 FROM (SELECT 1 n UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8) r CROSS JOIN (SELECT 1 n UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10 UNION SELECT 11 UNION SELECT 12) c;

这段 SQL 用数字表和 CROSS JOIN 生成笛卡尔积,一条语句就能把 96 个座位全量插入。演示时先手动创建一笔订单、完成支付,再把支付记录的创建时间改到昨天,这样后台的票房统计和订单列表都有数据可看,不用现场造数。

最后说个我自己的习惯:每次改完数据库字段,第一时间用 EXPLAIN 看一下核心 SQL 的执行计划,确认索引有没有被命中。电影票系统虽然数据量不大,但 order 表按 order_no 查询的场景非常多,务必给 order_no 建唯一索引,seat 表给 (schedule_id, seat_row, seat_col) 建联合唯一索引——这个索引能从根本上阻止同一场次同一座位的重复行出现,是数据一致性最底层的保障。把这些细节都照顾到之后,这个项目不光是能跑,而是能经得起盘问。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询