Java订票系统核心设计:并发锁、Redis库存与订单状态机
2026/9/14 6:22:51 网站建设 项目流程

简介:这是一份基于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_idseat_nostatus,status 的值建议用整数枚举,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; }; } }

状态机里CANCELLEDREFUNDED是终态,任何代码都不允许从终态再跳回待支付。这样做的价值在支付回调场景里体现得最明显:用户已经取消订单,支付平台的回调晚到了几秒,如果状态机没有硬限制,就会出现“已取消的订单被标记成已支付”这种对不上账的情况。数据库层面再加一个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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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_idschedule_idseat_noexpire_at。定时任务每 10 秒扫一次这张表,找出已过期且状态仍是待支付的订单,先执行UPDATE ticket_order SET status = 2 WHERE order_no = ? AND status = 0,影响行数为 1 才表示这次更新是自己抢到的补偿权,然后再执行 Redis 的 SADD 和 seat 表的状态回滚。

@Scheduled(fixedDelay = 10000) public void compensateExpiredOrders() { List<TicketTask> 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_202501ticket_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_idcinema_idcitytotal_amountstatuscreate_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 接口和 XML;service放业务逻辑;web放 controller 和启动类。这样管理端报表要复用订单实体的字段定义时,只需要依赖commondal,不会把 controller 层的代码也拖进来。

6.2 排片海报与座位图的压缩处理

排片列表页要显示海报图,原始海报文件可能几 MB,直接返给前端会拖垮首屏。使用 Thumbnailator 在第一次请求时生成缩略图并缓存到本地目录或对象存储,压缩到 480px 宽、80% 质量,肉眼几乎无差别,体积能缩小到原来的十分之一。这个技巧同样适用于座位示意图,座位图是程序生成的矩形图,用 Java 的BufferedImage直接绘制并输出为 PNG,没必要让前端传图片。

6.3 启动脚本里的 GC 参数:Java 8 与 Java 17 的取舍

生产环境的 JVM 参数不要用默认值,至少要把堆大小和 GC 策略定下来。Java 8 默认并行收集器在几十个座位锁竞争的场景下表现一般,我会显式指定 G1;Java 17 环境可以直接上 ZGC,停顿时间控制在毫秒级,适合订票峰值期的响应要求。

java -Xms2g -Xmx2g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=100 \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/dump.hprof \ -jar cinema-ticket-web.jar

线上出现内存溢出时,HeapDumpPath会自动生成堆转储文件,把dump.hprof下载到本地用 MAT 打开,先看 Dominator Tree,定位哪个对象占用了大部分堆内存,再沿着引用链找到业务代码里的集合类,十有八九是缓存没设上限或者一次查询捞了全表数据。JVM 调优不求参数花哨,把这套基础配置吃透,比背一堆冷门参数有用得多。

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

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

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

立即咨询