ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java电影院购票系统:从并发锁座到支付回调的完整实现

Java电影院购票系统:从并发锁座到支付回调的完整实现 简介基于SpringBoot实现的电影院购票系统完整源码面向计算机、电子信息工程等专业的学生适合作为毕业设计、课程设计或期末大作业。系统采用B/S架构与MVC模式整合Java、Maven、Mybatis、Ajax、Vue等技术栈涵盖用户购票、场次管理、订单处理等典型业务模块。压缩包共773个文件约26.14MB包含109个Java源码、58个Vue前端组件、156个JavaScript脚本、49个CSS样式以及数据库脚本、配置文件、说明文档和演示视频等方便直接导入IDEA运行调试。代码经严格测试并配有构建启动脚本如1-install.bat、2-run.bat、3-build.bat可快速搭建环境。目前已有614人学习下载。对于需要掌握SpringBoot全栈开发流程、完成课程项目或快速复用一套可用系统的学习者这份资源提供了清晰的代码结构和完整的前后端交互示例能有效减少从零搭建的时间成本。1. 电影院购票系统代码的核心不是 CRUD是最后一把椅子的归属电影院购票系统在 Java 项目里常被当作练手 CRUD但真正决定这套代码能不能上线、能不能扛住周末晚场抢座的从来不是排片表和用户表怎么增删改查而是“同一场次的最后一个座位两个人同时点下单最后卖给谁”。卖票的本质是并发抢一个座位资源所以这篇就围绕 Java 电影院购票系统代码的主线展开先拆数据模型再实现锁座、下单、支付回调最后给出一份能直接复现的并发验证方法。适合 Java 基础已过关、想搞懂并发下单边界的人也适合准备把影院项目写进项目经历、等着被面试官追问的人。2. 先建表再写代码把“座位”当作有状态的资源来建模2.1 为什么不能只建一张订单表拆开才能让数据库帮忙兜底很多人写第一版电影院购票系统都是先建一张订单表字段里塞一个seat_ids字符串下单时把座位号拼接进去。单机调试怎么跑都通一上并发就露馅MySQL 行锁的粒度是行不是字段你把三张座位号塞进一个字符串数据库根本没法对单个座位做并发约束。两个人同时读同一行、查到同样三个座位号、各自拼接成订单后写的人直接覆盖前一个一票两卖就这么出来的。用面向对象编程的思路重新建模按领域把数据拆成四张表排片表showtime、场次座位表showtime_seat、订单表ticket_order、订单座位表order_seat。每张表只回答一个明确的问题这场的电影几点放、这个场次的某个座位现在什么状态、谁买了什么、订单里的座位明细是什么。这个分层不是过度设计它直接决定了后面锁座能不能用一条带条件的 UPDATE 写完也决定了事务回滚时哪些数据能被恢复。2.2 建表 SQLshowtime_seat 的 status 字段是整个系统的核心我一般用 Spring Boot MyBatis MySQLJDK 8 起步数据库引擎必须 InnoDB。四张表的建表语句如下-- 排片表一场电影在一个影厅里的一个放映时间 CREATE TABLE showtime ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL COMMENT 电影ID, hall_id BIGINT NOT NULL COMMENT 影厅ID, start_time DATETIME NOT NULL COMMENT 开场时间, end_time DATETIME NOT NULL COMMENT 散场时间, price DECIMAL(8,2) NOT NULL COMMENT 基础票价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-售票中 0-已停售, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_start_time (start_time), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排片表; -- 场次座位表某个场次里每个座位的实时状态 CREATE TABLE showtime_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, showtime_id BIGINT NOT NULL COMMENT 排片ID, seat_row VARCHAR(4) NOT NULL COMMENT 排号如A排, seat_no INT NOT NULL COMMENT 座号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-可售 1-锁定 2-已售, price DECIMAL(8,2) NOT NULL COMMENT 本场次该座位售价, locked_by VARCHAR(32) DEFAULT NULL COMMENT 锁定该座位的订单号, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁预留, UNIQUE KEY uk_showtime_seat (showtime_id, seat_row, seat_no), KEY idx_showtime_status (showtime_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次座位表; -- 订单表 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号, user_id BIGINT NOT NULL, showtime_id BIGINT NOT NULL, total_amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已退款, expire_at DATETIME NOT NULL COMMENT 支付截止时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_showtime_id (showtime_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 订单座位表订单和座位的关联唯一索引是兜底保险 CREATE TABLE order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, showtime_id BIGINT NOT NULL, seat_row VARCHAR(4) NOT NULL, seat_no INT NOT NULL, UNIQUE KEY uk_order_seat (showtime_id, seat_row, seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单座位表;showtime_seat.status是整个系统里最关键的字段0 可售、1 锁定、2 已售。锁定和已售必须分开因为锁定是临时的一个用户锁了座只有 15 分钟支付时间超时后这台座位要“后悔药”——被释放回可售已售则代表这笔钱已经进了账永远不能因为超时被放回去。如果把两个状态合并成一个“不可用”定时释放时会把别人已经付完款的座位也清了那才是大事故。order_seat上的联合唯一索引是最后一道保险。锁座逻辑理论上已经阻止了并发但万一哪天代码改坏、状态字段被脏写唯一索引会直接拒绝第二条插入保证一个场次的一个座位只能出现在一个订单里。这个设计是“事故兜底”不是业务主键要保留。2.3 Maven 工程结构与实体类怎么摆分层的边界就是事务的边界项目结构按 Spring Boot 惯例分四层事务逻辑只允许出现在 service 层src/main/java/com/cinema ├── controller/OrderController.java ├── service/OrderService.java ├── mapper/ShowtimeSeatMapper.java ├── mapper/TicketOrderMapper.java ├── mapper/OrderSeatMapper.java ├── entity/ShowtimeSeat.java ├── enums/SeatStatus.java └── util/OrderNoGenerator.javacontroller 只做参数校验和返回值包装mapper 只做 SQL 映射真正调用事务注解、判断锁座结果、决定回滚的全部在OrderService里。这个边界定死以后事务才不会因为“在某处多 new 了一个 service”而悄悄裂成两个事务。实体类里最重要的不是字段齐全而是状态字段不要用魔法数字。定义一个SeatStatus枚举public enum SeatStatus { AVAILABLE(0, 可售), LOCKED(1, 锁定), SOLD(2, 已售); private final int code; private final String desc; SeatStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } }ShowtimeSeat实体就对应那张核心表字段名和表字段一一对应。这里有个参数要说明price要从排片表冗余到每张场次座位表上因为影院常见“早场特惠、晚场原价”同一个座位在不同场次价格不同锁座那一刻必须把价格定死在座位的行记录上订单金额直接从锁到的座位算否则排片表改了价已锁订单金额也跟着变对账永远对不平。3. 用 Java 跑通售票核心链路锁座、下单、支付回调3.1 锁座的核心带状态条件的 UPDATE很多人第一次写锁座都是先查再改// 错误示例并发下两个线程都查到 status0然后都往下走 ShowtimeSeat seat showtimeSeatMapper.selectById(seatId); if (seat.getStatus() 0) { showtimeSeatMapper.updateStatus(seatId, 1); }这段 Java 代码在并发下必翻车两个线程同时 SELECT 到 status 都是 0然后依次执行 UPDATE都以为自己锁座成功最后一个座位就卖给了两个人。SELECT 和 UPDATE 之间不是原子的这就是所谓“先查后改”的竞态。正确做法是把状态判断直接写进 UPDATE 的 WHERE 条件里让数据库的行锁来裁决Update(UPDATE showtime_seat SET status 1, locked_by #{orderNo}, version version 1 WHERE showtime_id #{showtimeId} AND seat_row #{seatRow} AND seat_no #{seatNo} AND status 0) int lockSeat(Param(showtimeId) Long showtimeId, Param(seatRow) String seatRow, Param(seatNo) Integer seatNo, Param(orderNo) String orderNo);这条 UPDATE 在 InnoDB 里是“当前读”执行时会对命中的那一行加排他锁然后判断 status 是否为 0。两个事务同时执行时第一个事务拿到行锁并修改 status 为 1第二个事务等到锁释放后再判断 WHERE 条件发现 status 已经不是 0影响行数为 0。MyBatis 的lockSeat返回值是 int也就是 affected rows用这个返回值判断就能知道自己的锁到底成没成。3.2 事务里失败的整单回滚与 Redis 锁的补偿差异锁座很少只锁一个座通常是一下子选三四张连座。只要有一个座位没锁到整个订单就得作废之前锁到的那些座位也必须还回去。这件事交给Transactional做最省心Service public class OrderService { private static final long ORDER_TIMEOUT_MINUTES 15; Resource private ShowtimeSeatMapper showtimeSeatMapper; Resource private TicketOrderMapper ticketOrderMapper; Resource private OrderSeatMapper orderSeatMapper; Transactional(rollbackFor Exception.class) public String reserve(Long userId, Long showtimeId, ListString seatKeys) { // 座位格式如 A:1,A:2先排序固定加锁顺序避免死锁 seatKeys.sort(Comparator.comparing(k - k.split(:)[0]) .thenComparing(k - Integer.parseInt(k.split(:)[1]))); String orderNo OrderNoGenerator.generate(); for (String seatKey : seatKeys) { String[] parts seatKey.split(:); int affected showtimeSeatMapper.lockSeat( showtimeId, parts[0], Integer.parseInt(parts[1]), orderNo); if (affected 0) { throw new BizException(座位已被锁定或售出: seatKey); } } // 所有座位都锁到了再写订单 BigDecimal total calculateTotal(showtimeId, seatKeys); LocalDateTime expireAt LocalDateTime.now().plusMinutes(ORDER_TIMEOUT_MINUTES); ticketOrderMapper.insert(orderNo, userId, showtimeId, total, expireAt); for (String seatKey : seatKeys) { String[] parts seatKey.split(:); orderSeatMapper.insert(orderNo, showtimeId, parts[0], Integer.parseInt(parts[1])); } return orderNo; } }几个参数和边界要说明rollbackFor Exception.class必须写。Spring 默认只对 RuntimeException 回滚如果你在锁座后抛了一个受检异常事务不会回滚前面锁掉的座位会永久卡在“已锁定”那比业务报错更恶心。锁座的循环顺序要先排序。四个座位的锁定有先后如果两个用户恰好选了两组交叉的座位各自按不同顺序去锁行就可能互相等对方释放行锁形成死锁。按座号排序后所有人锁行顺序一致死锁概率降到最低。这段逻辑里不需要手动“补偿释放”之前锁到的座位。因为 MySQL 行锁产生的“状态改为 1”和订单写入在同一个事务里任何一步抛异常导致回滚前面所有 UPDATE 都会被回滚座位自动回到 0。但如果你在锁座前拿了 Redis 分布式锁Redis 锁不在数据库事务管辖范围内必须用 try-finally 手动释放这是两条完全不同的释放路径混在一起最容易漏。3.3 支付回调的幂等写法重复通知不能重复出票支付回调是整个链路里最容易变成黑匣子的地方支付渠道的超时重试、消息队列的重复投递、人工补单每一个环节都可能把同一个支付结果送过来两次。回调处理的第一件事不是改状态而是查状态Transactional(rollbackFor Exception.class) public void handlePayCallback(String orderNo) { TicketOrder order ticketOrderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在: orderNo); } // 幂等返回已经支付过的订单重复回调不重复处理 if (order.getStatus() 1) { return; } // 订单已取消或已退款不允许支付回调把状态改回去 if (order.getStatus() 2 || order.getStatus() 3) { throw new BizException(订单已关闭无法支付); } // 超过支付截止时间钱收了也不能出票要进入退款流程 if (order.getExpireAt().isBefore(LocalDateTime.now())) { throw new BizException(订单已超时请联系退款); } ticketOrderMapper.updateStatus(order.getOrderNo(), 1); showtimeSeatMapper.markSoldByOrderNo(order.getOrderNo()); }markSoldByOrderNo的 SQL 同样带条件UPDATE showtime_seat SET status 2 WHERE locked_by #{orderNo} AND status 1这里有个容易被忽略的细节支付成功后座位状态从 1 锁定直接改成 2 已售条件里必须带着status 1。如果去掉这个条件一旦订单状态机出错、已取消的订单进了支付回调就会把已经释放回可售的座位直接标成已售场上凭空少一个座位比锁座失败更难排查。4. 并发下单的两种锁方案MySQL 行锁和 Redis 分布式锁怎么选4.1 MySQL 行锁能扛住多大的购票并发一个普通影厅少则 80 座多则 200 座同一场次的座位天然就是有限的。单机 MySQL 的 InnoDB 行锁完全可以扛住这种量级的抢座压力——2012 年春运的秒杀系统还有不少是数据库行锁扛下来的更别说一个影院的晚场。前提是锁座的 UPDATE 必须命中索引InnoDB 才能精准锁那几行如果showtime_id, seat_row, seat_no上没有索引这条 UPDATE 会变成全表扫描锁的是整张表一个用户锁座时整个影院所有场次都动弹不得。连接池参数我一般这样设参数建议值理由初始连接数5平时低负载不占资源最大连接数20单机并发选座 20 个并发事务足够再多是给数据库加压力连接等待超时3 秒拿不到连接快速失败比排队等死好单场 200 个座位意味着极端情况下同时有 200 个用户对应 200 个事务在锁行但这个数字是理论峰值。实际影院的晚场黄金位就那几十个热点行的竞争远没有那么大。所以第一版方案不需要上 Redis把数据库这条锁链路做扎实90% 的场景已经够用。4.2 Redis 分布式锁跨场次、跨库时才需要站在 MySQL 前面什么情况下 MySQL 行锁不够用最常见的两种一是系统按场次分库分表了锁座事务可能落在不同的库上数据库行锁管不住跨库资源二是同时开了多个应用实例请求被负载均衡到不同 JVM每个 JVM 各自连数据库虽然数据库行锁仍然有效但大量无效的锁竞争请求会打到 MySQL 上。这时常见做法是把 Redis 分布式锁放在 MySQL 行锁前面先做一轮快速筛选锁不到的请求直接返回“座位已被抢”不再去数据库排队public boolean tryLockSeat(Long showtimeId, String seatKey, String requestId) { String lockKey String.format(cinema:lock:%d:%s, showtimeId, seatKey); // 加锁3 秒自动过期防止进程挂了锁变死锁 Boolean ok redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); } public void unlockSeat(Long showtimeId, String seatKey, String requestId) { String lockKey String.format(cinema:lock:%d:%s, showtimeId, seatKey); // 用 Lua 保证“比对删除”原子执行防止删掉别人刚拿到的锁 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(lua, Long.class), Collections.singletonList(lockKey), requestId); }requestId用 UUID 生成是这次加锁的唯一凭证。释放锁时必须带着它去 Lua 里比对只删自己加的那把锁。很多人在这一步简化成“先 GET 再 DEL”两行代码之间如果锁刚好过期、另一个线程恰好拿到了同一把锁就会把别人的锁误删然后各自都以为自己在临界区里分布式锁形同虚设。4.3 锁过期时间、重试次数与降级开关怎么设Redis 锁的过期时间是最难拍的参数。太短业务还没执行完锁就到期后面的请求冲进来太长用户锁住座位不支付座位被别人占着。我一般按“预估业务耗时 × 3 余量”来定锁座加订单写入的数据库操作在几十毫秒内完成3 秒足够但如果你的业务里还要调远程接口核销会员优惠券网络抖动一下就到几百毫秒3 秒仍然够。真遇到极端慢查询宁可让锁超时后被人抢走也不能让锁长时间占着因为用户可以重新下单资源绝不能死锁。还有一个容易忽略的降级开关Redis 不可达时不能直接让整个下单接口报 500。我会在配置里加一个开关Redis 锁失败时放行到 MySQL 行锁那条路让数据库来兜底。多一次无效查询顶多是性能差一点但至少不会因为缓存集群抖动导致一场票完全卖不了。这个降级开关在线上要默认打开并且要监控 Redis 锁失败的次数如果频繁触发降级说明锁过期时间或业务耗时已经失衡了。5. 五个把购票系统搞挂的坑现象、原因、解决5.1 一票两卖不是玄学先查后改在并发下必翻车现象压测报告里显示同一个座位被两个订单同时支付成功客诉电话打爆。原因代码写成了“SELECT 查座位状态if (status 0) 再 UPDATE 改成锁定”这中间隔着一次网络往返和一个 JVM 线程调度两个线程完全可能都读到 0然后都执行 UPDATE。SELECT 不锁行它只给你一个过期的快照。解决把状态判断写进 UPDATE 的 WHERE 条件用 affected rows 判断胜负。这条规则同样适用于所有“先查后写”的资源占用类逻辑库存扣减、优惠券领取、房间预订全是同一个套路。5.2 锁座成功但订单没有落库事务边界画错现象数据库里 showtime_seat 的 status 变成了 1但 ticket_order 表里找不到对应的订单座位被白白锁住。原因锁座和订单写入不在同一个事务里。常见的是把锁座抽成了一个独立方法并用Transactional标注然后在另一个事务方法里调用它Spring 的默认传播行为是 REQUIRED正常情况下会合并到同一个事务。真正翻车的是在 service 里自己 try-catch 吞掉了异常或者事务方法内部抛的是受检异常而rollbackFor没配导致 Spring 认为“执行完了”而提交了前面的锁座 UPDATE。解决Transactional(rollbackFor Exception.class)并且不要在事务方法内部吞异常。如果业务确实需要捕获某些已知异常捕获后要么抛出另一个 RuntimeException要么标记TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。5.3 订单取消后座位一直锁着缺少自动释放现象用户选了座进支付页最后没付钱15 分钟后这个座位仍然显示“已锁定”后面的观众买不了。原因没有“订单超时释放座位”这个定时任务。锁座只是临时的必须有配套的回收机制。解决我一般用定时任务每 30 秒扫一次SQL 用 JOIN 精确定位超时订单锁的座位并且只回收“待支付且已过期”的订单绝不碰已支付订单UPDATE showtime_seat st JOIN ticket_order t ON st.locked_by t.order_no SET st.status 0, st.locked_by NULL WHERE t.status 0 AND t.expire_at NOW() AND st.status 1这里的 JOIN 条件st.locked_by t.order_no是钥匙它保证了只释放“那个超时订单自己锁的座位”不会因为状态判断失误把别的订单的座位也释放了。SQL 里的三个条件缺一不可尤其是最后的st.status 1防止扫到一个已经是已售状态的座位又把它置回 0。5.4 Redis 锁被误删释放时没比对 requestId现象某次线上告警显示同一座位有两个用户同时进入下单流程但数据库最终没有卖重。看起来“没出事”但分布式锁已经失效了。原因线程 A 拿到锁后业务执行超过 3 秒锁自动过期线程 B 拿到同一把锁A 执行完 finally 释放锁时直接用无条件 DEL把 B 的锁删了线程 C 也拿到锁进来了。A、B、C 同时在临界区里。解决释放锁必须用 Lua 脚本比对requestId只有值对得上才删除。这不算高性能优化这是分布式锁正确性的底线。代码在 4.2 节拿来直接用不要改成 GET DEL 两行。5.5 支付回调重复执行幂等表没建唯一索引现象渠道方超时重发支付结果同一笔订单被回调四次订单表状态被来回改写最后出票记录和支付流水对不上账。原因回调处理函数没有先查订单当前状态直接在收到通知时执行“状态改成已支付、座位标记已售”重复通知就把状态又“覆盖”了一遍。更隐蔽的坑是并发场景下两次回调几乎同时到达都通过了自己的状态判断。解决两层保险。业务上先查订单状态已经是已支付就直接返回成功数据库层依赖order_seat的唯一索引兜底重复插入会抛 DuplicateKeyException事务回滚后幂等返回。不要把幂等判断只写在代码里代码判断在极端并发下同样有竞态窗口只有唯一索引是绝对的。6. 把并发验证写成一个 Java 小工具100 个线程抢一个座位系统的并发逻辑对不对不能靠“我点开两个浏览器试了一下”来验证。我习惯在写完锁座功能后立刻写一个并发验证小工具用 CountDownLatch 模拟 100 个用户同时抢同一个场次的同一个座位看最终成功下单数是不是 1public class ReserveConcurrencyTest { public static void main(String[] args) throws Exception { int threadCount 100; Long showtimeId 1L; String seatKey A:1; CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(); String url http://localhost:8080/api/v1/order/reserve?showtimeId showtimeId seatKeys seatKey; for (int i 0; i threadCount; i) { final long userId 10000 i; new Thread(() - { ready.countDown(); try { start.await(); // 用 userId 区分请求用 HttpClient 发真实 HTTP 请求 int code HttpClientUtil.post(url, userId userId); if (code 200) { successCount.incrementAndGet(); } } catch (Exception ignored) { // 失败不计数 } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await(10, TimeUnit.SECONDS); System.out.println(成功下单数: successCount.get()); System.out.println(successCount.get() 1 ? 锁座逻辑正确 : 锁座逻辑已被并发击穿); } }运行前先把 Spring Boot 服务启动好然后用测试用户的 token 或者直接调无鉴权的测试接口。注意每个线程用不同的 userId否则会被 controller 的幂等参数拦截测的就不是座位锁而是接口去重了。这个工具的价值在于它能让你在 CI 里一键跑出“同座位并发下单成功率”而不是靠肉眼在数据库里翻状态字段。我自己曾经就吃过亏开发环境一切正常压测一上来数据库连接池被打满原因是锁座 SQL 没走索引锁了全表。从那以后每个服务上线前的并发验证都会写上这类脚本而不是等线上客诉来敲门。希望帮到你。本文还有配套的精品资源点击获取
返回列表