简介这是一份面向数据库课程设计的完整项目源码主题为“电影院售票系统”源自山东大学数据库系统课程设计。压缩包共158个文件主要由TypeScript/TSX前端逻辑、CSS样式、JavaScript脚本、SQL数据库脚本及JSON配置等组成整体大小4.54MB适合学习数据库设计与Web系统开发的本科生参考。项目中包含电影查询、选座订票、支付等业务的前端页面与后端数据交互设计可帮助读者理解需求分析、E-R模型转换、表结构设计、索引与事务处理等数据库核心知识点。压缩包目录结构清晰前端模块、样式文件与SQL脚本分离便于按模块研读和二次开发。已有127人学习下载。1. 电影院售票系统课程设计为什么选这个题目的人最后都在补事务和索引很多人选“电影院售票系统”做数据库课程设计是奔着“业务简单、界面好画”去的。等到交验收材料才发现这个题目恰好把数据库系统概论里最难讲清楚的东西全考了一遍ER 图怎么画才不会被老师追问出漏洞、购票时余票怎么保证不超卖、退票后座位怎么释放、统计上座率的 SQL 为什么越查越慢。标题里的 cinema-ticketing.zip 不是一套普通作业而是一个覆盖实体设计、存储过程、触发器、并发控制和报表优化的完整课设方向。这篇笔记按我实际带过几届课设的思路来写先划业务边界做 ER 图和建表再写售票核心的存储过程和事务接着补触发器和并发锁最后集中给验收时常踩的坑。你如果正在做这个题目可以直接按章节对照自己的工程改如果是想拿这套东西扩展成项目经历后半部分的锁和索引排查思路会比建表更有价值。2. 从需求到 ER 图把电影院售票的业务边界一次划清楚2.1 电影院售票系统涉及哪些实体与联系在动笔画 ER 图之前先把业务流程走一遍用户查电影、选场次、选座位、提交订单、支付、出票运营方维护电影、影厅、场次还要看每日报表。按这个流程拆分最少需要这些实体实体关键属性说明用户用户ID、用户名、手机号、会员等级会员等级会影响票价折扣电影电影ID、片名、时长、上映日期、导演不做演员表也能通过验收影厅影厅ID、影厅名、行数、列数行数列数用于座位合法性校验场次场次ID、电影ID、影厅ID、开始时间、票价、已售座位数这是核心实体余票判断全靠它订单订单ID、用户ID、场次ID、总价、状态状态至少要有已支付、已取消、已退款订单明细明细ID、订单ID、排号、列号一个订单可以买多张票所以需要明细表拆开支付记录支付ID、订单ID、支付方式、金额、时间课程设计里这张表最容易漏关系上注意两点。第一场次和影厅是多对一一个影厅在不同时间有多个场次这个用外键就能表达。第二用户和场次之间是典型的多对多一个用户可以买多个场次一个场次被多个用户购买必须通过订单表和订单明细表拆开。ER 图里如果只画用户直接连场次没有拆中间表答辩时基本会被问倒。2.2 ER 图转关系模式主键、外键与删除策略实体和联系确认后转关系模式时有三个决定会影响后面的所有代码。第一个是主键选择。订单表我习惯不用自增 ID 做主键而是单独维护一个 order_no 业务编号。原因是课设演示时经常要“当场翻数据”给老师看order_no 可以设计成“日期 场次ID 随机数”的组合比如 20250614_0032_4821一眼能看出是哪天的哪个场次。自增主键依然保留但只作为内部关联用。第二个是外键删除策略。默认的 RESTRICT 在删除影厅或电影时会直接报错很多同学嫌烦改成 CASCADE结果删一个测试数据把订单明细全带没了。我的建议是订单和订单明细之间用 CASCADE因为明细属于订单订单删除时明细留着没有意义场次和影厅之间用 RESTRICT防止正在排片的影厅被误删订单和场次之间也用 RESTRICT已经有人买过票的场次不允许直接删只允许通过修改场次状态来下架。第三个是座位编号的存储方式。不要想着把座位存成“A1”“B3”这样的字符串。排号和列号分开用 TINYINT 存将来排序、判重、画座位图都方便。这条看着小但是课设里改起来最痛的一个决定。2.3 建库建表一套能直接改用的 DDL 脚本对应的 MySQL 建表脚本我按上面的设计整理了一份核心表都在里面。数据库名用 cinema字符集统一 utf8mb4排序规则统一 utf8mb4_unicode_ci。CREATE DATABASE cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE cinema; CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), level TINYINT DEFAULT 0 COMMENT 0普通 1银卡 2金卡, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE cinema ( cinema_id INT PRIMARY KEY AUTO_INCREMENT, cinema_name VARCHAR(50) NOT NULL, address VARCHAR(100) ); CREATE TABLE hall ( hall_id INT PRIMARY KEY AUTO_INCREMENT, cinema_id INT NOT NULL, hall_name VARCHAR(30) NOT NULL, row_count TINYINT NOT NULL, col_count TINYINT NOT NULL, seat_count SMALLINT GENERATED ALWAYS AS (row_count * col_count) STORED, FOREIGN KEY (cinema_id) REFERENCES cinema(cinema_id) ); CREATE TABLE movie ( movie_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, duration SMALLINT NOT NULL COMMENT 单位分钟, release_date DATE, director VARCHAR(50), price_base DECIMAL(8,2) NOT NULL ); CREATE TABLE screening ( screening_id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(8,2) NOT NULL, seats_sold INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1售票中 0已下架, FOREIGN KEY (movie_id) REFERENCES movie(movie_id), FOREIGN KEY (hall_id) REFERENCES hall(hall_id), INDEX idx_screening_time (start_time), INDEX idx_screening_status (status) ); CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, screening_id INT NOT NULL, total_price DECIMAL(8,2) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1已支付 2已取消 3已退款, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(user_id), FOREIGN KEY (screening_id) REFERENCES screening(screening_id), INDEX idx_orders_user (user_id), INDEX idx_orders_status (status) ); CREATE TABLE order_detail ( detail_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, seat_row TINYINT NOT NULL, seat_col TINYINT NOT NULL, ticket_price DECIMAL(8,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE, INDEX idx_detail_order (order_id) ); CREATE TABLE payment ( payment_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, pay_type TINYINT COMMENT 1微信 2支付宝 3银联, amount DECIMAL(8,2) NOT NULL, pay_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (order_id) REFERENCES orders(order_id) );几个字段值得专门解释。seats_sold 是一个冗余字段它存“已卖多少座”目的就是让余票查询不用每次去 count 订单明细这个字段会在后面的触发器和存储过程里由程序维护。hall 表里的 seat_count 用了存储生成列不需要应用层手动更新。price 和 total_price 全部用 DECIMAL(8,2)不要用 FLOAT否则多张票累加会出现小数尾差。建完表顺手做两件事一是往 movie、cinema、hall 里各插三五条测试数据二是确认外键没有漏建。很多同学到写存储过程时才返回来补外键补的时候又有历史脏数据非常被动。3. 用存储过程和事务实现购票核心余票扣减与订单写入必须是一个原子操作3.1 为什么购票逻辑必须写成存储过程正常业务系统现在普遍用 ORM 加应用层事务但课程设计场景里我强烈建议把购票逻辑写成一个存储过程。原因有三个。第一答辩时老师大概率会问“你怎么保证两个用户同时买最后一张票不会超卖”存储过程里可以直接展示 SELECT ... FOR UPDATE 和事务的配合比贴一大段 Java 代码直观得多。第二存储过程把 SQL 集中在数据库端演示时用 Navicat 或命令行直接调用不用先启动 Web 服务。第三这也是数据库系统原理课程里“事务隔离级别”和“并发控制”两个知识点的直接落地和课设评分点对口。这里选的数据库是 MySQL 8.0默认存储引擎 InnoDB隔离级别可调。演示并发时建议把隔离级别设为 REPEATABLE READ 并配合显式行锁不要依赖默认的隐式行为。3.2 售票存储过程锁场次、查座位、写订单、扣余票下面的存储过程实现单张购票一次性完成“检查场次状态 → 锁定场次行 → 检查座位是否已售 → 插入订单 → 写明细 → 更新余票”这几件事。DELIMITER $$ CREATE PROCEDURE sp_buy_single_ticket( IN p_user_id INT, IN p_screening_id INT, IN p_seat_row TINYINT, IN p_seat_col TINYINT, IN p_pay_type TINYINT, OUT p_order_id INT ) proc_label: BEGIN DECLARE v_price DECIMAL(8,2); DECLARE v_seats_sold INT; DECLARE v_seat_count SMALLINT; DECLARE v_status TINYINT; DECLARE v_sold_count INT DEFAULT 0; -- 任何 SQL 异常都回滚并重新抛出错误 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 锁定场次行同时取出票价、余票、场次状态 SELECT s.price, s.seats_sold, s.status, h.seat_count INTO v_price, v_seats_sold, v_status, v_seat_count FROM screening s JOIN hall h ON s.hall_id h.hall_id WHERE s.screening_id p_screening_id FOR UPDATE; IF v_status IS NULL OR v_status 1 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 场次不存在或已停止售票; END IF; -- 检查目标座位是否已售出 SELECT COUNT(*) INTO v_sold_count FROM order_detail od JOIN orders o ON od.order_id o.order_id WHERE o.screening_id p_screening_id AND od.seat_row p_seat_row AND od.seat_col p_seat_col AND o.status IN (1, 3); IF v_sold_count 0 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 该座位已被购买; END IF; IF v_seats_sold v_seat_count THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 本场次余票不足; END IF; -- 插入订单 INSERT INTO orders(order_no, user_id, screening_id, total_price, status) VALUES (CONCAT(DATE_FORMAT(NOW(), %Y%m%d), _, p_screening_id, _, FLOOR(RAND() * 9000) 1000), p_user_id, p_screening_id, v_price, 1); SET p_order_id LAST_INSERT_ID(); -- 写订单明细 INSERT INTO order_detail(order_id, seat_row, seat_col, ticket_price) VALUES (p_order_id, p_seat_row, p_seat_col, v_price); -- 更新已售座位数 UPDATE screening SET seats_sold seats_sold 1 WHERE screening_id p_screening_id; -- 记录支付 INSERT INTO payment(order_id, pay_type, amount) VALUES (p_order_id, p_pay_type, v_price); COMMIT; END$$ DELIMITER ;这段代码的关键点在 SELECT ... FOR UPDATE。它把 screening 表里对应的那一行锁住直到事务提交或回滚才释放。两个会话同时执行这个存储过程时第二个会被阻塞在 SELECT 上等第一个提交后才能读到最新余票从机制上避免了超卖。关于座位重复校验这里用了 orders 表内连接 order_detail 的查询。注意 o.status IN (1, 3) 这个条件3 是退款状态退款的座位不允许马上再卖因为退款流程还没真正完成时座位状态需要人工确认。这个细节不写老师追问退款座位时容易卡壳。事务里所有写操作都在 COMMIT 之前任何一步报错都会走 EXIT HANDLER 回滚。特别注意 order_no 的生成方式这里用日期、场次ID和随机数拼接课设场景够用但真实项目里抗并发碰撞不够需要换成真正的发号器。3.3 调用方式与常用 DEBUG 手段存储过程写完后直接在命令行调用验证-- 调用存储过程用户1购买第3排第5座微信支付 CALL sp_buy_single_ticket(1, 1, 3, 5, 1, order_id); SELECT order_id; -- 验证数据 SELECT o.order_no, o.total_price, od.seat_row, od.seat_col, p.pay_type FROM orders o JOIN order_detail od ON o.order_id od.order_id JOIN payment p ON o.order_id p.order_id WHERE o.order_id order_id; -- 查询该场次余票 SELECT seats_sold, price FROM screening WHERE screening_id 1; -- 故意重复买同一座位验证报错 CALL sp_buy_single_ticket(2, 1, 3, 5, 1, order_id);调试时如果报错但不知道错在哪一步两个办法最实用。一是在 Navicat 或 MySQL Workbench 里把存储过程体中的 START TRANSACTION 临时注释掉逐段执行看哪块数据异常。二是把 EXIT HANDLER 里的 RESIGNAL 临时改成 SELECT CONCAT(Error: , MESSAGE_TEXT)这样能看到自定义错误信息。注意调试完要改回来。如果存储过程调用时一直卡住不返回大概率是行锁被别人持有。开另一个会话执行SELECT * FROM information_schema.innodb_trx;看有没有未提交事务这是一个很常用的定位手段。4. 触发器与并发控制报表统计字段和超卖问题一起处理4.1 用触发器维护统计字段与订单状态变更日志余票字段 seats_sold 虽然可以由存储过程来更新但退票、改签这些操作如果直接在应用层改数据表容易漏更新。更稳妥的做法是加触发器兜底把维护逻辑固化在数据库端。一个实用的触发器是订单状态变更日志。老师在验收时看到“每次修改订单状态都会自动留下记录”这一条就能加不少印象分。CREATE TABLE order_status_log ( log_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, old_status TINYINT, new_status TINYINT, change_time DATETIME DEFAULT CURRENT_TIMESTAMP, operator VARCHAR(30) COMMENT 触发来源 ); DELIMITER $$ CREATE TRIGGER trg_orders_status_update AFTER UPDATE ON orders FOR EACH ROW BEGIN IF OLD.status NEW.status THEN INSERT INTO order_status_log(order_id, old_status, new_status, operator) VALUES (NEW.order_id, OLD.status, NEW.status, CURRENT_USER()); END IF; END$$ DELIMITER ;-- 测试触发器把订单3从已支付改为已取消 UPDATE orders SET status 2 WHERE order_id 3; -- 查看日志 SELECT * FROM order_status_log WHERE order_id 3;这个触发器里 IF OLD.status NEW.status 的判断很关键。没有这个判断任何字段的更新都会顺手写一条日志日志表里会充满无意义记录。注意 MySQL 的触发器不能用 NEW 字段去回更新同一张表所以日志必须落到独立的新表里这个设计本身就是课程里讲过的“触发器与基表分离”原则。4.2 并发场景下验证行锁两个会话重复买票存储过程写好了但你要能当场演示它确实能扛并发。课设验收时最有力的演示是同时打开两个 MySQL 客户端模拟两个用户同时买同一个座位。-- 会话 A USE cinema; START TRANSACTION; SELECT * FROM screening WHERE screening_id 1 FOR UPDATE; -- 先不提交模拟正在出票 -- 到这里 A 持有场次 1 的行锁 -- 会话 B另开一个连接 USE cinema; CALL sp_buy_single_ticket(2, 1, 3, 5, 1, order_id); -- B 会卡住等 A 提交或回滚后才继续让 B 停在阻塞状态后再用第三个连接查锁信息SELECT trx_id, trx_state, trx_started, trx_rows_locked FROM information_schema.innodb_trx;正常会看到两条记录一条是 A 的 RUNNING 状态另一条是 B 的 LOCK WAIT 状态。然后把 A 提交B 立即恢复执行。这个演示比口头解释“锁能防超卖”有说服力得多。4.3 死锁怎么看从 innodb 状态信息里读关键行死锁是并发控制的必考题目。为了演示死锁可以故意构造会话 A 先锁 screening 再锁 orders会话 B 先锁 orders 再锁 screening两个事务互相等对方的锁。遇到死锁后别急着截图用下面的命令把 InnoDB 诊断信息拉出来SHOW ENGINE INNODB STATUS\G重点看 LATEST DETECTED DEADLOCK 字段。它会列出两个事务各自持有的锁和等待的锁。多数课设里的死锁逃不出两种一是不同事务访问多张表的顺序不一致二是触发器中额外拿锁让事务持锁范围变大。这里有一个容易忽略的点MySQL 死锁不会永久卡住默认会回滚其中较小的事务并报错 1213。所以应用层看到“Deadlock found when trying to get lock; try restarting transaction”时正确的做法是捕获异常后重试整个事务而不是提示用户稍后再试。这个结论写在答辩文档里很加分。5. 电影院售票系统避坑指南从期末验收角度看 7 个高频问题5.1 座位编号用字符串导致排序错乱现象后台查座位明细时第10排排在第2排前面按列排序也是乱的。原因seat 字段设计成 VARCHAR排序按字典序而不是数值序。课设系统里用“A-01”“A-02”这类编码很常见但一旦场次座位数超过 10 排就会出问题。解决把排号和列号拆成独立 TINYINT 字段排序时直接 ORDER BY seat_row, seat_col。如果已经存了字符串就用 SUBSTRING_INDEX 切分后转数字排序但这是权宜之计建表阶段就该拆开。5.2 seats_sold 被扣成负数现象连开多个终端疯狂买票最终场次的 seats_sold 出现负数但订单表里座位明细并不重复。原因存储过程里没有对场次行加 FOR UPDATE所有事务同时读到同一个 seats_sold 值各自加 1 后写回丢失更新。解决在读取场次数据的地方统一加 FOR UPDATE。注意不是只对 UPDATE 语句加锁而是先 SELECT ... FOR UPDATE 锁定行再做判断和更新。这条是我自己的血泪经验第一次做时只锁了 UPDATE 没锁 SELECT并发一上来照样超卖。5.3 删除影厅被外键拦着改成 CASCADE 后把订单明细删没了现象想删一个建错的影厅直接 DELETE FROM hall 报外键约束错误。改成 ON DELETE CASCADE 后删影厅成功但关联的订单明细和支付记录全部消失。原因CASCADE 把删除操作递归到了所有外键关联表。订单 → 订单明细 → 支付记录这条链上只要有一层 CASCADE就能一路删到底。解决业务数据表之间不用 CASCADE。保存这种需要保留历史的表加 is_deleted 软删除标记查询统一过滤。不要依赖物理删除这不是课设偷懒的地方。5.4 订单状态机不闭环报表统计对不上现象报表页面显示已售票数时好时坏核对后发现有些订单状态是已取消但座位明细还在生效列表里。原因只做了购票流程没做取消和退款的状态流转。统计时没过滤掉 status 2 或 3 的订单。解决所有统计查询都带状态条件至少区分“已支付1”“已取消2”“已退款3”。上座率计算只统计状态 1 的订单余票判断则把状态 1 和 3 都视为不可售。这个规则要在需求阶段定死不要边写边拍脑袋改。5.5 存储过程执行一半报错数据留在数据库里现象调用存储过程买票时报“座位已被购买”但查订单表发现刚才的记录还在余额和余票却对不上。原因存储过程体里没有声明 EXIT HANDLER也没有手动 或 ROLLBACK 的分支。默认情况下报错语句之前的写操作会保留在事务中等连接关闭时才回滚。解决每个写操作的存储过程开头都声明异常处理。示例里用的DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;就是标准写法。写完存储过程后故意触发一次错误确认报错前后数据没有残留变化。5.6 统计报表越查越慢一个 COUNT 查询卡住页面现象数据量不大一千多张订单但每月报表查询耗时 5 秒以上。原因报表 SQL 写了多表 JOIN过滤条件没有可用的索引或者一张表上建了四五个冗余单列索引优化器最终选错执行计划。解决先给 WHERE 条件里的字段建组合索引。常见组合是 (screening_id, status) 和 (create_time)前者服务购票流程后者服务日报表。建完索引后用 EXPLAIN 查看执行计划确认 type 不是 ALLrows 有明显下降。5.7 触发器里写动态 SQL导致整个事务变慢甚至死锁现象程序偶尔报错错误信息指向一个以前没见过的触发器跟业务代码完全无关。原因有人在触发器里拼接动态 SQL 去操作另一个库的表这种做法在 MySQL 里限制多运行时容易拿到额外锁造成死锁或性能抖动。解决不在触发器里写 PREPARE/EXECUTE 动态语句触发器只做简单的行级写入。需要跨表统计时把逻辑放到存储过程的事务里执行明确锁的获取顺序。这个坑课设里不常见但如果你想把项目写成“可扩展”给老师看别为了花哨去踩。6. 上座率统计的索引设计与验收演示技巧报表功能做完了还要让它跑得快。上座率统计的典型查询是按电影或按天聚合例如计算某部电影所有场次的平均上座率SELECT s.movie_id, AVG(s.seats_sold / h.seat_count) AS occupancy FROM screening s JOIN hall h ON s.hall_id h.hall_id WHERE s.start_time 2025-06-01 AND s.start_time 2025-07-01 GROUP BY s.movie_id;这个查询在 5 万条场次数据下要跑出好效果至少要一个组合索引 (start_time, movie_id, hall_id)。验证索引有没有生效用 EXPLAINEXPLAIN SELECT s.movie_id, AVG(s.seats_sold / h.seat_count) AS occupancy FROM screening s JOIN hall h ON s.hall_id h.hall_id WHERE s.start_time 2025-06-01 AND s.start_time 2025-07-01 GROUP BY s.movie_id;看输出里的 type 列出现 range 或 ref 都算正常如果还是 ALL说明索引建偏了。还可以用SHOW INDEX FROM screening;检查索引冗余比如已经有了 (movie_id, start_time) 再建一个单独的 start_time 索引用处不大。答辩时被问到并发问题照这条线答先用 FOR UPDATE 锁场次行保证余票安全再用触发器记录订单状态变更最后用唯一索引兜底。唯一性这个点很多人会漏订单明细表里加一个 UNIQUE KEY uk_seat (screening_id 需要冗余进来才能建这个唯一约束)即使存储过程逻辑漏掉数据库也会拒绝重复座位。把这三层机制讲清楚比单纯背书本定义强得多。我自己的习惯是验收前一天把所有存储过程和触发器重新执行一遍创建脚本确保工作目录里那份 .sql 文件能一键重建全部对象。有一回答辩现场临时抽走一台电脑仓库里的脚本版本和库里对不上当场手工补一个触发器才稳住场面。从那以后我把“先跑脚本恢复数据库、再跑购票流程、最后查报表”当成固定流程。这份体力活不会白费希望帮到你。本文还有配套的精品资源点击获取
