☰
基于SpringBoot电影票预订系统:选座锁座与订单超时实战
2026/10/4 1:32:09 网站建设 项目流程

简介:这是一套面向高校计算机专业学生与Java初学者、用于课程作业或毕业设计的电影票预订系统完整项目资料,基于SpringBoot框架开发,配套论文、开题报告与答辩PPT,可帮助读者快速搭建一个功能闭环的购票平台并完成选题交付。项目采用IDEA2022、JDK1.8、Tomcat8.5环境,前端使用Vue、HTML、JS与CSS,后端为SpringBoot,数据库为MySQL5.7配合Navicat管理。前台涵盖用户注册登录、电影信息列表与详情、在线选座购票、在线支付、按喜好类别的电影推荐、电影评论与论坛等模块;后台则提供管理员对用户、影片、推荐、评论、论坛、订单、购票与支付信息的统一管理,注册用户亦可维护个人资料、订票、支付与评价记录。资源包共1229个文件,以js、html、css、png、gif等前端资源为主,另含java源码、class文件、xml配置、sql脚本及pdf、docx文档,压缩包约18.36MB,目录结构清晰。目前已有59人学习,适合需要完整赛题方案、模块化代码参考与论文写作素材的读者。

1. 电影票预订系统:从选座锁座到订单超时,一套 SpringBoot 方案能扛住什么

影院售票和普通电商最大的区别在于「座位是唯一库存」。同一场次同一个座位,两个人同时点下去,谁都不希望出现「付款成功却出票失败」。电影票预订系统要解决的核心就是这件事:场次排期、座位图渲染、选座锁座、下单支付、超时释放、出票核销,一条链路走完还不能超卖。用 Java + SpringBoot 做这套系统,是计算机专业毕业设计里最典型也最容易翻车的题目之一——看着简单,真做起来锁座并发、订单超时、座位状态同步全是坑。这篇笔记面向正在做「基于 SpringBoot 电影票预订系统设计与实现」的同学,也面向想拿它当练手项目的 Java 开发者,把技术选型、库表设计、锁座实现、论文与开题怎么搭讲清楚,让你能照着复现一套跑得通、讲得清、答辩扛得住追问的系统。

2. 技术选型与工程骨架:为什么是 SpringBoot + MyBatis-Plus + Redis

2.1 分层结构与依赖清单

一套能写进论文、也能真跑起来的电影票系统,后端主流做法是 SpringBoot 做 Web 层,MyBatis-Plus 做持久层,MySQL 存业务数据,Redis 扛座位锁和缓存。前端常见两种:Thymeleaf 服务端渲染,或者 Vue 前后端分离。毕业设计里如果时间紧,Thymeleaf 更快出效果;如果想让简历好看,Vue + 前后端分离更合适。下面是我一般会用的 Maven 依赖骨架,版本按你本地 JDK 选,JDK 8 配 SpringBoot 2.7.x,JDK 17 配 3.x,别硬凑。

<!-- pom.xml 关键依赖,版本按本地 JDK 对齐 --> <dependencies> <!-- Web 层:提供 REST 接口和内置 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:MyBatis-Plus 简化单表 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- Redis:座位锁 + 场次缓存 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验,下单接口必用 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

依赖说明:spring-boot-starter-web负责接口和 JSON 序列化;MyBatis-Plus 的BaseMapper能省掉大量单表 SQL,但座位这种带并发语义的操作建议手写 SQL;Redis 的spring-boot-starter-data-redis默认用 Lettuce 客户端,够用。参数校验别省,下单接口的场次 ID、座位 ID 列表、用户 ID 都要@NotNull,否则脏数据进库后排查起来就是黑匣子。

2.2 配置文件里必须改的几个参数

application.yml里几个参数直接决定系统能不能跑通,尤其是连接池和 Redis 序列化。默认的 JDK 序列化在 Redis 里存对象会乱码,必须换成 JSON 序列化,否则你redis-cli看到的全是二进制,调试时想哭。

spring: datasource: url: jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password hikari: maximum-pool-size: 20 # 并发下单时连接池别太小 connection-timeout: 3000 redis: host: localhost port: 6379 database: 0 timeout: 3000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

参数说明:maximum-pool-size设 20 是经验值,锁座接口会短暂持有连接,太小会在压测时排队超时;serverTimezone必须显式指定,否则订单时间可能差 8 小时,出票时间对不上;Redis 的timeout别设太长,锁座失败要快速返回而不是让用户干等。RedisTemplate 的序列化配置建议单独写一个@Configuration,key 用 StringRedisSerializer,value 用 GenericJackson2JsonRedisSerializer,这样存座位锁对象时人能看懂。

2.3 库表设计:座位状态到底存哪

这是整个系统最容易设计错的地方。常见做法有两种:一是把每个座位的状态直接存seat表,二是只存订单,座位状态由订单推导。我推荐前者,因为座位图渲染需要快速查状态,推导太慢。核心表如下。

表名关键字段说明
filmid, name, duration, poster影片信息
cinema_hallid, name, row_count, col_count影厅,行列数决定座位图
scheduleid, film_id, hall_id, start_time, price场次,座位库存的归属单位
seatid, schedule_id, row_no, col_no, status座位,status: 0可售 1锁定 2已售
orderid, user_id, schedule_id, seat_ids, amount, status, expire_time订单,status: 0待支付 1已支付 2已取消
order_seatorder_id, seat_id订单与座位关联,便于退票释放

设计要点:seat表按场次冗余,一个场次生成一批座位记录,schedule_id + row_no + col_no建唯一索引,防止重复生成。order表的expire_time是超时释放的关键,下单时写入「当前时间 + 15 分钟」,定时任务扫这个字段。order_seat中间表别省,退票时按订单反查座位释放,比在seat_ids字符串里解析靠谱得多。

3. 选座锁座与订单超时:并发场景下怎么不超卖

3.1 锁座的两种实现与选型理由

锁座本质是「把可售座位改成锁定,且只能成功一次」。数据库悲观锁SELECT ... FOR UPDATE能保证,但高并发下锁行会拖慢整个场次;Redis 分布式锁 + Lua 脚本原子操作更快,但引入了一致性问题。毕业设计里我一般推荐「Redis 预锁 + 数据库最终扣减」的组合:先用 Redis 的SETNX或 Lua 脚本抢占座位,抢到后再异步或同步落库。这样既能在答辩时讲清楚并发控制,又不会因为纯数据库锁把系统压垮。

// 锁座核心:用 Lua 脚本保证「检查 + 锁定」原子性 // KEYS[1] = 场次座位锁前缀, ARGV[1] = 座位ID, ARGV[2] = 用户ID, ARGV[3] = 过期秒数 private static final String LOCK_LUA = "if redis.call('exists', KEYS[1]) == 0 then " + " redis.call('setex', KEYS[1], ARGV[3], ARGV[2]) " + " return 1 " + "else return 0 end"; public boolean lockSeat(Long scheduleId, Long seatId, Long userId) { String key = "seat:lock:" + scheduleId + ":" + seatId; Long result = redisTemplate.execute( new DefaultRedisScript<>(LOCK_LUA, Long.class), Collections.singletonList(key), userId.toString(), "900" // 15 分钟 = 900 秒 ); return result != null && result == 1L; }

逻辑说明:Lua 脚本在 Redis 单线程里执行,exists和setex之间不会被打断,这是原子性的来源。ARGV[3]设 900 秒和订单超时时间对齐,锁过期后座位自动回到可售,避免用户下单不付款把座位永久占死。参数上,key 的命名要带scheduleId,否则不同场次的同一座位号会互相干扰——这是血泪经验,我第一次写就漏了场次维度,测试时两个场次抢同一个座位号直接串了。

3.2 下单接口的完整流程

下单不是简单插一条记录,要按顺序做四件事:校验场次和座位、逐个锁座、写订单和关联、返回支付信息。任何一步失败都要回滚已锁的座位,否则座位会被「幽灵锁定」。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, Long scheduleId, List<Long> seatIds) { // 1. 校验场次存在且未开场 Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule == null || schedule.getStartTime().before(new Date())) { throw new BizException("场次不存在或已开场"); } // 2. 逐个锁座,失败则释放已锁的 List<Long> locked = new ArrayList<>(); for (Long seatId : seatIds) { if (!lockSeat(scheduleId, seatId, userId)) { locked.forEach(id -> unlockSeat(scheduleId, id, userId)); throw new BizException("座位 " + seatId + " 已被占用"); } locked.add(seatId); } // 3. 写订单,expire_time = 当前 + 15 分钟 Order order = new Order(); order.setUserId(userId); order.setScheduleId(scheduleId); order.setAmount(schedule.getPrice().multiply(new BigDecimal(seatIds.size()))); order.setStatus(0); order.setExpireTime(new Date(System.currentTimeMillis() + 15 * 60 * 1000)); orderMapper.insert(order); // 4. 写订单座位关联,并更新 seat 表状态为锁定 for (Long seatId : seatIds) { orderSeatMapper.insert(new OrderSeat(order.getId(), seatId)); seatMapper.updateStatus(scheduleId, seatId, 1); } return new OrderVO(order.getId(), order.getAmount(), order.getExpireTime()); }

参数说明:@Transactional保证订单和关联表同生共死,但注意 Redis 锁不在事务里,所以失败时要手动unlockSeat。expire_time用 15 分钟是行业常见值,太短用户来不及付款,太长座位周转率低。seatMapper.updateStatus建议用带status = 0条件的更新,返回影响行数为 0 说明座位已被别人改过,要抛异常回滚。

3.3 订单超时释放的定时任务

超时释放是电影票系统的「后悔药」机制。用户下单不付款,15 分钟后座位必须自动回到可售,否则场次很快就被占满。实现方式有两种:Spring 的@Scheduled定时扫表,或者用延迟队列。毕业设计里定时任务足够,简单可控。

// 每分钟扫一次过期未支付订单,释放座位 @Scheduled(cron = "0 * * * * ?") public void releaseExpiredOrders() { List<Order> expired = orderMapper.selectList( new LambdaQueryWrapper<Order>() .eq(Order::getStatus, 0) .lt(Order::getExpireTime, new Date()) ); for (Order order : expired) { // 释放 Redis 锁 List<OrderSeat> seats = orderSeatMapper.selectByOrderId(order.getId()); for (OrderSeat os : seats) { unlockSeat(order.getScheduleId(), os.getSeatId(), order.getUserId()); seatMapper.updateStatus(order.getScheduleId(), os.getSeatId(), 0); } // 订单置为已取消 order.setStatus(2); orderMapper.updateById(order); } }

逻辑说明:cron表达式0 * * * * ?表示每分钟第 0 秒执行。查询条件用status = 0且expire_time < now,只处理待支付且已过期的。释放时先删 Redis 锁再改数据库状态,顺序反了会出现「数据库可售但 Redis 还锁着」的窗口。注意这个方法要加分布式锁或单机部署,多实例部署时同一订单可能被释放两次,虽然幂等但浪费资源。

4. 避坑与排查:锁座、超时、座位图渲染的 5 个真实翻车点

4.1 座位被锁死无法释放

现象:用户下单后没付款,15 分钟后座位还是灰色不可选。原因通常是定时任务没生效,或者 Redis 锁的过期时间设成了 -1(永不过期)。排查时先看@Scheduled所在类有没有加@EnableScheduling,这是最常见的低级错误;再看 Redis 里ttl seat:lock:*返回值,如果是 -1 说明setex写成了set。解决:启动类加@EnableScheduling,锁统一用setex或SET key value EX 900 NX。

4.2 同一座位被两个订单同时锁定

现象:压测时两个请求都返回下单成功,order_seat里出现重复座位。原因是锁座和写库之间有间隙,或者 Lua 脚本的 key 没带场次维度。解决:key 必须seat:lock:{scheduleId}:{seatId},写库时seat表的更新加status = 0条件并检查影响行数,为 0 就抛异常回滚。数据库层面再给order_seat加(schedule_id, seat_id)唯一索引兜底。

4.3 座位图渲染慢或错位

现象:场次座位图加载要好几秒,或者行列对不上。原因是每次渲染都全表查seat,或者前端把row_no、col_no当成了数组下标。解决:座位图按场次缓存到 Redis,key 用seat:map:{scheduleId},缓存 5 分钟;前端渲染时用row_no和col_no显式定位,别依赖查询顺序。影厅行列数存在cinema_hall表,生成座位时就按它来,别在代码里写死。

4.4 订单金额和座位数对不上

现象:选了 3 个座位,订单金额只算了 1 个。原因是seatIds传参时前端去重了,或者后端用Set接收导致重复座位被合并。解决:接口参数用List,进方法后先校验size和去重后的size是否一致,不一致直接拒绝。金额计算用BigDecimal,别用double,票价乘数量时浮点误差会让对账出问题。

4.5 支付回调后座位状态没更新

现象:用户付款成功,但座位还是锁定状态,出票失败。原因是支付回调接口没做幂等,或者回调里只改了订单状态没改座位。解决:回调接口用订单号做幂等键,先查订单状态,已支付就直接返回;更新座位时把status从 1 改成 2,并同步删掉 Redis 锁。回调日志一定要打全,出问题时这是唯一的黑匣子。

5. 论文、开题与 PPT:把能跑的系统讲成能过的答辩

5.1 论文框架怎么搭才不空

毕业设计的论文最怕写成产品说明书。我一般建议按「绪论 → 相关技术 → 需求分析 → 系统设计 → 系统实现 → 测试 → 总结」走,但重点放在设计和实现两章。绪论里把电影票预订的现状和痛点写清楚,比如超卖、锁座、超时释放;相关技术别堆砌,SpringBoot、MyBatis-Plus、Redis 各写一段「为什么选它」就够了。系统设计章放架构图、ER 图、核心表结构;实现章放锁座 Lua 脚本、下单流程、定时任务的关键代码和说明。测试章要有并发测试数据,比如 100 并发抢同一座位只有 1 个成功,这是答辩时最有说服力的证据。

5.2 开题报告的任务书怎么写

开题任务书的核心是「研究内容」和「技术路线」两块。研究内容按功能模块列:影片管理、场次排期、选座锁座、订单支付、超时释放、后台管理。技术路线写清楚前后端分离还是服务端渲染、数据库选型、缓存和锁的方案。进度安排按周排,别写太满,留两周给联调和论文修改。常见坑是任务书写得太泛,比如「实现一个电影票系统」,答辩老师一问「你的创新点在哪」就答不上来。把「Redis 原子锁座防超卖」和「定时任务超时释放」写成技术难点,比空谈「用户体验」实在得多。

5.3 PPT 只讲三件事

答辩 PPT 别超过 15 页,讲三件事就够:系统做了什么(功能演示截图)、难点怎么解的(锁座和超时释放的流程图)、测试结果(并发数据)。演示环节提前录屏,现场网络和数据库不一定靠谱。老师最爱问的三个问题提前准备:座位怎么防超卖、订单超时怎么释放、Redis 和数据库一致性怎么保证。答案就在第 3 章里,背熟锁座 Lua 脚本和定时任务的逻辑,基本稳过。

5.4 一个能加分的进阶技巧

如果时间还够,给锁座加一个「排队降级」:Redis 抢锁失败时,不直接返回失败,而是把请求丢进一个本地队列短暂重试 200 毫秒,很多「秒杀」场景下用户其实能接受这点延迟。实现上用Thread.sleep加循环重试三次即可,别引入 MQ 把系统搞复杂。这个点写进论文的「优化与展望」里,答辩时能体现你思考过真实高并发场景,比单纯堆功能强。我自己做这类系统最大的教训就是:先把锁座和超时这两条链路跑通再堆功能,否则功能越多,座位状态越乱,最后连自己都说不清某个座位到底该是什么状态。希望帮到你。

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

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

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

立即咨询