电影院售票系统实战:从六张表建模到防超卖状态机设计

发布时间:2026/10/3 7:57:34
电影院售票系统实战:从六张表建模到防超卖状态机设计 简介这份文档是电影院售票管理系统课程设计的完整实验报告面向数据库、软件工程相关课程的学生与需要参考系统设计文档的开发者。内容围绕售票业务的需求分析、数据字典、系统结构图、数据流图展开并完整覆盖概念模型E-R图、逻辑模型、物理模型及存储过程与触发器设计同时对UML建模工具、Java/Python实现框架等软件工程知识点作了归纳。资源包共1个文件为doc格式文档体积约2.8MB适合用于课程设计参考、实验报告撰写以及数据库系统设计的案例学习。已有182人学习下载文档内容结构清晰、图表与文字配合完整可帮助读者快速梳理从需求到实现的整体流程直接套用其中的设计思路与数据库模型。1. 电影院售票管理系统先定业务边界再谈技术选型电影院售票管理系统说到底是把“影片排片、选座锁座、订单支付、入场核验、退票结账”这条业务链路用软件重新做一遍。很多第一次做这个标题的人拿到需求就急着画页面等联调才发现抢座超卖、支付回调后不出票、日终对账不平才是真正卡住上线的问题。一套能落地的售票系统的价值在于观众看到哪些场次剩余多少座、哪些座位可选售票员两三秒完成出票运营者拿到一张经得起核对的日报表。它适合正在做课程设计的同学也适合给中小型影厅搭自营售票后台的从业者。动手写代码前把业务边界梳理清楚后面能少走很多弯路。2. 数据建模电影、场次、座位三张表的边界怎么划开工第一件事不是做登录注册而是把库表建稳。电影院售票管理系统的表结构网上能找到的示例很多但不少把座位、场次、订单揉成一张表一上并发就崩。我的习惯是先拆出六张核心表影片、影厅、座位、放映计划、订单、电影票。它们之间的关系是电影被安排进影厅成为场次座位归属于影厅场次和座位组合起来才是可售卖的商品卖出后产生订单和电影票。2.1 为什么拆成六张表而不是三张有人会说把 movie 字段直接加进 schedule把座位存成 hall 表里的一个 JSON 字符串不也能跑短期确实跑得动但越往后越别扭。排片页需要展示“近期热映”的影片名和时长如果影片信息挤在 schedule 上每加一个场次就要复制一遍片名和时长哪天改片名要用 UPDATE 扫一遍所有场次座位如果存在 hall 表的字符串字段里影院改造加一排座位就没法增量处理只能整厅重置。影厅和座位是物理资源受装修和硬件影响一次建好长期不变场次是销售资源同一个影厅一天放六部片就有六个场次场次之间互不影响各自维护自己的座位占用情况。订单和票则是销售结果属于流水数据要尽可能多地冗余业务发生时的现场信息方便后续对账和打印。把物理资源、销售资源、流水数据分开是这个系统建模的核心原则。如果只拆三张表通常是把 hall 和 seat 合并、把 movie 和 schedule 合并这样写页面时确实少两次 join但等你要做“某个厅未来七天的排片列表”或者“某部电影在哪些厅哪些时段上映”的时候就得在一个表里做各种带条件的聚合索引很难设计。拆成六张表以后每一张表的职责都很单一索引也容易命中。2.2 建表 SQL 与关键字段说明下面是我在项目里第一版就能直接跑起来的基础 DDL去掉了权限和审计字段只留核心骨架。先建影片表CREATE TABLE movie ( movie_id INT AUTO_INCREMENT PRIMARY KEY, movie_name VARCHAR(64) NOT NULL COMMENT 片名, duration_min SMALLINT NOT NULL COMMENT 时长分钟, language VARCHAR(20) DEFAULT 国语 COMMENT 语种, launch_date DATE NULL COMMENT 上映日期, status TINYINT DEFAULT 1 COMMENT 1排片中 0已下线, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_status_launch (status, launch_date) ) COMMENT影片基础信息;duration_min 必须单独存。它不只展示用还决定 schedule 的 end_time 冗余值排片页也是靠它算散场时间。launch_date 用于“即将上映”和“正在热映”的分组status 用来下架老片下架后不再出现在新场次可选列表里。然后是影厅和座位两张物理表CREATE TABLE hall ( hall_id INT AUTO_INCREMENT PRIMARY KEY, hall_name VARCHAR(30) NOT NULL COMMENT 厅名如1号厅, row_count SMALLINT NOT NULL COMMENT 总排数, seat_per_row SMALLINT NOT NULL COMMENT 每排座位数, layout_desc VARCHAR(255) NULL COMMENT 特殊区域描述如情侣座区域, is_active TINYINT DEFAULT 1 COMMENT 1营业中 0停用 ) COMMENT放映厅; CREATE TABLE seat ( seat_id INT AUTO_INCREMENT PRIMARY KEY, hall_id INT NOT NULL COMMENT 所属影厅, row_no SMALLINT NOT NULL COMMENT 排号, col_no SMALLINT NOT NULL COMMENT 列号, seat_type TINYINT DEFAULT 0 COMMENT 0普通 1双人 2无障碍, deleted TINYINT DEFAULT 0 COMMENT 0正常 1停用, UNIQUE KEY uk_hall_seat (hall_id, row_no, col_no), KEY idx_hall_deleted (hall_id, deleted) ) COMMENT座位表;seat 表最关键的是一把联合唯一索引 uk_hall_seat。它在建表层面就杜绝了同一个厅里重复生成同排同列座位的情况后续批量生成座位可以放心跑 INSERT IGNORE。deleted 字段用于停用某个坏掉的座椅而不是物理删除行否则历史订单和座位快照会失去对应关系。接着是放映计划表 schedule它是这个系统的销售单元CREATE TABLE schedule ( schedule_id INT AUTO_INCREMENT PRIMARY KEY, movie_id INT NOT NULL COMMENT 影片ID, hall_id INT NOT NULL COMMENT 影厅ID, show_time DATETIME NOT NULL COMMENT 开场时间, end_time DATETIME NOT NULL COMMENT 冗余散场时间开场时长, base_price DECIMAL(8,2) NOT NULL COMMENT 基础票价, seat_status JSON DEFAULT NULL COMMENT 座位状态快照1可选 0占用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_movie_show (movie_id, show_time), KEY idx_hall_show (hall_id, show_time), CONSTRAINT fk_schedule_movie FOREIGN KEY (movie_id) REFERENCES movie (movie_id), CONSTRAINT fk_schedule_hall FOREIGN KEY (hall_id) REFERENCES hall (hall_id) ) COMMENT放映计划;end_time 是冗余字段选场次列表按“即将开场”排序时直接拿 end_time 过滤即可不用每行临时算一次。base_price 是基础票价实际订单金额可能会叠加会员折扣或活动价所以它只作为创建订单时的默认值。seat_status 是 JSON 快照选座页读取它就能一次画出 200 个座位的占用情况不用 join 订单和票表。最后是订单和电影票两张流水表CREATE TABLE orders ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务单号, schedule_id INT NOT NULL, show_time DATETIME NOT NULL COMMENT 冗余场次时间, movie_name VARCHAR(64) NOT NULL COMMENT 冗余影片名, hall_name VARCHAR(30) NOT NULL COMMENT 冗余厅名, seat_summary VARCHAR(255) NOT NULL COMMENT 座位快照如5排6座,5排7座, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已退票 4已过期, expire_time DATETIME NOT NULL COMMENT 支付截止时间, pay_time DATETIME NULL COMMENT 支付完成时间, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_schedule_status (schedule_id, status), KEY idx_created (created_at) ) COMMENT订单主表; CREATE TABLE ticket ( ticket_id INT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL COMMENT 所属订单, order_no VARCHAR(32) NOT NULL COMMENT 冗余订单号, schedule_id INT NOT NULL, seat_id INT NOT NULL COMMENT 座位ID, seat_label VARCHAR(16) NOT NULL COMMENT 排号座号快照, verify_code VARCHAR(8) NOT NULL COMMENT 入场核验码, status TINYINT DEFAULT 0 COMMENT 0未入场 1已入场, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_ticket_schedule_seat (schedule_id, seat_id), KEY idx_order (order_id) ) COMMENT电影票;订单表把 show_time、movie_name、hall_name、seat_summary 这些本可以 join 出来的字段都冗余了一份。原因是流水表要保留下单那一刻的事实幕布下半个月修改了片名历史订单也不能跟着变。ticket 表上那把联合唯一索引 uk_ticket_schedule_seat 是整个系统防超卖的兜底同一个场次同一个座位数据库层面只能存在一条有效记录。2.3 场次与座位状态缓存JSON 字段的取舍打开选座页时如果每次都去查 schedule、join hall、再 join ticket 才能算出哪些座位可售一个 200 座的影厅在大流量下会反复执行多表查询。常见做法是在 schedule.seat_status 里放一份座位状态快照每个场次一行 JSON值用 1 表示可选、0 表示已占用。页面一次查询拿到全部座位状态体验和数据库压力都友好。下单成功时在同一事务里执行 INSERT ticket 和 UPDATE schedule.seat_status 两条语句保证快速生成的快照和实际票数据一致。这样做的代价是 JSON 字段无法像普通行那样做细粒度索引但单场次座位量级只有几百全量读写完全扛得住。需要强调的是seat_status 只是读缓存真正的凭证永远在 ticket 表如果缓存被写坏可以扫描 ticket 表重建它不作为唯一的业务依据。3. 售票核心链路从锁座到出票的状态机库表理顺之后就该面对整个系统里最容易出错的一段链路订单状态机。在这个系统里订单不是简单的一条数据它要在待支付、已支付、已出票、已退票、已过期之间流转。状态机画错就会出现用户付了钱座位又被抢、退完票座位仍是灰色占着这样的线上事故。这一章把锁座方案、代码骨架和超时参数一次说透。3.1 锁座方案选型数据库行锁与 Redis 过期键“同一场次、同一座位只能被一个人锁住”这件事有两条常见实现路径。第一条是纯数据库方案在 ticket 表先插入一条待支付记录靠唯一索引 uk_ticket_schedule_seat 拦住重复插入谁先插入成功谁就锁定座位。第二条是 Redis 加数据库组合方案先用 Redis SETNX 抢一个带过期时间的锁抢到后再写订单和 ticketRedis 负责挡住大部分并发冲突。纯数据库方案简单且绝对一致但致命缺点是票表会堆积大量待支付记录状态要等支付回调才能转正如果用户放弃支付还得定时任务清扫。Redis 加数据库方案把“快速抢座”和“最终落库”分开抢座阶段毫秒级返回数据库只处理真正进入下单流程的请求。我的建议是日售票量在五千张以下直接走纯数据库方案完全够用超过这个量级再上 Redis避免为了并发而并发给自己增加双写一致性的维护成本。下面的代码骨架按 Redis 加数据库方案写这是我在线上项目里用得最多的组合。3.2 下单出票的时序逻辑与代码骨架下单流程按顺序分五步校验座位可售、抢占 Redis 锁、数据库事务写入订单和票、更新场次座位快照、释放锁。下面这段代码展示的是最核心的锁座和订单创建逻辑from datetime import datetime, timedelta import random, string def create_order(db, redis, schedule_id, seat_ids, account_id): # 锁 key 用场次加座位集合拼接保证同一批座位只有一个并发请求能拿到 seat_part ,.join(sorted([str(s) for s in seat_ids])) lock_key flock:schedule:{schedule_id}:seats:{seat_part} lock_ok redis.set(lock_key, account_id, nxTrue, ex900) if not lock_ok: raise BizError(409, 座位锁定中请重新选座) try: with db.transaction(): # 先查已售记录FOR UPDATE 锁住这一批座位的判定行 rows db.query( SELECT seat_id FROM ticket WHERE schedule_id%s AND seat_id IN (%s) FOR UPDATE, (schedule_id, ,.join([str(s) for s in seat_ids])), ) if rows: raise BizError(409, 座位已被售出) order_no generate_order_no() expire_time datetime.now() timedelta(minutes15) db.execute( INSERT INTO orders (order_no, schedule_id, show_time, movie_name, hall_name, seat_summary, total_amount, status, expire_time) VALUES (%s, %s, %s, %s, %s, %s, %s, 0, %s), (order_no, schedule_id, schedule.show_time, schedule.movie_name, schedule.hall_name, seat_summary, schedule.base_price * len(seat_ids), expire_time), ) for seat_id in seat_ids: db.execute( INSERT INTO ticket (order_id, order_no, schedule_id, seat_id, seat_label, verify_code) VALUES (%s, %s, %s, %s, %s, %s), (order_id, order_no, schedule_id, seat_id, label_of(seat_id), random_code(6)), ) return {order_no: order_no} except BaseException: redis.delete(lock_key) raise这段代码里有三个参数值得单独说。第一个是 Redis 锁的 ex900表示锁住 900 秒也就是 15 分钟和订单表的 expire_time 保持一致用户有 15 分钟完成支付超时后定时任务会把订单置为过期并释放座位。第二个是 ticket 表唯一索引 uk_ticket_schedule_seat它才是防超卖的最后防线Redis 锁只是把并发冲突挡在前面即使 Redis 出问题数据库唯一索引也不会允许同一座位插入两次。第三个是 verify_code我用大写字母加数字混成的六位随机码入场检票时输入这个码即可。注意代码里 Redis 锁的 key 是“场次加座位集合”的拼接结果不是每个座位单独一个锁。这样设计的好处是用户一次选四个座位时四个座位要么全部锁定要么全部放弃不会出现只锁住一半的中间态。3.3 超时、退款与释放回写的参数设置订单创建完成后定时任务要负责清扫过期订单。我的习惯是每 60 秒扫一次SQL 条件是 status0 且 expire_time NOW()把订单状态批量改成 4已过期同时把对应座位的 ticket 记录标记为失效并回写 schedule.seat_status。这个动作必须和主动退票复用同一个释放函数不要两处各写一套。否则非常容易出现在 A 处更新了订单状态、忘了回写座位快照B 处回写了座位快照、忘了把票作废的错位问题。释放座位的函数签名建议设计成 release_seats(schedule_id, seat_ids, reason)reason 区分过期释放和主动退票。函数内部在一个事务里做三件事更新订单状态、更新 ticket 状态、更新 schedule.seat_status。这样任何一步失败都会整体回滚不会出现票已作废但座位还占着的半截状态。退款业务还有一个参数值得做成配置项可退票的截止时间。常见做法是开映前 30 分钟允许用户在线退票开映后只能走现场人工处理。把这个时间阈值放在系统参数表里而不是写死在代码里方便运营随时调整。另外要提醒一点如果接入了微信或支付宝支付退票时需要同步发起原路退款订单状态要等支付平台回调成功后再从“已退票”变成“退款完成”这中间存在时间差报表统计时要注意区分。4. 电影院售票管理系统排查五个必踩的坑前面把链路讲通了但真正折磨人的是各种边界情况。这五个坑是我在不同项目里反复见过、也亲手修过的按“现象、原因、解决”的顺序整理出来希望能帮你绕开。4.1 座位超卖数据库唯一索引才是底线现象高峰期同一个场次同一排座位系统里出现了两张有效电影票两个观众都拿着票进场现场乱成一团。原因很多实现把“座位是否可售”的判断放在内存或 Redis 缓存里先更新缓存再写数据库或者先查后写。两个操作之间存在时间差两个并发请求同时读到“可售”同时往下走于是超卖发生。缓存只是加速器不是一致性的保证。解决把 ticket 表的 uk_ticket_schedule_seatschedule_id seat_id当作防超卖的唯一底线。业务代码不要做“先 SELECT 判断再 INSERT”而是直接 INSERT由数据库唯一索引来决定谁能成功。插入撞了唯一索引就立刻返回“座位已被选择”。Redis 锁能把 99% 的冲突请求挡在前面剩下 1% 的漏网之鱼由唯一索引兜住这样两层配合才是安全方案。4.2 支付回调后不出票订单状态回滚惹的祸现象用户在微信支付里已经扣款成功但系统订单还停留在“待支付”座位被释放用户到前台取不到票。原因支付回调处理流程中不少实现是先更新订单状态、再插入 ticket、再更新座位快照。这三步里任何一步抛出异常整个流程都可能被事务回滚但支付平台那边已经扣款成功两边状态就对不上了。更隐蔽的问题是回调可能被支付平台重试多次如果代码没有做幂等重复回调就可能重复触发后续动作。解决给订单状态迁移加一个强约束只有 status0 的订单才允许被回调置为 1已经置为 1 的直接返回成功不再重复操作。ticket 表已经存在则直接返回不要重复插入。所有写入操作都基于 order_no 做幂等键确保重复回调不会产生副作用。最关键的是支付回调处理流程不要手工开启事务包住“更新状态、生成票、更新快照”这三件事而是先更新订单状态并提交再用可靠的异步任务去生成票和更新快照任务失败可以重试。4.3 幽灵座位定时释放与座位回写不同步现象开场前两小时没有新增订单选座页上却出现一大片灰色不可选座位用户以为满场了。原因定时任务把超时订单的状态改成了“已过期”但没有调用释放座位的方法。或者释放座位的方法里只更新了 ticket 状态、忘了同步 schedule.seat_status 的 JSON 快照导致前端读到的还是占用状态。解决超时释放和主动退票必须共用同一个 release_seats 方法这个方法在同一事务里同步完成“更新订单状态、作废 ticket、回写 seat_status”三件事。另外配一条核对 SQL 定时巡检把 schedule.seat_status 里标记为占用的座位和 ticket 表实际存在的有效记录做比对发现不一致就报警并自动重建快照。这个巡检脚本上线前期每天跑一次稳定之后改成每天一次即可。4.4 退票座位不可再售异步更新的时序问题现象用户退票成功退款也显示了但后台把这个座位从库存里释放后选座页上仍然显示灰色刷新也没用。原因退票流程里把“改订单状态”和“释放座位”拆成了两个步骤释放座位被丢进消息队列异步执行。订单状态先更新释放动作排队等消费。高峰期队列积压释放动作晚了几分钟甚至更久于是出现退票成功但座位迟迟不可选。还有一个类似的情况释放消息被重复消费或者消费顺序颠倒也会导致状态错乱。解决退票释放座位应该和订单状态更新放在同一个数据库事务里同步完成不要拆成异步任务。只有真正需要跨系统通知时才用消息队列比如通知支付平台退款而释放座位这个动作是纯本地的直接同步执行不会有多少性能损耗。如果确实因为某些原因必须异步那释放任务必须支持幂等重试并且重试依据是订单状态而不是座位状态。4.5 日报表对不上账退款与售卖混在一起统计现象当天售票系统汇总显示卖出 12000 元财务从支付平台拉出来的实收只有 10500 元中间差了 1500 元一查全是当天退回的票款。原因两个统计口径打架。售票模块统计的是“订单状态为已支付”的金额财务统计的是“当天实际入账减退款”的金额。如果报表 SQL 里没有把退款单拎出去也没有按支付完成时间分组就会把跨天的订单和退款全都搅在一起。解决报表统计统一按 pay_time 作为业务日期而不是 create_time。金额口径拆成三列总支付金额、退款金额、净收入金额。退款金额只统计状态为已退票的订单净收入等于两者之差。下面是对账用的标准 SQLSELECT DATE(pay_time) AS biz_date, COUNT(DISTINCT order_id) AS pay_orders, SUM(total_amount) AS gross_amount, SUM(CASE WHEN status 3 THEN total_amount ELSE 0 END) AS refund_amount, SUM(total_amount) - SUM(CASE WHEN status 3 THEN total_amount ELSE 0 END) AS net_amount FROM orders WHERE status IN (1, 2, 3) AND pay_time %s AND pay_time %s GROUP BY DATE(pay_time);执行这条 SQL 得到的结果net_amount 应该和支付平台账单的当天净收入一致。如果还有差异优先检查有没有支付回调还没完成的中间态订单以及是否有线下手工改单的数据没进入报表。这个核对脚本要放在每天日结任务里自动跑不平就要发报警。5. 用数据核对和压测验证整套系统上线前的最后两道关代码写完了联调也过了最后我会用两类手段确认系统真正能扛事数据一致性核对和并发压测。这两件事都做扎实了上线才有底气。5.1 用 SQL 做日终一致性核对核对的核心目标是回答一个问题已售座位数和实际座位数对得上吗我常用下面这条 SQL把场次的已售数和厅里的物理座位数拉出来比对SELECT s.schedule_id, s.movie_name, s.show_time, (SELECT COUNT(*) FROM seat se WHERE se.hall_id s.hall_id AND se.deleted 0) AS total_seats, (SELECT COUNT(*) FROM ticket t WHERE t.schedule_id s.schedule_id AND t.status IN (0, 1)) AS sold_seats FROM schedule s WHERE s.show_time %s AND s.show_time %s ORDER BY s.show_time;只要出现 sold_seats 大于 total_seats 的行说明超卖发生了立刻查那场次的订单明细。sold_seats 等于 total_seats 但选座页还能看到可选座位说明 seat_status 快照和实际票数据不一致需要用 ticket 表重建场次快照。这条 SQL 在高并发的压测之后跑一遍能发现很多代码里肉眼看不见的状态错乱。5.2 简单并发压测怎么设参数压测不用一上来就搞复杂工具先用命令行的 ab 或自己写一段多线程脚本即可。关键是把并发请求全部打到同一个场次同一个座位上看最终表现ab -n 200 -c 50 -p seat_order.json \ -H Content-Type: application/json \ -H Cookie: SESSIONtest \ http://127.0.0.1:8080/api/v1/orders-n 200 表示总共发出 200 个请求-c 50 表示同时保持 50 个并发-p 指定 POST 请求的 JSON 文件里面写死一个 schedule_id 和两个 seat_id。判定标准有两个第一200 个请求里成功创建订单的数量不能超过该场次剩余可售座位数第二去数据库查 ticket 表同一个 schedule_id 加 seat_id 绝对不能有两条有效记录。如果 Redis 锁生效成功数会远小于并发数大部分请求会收到 409 冲突提示这是正常现象。压测时还要关注接口响应时间。普通影院的峰值并发并不高同一个场次同时选座的人数很难超过 50所以把接口 P95 响应时间压在 1 秒以内就足够。如果响应时间飙到 3 秒以上优先检查是不是每请求多次查库或者 Redis 连接池配太小。做完这些验证我还有一个坚持多年的习惯上线后连续三天抽查真实订单把人工售票窗口的票根和系统订单逐一比对确认打印出来的票号、场次、座位号完全一致。技术上的坑可以用代码和数据脚本填平数据口径的坑只能靠日结习惯慢慢磨。希望帮到你。本文还有配套的精品资源点击获取