简介:这份资源是一份基于Java的电影购票系统毕业设计论文,面向计算机相关专业学生及需要开发类似系统的开发者,用于解决传统票务信息管理难度大、容错率低、处理数据费工费时等问题。文档基于MySQL数据库、Java语言和SSM框架,围绕电影管理、场次管理、评价管理、收藏管理、订单管理、用户管理、类型管理等系统功能展开,涵盖了从绪论、开发环境到系统设计实现的完整论述。资源包共1个文件,为docx格式,大小3.43MB,包含摘要、Abstract、目录、正文等规范结构,便于直接阅读或二次编辑。已有87人学习浏览。参考这份文档,既可学习SSM框架在Web应用中的具体落地方式,也能快速理解电影票务业务中排片、订单、评价等核心流程,为毕业设计选题、系统开发或论文写作提供结构化思路和方案借鉴,有效节省从零整理框架与业务逻辑的时间。
1. 基于java的电影购票系统:并发锁票才是真正的考点
电影购票系统听上去像管理后台加几个 CRUD 页面,但真正上线过的人会告诉你,核心在"锁座"。一个场次 200 个座位,热映场开场前几千人同时选座,从选完到点击下单之间有几十秒犹豫期,这期间座位不能被抢走,也不能让两个人同时下单成功。所以基于 java 的电影购票系统,本质在解决两件事:座位状态如何原子变更,订单状态机如何在待支付、已支付、已取消之间不产生脏数据。这套设计能平移到酒店预订、演出票务和火车票场景。对准备 java 面试的人来说,它把超卖、分布式锁、事务传播这些八股文考点落到数据表上;对课程设计而言,这是不难演示但很见功力的题目。下面按我实际会用的方案,从表结构讲到并发验证。
2. 基于java的电影购票系统的技术选型与数据模型
2.1 技术栈定在单体:Spring Boot + MyBatis-Plus + MySQL + Redis
常见做法是单体应用起步。Spring Boot 负责接口和生命周期,MyBatis-Plus 减少单表 CRUD 的样板代码,MySQL 8 存座位和订单,Redis 只承担两件事:选座阶段的分布式锁、下单接口的限流。不要为这个体量上微服务——需要保证的只是单进程内并发安全,加上一个能跨进程生效的锁,微服务的分布式事务在这里是负收益。
选 MyBatis-Plus 而不是 JPA,是因为这个系统里 SQL 必须被精确控制。座位更新必须写成UPDATE t_seat SET status = 1 WHERE id = ? AND status = 0,用受影响行数判断是否发生并发冲突;JPA 在这类条件更新上要么写@Modifying,要么绕很大弯子。MyBatis 的 mapper XML 可以直接承载这句带业务语义的原生 SQL。
Java 版本按团队环境选 JDK 8 + Spring Boot 2.7 或 JDK 17 + Spring Boot 3.x 都可以,差异主要是javax包名换成jakarta。Redis 客户端用StringRedisTemplate而不是RedisTemplate,后者默认 JDK 序列化会把整数和字符串变成二进制乱码,java 基础不扎实的人常在这里浪费一个下午。
2.2 六张核心表:座位表设计决定并发上限
用户表、电影表、场次表、座位表、订单表、支付记录表,六张表足够支撑完整流程。电影表和场次表是普通的主从关系,支付记录表只做流水。真正要花心思的是座位表和订单表。
t_seat 是并发控制的主战场,字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| schedule_id | bigint | 场次 ID |
| row_no | varchar(4) | 排号,如 A、B |
| col_no | varchar(4) | 列号,如 01、02 |
| status | tinyint | 0 可售,1 锁定,2 已售 |
| version | int | 乐观锁版本号 |
| update_time | datetime | 最后更新时间 |
t_order 是状态流转的核心,关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_no | varchar(40) | 业务订单号,唯一索引 |
| user_id | bigint | 用户 ID |
| schedule_id | bigint | 场次 ID |
| seat_ids | varchar(100) | 座位 ID 列表,逗号分隔 |
| total_amount | decimal(10,2) | 总金额 |
| status | tinyint | 0 待支付,1 已支付,2 已取消,3 已退款 |
| lock_expire_time | datetime | 座位锁定的过期时间 |
| created_time | datetime | 创建时间 |
初始化场次时,每个场次要按影厅的座位矩阵批量生成座位行。批量插入用一条 SQL 拼多组 VALUES,不要循环单条 insert。如果确实要并发初始化多个场次,等所有写线程都完成再开放购票入口,这是 java 里CountDownLatch的典型用法。
2.3 座位表唯一索引是最后一道闸
t_seat 上要建唯一索引(schedule_id, row_no, col_no),建表语句如下:
CREATE TABLE t_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no VARCHAR(4) NOT NULL, col_no VARCHAR(4) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售', version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;唯一索引保证同一个场次的同一排同一列只有一行数据。status = 0的条件更新在 InnoDB 行锁下是串行的:两个事务同时执行条件更新,第二个事务必须等第一个提交或回滚后才能拿锁,而等待结束后 status 已经不是 0,受影响行数为 0。这一层是数据库层面的最终兜底,即使 Redis 里的锁全失效,也不会超卖。
2.4 lock_expire_time 放在订单表而不是座位表
座位表的 status 字段同时承载"用户正在选座"和"用户已下单待支付"两种语义,如果锁过期时间放在座位表,超时释放任务得先扫座位表,再回查订单表确认是否有未支付单子,两表耦合很难维护。把lock_expire_time单独放在订单表,职责就清晰了:订单创建时写入now + 15 分钟,调度任务只扫订单表,命中待支付且超时的记录,再回写座位状态。座位表里只留 status 和 version,状态变更来源单一。
3. 基于java的电影购票系统的锁座与下单核心流程
3.1 选座阶段:用 Redis Lua 脚本做批量原子锁座
用户点选座位时,前端逐个调选座接口。最直接的做法是"先查 Redis 再写入",但查和写在两个步骤之间一定会被并发插队。正确做法是把检查加写入放进一个 Lua 脚本,Redis 单线程执行脚本,天然原子。
-- KEYS: seat:{scheduleId}:{seatId1} seat:{scheduleId}:{seatId2} ... -- ARGV[1]: userId -- ARGV[2]: 锁有效期毫秒 for i = 1, #KEYS do local st = redis.call('HGET', KEYS[i], 'status') if st and st ~= '0' then return i end end for i = 1, #KEYS do redis.call('HSET', KEYS[i], 'status', '1', 'userId', ARGV[1]) redis.call('PEXPIRE', KEYS[i], ARGV[2]) end return 0脚本先循环检查所有目标座位,任何一个已被锁定或售出就立刻返回冲突座位的下标;全部可用才批量写入锁定状态并设置过期时间。返回 0 表示整批锁定成功,返回非 0 表示第几个座位冲突,前端可以精确提示用户哪个位置刚被选走。PEXPIRE保证锁一定会过期,用户选完不点下单也不会把座位永久占住。
调用侧代码:
private static final Long OK = 0L; public boolean lockSeats(Long scheduleId, List<Long> seatIds, Long userId) { List<String> keys = seatIds.stream() .map(id -> "seat:" + scheduleId + ":" + id) .collect(Collectors.toList()); Long result = stringRedisTemplate.execute( new DefaultRedisScript<>(SEAT_LOCK_SCRIPT, Long.class), keys, userId.toString(), String.valueOf(300_000)); return OK.equals(result); }参数说明:300_000是锁有效期毫秒数,对应 5 分钟,覆盖用户从选座到提交订单的犹豫期。前端可以在页面停留时每隔 30 秒调一次续期接口,重新对这批 key 执行PEXPIRE,避免用户思考太久锁被自动释放。注意 hash 里存了userId,续期和释放前都要校验归属,防止 TTL 过期后别的用户锁到同一批座位,原用户回来反而把别人的锁释放掉。
3.2 提交订单:数据库条件更新才是权威裁决
Redis 锁只负责在选座阶段挡流量,真正决定座位归属的是数据库这层条件更新。提交订单接口按以下顺序执行:
- 对座位表执行
status = 0到status = 1的条件更新,受影响行数必须等于请求的座位数。 - 扣减场次余票,用
UPDATE t_schedule SET remain_seats = remain_seats - #{n} WHERE id = ? AND remain_seats >= #{n},受影响行数为 0 说明余票不足。 - 插入订单记录,状态为待支付,写入
lock_expire_time。 - 事务提交后再删除 Redis 里的锁 key。
Redis 锁和数据库锁是分层关系:Redis 挡住大部分无效请求,数据库在提交订单瞬间做最终裁决。Redis 锁过期不会导致超卖,最多是用户选完座提交订单时发现座位已售出,体验受损但数据不失真。
3.3 超时释放:三种方案对比
支付超时的订单需要把座位释放回可售状态。三种常见方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 定时任务扫表 | @Scheduled 每 30 秒扫超时订单 | 实现简单,可控 | 释放有延迟 |
| Redis 延迟队列 | zset 存到期时间,轮询取到期单 | 秒级释放 | 多一层组件,数据仍需落库兜底 |
| 懒释放 | 下单时发现座位锁定且订单超时,主动释放 | 无额外任务,实时 | 依赖新的请求触发 |
我一般选定时任务为主、懒释放为辅。扫描任务每 30 秒跑一次,批量取 100 笔超时待支付订单,逐笔做两件事:订单状态改为已取消,对应座位状态改回可售。批量限制 100 是为了防止一次扫太多拖慢主库。取消订单的条件更新也要带status = 0,防止和用户手动取消并发时重复释放座位。
3.4 死锁与锁等待的边界
多个用户同时提交不同组合的座位时,条件更新 SQL 对 IN 列表里的 id 按索引扫描顺序加行锁,不保证按传入顺序。两个事务以相反顺序申请行锁就可能死锁,InnoDB 会自动回滚其中一个事务。应对方法是两层:应用层把 seatIds 排序后传入,减少交叉概率;数据库层把innodb_lock_wait_timeout从默认 50 秒调成 5 秒,让冲突快速失败返回业务错误,而不是让用户盯着页面转圈。
4. 基于java的电影购票系统的关键代码与参数落地
4.1 Mapper XML:条件更新与余票扣减
座位锁定的核心 SQL 写在 mapper XML 里:
<update id="lockSeats"> UPDATE t_seat SET status = 1, version = version + 1, update_time = NOW() WHERE schedule_id = #{scheduleId} AND status = 0 AND id IN <foreach collection="seatIds" item="sid" open="(" separator="," close=")"> #{sid} </foreach> </update>这条语句在 InnoDB 行锁下对目标座位逐行加锁并更新,返回受影响行数。调用方拿到返回值后和seatIds.size()比较,不相等就说明有座位被别人抢先,抛业务异常让用户重新选座。version = version + 1配合 MyBatis-Plus 的@Version乐观锁插件,可以在支付阶段再次校验座位状态没有被中间流程篡改。
余票扣减单独一条语句:
<update id="deductRemainSeats"> UPDATE t_schedule SET remain_seats = remain_seats - #{n} WHERE id = #{scheduleId} AND remain_seats >= #{n} </update>remain_seats >= #{n}是防超卖的第二道保险,受影响行数为 0 说明余票不足,整个事务回滚,前面锁定的座位状态一并还原。
4.2 Service 层:事务边界与锁释放时机
@Transactional public Order createOrder(CreateOrderDTO dto, Long userId) { List<Long> seatIds = dto.getSeatIds().stream() .sorted().collect(Collectors.toList()); int locked = seatMapper.lockSeats(dto.getScheduleId(), seatIds); if (locked != seatIds.size()) { throw new BizException(SEAT_CONFLICT, "部分座位已售出,请重新选择"); } int deducted = scheduleMapper.deductRemainSeats(dto.getScheduleId(), seatIds.size()); if (deducted == 0) { throw new BizException(SOLD_OUT, "余票不足"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(dto.getScheduleId()); order.setSeatIds(String.join(",", seatIds.stream().map(String::valueOf).toList())); order.setTotalAmount(calcAmount(dto.getScheduleId(), seatIds.size())); order.setStatus(0); order.setLockExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { seatLockService.unlockSeats(dto.getScheduleId(), seatIds); } }); return order; }逻辑说明:seatIds先排序再传给 SQL,减少死锁概率;座位锁定失败或余票不足直接抛异常,@Transactional默认对RuntimeException回滚,前面已更新的座位行和数据全部还原。Redis 锁的删除放在afterCommit回调里,事务提交成功才释放,事务回滚则让 Redis 锁等到 TTL 自然过期,避免早期释放后其他用户锁到同一批座位却下单失败。
@Transactional在这里依赖 Spring 的 AOP,本质是 java 动态代理。要注意调用方必须从外部注入这个 Service 再调createOrder,同类内部this.createOrder()直接走原生对象,事务注解不生效,这是动态代理最常见的失效场景。
4.3 接口层:参数校验、幂等与限流
提交订单请求里必须校验座位数上限(比如单笔最多 6 个)、座位 ID 是否属于当前场次。用 Bean Validation 的@Valid加自定义注解即可。同时要求前端在请求头携带Idempotency-Key,防止用户双击或网络重试导致重复下单:
@PostMapping("/order") public Result<OrderVO> createOrder( @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody CreateOrderDTO dto) { if (idemService.isProcessed(idempotencyKey)) { return Result.duplicate(); } Long userId = JwtUtil.getUserId(request); // Redis 限流:每个用户每秒最多 2 次提交 if (rateLimiter.tryAcquire("order:" + userId, 2, 1)) { return Result.busy(); } Order order = orderService.createOrder(dto, userId); return Result.ok(order); }参数说明:Idempotency-Key在前端生成,同一笔订单的所有重试请求都用同一个 key;isProcessed在 Redis 里判断并原子写入标记,保证同一 key 只处理一次。限流器用 Redis 的INCR加过期时间实现,2 次每秒是按真实用户操作节奏估算的值,脚本刷票会被直接挡掉。
4.4 核心参数配置表
| 参数 | 建议值 | 说明 |
|---|---|---|
| Redis 锁有效期 | 300000 ms | 覆盖 5 分钟选座窗口,前端心跳续期 |
| 订单支付超时 | 15 min | 对应 lock_expire_time 写入 |
| 调度扫描间隔 | 30 s | @Scheduled(fixedDelay = 30000) |
| innodb_lock_wait_timeout | 5 s | 锁冲突快速失败 |
| 单笔最大座位数 | 6 | 防批量化刷票 |
| 下单限流 | 2 次/秒/用户 | Redis INCR 实现 |
模拟支付渠道可以按 java 策略模式拆成多个支付实现,PayStrategy接口下挂MockPayStrategy,根据支付方式枚举路由,后面接真实渠道时只加实现类不动业务代码。
5. 基于java的电影购票系统的并发验证与兜底技巧
5.1 最小并发验证脚本
选座和下单接口写完,先用命令行脚本验证不超卖。拿两个连续座位开 100 个并发请求抢单:
seq 1 100 | xargs -P 50 -I {} curl -s -X POST \ -H "Idempotency-Key: key-{}" \ -H "Token: test-token" \ -d '{"scheduleId":1,"seatIds":[10,11]}' \ http://localhost:8080/api/order-P 50表示 50 并发,100 个请求目标都是场次 1 的 10、11 号座位。预期结果:恰好 1 个请求返回成功订单号,其余返回"部分座位已售出"或"余票不足"。执行后查库验证两件事,t_order表中schedule_id = 1且seat_ids含 10 或 11 的待支付订单只有 1 笔,t_seat中这两个座位 status 为 1 而不是 0。任何数量偏差都说明锁逻辑有漏洞。
5.2 排错先看这三个日志位置
并发问题定位顺序固定:先看应用日志里的业务异常计数,再查 Redis,最后看 MySQL。grep "部分座位已售出" app.log | wc -l确认冲突被正常拦截;Redis 侧用redis-cli slowlog get 10看 Lua 脚本是否有慢执行;MySQL 死锁用SHOW ENGINE INNODB STATUS\G查看LATEST DETECTED DEADLOCK部分,里面会列出两个事务各自持有的锁和等待的锁,据此调整座位加锁顺序。
5.3 兜底技巧:支付回调的二次校验
支付确认环节再加一道查询,防止极端情况下出现一单多付:
SELECT COUNT(*) FROM t_order WHERE schedule_id = #{scheduleId} AND FIND_IN_SET(#{seatId}, seat_ids) AND status IN (0, 1);支付渠道回调时对订单里的每个座位执行这条 SQL,结果大于 1 说明同一座位关联了多笔有效订单,直接拒绝支付并报警。正常流程下因为条件更新的存在,这条 SQL 永远返回 1,但它作为最终防线能在评审和辩解答疑时说明"系统有数据级兜底"。压测时把日志级别调到 WARN,避免 INFO 日志里的堆栈打印吃掉 CPU,干扰并发数据准确性。
本文还有配套的精品资源,点击获取