
简介这是一份基于SpringBoot的电影订票系统完整Java源码包适合计算机、电子信息等专业学生用于毕业设计、课程设计或期末大作业。系统采用B/S架构与MVC分层整合Mybatis、Ajax、Vue等主流技术涵盖前台选座购票、后台影片与订单管理等功能模块代码结构清晰便于二次开发与学习。压缩包共619个文件包括161个svg图标、147个java后端类、107个vue前端组件、41个js脚本及19个xml配置等资源包整体大小约21.09MB并附带bat启动脚本与数据库相关配置可在IDEA、Eclipse等环境中快速导入运行。目前已有274人学习下载代码经过严格测试可放心用于项目实战或答辩演示。下载后按内置说明配置Maven与MySQL5.7环境即可启动遇到问题可随时与作者沟通适合希望通过完整项目快速掌握SpringBoot全栈开发流程的学习者。1. 选座页面卡住的那两秒Java 订票系统要解决的不是 CRUD周五晚八点新片开票。你点进一个场次选好座位提交订单页面卡了两秒回来提示“该座位已被锁定”。这背后的 Java 服务在几毫秒里做了一件事把一张订单标记为待支付同时把那个座位从“可售”改成“已锁定”。看似简单真正写好的人不多——库存超卖、重复下单、超时没回滚是这类系统最常见的线上事故。这篇不贴某份完整的电影院管理系统源码而是把电影订票系统代码里最容易写错的四块拆开讲Java 实体与建表怎么设计锁座位用乐观锁还是悲观锁Redis 库存怎么做到秒级出票以及运营看板和管理端报表怎么统计。适合正在做课程设计或毕业设计的同学也适合准备 Java 面试时想验证并发与分布式实践的工程师。2. 核心数据模型与建表用 Java 实体定义场次、座位与订单状态机2.1 为什么要把“座位”拆成独立表锁粒度决定并发上限很多课设版本的电影订票系统只有两张表电影表和订单表。用户选座时把“A1、A2”这种字符串塞进订单的一个字段里一个热门场次的全部座位都放在一行。这样做在并发为个位数时看不出问题但两个用户同时选中同一个座位时程序只能在应用层做字符串包含判断等于用“读整行再写回”的方式做并发控制锁粒度是整个场次而不是被占用的那一把椅子。我一般的做法是把座位拆成独立实体。一个场次对应一张排片表schedule一张排片下挂几十到几百个座位seat每个座位是一行独立的记录有自己的状态。这样的设计带来的直接好处是行锁的粒度最小化。MySQL 在REPEATABLE READ隔离级别下对存在唯一索引的单行做更新时只锁这一行同场次其他座位仍可被并发购买。真正售票系统的并发上限不是单场总票数而是“同一瞬间有多少人抢同一把椅子”把椅子拆成行并发度瞬间被释放。public class Schedule { private Long id; private Long movieId; private Long hallId; private LocalDateTime startTime; private LocalDateTime endTime; private BigDecimal basePrice; }排片表只存电影、影厅和放映时间不存价格浮动规则。票价折扣、会员价这类逻辑属于营销域建议单独建 price_rule 表别往 schedule 里堆字段。座位上要挂schedule_id、seat_no和statusstatus 的值建议用整数枚举0 表示可售、1 表示已锁定、2 表示已售出。seat 表与 schedule 表通过schedule_id关联属于典型的从表设计。2.2 订单状态机与 Java 枚举从待支付到已退款只允许四条迁移路径订单表是整个系统里最容易被写烂的地方。常见错误是状态字段用字符串随便什么值都能塞进去时间一长就出现一堆查不到原因的脏数据。正确做法是在 Java 侧定义枚举把允许的状态迁移路径写死在代码里数据库只存整数。public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), CANCELLED(2, 已取消), REFUNDED(3, 已退款); private final int code; private final String desc; public boolean canTransferTo(OrderStatus target) { return switch (this) { case PENDING - target PAID || target CANCELLED; case PAID - target REFUNDED; case CANCELLED, REFUNDED - false; }; } }状态机里CANCELLED和REFUNDED是终态任何代码都不允许从终态再跳回待支付。这样做的价值在支付回调场景里体现得最明显用户已经取消订单支付平台的回调晚到了几秒如果状态机没有硬限制就会出现“已取消的订单被标记成已支付”这种对不上账的情况。数据库层面再加一个CHECK (status IN (0,1,2,3))约束双保险。订单表里要区分两个时间字段created_at是下单时间paid_at是支付成功时间cancelled_at是取消时间。超时未支付的订单需要定时任务补偿补偿条件就是created_at加上过期时间小于当前时间这在后面章节会展开。2.2.1 订单实体与状态迁移的代码落点订单实体里的状态转换方法不要直接暴露 setter。我更推荐把paid()、cancel()、refund()这类业务方法写在实体上方法内部先调用canTransferTo()校验校验失败直接抛业务异常。这样无论是 controller 还是消息队列的消费者来触发状态变更走的都是同一条受控路径不会出现 A 处放行、B 处绕过校验的局面。2.3 建表 DDL 与索引设计订单号唯一约束兜住重复下单数据库设计上我会在三个地方专门做约束seat 表的(schedule_id, seat_no)加唯一索引防止同一场次出现两把 A1 椅子order 表的order_no加唯一索引这个订单号由 Java 侧生成通常是“日期 随机数 用户 ID 后四位”但数据库唯一索引才是最终兜底它保证同一笔订单不可能被插入两次order 表给(user_id, schedule_id)加普通索引因为用户查“我的订单”时高频走这个条件。下面是简化版建表 SQL。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, KEY idx_start_time (start_time), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seat_no VARCHAR(10) NOT NULL, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_schedule_seat (schedule_id, seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;seat 表里的version字段是给乐观锁用的后面章会详细讲。建表时还有一个容易忽略的点seat_no不要设计成CHAR(3)因为影厅将来可能扩容到三位数排号字符串类型长度留余量。order 表我再提一句不要把座位信息冗余成字符串存进订单订单通过seat_id关联座位查详情时 JOIN 即可。冗余存储一时爽后续对账和统计全是坑。3. 锁座位与并发控制乐观锁、悲观锁和 MySQL 行锁的边界3.1 超卖是怎么发生的两个事务同时读到同一个可售座位先复现一个典型的超卖场景。两个用户同时点了同一个场次的同一个座位两个事务各自执行SELECT * FROM seat WHERE schedule_id ? AND seat_no A1都读到status 0。接着两个事务都执行UPDATE seat SET status 1 WHERE id ?第一条更新成功后第二条更新虽然在 MySQL 里会阻塞到第一条提交后才拿到行锁但因为它读到的快照已经是旧数据直接拿旧值覆盖新值——这就是丢失更新。最终结果是两个用户都以为自己锁座成功库里这个座位的最终状态却是 0彻底卖超。有人会说明明有行锁为什么还会发生因为行锁保护的是“写”不保护“读”。两条更新语句本身是串行的问题出在“先查后改”这个组合上查询用的是普通SELECT走的是 MVCC 快照读拿不到最新已提交数据。理解了这一点后面所有并发方案都是围绕“让查和改变成同一个原子操作”展开的。3.2 乐观锁实现version 字段与受影响行数判断失败就重试乐观锁的思路是更新时不锁读但在写的时候校验数据有没有被别人改过。seat 表里的version字段就是干这个的。每次更新座位状态时WHERE条件里带上version 本次读到的那一版同时把version加一。如果影响行数是 0说明读到的版本已经过期重试整个流程。Transactional public boolean lockSeatWithOptimistic(Long seatId) { Seat seat seatMapper.selectById(seatId); int affected seatMapper.updateStatusWithVersion( seatId, SeatStatus.LOCKED.getCode(), seat.getVersion()); if (affected 0) { throw new BizException(座位已被锁定请重新选座); } return true; }对应 XML 里的更新语句是UPDATE seat SET status #{newStatus}, version version 1 WHERE id #{id} AND version #{oldVersion}。注意affected 0时抛出业务异常后整个事务会被标记为 rollback-only调用方需要捕获异常并提示用户重新选座。如果要在高并发下做“自动换座”体验可以在 catch 块里重新查询可售座位列表推荐给用户旁边的位置但这个逻辑别放进事务里事务越小越好。乐观锁的适用前提是冲突率不高。电影票务场景里一个场次几百个座位分散到几十个用户同时抢同一把椅子被撞上的概率没那么高所以乐观锁是够用的。真正冲突率高的是秒杀类活动那种场景下建议直接走悲观锁。3.3 悲观锁 SELECT ... FOR UPDATE 的适用边界限单机事务跨服务失效悲观锁的做法是在事务内显式给行加锁SELECT * FROM seat WHERE id #{id} FOR UPDATE。这条语句走的是当前读直接读取最新已提交数据并且把这一行锁住直到事务提交或回滚才释放。好处是业务逻辑写起来简单不用考虑版本号重试坏处是锁的持有时间等于整个事务的持续时间如果事务里还调用了第三方支付接口这一行会被锁十几秒直接拖垮同场次其他座位的购买。所以悲观锁的代码里有个铁律锁住行之后事务里只做内存操作和数据库写绝对不要调用远程接口。支付回调、发短信、写通知这类动作全部放到事务提交后通过事件或者消息队列异步处理。另外要清楚SELECT ... FOR UPDATE只能用在本服务直连的 MySQL 上一旦系统拆成多实例部署锁就只能在单机上生效跨服务的并发控制必须交给分布式锁这是面试官最爱追问的扩展点。4. 把座位编号塞进 Redis库存聚合、SPOP 出票与一致性回滚4.1 为什么要用 Redis 做库存数据库行锁扛不住开票瞬间的流量数据库行锁的方案在座位粒度下虽然可行但热门影片开票瞬间的并发会全部落到 InnoDB 的行锁等待上。连接池被占满数据库 CPU 飙高接口平均响应时间从 50ms 变成 5 秒。常规做法是把“可售座位清单”预加载到 Redis 里用 Redis 的单线程模型和原子命令做座位分配数据库只负责落单和最终持久化。Redis 里的数据结构我用 Set。每个场次一个 key命名规则是sched:seats:{scheduleId}value 是这一场所有可售座位的编号比如A1、A2、B3。开票前由管理端的排片接口把座位初始化进 Redis用户请求进来时用SPOP从集合里随机弹出一个座位编号——注意是随机弹出而不是用户指定座位。如果业务上必须支持用户手动选座那要换SREM key member指定删除某个成员并先判断成员是否存在。这套设计的核心是 Redis 的 Set 操作是原子的多个线程同时 SPOP同一个座位只会被弹出去一次。4.2 SPOP 原子弹出座位与 SADD 回滚秒级出票的代码实现用户发起订票请求后后端逻辑分四步第一步 SPOP 弹出座位编号弹出来为 null 就返回“已售罄”第二步拿着座位编号生成订单号并插入订单记录状态为待支付第三步异步把 seat 表里对应座位的状态改成已锁定第四步把座位编号和订单关联关系返回给前端。整个流程中 SPOP 是唯一入口数据库的 seat 表不再是选座的依据而是对账用的事实源。public String popSeat(Long scheduleId) { String key sched:seats: scheduleId; String seatNo redisTemplate.opsForSet().pop(key); if (seatNo null) { return null; } // 生成订单、更新数据库等后续动作 return seatNo; }用户取消订单或超时未支付时要把座位加回 Redis。这里直接用SADD把座位编号加回去即可Set 本身保证唯一性就算补偿任务和下一次 SPOP 同时执行Redis 内部的原子性也不会造成重复加座。真正需要小心的不是 Redis 本身而是数据库侧的补偿下一节讲。4.3 MySQL 与 Redis 的最终一致用一张任务表补偿超时未支付的座位Redis 里的座位被弹走后MySQL 的 seat 表状态还没变这个不一致是允许的但必须在订单超时后把两边同时回滚。我一般会建一张ticket_task表记录每笔待支付订单的order_id、schedule_id、seat_no和expire_at。定时任务每 10 秒扫一次这张表找出已过期且状态仍是待支付的订单先执行UPDATE ticket_order SET status 2 WHERE order_no ? AND status 0影响行数为 1 才表示这次更新是自己抢到的补偿权然后再执行 Redis 的 SADD 和 seat 表的状态回滚。Scheduled(fixedDelay 10000) public void compensateExpiredOrders() { ListTicketTask tasks taskMapper.selectExpiredUnpaid(); for (TicketTask task : tasks) { int affected orderMapper.cancelIfPending(task.getOrderNo()); if (affected 1) { seatMapper.updateStatus(task.getSeatId(), 0); redisTemplate.opsForSet().add(sched:seats: task.getScheduleId(), task.getSeatNo()); taskMapper.markDone(task.getId()); } } }这里的cancelIfPending必须带WHERE status 0否则补偿任务和用户主动取消同时发生时可能把已取消的订单再标记一次。定时任务的扫描间隔不用太短10 秒一次足够频繁扫库反而给数据库增加无谓压力。这套机制属于最终一致方案Redis 和 MySQL 之间没有强事务允许存在秒级的时间窗口不一致这个窗口期用户看到的状态是“座位已被锁定订单待支付”。5. 管理端报表与运营看板SQL 按月分表Elasticsearch 做实时聚合5.1 订单按月分表movie_order_YYYYMM 与典型统计 SQL订单表是增长最快的表一个中型影院一天几千单一年百万级全部塞在一张表里统计 SQL 会越来越慢。常规做法是按月分表每个月一张ticket_order_202501、ticket_order_202502分表规则在 Java 的 DAO 层做根据当前月份拼表名。应用层需要统计时按月遍历分表再合并结果。-- 按月统计某影院某月的票房 SELECT SUM(amount) AS total_amount FROM ticket_order_202501 WHERE cinema_id 12 AND status 1 AND paid_at BETWEEN 2025-01-01 AND 2025-01-31;统计口径上注意一个坑SUM(amount)只统计状态为已支付的订单待支付和已取消的订单必须用status 1过滤。上座率的统计则要关联 schedule 和 seat 两张表公式是已售座位数 / 场次总座位数。这些 SQL 适合跑离线报表比如每天早上生成前一天的经营日报但运营想看“现在这一刻的下单量”就力不从心了实时统计要交给 Elasticsearch。5.2 Elasticsearch 实时聚合按分钟统计下单量与取消率应用接单成功后向 Elasticsearch 写入一条订单文档字段包括movie_id、cinema_id、city、total_amount、status、create_time。用户在详情页下单、取消、支付完成每个动作都异步写一条事件。Elasticsearch 聚合查询能直接按分钟粒度计算下单量和取消率时延在百毫秒级足够支撑运营看板。{ size: 0, aggs: { orders_per_minute: { date_histogram: { field: create_time, fixed_interval: 1m }, aggs: { cancelled_rate: { avg: { field: is_cancelled, missing: 0 } } } } } }聚合结果里doc_count是每分钟的下单总数cancelled_rate是取消率平均值。运营侧重点关注的是开票后前三分钟的曲线如果下单量在开场前突然暴涨而取消率同时飙升通常是系统卡顿导致用户重复点击下单需要排查接入层限流和前端按钮防抖。Elasticsearch 里的数据允许有秒级延迟它是分析系统不是交易系统的数据源订单的准实时状态仍以 MySQL 为准。管理端报表页面我建议直接用 Kibana 或者 Grafana 接 ES 数据源别自己从零写图表组件维护成本不在一个量级。6. 部署与 Java 代码组织的 3 个提示Maven 模块、图片压缩、JVM 参数6.1 Maven 多模块划分把 entity 拆出来给管理端复用电影订票系统如果只有一个大包管理端和用户端都从同一个 controller 入口进代码会随着排片、订单、报表功能增加迅速腐化。我推荐拆四个模块common放实体类、枚举、公共工具dal放 MyBatis 的 Mapper 接口和 XMLservice放业务逻辑web放 controller 和启动类。这样管理端报表要复用订单实体的字段定义时只需要依赖common和dal不会把 controller 层的代码也拖进来。6.2 排片海报与座位图的压缩处理排片列表页要显示海报图原始海报文件可能几 MB直接返给前端会拖垮首屏。使用 Thumbnailator 在第一次请求时生成缩略图并缓存到本地目录或对象存储压缩到 480px 宽、80% 质量肉眼几乎无差别体积能缩小到原来的十分之一。这个技巧同样适用于座位示意图座位图是程序生成的矩形图用 Java 的BufferedImage直接绘制并输出为 PNG没必要让前端传图片。6.3 启动脚本里的 GC 参数Java 8 与 Java 17 的取舍生产环境的 JVM 参数不要用默认值至少要把堆大小和 GC 策略定下来。Java 8 默认并行收集器在几十个座位锁竞争的场景下表现一般我会显式指定 G1Java 17 环境可以直接上 ZGC停顿时间控制在毫秒级适合订票峰值期的响应要求。java -Xms2g -Xmx2g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/dump.hprof \ -jar cinema-ticket-web.jar线上出现内存溢出时HeapDumpPath会自动生成堆转储文件把dump.hprof下载到本地用 MAT 打开先看 Dominator Tree定位哪个对象占用了大部分堆内存再沿着引用链找到业务代码里的集合类十有八九是缓存没设上限或者一次查询捞了全表数据。JVM 调优不求参数花哨把这套基础配置吃透比背一堆冷门参数有用得多。本文还有配套的精品资源点击获取