基于Java与Spring Boot的电影订票系统:锁座、事务与并发控制实战

发布时间:2026/10/11 9:57:19
基于Java与Spring Boot的电影订票系统:锁座、事务与并发控制实战 简介面向有一定Java基础、希望学习或实际开发电影在线订票系统的开发者与项目负责人这份设计实现文档以Spring Boot为后端框架搭配Spring Data JPA、Thymeleaf与MySQL完整覆盖普通用户、会员用户和管理员三类角色的核心业务功能。资源共1个docx文档压缩包仅34KB体量虽小但结构完整已有314人学习下载。文档从功能介绍出发依次说明系统截图、开发工具、技术栈解析再深入到数据库设计和业务流程同时提供了用户注册登录、电影信息查询等核心代码示例并给出IntelliJ IDEA下Maven编译与Spring Boot启动的完整步骤。针对会员折扣、积分系统、预约优先、管理员数据统计等特色模块文档也逐一解释了实现逻辑和注意事项可直接作为课程设计、毕业设计或企业票务平台搭建的参考其中对未来改进方向的讨论也有助于后续功能扩展。1. 为什么这套题最值得你动手复现一遍做毕设和面试准备时光看目录就知道“基于Java与Spring Boot的电影在线订票系统”这类标题最值钱的不是框架本身而是它把排片、选座、锁座、出票、退票这一整条业务链串了起来。我第一次接手类似需求时以为难点在页面和接口真正跑起来才发现选座的锁、订单的事务、座位的状态同步才是最容易翻车的地方。资料里如果带着一套能跑的完整程序和数据你省下的不是写代码的时间而是从零踩坑的时间。这套系统适合两类人一类是正在准备毕设、需要一个功能闭环项目的同学另一类是已经在写增删改查、想往真实业务靠拢的开发者。这篇会从表结构一路拆到并发验证按我自己的落地顺序讲。2. 先把需求拆清楚从“能出票”到“别超卖”的功能边界与表结构设计2.1 功能边界前台选座与后台排片是两套逻辑别混着做很多人拿到题目就先画页面结果前台选座、后台排片、订单查询全揉在一起改一个场次的时间座位图也跟着乱。正确的做法是先按角色拆边界前台面向用户只关心“今天有什么电影、哪个场次、哪些座位能选、下单后多久要支付”后台面向运营只关心“排什么片子、什么时间放、卖什么价、座位模板怎么复用”。这两套逻辑的数据流向是单向的后台先排片生成场次和座位数据前台才能查询和下单。订单产生后再回写座位状态和票房数据。我一般会把模块拆成下面这个表然后照着它建表基本不会漏模块核心功能主要数据表前台-影片浏览影院热映列表、影片详情film前台-场次选择某影片当日场次、场次余座session, session_seat前台-选座下单座位图展示、锁座、生成订单session_seat, orders前台-订单中心订单查询、支付回调、退票/改签orders后台-排片管理新建场次、排片校验、票价策略session, seat_template后台-座位维护座位模板、禁用座位、状态修正seat_template, session_seat这里有一个容易被忽视的点票价策略不要硬编码在代码里而是作为场次的一个字段存进 session 表。比如早场 35 元、晚场 45 元同一个厅同一部电影只是时间不同票价就不同。把 price 落到 session 记录上后续统计票房也方便。2.2 落六张核心表DDL、字段说明与为什么这样设计表结构是整个项目的地基。我给出的建表脚本按最小完整链路设计不搞过度设计但每一张表都有它存在的理由。-- 用户表 CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT 密文存储, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 影片表 CREATE TABLE film ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 片名, poster_url VARCHAR(255) DEFAULT NULL COMMENT 海报地址, duration INT NOT NULL COMMENT 片长分钟, release_date DATE DEFAULT NULL COMMENT 上映日期, summary TEXT COMMENT 简介, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上映 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT影片表; -- 影厅座位模板表 CREATE TABLE seat_template ( id BIGINT NOT NULL AUTO_INCREMENT, hall_name VARCHAR(50) NOT NULL COMMENT 影厅名如1号厅, seat_row INT NOT NULL COMMENT 排号, seat_col INT NOT NULL COMMENT 列号, seat_type TINYINT NOT NULL DEFAULT 1 COMMENT 1普通 2情侣座 3无障碍, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_hall_row_col (hall_name, seat_row, seat_col) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位模板表; -- 场次表 CREATE TABLE session ( id BIGINT NOT NULL AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_name VARCHAR(50) NOT NULL COMMENT 冗余影厅名, session_time DATETIME NOT NULL COMMENT 开演时间, end_time DATETIME NOT NULL COMMENT 散场时间, price DECIMAL(10,2) NOT NULL COMMENT 本场票价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1售票中 0已结束, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_film_time (film_id, session_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次表; -- 场次座位表每排一场按模板生成一份座位快照 CREATE TABLE session_seat ( id BIGINT NOT NULL AUTO_INCREMENT, session_id BIGINT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, seat_status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, order_no VARCHAR(64) DEFAULT NULL COMMENT 锁定/售出时的订单号, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_session_status (session_id, seat_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次座位表; -- 订单表 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号业务唯一, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, seat_id BIGINT NOT NULL COMMENT session_seat.id, film_title VARCHAR(100) NOT NULL COMMENT 冗余片名, hall_name VARCHAR(50) NOT NULL, session_time DATETIME NOT NULL COMMENT 冗余开演时间, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这套 DDL 有四个设计决定值得你知道。第一session_seat是“快照表”排片时根据seat_template生成一份独立的座位数据之后修改模板不会影响已排场次避免“改模板导致历史场次座位错乱”的坑。第二订单表冗余了film_title、hall_name、session_time虽然违反了三范式但订单列表页不需要 join 三张表查询性能好很多这个取舍在业务系统里是常态。第三session_seat里有version字段后面做并发控制会用到现在先埋着。第四金额用DECIMAL(10,2)绝不用double误差问题在支付场景里最容易出事故。如果你用的是 MyBatis-Plus实体类可以直接按表名映射create_time这类字段用TableField(fill FieldFill.INSERT)自动填充省去大量重复代码。排序、分页用内置的Page对象基本不需要手写分页 SQL。3. 下单模块座位锁定、事务边界与状态机怎么编排3.1 下单链路从“用户点座位”到“订单可支付”的步骤拆分一条完整下单链路按我拆解的习惯是这样校验场次可售、校验座位当前状态、尝试锁座、创建订单、返回订单号。每一步都有独立的失败原因前端要拿到明确的提示而不是笼统的“下单失败”。最核心的是“锁座”和“创建订单”这两步。锁座不能只改内存状态因为 Java 应用部署多实例后内存锁只对自己实例生效也不能只靠数据库更新因为更新和订单创建的间隙另一个请求可能读到旧状态。我常用的做法是 Redis 分布式锁 数据库状态校验双重叠加Redis 锁挡住并发请求数据库的状态字段作为最终依据。3.2 加锁与事务的正确写法锁在外层事务在内层这里是最容易写错的地方。常见错误是把 Redis 锁写在Transactional方法里面然后锁释放时事务还没提交下一个线程拿到锁后读到旧座位状态超卖就发生了。正确做法是把“锁”和“事务”拆到两个类里外层类负责获取锁、调用事务方法、最终释放锁内层类只做数据库操作由 Spring 管理事务提交。下面是我会按此复现的样板代码。// OrderLockService外层负责Redis锁 Service public class OrderLockService { private static final String LOCK_KEY_PREFIX order:seat:; Autowired private StringRedisTemplate redisTemplate; Autowired private OrderTxService orderTxService; public OrderCreateResult lockSeatAndCreateOrder(Long userId, Long sessionId, Long seatId) { String lockKey LOCK_KEY_PREFIX sessionId : seatId; String lockValue UUID.randomUUID().toString(); boolean locked Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS)); if (!locked) { return OrderCreateResult.fail(座位被锁定请刷新座位图后重试); } try { // 锁在手事务在下一层 return orderTxService.createOrderTx(userId, sessionId, seatId); } finally { // 释放锁前校验value避免误删他人锁 String currentValue redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } } } }// OrderTxService内层只做DB操作事务由Spring管理 Service public class OrderTxService { Autowired private SessionSeatMapper sessionSeatMapper; Autowired private OrdersMapper ordersMapper; Transactional(rollbackFor Exception.class) public OrderCreateResult createOrderTx(Long userId, Long sessionId, Long seatId) { // 1. 乐观锁更新座位可售 - 锁定 SessionSeat seat sessionSeatMapper.selectById(seatId); if (seat null || !seat.getSeatStatus().equals(0)) { throw new BizException(座位不存在或已被售出); } SessionSeat update new SessionSeat(); update.setId(seatId); update.setSeatStatus(1); // where条件里带 seat_status 0保证并发下只有一个请求成功 int rows sessionSeatMapper.update(update, new LambdaUpdateWrapperSessionSeat() .eq(SessionSeat::getId, seatId) .eq(SessionSeat::getSeatStatus, 0)); if (rows 0) { throw new BizException(座位已被抢先一步); } // 2. 生成订单 String orderNo generateOrderNo(); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSessionId(sessionId); order.setSeatId(seatId); // 冗余字段从session表读取后填入 order.setStatus(0); ordersMapper.insert(order); return OrderCreateResult.success(orderNo); } }代码逻辑说明外层先抢 Redis 锁抢到后才进入事务方法事务方法内先查座位状态再用带seat_status 0条件的乐观更新这两道防线保证同一时刻只会有一个请求把座位从“可售”改成“锁定”。finally块释放锁时校验 value防止锁过期后另一个线程拿到了同一把锁而当前线程误删对方的锁。参数层面有三个点你需要调第一锁过期时间我通常设 10 秒按一次下单接口的预估耗时再乘 3 倍来估如果业务里要接第三方支付回调锁只覆盖“下单”动作不含支付等待10 秒足够。第二rollbackFor Exception.class必须显式声明Spring 默认只回滚 RuntimeException如果你在方法里抛了受检异常不加这个会导致数据提交了一半。第三锁 KEY 一定带sessionId否则不同场次的同一个座位号会互相阻塞吞吐量白白下降。3.3 订单状态机待支付、已支付、已取消、已退款怎么流转订单不只是“生成”和“支付”两个状态状态机设计不好后面统计报表、退票对账都会很痛苦。我常用的状态流转如下当前状态触发动作前置条件流转后状态待支付用户支付成功支付回调校验金额一致已支付待支付超时未支付定时任务扫描早于当前时间 15 分钟已取消待支付用户主动取消无已取消已支付用户退票距开场大于 30 分钟已退款已取消无不可再支付终态超时未支付的处理数据量不大时不需要引入消息队列一个定时任务每 30 秒扫描待支付且创建时间超过 15 分钟的订单即可。这个 15 分钟是支付平台的常见惯例你可以改。扫描到超时订单后要同时做两件事更新订单状态为“已取消”释放对应座位把session_seat状态从“锁定”改回“可售”。释放座位时必须带订单号条件避免误释放后来者已经占用的座位。4. 管理端与查询接口排片、座位图、当日统计怎么落地4.1 排片接口校验场次不冲突、座位模板复用、价格代入后台排片是这个系统的入口排片排错了前台全是脏数据。排一片新场次至少要校验三件事影片存在且在上映中、同一影厅同一时间没有已排场次、所选座位模板存在。时间冲突的校验SQL 层面用时间区间重叠判断不要用“开始时间相等”这种过于理想的条件。// ScheduleService 中创建场次的核心片段 public Long createSession(SessionCreateRequest req) { // 1. 校验影片 Film film filmMapper.selectById(req.getFilmId()); if (film null || film.getStatus() ! 1) { throw new BizException(影片不存在或已下架); } // 2. 校验同厅同时间段排片冲突 long conflict sessionMapper.selectCount(new LambdaQueryWrapperSession() .eq(Session::getHallName, req.getHallName()) .lt(Session::getSessionTime, req.getEndTime()) .gt(Session::getEndTime, req.getStartTime()) .eq(Session::getStatus, 1)); if (conflict 0) { throw new BizException(该影厅在选定时间段内已有场次); } // 3. 按座位模板生成场次座位快照 ListSeatTemplate seats seatTemplateMapper.selectList( new LambdaQueryWrapperSeatTemplate().eq(SeatTemplate::getHallName, req.getHallName())); for (SeatTemplate seat : seats) { SessionSeat ss new SessionSeat(); ss.setSessionId(nextSessionId()); ss.setSeatRow(seat.getSeatRow()); ss.setSeatCol(seat.getSeatCol()); ss.setSeatStatus(seat.getStatus()); // 模板禁用则初始即不可售 sessionSeatMapper.insert(ss); } return sessionId; }逻辑上第 1 步查影片是防脏数据第 2 步时间冲突查询是关键ltgt的组合能覆盖“新场次开始时间落在已有场次中间”“新场次结束时间落在已有场次中间”“新场次完全覆盖已有场次”三种情况第 3 步把模板复制成场次快照模板里禁用座位会在生成时同步为不可售状态。时间参数要注意统一用LocalDateTime后端接口接收字符串时统一格式化为yyyy-MM-dd HH:mm:ss不要依赖前端传什么就是什么。4.2 查询接口列表页、座位图、订单页的 SQL 写法与性能点页面上的查询只做读操作但写法还是决定性能。影片列表按status1过滤并分页场次列表按film_id和session_time排序座位图查询一次把整个场次的座位拉回来在内存里组装成二维数组不要循环查库。// 座位图查询一次性拉取内存组装 public SeatMapVO getSeatMap(Long sessionId) { ListSessionSeat seats sessionSeatMapper.selectList( new LambdaQueryWrapperSessionSeat() .eq(SessionSeat::getSessionId, sessionId) .orderByAsc(SessionSeat::getSeatRow) .orderByAsc(SessionSeat::getSeatCol)); SeatMapVO vo new SeatMapVO(); for (SessionSeat seat : seats) { vo.addSeat(seat.getSeatRow(), seat.getSeatCol(), seat.getSeatStatus()); } return vo; }这个查询没有任何 joinsession_id走了索引一个场次最多一两百个座位单次查询耗时在毫秒级。前端拿到数据后按seatRow和seatCol定位seatStatus直接映射三种样式0 绿色可选、1 黄色锁定、2 灰色已售。锁定的座位要显示倒计时通常前端会做 5 分钟轮询刷新避免用户长时间停留后提交才发现座位已失效。订单列表页的查询建议按user_id status建联合索引分页返回时只查必要字段不要select *。用 MyBatis-Plus 的Page对象时排序字段用白名单校验防 SQL 注入是基本要求。4.3 状态同步座位为什么经常出现“幽灵座位”“幽灵座位”是指用户看到座位可选点击下单却提示已被锁定。这通常是前端轮询间隔过长或者后端在锁释放与状态更新之间存在时间差。本项目不需要 WebSocket 实时推送常见做法是前端 10 秒轮询一次座位图下单失败时自动刷新并提示。后端要保证“锁定”状态对所有人可见用户点选座位后并不立刻改库而是提交订单时才锁座所以座位图上的“可选”只是一个瞬时快照这种体验在真实系统里也是常态。你可以在接口响应里加一个seatMapVersion字段前端发现版本变化就强制刷新能明显减少幽灵座位的投诉。5. 高频踩坑清单与排查思路锁、事务、时间的四个翻车现场5.1 现象多台设备同时订同一个座位订单都生成成功原因Redis 锁 KEY 设计错了。常见写法是只拼seatId没拼sessionId导致不同场次的同座位号共用一把锁或者更隐蔽的锁 KEY 拼了订单号每次生成的订单号不同锁也自然不同等于没锁。解决锁 KEY 固定为order:seat:{sessionId}:{seatId}value 用 UUID。排查时打开 Redis 监控手动并发访问接口观察锁 KEY 是否存在、过期时间是否生效。这个错误在日志里往往看不出明显异常只能在压测时暴露。5.2 现象日志显示锁释放了但数据库里座位还是锁定中原因锁释放先于事务提交也就是我 3.2 节说的“锁在事务方法内部”的经典错误。Transactional代理在方法返回后才提交而finally块的释放锁动作在 return 之前发生第二个请求拿到锁后事务可能还没提交读到的是旧状态。解决按下单模块的设计把 Redis 锁放到外层类事务放到内层类两层之间用方法调用串联确保锁释放发生在事务提交之后。还有一个兜底手段释放锁前查一次订单状态确认订单已生成且提交再释放锁。5.3 现象业务执行超过锁过期时间锁提前失效导致重复下单原因锁过期时间设太短且没有续期机制更麻烦的是线程 A 执行慢锁在 10 秒后过期线程 B 拿到锁进入方法此时线程 A 执行完走到 finallyvalue 判断不一致所以没删锁逻辑没误删但 B 已经读到 A 还没更新完的数据。解决锁过期时间要按业务最慢耗时估算我一般取正常耗时的 5 倍并发量上来后再引入“看门狗”续期或 Redisson 的RLock。这里的核心原则是锁过期时间永远要大于事务执行时间否则就会出现逻辑上“两个线程都在执行业务”的窗口期。5.4 现象查“今天”的场次把昨天和明天的场次也查出来了原因排片时间用String类型存储查询用LIKE %2025-01-01%做模糊匹配边界条件完全失效或者用Date类型但没处理时区服务器与数据库时区不一致日期偏移。解决统一用LocalDateTime存时间查询用范围条件session_time ? AND session_time ?不要用字符串拼接。排查时先看表结构字段类型再看执行的 SQL 是否走了索引最后检查 JDBC 连接串里的serverTimezone配置。时间问题属于“看日志看不出来数据一多就错乱”的隐性坑。5.5 一个快速定位问题的启动排查表症状优先排查点验证手段常见修法同座位重复下单锁 KEY 设计、乐观锁条件并发脚本压测下单接口修正锁 KEYupdate 带状态条件订单生成了座位没锁上事务与锁顺序观察日志锁释放与事务提交先后拆分锁服务与事务服务座位锁定超时锁过期时间字段压测超长耗时接口延长过期时间或引入续期日期查询错乱时间字段类型打印 SQL 检查 WHERE 条件统一 LocalDateTime 范围查询6. 用并发模拟验证整个链路没有压测你永远不知道锁写没写对代码跑通不代表逻辑对。我最推荐的验证方式是写一个极简的并发模拟20 个线程同时抢同一个座位看最终生成订单数和座位状态是否符合预期。// 模拟并发抢座验证核心逻辑 Test public void testConcurrentOrder() throws InterruptedException { int threadCount 20; CountDownLatch readyLatch new CountDownLatch(threadCount); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(0); for (int i 0; i threadCount; i) { new Thread(() - { readyLatch.countDown(); try { startLatch.await(); // 同一时刻放行 OrderCreateResult result orderLockService .lockSeatAndCreateOrder(1L, sessionId, seatId); if (result.isSuccess()) { successCount.incrementAndGet(); } } catch (Exception ignored) { } finally { endLatch.countDown(); } }).start(); } readyLatch.await(); startLatch.countDown(); endLatch.await(); // 预期20个并发请求只有1个成功 System.out.println(successCount successCount.get()); // 再查库里座位最终状态应该是2已售 }压测时看三个指标成功数是否为 1、session_seat里该座位的最终状态、orders表里是否有且仅有一条对应订单。如果成功数大于 1说明锁和乐观锁至少有一样失效了。验证事务回滚可以再写一个用例让支付回调抛异常确认订单状态回到待支付、座位状态回到可售。这个系统做完后还有一个我反复用的习惯每次改动涉及锁、事务、状态流转时先写清“谁负责上锁、谁负责提交、谁负责释放”再把这段流程画在纸上。虽然看着慢但远比上线后出问题再排查节省时间。遇到锁和事务纠缠不清的时候这个习惯救过我很多次希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询