ARTICLE DETAIL

资讯详情

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

电影院售票管理系统设计与实现:并发锁、事务与超时释放实战

电影院售票管理系统设计与实现:并发锁、事务与超时释放实战 简介一份面向数据库课程设计与软件工程实践的完整课程设计文档以电影院售票业务为背景系统讲解从需求分析、数据字典、系统结构图到数据流图、概念模型与逻辑模型的设计全过程适合计算机相关专业学生完成《数据库系统概论》等课程作业时参考。文档为doc格式资源包内仅包含1个文件大小2.8MB属于典型的单篇课程设计报告。内容覆盖项目需求与功能规定、数据项及数据结构说明、存储过程和触发器设计、UML建模与数据库性能优化思路并配有E-R图、物理模型等关键图表能够帮助读者快速理解电影院售票管理系统的整体架构与实现要点。已有182人学习下载可作为从需求分析到系统设计的完整案例对撰写课程设计报告或从事信息系统开发入门均有参考价值。1. 电影院售票管理系统到底是什么一个被低估的事务型项目“电影院售票管理系统”这个标题在课程设计和毕业设计题目里已经出现了十几年但很多人做出来的东西连“售票”两个字都担不住。它跟你想象的不一样难点不是登录注册、不是电影列表展示而是同一场电影的两个用户几乎同时点了同一个座位你如何保证系统只卖出去一张票。这个题目本质是一个强一致性的交易系统麻雀虽小五脏俱全。它适合正在学后端、想把数据库事务和并发控制真正用起来的人也适合需要交一份完整“设计与实现”文档的同学。这篇文章会把从需求分析到建表、接口、锁策略、超时释放再到并发验证的完整路径讲清楚你照着做能跑通也能答辩。2. 先画清楚业务边界需求分析、角色流程与状态机2.1 四类角色与一条主流程做设计之前先把角色和流程盘清楚。电影院售票系统不止买家和卖家。常见的有四类角色前台用户负责选座、下单、支付影院管理员负责维护影厅、座位、排片运营人员负责查看报表、处理异常订单系统本身还要承担定时释放超时订单这类后台任务。你如果一上来就写注册登录、写电影列表等到做座位并发时会发现业务线纠缠不清越往后改越痛苦。主流程只有一条用户选择影片和场次看到座位图勾选座位提交订单支付成功后系统出票。注意“提交订单”和“支付成功”之间有一个时间窗口。这个窗口里座位必须被锁定否则别人可能买走同一个座位。另一条副流程是退票已支付订单可以退退款后座位要回到可售状态。整个业务流程只要这两条线就够。别把会员积分、优惠券、小卖部都塞进第一版那是后续迭代的事塞进来只会让你的状态机在答辩前崩溃。设计时还有一个容易被忽略的地方支付方式。真实影院会对接微信、支付宝但课程设计阶段通常只要模拟支付——用一个“模拟支付成功”按钮。但你必须在文档里说明真实接口对接时系统只能相信支付回调不能在前端点击按钮后就立刻把订单置为已售。否则就会留下逻辑漏洞用户付款后没等回调座位已经释放了。2.2 把“场次-座位-订单”三者的关系钉死数据库设计最容易翻车的地方就是把“座位”和“场次”分开想。实际上一个影厅的座位是固定的但每个场次有自己独立的座位占用情况。所以不能只设计一张“座位表”而是要同时有“座席基础表”和“场次座位状态表”。后者记录“在某个场次里这个座位当前是什么状态”。我见过很多人图省事直接在订单表里存 seat_no然后用“查订单里有没有这个座号”来判断是否可售。这个做法第一版能用一旦出现取消订单、退款、改签就会产生十几处 if 分支最后根本维护不了。更合理的做法是拆成订单表和订单明细表订单表存用户ID、场次ID、总金额、订单状态、过期时间订单明细表每行关联一个场次座位ID。这样一张订单可以覆盖一个座位也可以覆盖多个座位退票时只需把对应明细和场次座位状态一起更新。场次座位表是整个系统的心脏它应该包含 schedule_id、seat_id、status、version 和 lock_order_id。lock_order_id 用于记录当前占用这个座位的订单释放时必须校验它。要把三者关系钉死有一个约束原则同一个 schedule_id seat_id 在同一时刻只能被一个有效订单占用。这个“有效”包括待支付、已支付、退款处理中。唯一索引只能防止重复插入但没法防止并发下两个事务都先查到 status0 再插入订单。所以还要靠事务和锁保证这一点在第4章详细展开。2.3 六张核心表的设计与关键字段参数我一般会建七张表电影表、影厅表、座席表、场次表、场次座位表、订单表、订单明细表。影院表可以合并进影厅表如果只有一个影院也可以省掉。下面这张表是我常用的字段和参数你直接抄可以但要根据自己的业务微调参数。表名核心字段说明filmid, title, duration, release_date电影基础信息duration 是分钟hallid, name, seat_count影厅seat_count 可以冗余缓存总座位数seatid, hall_id, row_no, col_no物理座席行列号唯一scheduleid, film_id, hall_id, show_time, price, end_time场次price 存 decimal别用 floatschedule_seatid, schedule_id, seat_id, status, version, lock_order_id核心状态表status 用 tinyintordersid, order_no, user_id, schedule_id, total_amount, status, created_at, expire_at订单主表order_no 必须有唯一索引order_detailid, order_id, schedule_seat_id订单明细每行一个场次座位字段类型上有几个参数要强调。金额一律用 DECIMAL(10,2)不要用 DOUBLE否则累计金额会对不上。时间戳用 DATETIME 而不是 TIMESTAMP除非你有跨时区的明确需求。status 字段用 TINYINT 并写注释0、1、2、3 分别表示什么必须写在建表语句里方便后面写 SQL 的人理解。expire_at 这个字段很多人会忘它存的是“待支付订单的过期时间”超时释放定时任务全靠它。外键我建议在业务层维护数据库不物理建外键。原因很简单MyBatis-Plus 这类 ORM 操作多表时物理外键会让删除、更新操作频繁报错而且高并发下数据库检查外键本身也有开销。你只要在索引上保证 schedule_seat.schedule_id、schedule_seat.seat_id、orders.schedule_id、order_detail.order_id 都建有普通索引即可。很多用老版本 MySQL 的同学会发现连表查询慢得一塌糊涂十有八九是这些索引漏了。3. 用 Spring Boot MyBatis-Plus 把核心接口跑通3.1 工程结构与依赖清单技术选型我推荐 Spring Boot MyBatis-Plus MySQL。不是因为它性能最强而是因为事务注解和 SQL 的写法最直观网上排错资料也多。你需要一个最精简的 Maven 工程结构如下src/main/java/com/example/cinema/ ├── controller/ // 接收请求 ├── service/ // 业务逻辑事务边界在这里 ├── mapper/ // MyBatis-Plus 的 Mapper 接口 ├── entity/ // 数据库实体 ├── common/ // 统一返回值与异常pom.xml 里依赖只需要 web、mybatis-plus、mysql 驱动、lombok。先把测试和热部署省掉减少变量。使用 Spring Boot 2.7.x 和 MyBatis-Plus 3.5.x 就够了。如果你以后要接入 Redis 做余量缓存再加 starter-data-redis。第一版不建议加因为缓存一致性坑比并发坑更大。3.2 DDL建表语句与索引选择下面是核心表的 DDL我删去了次要字段突出关键部分。注意 schedule_seat 表有两个唯一索引一个是 schedule_id seat_id 的物理唯一一个是 lock_order_id 的普通索引——lock_order_id 不是唯一因为订单取消后会被清空。CREATE TABLE schedule_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1锁定 2已售 3失效, version INT NOT NULL DEFAULT 0, lock_order_id BIGINT DEFAULT NULL, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_schedule_seat (schedule_id, seat_id), KEY idx_status_expire (status, update_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款, expire_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_schedule (schedule_id), KEY idx_expire_status (status, expire_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意idx_status_expire这个索引是给超时释放任务用的后面会讲。schedule_seat的update_time会被定时任务批量扫描有索引才能避免全表慢查询。orders表的idx_expire_status同理扫描待支付过期订单时走了这个索引才能秒级返回。3.3 选座 创建订单一个带事务的最小实现选座和创建订单是系统的核心链路必须放在同一个事务里。我直接写 service 层方法用Transactional保证原子性。Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long scheduleId, ListLong seatIds) { // 对座位ID排序避免多用户交叉锁座导致死锁 ListLong sortedSeatIds seatIds.stream().sorted().collect(Collectors.toList()); // 1. 使用 SELECT ... FOR UPDATE 锁定这些场次座位行 ListScheduleSeat seats scheduleSeatMapper.selectForUpdate(scheduleId, sortedSeatIds); // 2. 校验座位状态只要有一个已被占用就回滚 for (ScheduleSeat seat : seats) { if (seat.getStatus() ! 0) { throw new BizException(500, seat_occupied); } } // 3. 生成订单主记录 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(scheduleId); order.setTotalAmount(calculatePrice(seats)); order.setStatus(0); order.setExpireAt(new Date(System.currentTimeMillis() 15 * 60 * 1000)); orderMapper.insert(order); // 4. 插入订单明细并更新场次座位状态为锁定 for (ScheduleSeat seat : seats) { orderDetailMapper.insert(new OrderDetail(order.getId(), seat.getId())); scheduleSeatMapper.lockSeat(seat.getId(), order.getId()); } return order; }对应 Mapper XML 里的核心 SQL 长这样select idselectForUpdate resultTypeScheduleSeat SELECT * FROM schedule_seat WHERE schedule_id #{scheduleId} AND seat_id IN foreach collectionseatIds itemsid open( close) separator, #{sid} /foreach FOR UPDATE /select update idlockSeat UPDATE schedule_seat SET status 1, lock_order_id #{orderId}, version version 1 WHERE id #{id} AND status 0 /update逻辑说明selectForUpdate必须在事务里执行否则行锁不会等到提交才释放。先把要买的座位行锁住别人再买同一批座位时就会被阻塞等第一个事务提交后后者重新读到 status1抛出异常。lockSeat的 WHERE 条件又做了一次 status0 的兜底相当于双保险。参数说明锁超时默认是 50 秒如果事务卡住后面会抛出Lock wait timeout exceeded。你可以通过修改 MySQL 的innodb_lock_wait_timeout来调但更根本的是让事务里不要执行 RPC、不要远程调用只做数据库操作。calculatePrice我建议按当前场次的单价乘以座位数实时计算而不是前端传总价否则用户改一下请求体就能免费看电影了。4. 并发不超卖的关键锁策略、座位状态与超时释放4.1 悲观锁和乐观锁选型不能只看“高效”两个字座位是一个高冲突资源。一场电影 100 个座位热门场次前十分钟可能同时有几万人盯着。在这种场景下我首选悲观锁也就是SELECT ... FOR UPDATE。原因有两个一是实现简单锁住行之后状态检查、订单插入、座位更新都在事务里做完没有重试逻辑二是冲突概率高乐观锁的重试会让大部分请求在“版本号对不上”这里打转用户体验反而更差。乐观锁适合低冲突场景比如更新用户昵称、文章阅读量。如果你非要用乐观锁SQL 可以这样写UPDATE schedule_seat SET status 1, version version 1 WHERE id #{id} AND version #{oldVersion}代码里要 catch 更新影响行数为 0 的情况然后重试整个事务。但重试意味着要重新查状态、重新计算价格复杂度成倍上涨。而且重试过程中用户看到的座位明明还在点击后却报“已被买走”这个体验比“选座按钮灰色”更差。所以别为了秀技术选乐观锁这个系统用悲观锁更稳。要注意的是FOR UPDATE锁的是行不是表。但如果你没走索引MySQL 可能会锁表。比如WHERE schedule_id #{scheduleId} AND seat_id IN (...)如果 schedule_id 上没有索引InnoDB 会先锁全表扫描的所有行再逐行判断。所以第 3 章的 DDL 里schedule_seat必须建(schedule_id, seat_id)的联合索引。这也是为什么我在第 3 章特意强调索引。4.2 座位状态机从锁定到失效的四种状态场次座位表的状态不能随心所欲地改否则会出现“已售的座位又被锁定”这种灵异事件。我用字段注释把四种状态钉死状态值含义什么时候进入0空闲场次初始化、订单取消/退款后1锁定用户提交订单未支付2已售支付成功3失效场次结束、座位损坏等订单状态需要和它联动。订单状态 0 待支付、1 已支付、2 已取消、3 已退款。联动规则如下提交订单后座位从 0→1支付成功后座位从 1→2超时未支付或用户主动取消座位从 1→0支付后退款座位从 2→0。这里有一条红线只有座位状态为 1 且lock_order_id等于当前订单 ID 时这个订单才能执行“支付成功”操作。很多测绘系统之所以卖重票是因为把座位状态和订单状态当成了两个独立的字段更新时没有事务。正确的做法是在同一个事务里同时更新两个表并且用条件更新来防止状态漂移。比如支付时UPDATE orders SET status 1 WHERE id #{orderId} AND status 0; UPDATE schedule_seat SET status 2 WHERE lock_order_id #{orderId} AND status 1;两个 SQL 都要判断返回影响行数只要有一个为 0整个事务回滚。4.3 定时任务兜底超时未支付怎么自动释放用户提交订单后可能直接关浏览器座位不能永远锁下去。常见做法是用 Spring 的Scheduled每分钟扫一次过期订单把超时未支付的订单取消同时释放座位。这里核心是保证“取消订单”和“释放座位”在同一事务且不能误伤刚支付成功的订单。Component public class OrderTimeoutTask { Scheduled(fixedDelay 60000) Transactional(rollbackFor Exception.class) public void cancelExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredOrders(new Date()); for (Order order : expiredOrders) { int cancelResult orderMapper.cancelIfStillPending(order.getId(), new Date()); if (cancelResult 0) { continue; // 状态已被其他流程修改 } orderDetailMapper.releaseSeatsByOrderId(order.getId()); } } }SQL 细节很关键。cancelIfStillPending必须带上status0 AND expire_at now条件然后用UPDATE orders SET status2 WHERE id#{id} AND status0 AND expire_at #{now}。如果影响行数为 0说明订单已经支付成功或被用户取消就跳过释放座位的步骤。这样就能避免第 5 章要讲的那个坑——定时任务把刚支付的订单给取消了。selectExpiredOrders对应的 SQL 是SELECT * FROM orders WHERE status 0 AND expire_at #{now} LIMIT 500加 LIMIT 是防止一次任务处理太多订单把数据库拖垮。处理完一批后下一次定时任务再扫下一批。生产环境建议加上分布式锁比如 Redis 锁或数据库锁避免多个实例同时跑任务但课程设计单机部署可以不做。5. 设计与实现中的五个翻车现场与排查记录5.1 座位被重复卖事务没生效原因是方法自调用现象两个用户同时买同一个座位偶尔会出现都成功数据库里两个订单并存。查代码service 里确实写了Transactional逻辑也没问题。原因你在 controller 里注入的是 userService但createOrder方法内部调用的是this.createOrder或者调用同类中的另一个事务方法。Spring 的事务是基于 AOP 代理实现的自调用不会经过代理事务注解直接失效。这是最常见的事务失效原因。解决把核心下单逻辑放到独立的一个 Service 类里或者注入ApplicationContext获取代理对象。最简单的是拆类把下单逻辑放OrderService把支付模拟放PayService交叉调用都用注入进来的 Bean。然后确认启动类上加了EnableTransactionManagementSpring Boot 默认开启再打断点看代理对象是否生效。5.2 锁了一行却锁不住整个场次行锁与表锁的边界现象并发压测时发现FOR UPDATE没有锁住对应的座位反而出现死锁异常或者整个schedule_seat表被锁吞吐量直接掉到个位数。原因一是schedule_id列没有索引导致 MySQL 对全表记录加锁二是多用户购买不同座位时事务锁多行如果加锁顺序不一致会产生死锁。解决首先确认索引是否生效用EXPLAIN SELECT ... FROM schedule_seat WHERE schedule_id ? AND seat_id IN (...)看key列必须是uk_schedule_seat否则就是类型转换或字段函数问题。其次在代码里对seatIds排序保证所有事务都按同一个顺序获取行锁。最后设置合理的innodb_lock_wait_timeout我习惯设为 30 秒超过就立刻失败不要让用户一直转圈。5.3 订单表越来越大忘了按场次归档导致查询变慢现象系统跑了一个月后用户查询“我的订单”越来越慢后台订单列表也要等几秒。检查 SQL发现orders表已经几十万行idx_schedule和idx_expire_status还是阻挡不了大范围扫描。原因订单表是典型的持续增长表不做归档或分区索引再完善也会因数据量增大而性能衰减。另一个原因是查询条件经常用user_id status但索引只建了schedule_id导致全表扫描。解决课程设计能跑通的前提下至少给orders表加(user_id, status)联合索引。如果时间允许可以按月分表比如orders_202501或者写一个定时任务把“已支付且半年以上”的订单转移到orders_history表。数据库设计阶段就要预计到这个问题在文档里写上“订单表预期年增长 XX 行故采用按月归档策略”答辩时能加不少分。5.4 超时释放任务把刚支付的订单取消了状态校验缺失现象用户手机上明明支付成功了但刷新订单列表却发现订单被取消座位也变成空闲。登录日志发现执行了cancelExpiredOrders。原因定时任务扫描时读到订单状态还是 0待支付而用户支付回调还没落库或者支付回调事务和定时任务并发定时任务先执行了取消支付回调再更新订单但状态已经变成 2更新失败。本质是取消操作没有做状态条件更新。解决如第 4 章所写取消订单 SQL 必须带status 0 AND expire_at now。同时释放座位不能直接按 order_id 无条件释放要先查询订单当前状态是否为“已取消”。在代码里我用cancelIfStillPending这个方法名意图就很明显。另外支付回调里也要加状态判断只有status0才允许改成status1。这两个条件加起来就能保证并发下不会出现“支付成功但被取消”的错乱。5.5 同一用户疯狂点选座直接卡死缺少前端防抖与幂等键现象用户手抖双击“确认选座”浏览器发出两个完全相同的 POST 请求。两个请求都成功生成了两个订单占用四个座位用户只付了一个订单另一个超时释放看起来像幽灵座位。原因后端没有做接口幂等控制。同一个用户、同一个场次、同一组座位在第一次请求还没返回时第二次请求应该被拦截但后端却当新订单处理了。解决前端在提交后立即禁用按钮设置 1 秒倒计时。后端加幂等键比如前端生成一个随机 token在 Redis 里存token - userId收到请求时先 SETNX失败就说明是重复请求。最简单的是一个「防重表」建一张order_request表唯一键用user_id schedule_id seat_ids_hash插入成功才允许继续下单插入失败直接返回“请勿重复提交”。这个设计还能防恶意刷接口属于性价比极高的防护措施。6. 从“能跑”到“敢上线”并发验证与文档撰写技巧系统写完最怕的是“自己点一下没问题一并发就翻车”。我习惯在上线前夜做一件事用 JMeter 或者最简单的ab命令压 20 个并发抢同一个座位看是否只创建了一个订单。比如这样ab -n 20 -c 20 -H Content-Type: application/json \ -p create_order.json -T application/json \ http://localhost:8080/api/order然后检查订单表里该场次同一座位的有效订单数。如果大于 1说明锁策略有漏洞回到第 4 章逐条排查。这个验证脚本要留到答辩现场演示比任何 PPT 都管用。另外写“设计与实现”文档时别把标题只写成“系统功能描述”要按“需求分析 - 数据库设计 - 模块设计 - 事务与并发控制 - 测试结果”的顺序走重点写清楚你用什么锁、为什么这么选、超时释放怎么保证一致性。这些内容在答辩时就是你的护身符。很多次我都是靠这套流程在两个深夜排掉了看似不可能的超卖 bug。先把自己最粗陋的方案跑通验证比追求花哨的技术更稳。希望帮到你。本文还有配套的精品资源点击获取
返回列表