最近在帮学弟学妹们看毕业设计发现“电影购票系统”这个选题热度一直很高。但很多同学做到一半就卡住了问题大多集中在多人同时抢一个座位怎么办明明显示有票下单时却提示售罄支付成功了订单状态却没更新这些问题看似简单背后却涉及高并发、数据一致性等核心挑战。今天我就结合一个实际可落地的方案跟大家聊聊如何从零搭建一个靠谱的电影购票系统。1. 背景与核心痛点分析在做毕设之前我们必须先想清楚系统要解决哪些实际问题。一个典型的购票流程是用户查询场次和座位 - 选择座位 - 创建订单 - 支付 - 出票。在这个过程中有几个“坑”是必须要填的选座并发冲突这是最经典的问题。假设座位A是空闲状态用户甲和用户乙几乎同时点击了它。如果没有保护机制系统可能会为两人都创建订单导致“一票多卖”。这本质上是共享资源的并发写问题。库存超卖和电商秒杀类似。系统显示剩余1个座位但瞬间涌入10个请求每个请求都判断“库存0”然后执行扣减最终导致卖出10张票严重的数据不一致。订单状态不一致支付成功回调后订单状态应该从“待支付”变为“已支付”。但如果回调处理过程中服务重启或异常可能导致支付成功了订单还是“待支付”用户无法取票。这涉及到分布式事务的最终一致性。事务回滚失败在创建订单时我们通常需要在一个事务里做多件事扣减座位库存、生成订单记录、写入订单明细。如果其中某一步失败需要全部回滚。但如果回滚本身也失败了比如网络问题就会留下脏数据。理解了这些痛点我们才能有针对性地进行技术选型和设计。2. 技术选型对比与决策面对上述问题市面上有很多解决方案。如何选择最适合毕设兼顾学习价值和实现复杂度的技术栈呢2.1 分布式锁Redis vs ZooKeeper解决选座并发核心是需要一把“锁”保证同一时间只有一个请求能操作某个座位。分布式锁的常见实现有基于Redis和基于ZooKeeper两种。Redis分布式锁通常使用SET key value NX PX timeout命令实现。NX表示只有key不存在时才设置PX设置过期时间。优点是性能极高内存操作实现相对简单社区方案成熟如Redisson。缺点是它并非CP强一致性系统在主从切换时可能出现锁失效虽然概率低。对于毕设场景这个缺点是可以接受的。ZooKeeper分布式锁利用ZooKeeper的临时顺序节点和Watch机制实现。它是CP系统锁的强一致性有保障。但缺点是性能比Redis差且需要额外维护一个ZooKeeper集群增加了系统复杂度。结论对于电影购票毕设推荐使用Redis。理由1性能要求高选座是高频操作2实现简单学习曲线平缓3即使极端情况下锁失效我们还有后续的数据库唯一约束等兜底方案。我们可以用Lua脚本保证锁操作的原子性。2.2 分布式事务本地消息表 vs Seata订单创建和支付回调涉及多个服务订单服务、库存服务、支付服务如何保证数据最终一致性常见方案有本地消息表、Seata等。本地消息表核心思想是“将分布式事务拆分为一系列本地事务”。例如订单服务在本地事务中1扣减库存2生成订单3向本地消息表插入一条“待发送”的消息。然后有一个定时任务扫描消息表将消息可靠地投递给支付服务。支付服务处理成功后再回调订单服务更新状态。优点是架构简单对业务侵入小不需要额外中间件。缺点是需要自己实现消息的可靠投递和幂等消费。Seata阿里开源的分布式事务解决方案提供了AT、TCC等模式。功能强大能实现强一致性或最终一致性。但缺点是需要引入额外的Seata-Server配置较为复杂对于毕设来说有点“杀鸡用牛刀”也增加了理解成本。结论对于毕设推荐使用本地消息表。理由1能让你深刻理解最终一致性的实现原理2不依赖额外组件项目结构清晰3足够解决购票系统的业务场景。我们可以结合状态机来清晰定义订单的各种状态流转。基于以上分析我们的基础技术栈可以定为Spring Boot MySQL Redis。Spring Boot快速构建服务MySQL存储核心业务数据用户、电影、场次、订单Redis用于缓存热点数据如电影信息、场次余票和实现分布式锁。3. 核心业务流程与实现细节接下来我们拆解“选座 - 下单 - 支付”这个核心链路看看每一步具体怎么做。3.1 座位锁定关键分布式锁 Lua脚本用户提交选座请求时我们不能直接去数据库更新座位状态那样并发下会出问题。正确的流程是请求到达后端服务首先尝试获取该场次下所选座位的Redis分布式锁。获取锁成功后检查座位状态可以从Redis缓存或数据库中查。如果已被占用则释放锁并返回“座位不可选”提示。如果座位空闲则执行锁定操作。这里强烈建议使用Lua脚本将“检查设置”的操作原子化防止在检查之后、设置之前的间隙被其他请求钻空子。锁定成功后在Redis中为该座位设置一个较短的过期时间如5分钟代表“预占”。同时将锁释放。预占信息里可以包含用户ID和订单Token。-- Lua脚本示例原子化锁定座位 -- KEYS[1]: 座位锁的key (如 lock:schedule:1:seat:A1) -- KEYS[2]: 座位状态的key (如 seat_status:schedule:1:A1) -- ARGV[1]: 锁的value (可用UUID) -- ARGV[2]: 锁过期时间(毫秒) -- ARGV[3]: 座位预占信息 (如 userId:orderToken) -- ARGV[4]: 座位预占过期时间(秒) -- 尝试设置锁 if redis.call(setnx, KEYS[1], ARGV[1]) 1 then -- 设置锁成功设置锁的过期时间 redis.call(pexpire, KEYS[1], ARGV[2]) -- 检查座位状态是否为空闲 (0表示空闲) if redis.call(get, KEYS[2]) 0 then -- 空闲则预占座位设置状态为1预占并设置预占信息 redis.call(setex, KEYS[2], ARGV[4], 1: .. ARGV[3]) return 1 -- 成功 else -- 座位已被占释放锁 redis.call(del, KEYS[1]) return 0 -- 失败座位不可用 end else return -1 -- 获取锁失败可能其他请求正在操作 end3.2 创建订单关键本地事务 本地消息表座位预占成功后前端引导用户去创建订单。订单服务需要处理再次校验座位预占信息防止请求被篡改或预占过期。开启本地数据库事务。在事务内插入订单主表状态为“待支付”、插入订单座位明细表、更新场次座位表状态为“已锁定”。这里数据库层可以通过唯一索引防止同一座位插入多条明细。在同一个事务内向本地消息表插入一条记录消息内容包含订单ID、业务类型如“订单创建”、状态“待发送”。这一步是保证后续流程可靠性的关键。提交事务。如果以上任何一步失败事务回滚同时需要清理Redis中的座位预占状态可以通过一个异步的补偿任务来做。订单创建成功后返回订单信息给前端引导用户支付。3.3 支付与回调关键幂等性 状态机用户支付完成后支付渠道如支付宝、微信会异步回调我们的系统。支付回调接口设计必须幂等。这意味着无论支付平台回调多少次系统处理的结果都应该是一样的。实现方式在回调接口中首先根据回调中的订单号查询订单并判断当前状态。只有处于“待支付”状态的订单才继续处理。可以使用数据库乐观锁update order set status 已支付 where id ? and status 待支付来实现更新成功的行数如果为1才执行后续逻辑。支付成功后的业务处理更新订单状态为“已支付”、更新座位状态为“已售出”、可能还需要更新用户的积分等。这些操作可以放在一个本地事务中或者通过发送消息驱动其他服务异步处理取决于业务复杂度。状态机设计订单的状态流转必须清晰、严谨。例如“待支付” - (“已支付” / “已取消” / “已过期”) - (“已完成”/“已退款”)。任何状态变更都需要有明确的触发条件和前置状态校验这能有效防止状态混乱。可以在代码中用枚举Enum来定义状态和流转规则。// 订单状态机伪代码示例 (使用枚举和简单逻辑) public enum OrderStatus { INIT(待支付) { Override public boolean canChangeTo(OrderStatus newStatus) { return newStatus PAID || newStatus CANCELLED || newStatus EXPIRED; } }, PAID(已支付) { Override public boolean canChangeTo(OrderStatus newStatus) { return newStatus COMPLETED || newStatus REFUNDING; } }, // ... 其他状态 CANCELLED(已取消), EXPIRED(已过期), COMPLETED(已完成); private String desc; // 构造函数、getter省略... // 默认不允许状态随意变更子类可覆写 public boolean canChangeTo(OrderStatus newStatus) { return false; } } // 在变更状态的方法中 public boolean changeStatus(String orderId, OrderStatus newStatus) { Order order orderDao.findById(orderId); if (order.getStatus().canChangeTo(newStatus)) { // 执行状态更新和后续业务... return true; } throw new IllegalStateException(订单状态不允许从 order.getStatus() 变更为 newStatus); }4. 性能与安全考量一个完整的系统不能只关注功能性能和安全性同样重要。4.1 缓存与防护缓存击穿某个热点场次比如热门电影首映场的座位数据缓存过期了此时大量请求同时涌来都会去查数据库造成压力。解决方案1永不过期不行数据会旧。2使用互斥锁Mutex Key第一个请求发现缓存为空时去查数据库并重建缓存其他请求等待。在Redis层面可以用SETNX实现简单的互斥锁。缓存雪崩大量缓存key在同一时间过期导致所有请求都打到数据库。解决方案给缓存过期时间加上一个随机值如基础时间随机几分钟让失效时间分散开。4.2 接口安全防刷防止恶意用户用脚本刷票。简单方案1图形验证码在提交选座前验证。2IP或用户ID频率限制使用Redis记录单位时间内的请求次数。3关键业务环节如下单增加Token机制由服务端生成一次性有效。SQL注入防范这是最基本的安全要求。坚持使用MyBatis等框架的参数绑定方式#{}绝对不要手动拼接SQL字符串。对用户输入进行严格的格式校验如手机号、邮箱格式。5. 生产环境避坑指南来自实战的经验即使本地测试通过部署到服务器或给老师演示时也可能遇到问题。冷启动延迟项目第一次启动Redis缓存是空的如果瞬间有大量查询请求会全部打到数据库。可以在应用启动后执行一个预热任务加载热点数据如近期热门电影场次到缓存。Redis持久化配置误区在毕设环境中我们可能用单机Redis。默认的RDB持久化策略可能在宕机时丢失最近几分钟的数据导致座位锁或预占状态丢失。可以考虑启用AOFappend-only file并设置为每秒同步一次appendfsync everysec在性能和可靠性间取得平衡。当然最重要的是理解不同持久化方式的原理。测试数据污染在开发测试时经常需要造数据。务必注意隔离1使用独立的测试数据库或表前缀。2测试脚本要有清理逻辑避免脏数据影响功能验证。3对于Redis可以使用不同的db index或者在key前加环境前缀如dev:test:。超时与重试网络调用如支付回调、服务间调用一定要设置合理的超时时间并考虑重试机制。但重试必须是幂等的否则可能造成重复扣款等严重问题。对于非幂等操作重试要非常小心。6. 总结与思考通过以上步骤我们基本上搭建了一个具备抗并发能力、保证数据最终一致性的电影购票系统核心骨架。它涵盖了从分布式锁、本地消息表到状态机、接口幂等的多个重要知识点完全足够支撑一个优秀的毕业设计。最后留一个思考题给大家这也是系统可以继续完善的方向如何支持退票后的座位释放与二次售卖这听起来简单但仔细想想退票可能发生在支付后用户主动退、也可能发生在支付前订单超时未支付自动取消。退票后座位状态要从“已售出”或“已锁定”变回“可售”。这个过程同样需要保证并发安全防止刚释放就被两个请求同时抢到并且可能需要考虑“是否立即释放回票池”还是“进入一个缓冲池隔一段时间再释放”防止被黄牛秒抢。你可以尝试设计这个流程并思考如何与现有的锁和状态机结合。希望这篇笔记能为你点亮思路。技术之路动手实践才是最好的老师。不妨现在就打开IDE参照这些思路开始构建你自己的“电影购票系统”吧