
简介这是一套基于SpringBoot开发的电影院购票系统完整源码面向计算机、电子信息工程等专业学生适用于毕业设计、课程设计及期末大作业等实践场景。系统采用B/S架构与MVC模式技术栈涵盖JavaJDK1.8、SpringBoot、MyBatis、Vue、Ajax及MySQL 5.7配套IDEA、Maven 3.6、Tomcat 8.0/9.0等主流开发环境开箱即用。压缩包共773个文件含109个Java后端逻辑文件、58个Vue前端组件、156个JS交互脚本、49个CSS样式文件、35个HTML页面及32个图片资源另有bat启动脚本、yml配置文件和SQL数据库脚本等关键支撑文件整体大小26.14MB。已有614人学习下载所有代码均经实测可运行包含清晰的模块划分如用户管理、影厅排座、订单支付、后台管理等并提供build/run/install三类批处理脚本显著降低部署门槛与调试成本。1. 电影院购票系统代码 Java不是写个“买票界面”就叫系统而是让座位锁、库存扣、支付回滚全在线上稳住不翻车你在网上搜“电影院购票系统代码 Java”大概率会撞见一堆只有 Swing 界面、硬编码影厅数据、点两次按钮就 ArrayIndexOutOfBoundsException 的“课程设计”。但真实场景里一个能跑在小影院后台的 Java 购票系统核心根本不是“怎么画按钮”而是三件事必须闭环并发抢座时不能重复卖同一座位、订单超时未支付要自动释放、退票后库存和已售状态得原子更新。这不是 Java 基础语法练习是典型的多线程事务状态机落地题——它卡在很多 Java 初学者从“能写 Hello World”到“敢接真实业务模块”的临界点上。本文不讲 MVC 分层理论只拆解我用 Spring Boot MyBatis Plus 在本地影院部署时从零搭起这个系统的真实路径怎么用乐观锁防超卖、为什么 seat_id 要建唯一索引、支付回调怎么避免重复处理、以及最关键的——当 200 人同时刷《奥本海默》最后一排 3 号座时系统到底靠哪几行代码守住底线。适合正在准备 Java 面试题、或手头真有小型影院信息化需求的开发者。2. 用 Spring Boot MyBatis Plus 搭出可运行骨架从建表到 Controller 层最小闭环2.1 数据库建模座位表必须带 version 字段且 seat_id show_time 组合唯一真实购票场景中“第5排第3座”在不同场次是不同实体。如果只用 seat_id 主键同一座位在不同场次会被当成同一个记录导致库存逻辑错乱。我们采用seat_id show_time联合唯一约束并为并发控制预留version字段CREATE TABLE cinema_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seat_id VARCHAR(20) NOT NULL COMMENT 座位编号如A01, show_time DATETIME NOT NULL COMMENT 放映时间, hall_id INT NOT NULL COMMENT 影厅ID, status TINYINT DEFAULT 0 COMMENT 0-空闲,1-已锁定,2-已售出, version INT DEFAULT 0 COMMENT 乐观锁版本号, order_id BIGINT DEFAULT NULL COMMENT 关联订单ID仅status2时非空, UNIQUE KEY uk_seat_showtime (seat_id, show_time), INDEX idx_hall_time (hall_id, show_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;提示UNIQUE KEY uk_seat_showtime是防超卖的物理防线。即使应用层锁失效数据库唯一约束也会拦截重复插入——这是最后的兜底不是可选配置。2.2 MyBatis Plus 实体类与 Mapper用 Version 注解激活乐观锁MyBatis Plus 的Version会自动在 UPDATE 语句中加入WHERE version #{version}条件失败则抛OptimisticLockException。实体类必须严格对应Data TableName(cinema_seat) public class CinemaSeat { TableId(type IdType.ASSIGN_ID) private Long id; private String seatId; JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime showTime; private Integer hallId; private Integer status; // 0:空闲, 1:锁定, 2:已售 Version // 关键触发乐观锁机制 private Integer version; private Long orderId; }Mapper 接口只需继承BaseMapperCinemaSeat无需额外 SQLMapper public interface CinemaSeatMapper extends BaseMapperCinemaSeat { // MyBatis Plus 自动提供 updateById()内置 version 校验 }2.3 锁座逻辑先查后更必须用 selectForUpdate不这里用乐观锁更轻量很多人第一反应是“用 SELECT ... FOR UPDATE 加行锁”。但在高并发下FOR UPDATE会阻塞后续请求而购票是短时操作500ms用乐观锁更合适——查、改、校验一气呵成失败重试成本低Service public class SeatService { Autowired private CinemaSeatMapper seatMapper; /** * 尝试锁定指定座位用于下单前预占 * param seatId 座位编号如A01 * param showTime 放映时间 * return true锁定成功false已被占用或锁定中 */ Transactional(rollbackFor Exception.class) public boolean tryLockSeat(String seatId, LocalDateTime showTime) { // 1. 查询当前座位状态 LambdaQueryWrapperCinemaSeat query new LambdaQueryWrapper(); query.eq(CinemaSeat::getSeatId, seatId) .eq(CinemaSeat::getShowTime, showTime); CinemaSeat seat seatMapper.selectOne(query); if (seat null) { throw new RuntimeException(座位不存在 seatId showTime); } // 2. 仅当座位为空闲时才尝试更新为已锁定 if (seat.getStatus() ! 0) { return false; // 已被占用或锁定 } // 3. 构造更新对象设置新状态和 version1 CinemaSeat update new CinemaSeat(); update.setId(seat.getId()); update.setStatus(1); // 锁定中 update.setVersion(seat.getVersion() 1); // 版本号1 // 4. MyBatis Plus 自动添加 WHERE version #{version} 条件 int rows seatMapper.updateById(update); return rows 1; // 更新成功说明没被其他人抢先 } }逻辑说明tryLockSeat()是无状态操作不依赖 session 或全局变量updateById()内部生成的 SQL 类似UPDATE cinema_seat SET status1, version2 WHERE id123 AND version1若并发请求同时查到 version1只有一个能成功更新另一个rows0直接返回 false关键参数version字段必须为Integer不能是 int否则空值时 MyBatis Plus 不会注入该字段到 WHERE 条件中。3. 订单创建与支付回调用状态机驱动流程拒绝“if-else 堆砌”3.1 订单状态定义5 个状态足够覆盖影院全生命周期影院订单不像电商有“发货”环节核心状态链是待支付 → 已支付 → 已出票 → 已退票 → 已过期。用枚举定义避免魔法值public enum OrderStatus { WAIT_PAY(0, 待支付), PAID(1, 已支付), TICKET_ISSUED(2, 已出票), REFUNDED(3, 已退票), EXPIRED(4, 已过期); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } // getter 省略 }3.2 创建订单先锁座再建单两阶段提交保一致性用户点击“确认购票”时必须保证座位锁定成功 → 订单创建成功 → 支付链接生成任一环节失败需回滚前序操作。Spring 的Transactional天然支持Service public class OrderService { Autowired private SeatService seatService; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Order createOrder(String seatId, LocalDateTime showTime, String userId) { // 1. 尝试锁定座位内部已含事务 if (!seatService.tryLockSeat(seatId, showTime)) { throw new RuntimeException(座位已被占用 seatId); } // 2. 创建订单status0 待支付 Order order new Order(); order.setSeatId(seatId); order.setShowTime(showTime); order.setUserId(userId); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setCreateTime(LocalDateTime.now()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); // 15分钟支付超时 orderMapper.insert(order); // 3. 返回订单号供前端跳转支付 return order; } }注意seatService.tryLockSeat()和orderMapper.insert()在同一事务中若 insert 失败锁座的 update 也会回滚——这是 ACID 的直接体现不是靠代码手动补偿。3.3 支付回调处理幂等性是生命线用订单号支付流水号双校验微信/支付宝回调可能重复推送网络抖动、超时重试必须确保同一笔支付只处理一次RestController RequestMapping(/api/pay) public class PayCallbackController { Autowired private OrderService orderService; PostMapping(/notify) public String handlePayNotify(RequestBody MapString, String notifyData) { String outTradeNo notifyData.get(out_trade_no); // 商户订单号 String tradeNo notifyData.get(trade_no); // 支付平台流水号 String result notifyData.get(result); // 支付结果 // 1. 校验签名省略实际需验签 if (!verifySign(notifyData)) { return fail; } // 2. 双重幂等查订单是否存在且状态是否已是已支付 Order order orderService.getByOrderNo(outTradeNo); if (order null || order.getStatus() ! OrderStatus.WAIT_PAY.getCode()) { return success; // 已处理过直接返回 success } // 3. 更新订单状态为已支付并标记支付流水号 boolean updated orderService.updateOrderStatus( outTradeNo, OrderStatus.PAID.getCode(), tradeNo ); if (!updated) { return fail; // 更新失败极小概率可能是并发回调 } // 4. 异步触发出票发短信、打印二维码等 CompletableFuture.runAsync(() - { orderService.issueTicket(outTradeNo); }); return success; } }关键设计点回调接口必须返回success字符串微信/支付宝约定不能是 JSONupdateOrderStatus()内部使用LambdaUpdateWrapper加set()和eq()确保只更新status0的订单出票动作放异步线程避免阻塞回调响应支付平台要求 5s 内返回。4. 并发抢座避坑指南那些让系统在大促日集体翻车的细节4.1 现象同一座位被卖出两次原因未对seat_id show_time建唯一索引或乐观锁字段version类型写成int非 Integer。当 seat 记录 version 为 null 时MyBatis Plus 默认不将 version 加入 WHERE 条件导致 UPDATE 无条件执行。解决检查建表 SQL 中version字段是否允许 NULL实体类中version必须声明为Integer包装类并在 INSERT 时设默认值 0。4.2 现象支付成功后订单状态仍是“待支付”原因支付回调中未校验订单当前状态直接UPDATE SET status1。若用户先退票status3回调又把状态改成 1逻辑错乱。解决回调内必须用UPDATE ... WHERE order_no ? AND status 0MyBatis Plus 的LambdaUpdateWrapper可这样写LambdaUpdateWrapperOrder wrapper new LambdaUpdateWrapper(); wrapper.eq(Order::getOrderNo, outTradeNo) .eq(Order::getStatus, OrderStatus.WAIT_PAY.getCode()) .set(Order::getStatus, OrderStatus.PAID.getCode()) .set(Order::getPayTradeNo, tradeNo); orderMapper.update(null, wrapper);4.3 现象大量超时订单未自动释放座位原因只靠定时任务扫描expire_time now()更新状态但未在更新时同步将cinema_seat.status改回 0。解决定时任务不能只更新订单表必须联动更新座位表// 定时任务每5分钟扫描过期订单 Scheduled(fixedRate 300_000) public void releaseExpiredOrders() { ListOrder expired orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, OrderStatus.WAIT_PAY.getCode()) .lt(Order::getExpireTime, LocalDateTime.now()) ); for (Order order : expired) { // 1. 更新订单状态为已过期 order.setStatus(OrderStatus.EXPIRED.getCode()); orderMapper.updateById(order); // 2. 释放对应座位注意必须用 seat_id show_time 查 LambdaUpdateWrapperCinemaSeat seatWrapper new LambdaUpdateWrapper(); seatWrapper.eq(CinemaSeat::getSeatId, order.getSeatId()) .eq(CinemaSeat::getShowTime, order.getShowTime()) .set(CinemaSeat::getStatus, 0) // 空闲 .set(CinemaSeat::getOrderId, null); seatMapper.update(null, seatWrapper); } }4.4 现象MySQL CPU 100%慢查询日志显示cinema_seat表全表扫描原因SELECT ... WHERE seat_id ? AND show_time ?没走索引因show_time是 DATETIME 类型但查询条件用了String格式如2024-06-15 19:00:00导致隐式类型转换。解决Java 代码中必须用LocalDateTime传参禁止字符串拼接 SQL检查 MySQL 的sql_mode是否包含STRICT_TRANS_TABLES避免隐式转换。4.5 现象用户退票后再次购票提示“座位已被占用”原因退票逻辑只更新了cinema_seat.status 0但未清空order_id字段。下次查 seat 时order_id非空业务误判为“已售”。解决退票时必须原子更新两个字段LambdaUpdateWrapperCinemaSeat wrapper new LambdaUpdateWrapper(); wrapper.eq(CinemaSeat::getId, seatId) .set(CinemaSeat::getStatus, 0) .set(CinemaSeat::getOrderId, null); seatMapper.update(null, wrapper);5. 用 Redis 缓存加速高频查询影厅排片页不卡顿的实操方案5.1 缓存什么只缓存“读多写少”的静态数据影厅排片页展示某影厅当天所有场次及余票数是典型高频读场景。但cinema_seat表每条记录代表一个座位一场电影 200 座位 × 10 场 2000 行全表查太重。我们缓存的是聚合后的余票数而非原始座位数据// 缓存 key 设计hall_{hallId}_date_{date} // valueJSON 字符串如 {2024-06-15 19:00:00: 182, 2024-06-15 21:00:00: 195} public class HallScheduleCache { private static final String CACHE_PREFIX hall_schedule:; Autowired private StringRedisTemplate redisTemplate; public MapString, Integer getScheduleByHallAndDate(Integer hallId, LocalDate date) { String key CACHE_PREFIX hall_ hallId _date_ date; String json redisTemplate.opsForValue().get(key); if (json ! null) { return new ObjectMapper().readValue(json, new TypeReferenceMapString, Integer() {}); } return computeAndCache(hallId, date, key); } private MapString, Integer computeAndCache(Integer hallId, LocalDate date, String key) { // 1. 查当日所有场次时间 ListLocalDateTime showTimes showTimeMapper.selectShowTimesByHallAndDate(hallId, date); // 2. 对每个场次统计 status0 的座位数空闲 MapString, Integer result new HashMap(); for (LocalDateTime time : showTimes) { Integer available seatMapper.selectCount( new LambdaQueryWrapperCinemaSeat() .eq(CinemaSeat::getHallId, hallId) .eq(CinemaSeat::getShowTime, time) .eq(CinemaSeat::getStatus, 0) ); result.put(time.format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)), available); } // 3. 写入 Redis过期时间设为 5 分钟平衡实时性与压力 redisTemplate.opsForValue().set(key, new ObjectMapper().writeValueAsString(result), Duration.ofMinutes(5)); return result; } }为什么只缓存余票数不缓存座位详情座位详情哪个位置空/已售需强一致性缓存易脏余票数允许短暂延迟5分钟内少显示几个座位不影响体验JSON 字符串体积小2KBRedis 存储效率高。5.2 缓存穿透防护空结果也缓存但 TTL 缩短至 2 分钟当用户恶意请求不存在的影厅 ID如 hallId9999数据库每次都会查空压垮 DB。解决方案空结果也写入 Redis但 TTL 设短private MapString, Integer computeAndCache(Integer hallId, LocalDate date, String key) { // ... 同上先查 DB ... if (result.isEmpty()) { // 空结果也缓存但只存2分钟避免长期占用内存 redisTemplate.opsForValue().set(key, {}, Duration.ofMinutes(2)); return Collections.emptyMap(); } redisTemplate.opsForValue().set(key, json, Duration.ofMinutes(5)); return result; }5.3 缓存击穿应对用 Redis 分布式锁保护热点 key 重建当hall_1_date_2024-06-15这个 key 过期瞬间大量请求同时穿透到 DB。用SET key value NX PX 30000命令实现加锁public MapString, Integer getScheduleByHallAndDate(Integer hallId, LocalDate date) { String key CACHE_PREFIX hall_ hallId _date_ date; String lockKey lock: key; // 尝试获取分布式锁30秒过期防止死锁 Boolean isLocked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(isLocked)) { try { // 获取锁成功查DB并写缓存 return computeAndCache(hallId, date, key); } finally { // 释放锁用 Lua 脚本保证原子性 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), 1); } } else { // 未获取到锁休眠后重试最多3次 Thread.sleep(50); return getScheduleByHallAndDate(hallId, date); // 递归重试 } }注意生产环境建议用 Redisson 封装好的RLock但此处手写 Lua 是为了让你看清锁的原子性本质——不是靠getset两步而是SETNX单命令。6. 验证系统健壮性的 3 个硬核技巧不用压测工具也能看出问题6.1 用 JMeter 模拟 200 并发抢座看数据库连接池是否打满别信“本地跑通就 OK”。真实压力下HikariCP 连接池可能成为瓶颈。在application.yml中显式配置spring: datasource: hikari: maximum-pool-size: 20 # 根据服务器 CPU 核心数设一般核数×2 minimum-idle: 5 connection-timeout: 30000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000然后用 JMeter 设置 200 线程循环 10 次请求/api/order/create?seatIdA01showTime2024-06-15%2019:00:00。观察若响应时间从 200ms 暴涨到 2s且错误率 5%说明连接池不够若 MySQL 的Threads_connected持续接近max_connections需调大 MySQL 限制血泪经验maximum-pool-size不宜设过大50否则线程上下文切换开销反超收益。6.2 故意制造支付回调重复用 curl 手动发两次相同 notify写个脚本用curl -X POST -d {out_trade_no:ORDER20240615001,trade_no:PAY20240615001,result:success} http://localhost:8080/api/pay/notify发两次。检查订单表status是否仍为 1未变成 3 或其他值cinema_seat表中该座位status是否仍为 2未被误改日志中是否只有一条 “订单已支付” 记录无重复处理日志。玄学排查法在回调方法开头加log.info(收到回调: {}, outTradeNo);结尾加log.info(回调处理完成: {}, outTradeNo);对比日志行数。6.3 查看 MySQL 的 InnoDB 行锁等待show engine innodb status\G当系统变慢时登录 MySQL 执行此命令重点关注SEMAPHORES和TRANSACTIONS部分若OS WAIT ARRAY INFO:下signal count远大于wait count说明锁竞争激烈在TRANSACTIONS中找---TRANSACTION xxx, ACTIVE xxx sec看mysql tables in use 1, locked 1和LOCK WAIT字样关键线索*** (1) WAITING FOR THIS LOCK TO BE GRANTED:后面的 SQL就是被阻塞的语句——通常是你没加索引的 WHERE 条件。我去年在线上遇到一次锁表最终发现是SELECT * FROM cinema_seat WHERE hall_id ? AND status 0没走索引因为status字段没建索引。加上INDEX idx_hall_status (hall_id, status)后锁等待消失。后悔药上线前务必用EXPLAIN检查所有涉及cinema_seat的查询确保 typeref 或 const。最后说一句这个系统不是炫技项目而是我在帮本地一家 3 厅影院做信息化时的真实沉淀。它不追求微服务、不堆中间件就用最朴素的 Spring Boot MySQL Redis把“锁座-下单-支付-出票-退票”这条链路跑通、跑稳、跑准。如果你正被面试官问“怎么设计一个购票系统”或者手头真有个小影院等着上线那就别纠结八股文了——先把cinema_seat的唯一索引建好再把Version加上剩下的都是水到渠成的事。希望帮到你。本文还有配套的精品资源点击获取