
简介基于Java的电影购票系统毕业设计论文文档面向计算机专业学生、毕业设计开发者及票务系统学习者。系统采用MySQL数据库、Java语言和SSMSpringSpringMVCMyBatis框架针对传统票务管理容错率低、处理效率差等问题设计并实现了电影管理、场次管理、评价管理、收藏管理、订单管理、用户管理及类型管理等核心功能模块业务覆盖完整。资源包共1个docx文件文件大小3.43MB内容包含中英文摘要、目录、绪论、开发环境与技术介绍、系统设计实现等章节结构清晰便于直接参考或按需修改。已有87人学习浏览适合需要快速掌握SSM框架在真实业务中应用、撰写毕业设计论文或搭建类似Web系统的读者。通过该文档可系统了解需求分析、数据库设计与功能实现思路获得从技术选型到业务落地的完整参考对提升项目开发与文档编写能力均有实际帮助。1. 基于 Java 的电影购票系统先从“设计”而不是“编码”说起一个电影购票系统的后台外行看到的是“能选座下单付款”内行看到的是“一场午夜场的余票状态如何在 200 个并发请求下不出错”。拿到“基于 Java 的电影购票系统设计与实现”这个题目不管你是做课设、做毕设还是公司内部要搭一个简单的售票平台真正值钱的都不是那几张 CRUD 页面而是场次库存、座位锁定、订单状态、超时释放这几件事有没有被设计清楚。本文默认你用的是 Java 后端最常见的组合Spring Boot 2.x/3.x 提供接口MyBatis-Plus 操作 MySQLRedis 承担缓存和分布式锁前端就留一个最小可用的 Vue 页面或者干脆用 Postman 验证。目标是让你照着这一套能把系统从“能跑”推到“扛得住小流量真实使用”的水平。适合正在做课设和毕设的学生也适合刚拿 Java 做业务项目、想看看这类系统典型落法的后端工程师。2. 购票系统的需求拆解与数据模型设计先把表和状态定死写代码前必须先把两个东西定死系统边界和状态机。边界决定了你要做多少功能状态机决定了订单和座位在什么条件下可以变成什么样子。很多购票系统写着写着就乱根因都是这两个东西没锁住。2.1 功能边界用户端、运营端、后端任务三块一个常规的 Java 电影购票系统功能上可以拆成三块用户端注册登录、浏览影片和场次、选择座位、下单支付、查看订单、退票。运营端影片信息维护、影厅和座位管理、排片、订单查询。后端任务超时未支付订单自动取消、座位释放、场次结束后归档。如果你在写课设建议把运营端做成一个只有管理员的简单页面如果你的场景是面试项目那后端的“超时取消 座位释放”这两块反而要重点讲这是你区别于别人的技术亮点。提示不要一上来就上微服务、消息队列。这个规模的项目单体 定时任务 Redis 锁完全够用把基础方案做扎实比堆技术栈更能说明你懂设计。2.2 库表设计电影、场次、座位、订单四张核心表绝大多数购票业务的表跑不出下面这四张核心表另外加一张用户的表即可。下面用 MySQL DDL 给出关键设计注意我标出的索引和唯一约束。-- 影片表 CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 片名, duration INT NOT NULL COMMENT 片长分钟, status TINYINT DEFAULT 1 COMMENT 1-上映中 2-已下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; -- 场次表 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_id BIGINT NOT NULL COMMENT 影厅ID, show_time DATETIME NOT NULL COMMENT 开场时间, sale_start DATETIME NOT NULL, sale_end DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_movie_show (movie_id, show_time) ) ENGINEInnoDB; -- 座位表每个影厅在排片时初始化一次 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT 场次ID, seat_row VARCHAR(4) NOT NULL, seat_col VARCHAR(4) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-可售 1-已锁定 2-已售出, order_id BIGINT DEFAULT NULL COMMENT 锁定/购买时的订单ID, lock_expire_at DATETIME DEFAULT NULL COMMENT 锁定过期时间, UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col) ) ENGINEInnoDB; -- 订单表 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已退票, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB;表结构里最值得说的是seat.status和orders.status这两个字段的设计。座位状态我用了 TINYINT 而不是字符串这样在 Java 里可以直接定义一个枚举类对应lock_expire_at是给“锁定超时释放”用的后面第 3 章我会专门讲它配合定时任务一起用比单纯用 Redis 的过期 key 多一道数据库层的兜底。订单表里的order_no要建唯一索引并且不要用自增 ID 直接当业务订单号给用户看防止别人根据订单号差值反推你的销量。一般用时间戳 用户 ID 随机数生成比如yyyyMMddHHmmss userId 4位随机数Java 里可以用UUID.randomUUID()再截断也可以用雪花算法这个项目规模下任何可读性好的方案都行。提示orders是数据库保留字有些 MySQL 版本建表虽然不报错但在写查询条件时容易踩奇怪的坑建议表名用orders写 SQL 时加反引号或者干脆用t_order这种带前缀的表名。我当时用orders踩过一次因为分库分表中间件对保留字敏感所以长期用t_order。2.3 Java 实体类的状态枚举定义搞定了数据库再来看 Java 侧的映射。用 MyBatis-Plus 时实体类字段和表字段用驼峰对应重点是状态字段在枚举类里怎么定义下面把座位和订单两个枚举贴出来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 enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), CANCELLED(2, 已取消), REFUNDED(3, 已退票); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } }这样设计枚举的好处是Java 代码里你永远不会出现魔法数字。比如你在 Service 层写if (order.getStatus() 0)虽然能跑但三个月后你自己都记不住 0 是什么写if (OrderStatus.PENDING_PAYMENT.getCode() order.getStatus())就又长又啰嗦。更常见的做法是让 MyBatis-Plus 直接枚举映射在字段上加EnumValue注解TableName(seat) public class Seat { TableId(type IdType.AUTO) private Long id; private Long scheduleId; EnumValue private SeatStatus status; private Long orderId; private LocalDateTime lockExpireAt; }这个方案需要 MyBatis-Plus 3.3.0 以上Spring Boot 3.x 用新版 MyBatis-Plus 就行。加了EnumValue后插入和查询都会自动按 code 值转换代码可读性提升一大截。列表查询和处理业务状态机时这也是一道很好的防呆机制。3. 选座和库存并发控制Redis 分布式锁之外还要有兜底这个系统能不能过技术面、能不能经受住小范围的真人使用完全看第 3 节这块怎么设计。购票系统的技术难点不在登录、不在分页查询而在两件事同一场次同一座位不能同时被两个人锁到锁到的座位在用户放弃支付后要能自动释放。关于并发控制的方案我在多个项目里来回换过最常见、最可靠的组合是 Redis 分布式锁控制选座原子操作 MySQL 乐观锁兜底 定时任务补偿释放。这个组合的好处是Redis 挂了还有数据库的锁兜底数据库锁也没抢到就等下一轮定时任务三层保障下基本不会超卖。3.1 为什么先想到的“select for update”不能拿来直接锁座位很多 Java 新手对 MySQL 悲观锁第一反应就是SELECT ... FOR UPDATE这个思路在这个场景有个致命问题如果高并发下一个热门场次有 300 人同时抢座位事务里对同一条 seat 记录做FOR UPDATEMySQL 会串行化处理这些请求。数据库层面是安全了但请求大量堆积在行锁上你的接口平均耗时直接爆炸网关层再一超时用户侧看到的就是“选个座卡死了”。另外MySQL 默认隔离级别是 REPEATABLE READSELECT ... FOR UPDATE加的是当前读能读到最新数据这没问题但它锁的范围在写得不规范时会膨胀。比如你写WHERE schedule_id ?忘掉加AND seat_row ?InnoDB 可能把这一场次的所有座位行全锁住。所以悲观锁不是不能用是必须控制好锁粒度。真正合理的做法是用 Redis 做前置的快速原子锁定把对数据库行锁的依赖降下来。Redis 的SETNX天然适合“谁先抢到算谁的”这种语义配合 Lua 脚本可以保证判断和写入是原子的。3.2 用 Redis Hash 存一场次座位状态锁粒度精确到一个座位我给这个系统设计的 Redis 数据结构是这样的每个场次的座位状态用一个 Hash 存key 是schedule:seat:{scheduleId}field 是row:colvalue 是状态。选座时对一个或多个座位执行HSETNX这个命令和SETNX的语义一样只有在 field 不存在时才会写入天然满足“不可重复锁定”。坐位的状态值用三个整数0 可售1 已锁2 已售出。这里注意一个点座位被锁住不是永久的用户可能放弃支付所以 value 存的不只是状态我使用1:{orderId}:{expireTimestamp}这样的格式把订单号和过期时间都塞进去这样定时任务扫描时不需要再去数据库查订单直接解析 value 就能知道这张座位锁给谁了、什么时候过期。3.3 Lua 脚本实现原子选座Java 端调用封装直接用HSETNX逐个座位执行会有一个问题多座位选座时前面选成功了、后面失败状态就半截了。所以要用 Lua 脚本把多个命令打包成一次原子操作-- KEYS[1] schedule:seat:{scheduleId} -- ARGV[1] 座位列表逗号分隔如 1排1座,1排2座 -- ARGV[2] 订单号 -- ARGV[3] 过期时间戳 for seat in string.gmatch(ARGV[1], ([^,])) do local current redis.call(HGET, KEYS[1], seat) if current then -- 已锁或已售出直接返回失败 return 0 end end for seat in string.gmatch(ARGV[1], ([^,])) do redis.call(HSET, KEYS[1], seat, 1: .. ARGV[2] .. : .. ARGV[3]) end return 1Java 端调用这段 Lua 脚本的代码逻辑如下public boolean lockSeats(Long scheduleId, ListString seats, String orderNo, long expireTs) { String key schedule:seat: scheduleId; ListString keys Collections.singletonList(key); ListString args Arrays.asList( String.join(,, seats), orderNo, String.valueOf(expireTs)); Long result redisTemplate.execute( new DefaultRedisScript( return redis.call(HSETNX, KEYS[1], ARGV[1], ARGV[2]), Long.class), keys, args); return result ! null result 1L; }这段代码背后的基本原理是Redis 是单线程执行命令的Lua 脚本内多个命令在执行期间不会被其他客户端的命令插入所以脚本内第一个循环检查完所有座位都可用第二个循环才开始写入这中间的状态对于其他客户端是原子的。如果你不用 Lua先HGET检查再HSET两个命令之间有间隙两个用户可能同时检查到“可售”然后同时锁定成功超卖就发生了。提示使用 Lua 脚本时DefaultRedisScript不要每次重新 new因为脚本要经过 Redis 的SCRIPT LOAD机制加载重复加载提高性能把脚本定义成 Spring 的Bean注入使用。3.4 定时任务兜底防止 Redis 和数据库状态不一致Redis 是不是可靠日常使用很可靠但你不能保证别的地方不出幺蛾子。比如极端情况下 Redis 主从切换数据可能丢一点点再比如并发高峰中某个用户的订单超时了Redis 里的座位锁还没释放他会看到座位还是被锁着。所以我始终坚持Redis 锁状态 数据库 seat 表状态双写再靠定时任务校准。校准任务的核心逻辑是查出所有锁定状态、但锁过期时间晚于现在、且关联订单已经变成取消或超时的记录统一释放Component public class SeatReleaseScheduler { Scheduled(fixedRate 60_000) public void releaseExpiredLocks() { LocalDateTime now LocalDateTime.now(); ListSeat expiredSeats seatMapper.selectList(new LambdaQueryWrapperSeat() .eq(Seat::getStatus, SeatStatus.LOCKED) .lt(Seat::getLockExpireAt, now) .isNotNull(Seat::getOrderId)); for (Seat seat : expiredSeats) { String orderStatus orderMapper.selectStatusById(seat.getOrderId()); if (!OrderStatus.PENDING_PAYMENT.name().equals(orderStatus)) { seatMapper.releaseSeat(seat.getId(), seat.getVersion(), status - ...); } } } }这里有一个关键点定时任务在执行releaseSeat更新数据库时要用乐观锁版本号。因为释放座位和用户正好此时发起支付可能产生竞争。seat 表加version字段每次更新SET status 0, version version 1 WHERE id ? AND version ?更新影响行数为 0 说明数据已经被其他操作改过了则跳过。3.5 为什么说这是三层防线而不是过度设计你可能会怀疑这套组合拳是不是有点重。我拆开算一下Redis Lua 锁承担 99% 的流量单次操作耗时小于 2 毫秒数据库乐观锁只在回调、异常补偿和特殊情况时用频率非常低定时任务每 60 秒跑一次校准占不了多少数据库开销。select for update的完整悲观锁逻辑在这个项目里我基本不用了。如果你在面试中被问到“为什么不用select for update”我会回答在保证数据一致性的前提下Redis 的原子操作能把同一把锁的并发处理能力提高一个数量级左右因为 Redis 是内存操作不能把大量并发请求压到 MySQL 的行锁上。4. 下单支付的核心 Java 实现事务边界和状态机是关键锁座成功之后接下来就是创建订单、支付回调、更新座位和订单状态。这个环节最大的坑是一个座位可能对应多个订单。比如用户选了 3 个座位系统生成了一个订单支付时用户取消其中 1 个座位重选旧订单上还有 2 个座位。所以下单的核心逻辑应该是锁座成功即生成订单订单号绑定这些座位支付前只做简单校验支付回调里才真正把座位状态从“锁定”改成“已售出”。4.1 Service 层顺序锁 Redis → 写订单 → 回写座位为了方便理解下面给出一段简化但可以跑的支付回调处理逻辑Service public class OrderService { Transactional(rollbackFor Exception.class) public void handlePayCallback(PayCallbackRequest request) { // 幂等校验 TOrder order orderMapper.selectByOrderNo(request.getOrderNo()); if (order null || order.getStatus() ! OrderStatus.PENDING_PAYMENT) { throw new BusinessException(订单不存在或状态不允许支付); } // 乐观锁更新订单状态 int count orderMapper.updateStatusByOrderNo( order.getOrderNo(), OrderStatus.PAID, OrderStatus.PENDING_PAYMENT, LocalDateTime.now()); if (count 0) { throw new BusinessException(支付状态更新失败); } // 更新座位为已售出 Seat seat seatMapper.selectByOrderId(order.getId()); int seatCount seatMapper.updateStatus( seat.getId(), SeatStatus.SOLD, SeatStatus.LOCKED, seat.getVersion()); if (seatCount 0) { throw new BusinessException(座位状态更新失败请重新选座); } // 清理 Redis 锁场次的座位标记 redisTemplate.opsForHash().put( schedule:seat: order.getScheduleId(), seat.getSeatRow() : seat.getSeatCol(), 2: order.getOrderNo()); } }这段代码里有几个值得讲的设计点。第一是支付的幂等。支付回调可能因为网络重试被推送两次支付平台常见的“通知退避”机制如果全程没有唯一约束会出现重复把订单状态从“待支付”改成“已支付”导致业务重复处理。上面的解法是使用乐观锁限制了只有status 待支付能更新成功第二次进来了 status 已经变成“已支付”更新影响行数为 0直接抛异常更保险的方法是再插入一条“支付事件流水表”来去重。第二是更新的粒度updateStatus带着SeatStatus.LOCKED这个条件如果座位已经被释放则更新失败回调会走入异常处理触发给用户原路退款的逻辑。这里用数据库的条件更新兜底比先 query 再 update 的写法更安全。4.2 超时自动取消的实现方案对比买过电影票的人都知道锁定座位后如果 15 分钟内不支付座位会自动释放。实现这个机制业界常见两个方案这里都写了方案一延迟队列。用户锁座后向 RabbitMQ 发一条延迟 15 分钟的消息消息的 TTL 到期后进入死信队列由消费者检查订单状态如果还是“待支付”就自动取消并释放座位。延迟队列方案对时间精确度要求很高时明显更好误差在秒级但你要多维护一个 MQ 集群对课设来说有点重。方案二定时任务扫描。每 60 秒扫描一次订单表查出status 待支付 AND create_time NOW() - INTERVAL 15 MINUTE的记录批量更新状态并释放座位。实现简单但存在最多 60 秒的延迟而且如果订单表数据量级到了千万条扫描条件要加索引否则很伤数据库。提示对课设和中小规模系统我推荐方案二。用定时任务扫描时一定要给status和create_time建联合索引并做分页处理否则扫描会越来越慢。如果后面订单量大了再升级为延迟队列方案属于平滑演进。下面是一个可以直接用的定时取消逻辑Component public class OrderTimeoutCancelTask { Scheduled(cron 0 * * * * ?) // 每分钟执行一次 public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListTOrder orders orderMapper.findPendingOrdersEarlierThan(deadline); for (TOrder order : orders) { if (order.getStatus() ! OrderStatus.PENDING_PAYMENT) { continue; } // 先把订单标记为取消成功后再释放座位 int cnt orderMapper.cancelOrderIfPending(order.getId(), order.getVersion()); if (cnt 0) { seatMapper.releaseSeatsByOrderId(order.getId()); redisTemplate.delete(schedule:seat: order.getScheduleId()); } } } }这里把“取消订单”和“释放座位”分两步中间用受影响行数cnt 0作为信号防止订单已经支付了却被重复取消。因为cancelOrderIfPending和releaseSeatsByOrderId在同一个Transactional里cnt 0才会执行释放如果释放座位有失败整个事务回滚订单状态还是待支付下一轮任务可以继续补偿。4.3 状态机设计防呆每种状态只允许固定的流转路径从上面对订单状态和座位状态的处理可以看出两个状态机是强关联的。订单是“待支付”时座位必须只能是“锁定”支付完成后订单变成“已支付”座位才允许变成“已售出”。下面对照表给出完整的合法流转你可以把它写到设计文档里也可以直接做成枚举校验当前订单状态触发操作目标订单状态对应座位状态变化待支付用户支付已支付锁定 - 已售出待支付15 分钟超时已取消锁定 - 可售待支付用户主动取消已取消锁定 - 可售已支付用户申请退票已退票已售出 - 可售已支付管理员强制取消已取消已售出 - 可售这样你以后不管别人在哪一层改了状态只要最终核对时不匹配这张表就是 bug。类设计里甚至可以实现一个校验器在关键操作前统一调用。5. 管理端的排片与场次初始化Java 代码里怎么避免脏数据排片这个功能看起来不就是“给电影选个影厅加时间”吗但实际做的时候有一个细节很容易被忽略场次一经发布座位数据就要为这个新场次初始化而且只在排片这一刻初始化一次。5.1 创建场次时自动生成座位表数据一个影厅有多少座位一般在影厅表里已经有模板了用hall_id查出该影厅的座位布局然后为新的schedule_id生成对应 seat 记录status 置为可售。代码如下Transactional(rollbackFor Exception.class) public Schedule createSchedule(ScheduleCreateRequest req) { Schedule schedule new Schedule(); schedule.setMovieId(req.getMovieId()); schedule.setHallId(req.getHallId()); schedule.setShowTime(req.getShowTime()); schedule.setSaleStart(req.getSaleStart()); schedule.setSaleEnd(req.getSaleEnd()); scheduleMapper.insert(schedule); // 根据影厅模板生成座位 ListHallSeat hallSeats hallSeatMapper.selectByHallId(req.getHallId()); ListSeat seatList hallSeats.stream().map(hs - { Seat s new Seat(); s.setScheduleId(schedule.getId()); s.setSeatRow(hs.getSeatRow()); s.setSeatCol(hs.getSeatCol()); s.setStatus(SeatStatus.AVAILABLE); return s; }).collect(Collectors.toList()); seatService.batchInsert(seatList); return schedule; }边界情况值得先说如果用户正在选这个场次的座位管理员这时改了影厅座位布局或删除了排片就会出问题。所以管理端设计上通常是“发布即不可修改座位布局只能操作下架”修改座位模板只影响未来的新场次。这是个约定大于实现的规则在做需求评审时把这条写到业务文档里最好。5.2 排片冲突检测防止两个场次同一影厅时间重叠两个场次在同一影厅的时间不能重叠这个判断要在 Java 服务层做。常见做法是查这个影厅当天已有的场次逐一比对时间。下面是核心判断代码public boolean isHallAvailable(Long hallId, LocalDateTime start, LocalDateTime end) { ListSchedule schedules scheduleMapper.selectByHallIdBetweenTime(hallId, start.minusMinutes(30), end.plusMinutes(30)); // 加 30 分钟清理时间缓冲 for (Schedule s : schedules) { LocalDateTime existStart s.getShowTime(); LocalDateTime existEnd s.getShowTime().plusMinutes(s.getDuration() 30); if (start.isBefore(existEnd) end.isAfter(existStart)) { return false; } } return true; }这个判断的本质是区间重叠检测公式是newStart oldEnd newEnd oldStart。会写这个判断说明你是能考虑到边界情况的人。加上 30 分钟的缓冲时间是因为电影结束后需要散场和保洁这也是影票产品里一个常见但新手容易漏掉的规则。5.3 管理端查询用 MyBatis-Plus 的分页和条件构造器管理端列表页用 MyBatis-Plus 的分页查询配合LambdaQueryWrapper写条件效率高、代码短public IPageScheduleVO pageSchedules(long page, long size, String movieTitle, LocalDateTime date) { PageSchedule p new Page(page, size); LambdaQueryWrapperSchedule wrapper new LambdaQueryWrapper(); wrapper.like(movieTitle ! null, Schedule::getMovieTitle, movieTitle) .ge(date ! null, Schedule::getShowTime, date) .orderByAsc(Schedule::getShowTime); return scheduleMapper.selectPage(p, wrapper); }like和ge的第一个参数是布尔表达式条件为 false 时该条件不参与拼接。这个写法相比手动拼 SQL 的 if 判断Java 代码干净很多是 MyBatis-Plus 的日常核心操作。6. 进阶玩法把这套 Java 系统从课设水平提升到可上线的检查清单如果你不是只想要一个交差的课设而是想把这个基于 Java 的电影购票系统做成一个能给别人演示、甚至能在内网真实使用的项目最后一章给你收敛成十个检查点。把这些全过一遍项目的完成度会明显高于普通作业。第一统一接口返回结构。别到处散放MapString, Object定义一个ResultT包含 code、message、data 三要素。前后端联调时能省你大量时间。第二全局异常处理。用RestControllerAdvice捕获业务异常、参数校验异常、兜底异常。异常信息要放在服务端日志里返回给前端的只保留友好提示不要让用户直接看到 SQL 报错。第三关键操作打日志。选座、支付回调、定时释放任务这三个操作的入参、出参和耗时用log.info记录下来。线上排查问题全靠这几种日志。第四支付回调幂等已经讲过多次接口层也要做防重提交前端按钮置灰之外后端锁座接口要检查此座位对应的新订单是否已存在。第五定时任务加开关。Scheduled默认所有环境都跑建议用一个配置项如task.enabledfalse控制生产环境的开关避免在多人联调时定时任务误释放别人的测试订单。第六Redis Key 统一规范。schedule:seat:{id}、user:token:{id}这类不复杂写一个 RedisKey 常量类收口不要散写在各个 Service 里。第七锁座 Redis 缓存要设置长一点的过期时间。座位锁的过期时间推荐设置成 30 分钟大于支付超时 15 分钟这样即使定时任务没跑Redis 里的锁标记也已经失效了兜住释放逻辑。第八seat表的lockExpireAt必须建立索引。定时任务扫描WHERE lock_expire_at NOW()时没有索引会全表扫数据量上来了很难看。第九用 Git 管理代码每次改动写清 commit message。这不是空话是帮你面试时能清晰讲出系统演进历程的最省力办法。第十给上面这张检查点做一个验证实验会非常有说服力用两个浏览器同时选同一个座位后点的那个必须看到“已被选了”的提示再用两个浏览器同时抢同一场次最后一张票看能否保证只成功一个。这个实验可以直接在现场演示比你口述架构强得多。最后如果你还想继续延伸可以把这个单体系统的选座模块拆出来做一个独立的库存服务用 Redis 作为库存主存储、MySQL 做最终落库那又是另一个更深度的“系统设计”话题了。当前这套基于 Java 的完整实现先把上面 10 条守住就是一个拿得出手、说得清楚的购票系统。本文还有配套的精品资源点击获取