数据库课程设计实战:机票预订系统ER图、事务与并发控制详解

发布时间:2026/10/11 20:51:53
数据库课程设计实战:机票预订系统ER图、事务与并发控制详解
简介数据库课程设计机票预订系统是一份面向高校计算机专业学生的课程设计参考文档尤其适合正在完成Oracle数据库课程设计或综合实训的读者。文档以机票预订业务为真实背景完整梳理了需求分析、E-R模型构建、关系模式转换、表空间分配及建表、视图设计、存储过程与函数编写、触发器编制以及角色权限规划和备份策略等核心环节。通过该案例读者能理解如何把航班、机票、乘客、业务员等业务实体转化为数据库模型并在Oracle中用PL/SQL实现约束、封装和自动处理。压缩包内为1个doc格式文档容量1.2MB按序言、需求分析、分析和设计、课程设计总结等章节组织目录层次清晰方便按步骤对照学习。文档中给出的表空间创建语句、备份命令和权限分配SQL也可直接迁移到类似项目中复用。目前已有84人学习下载适合作为课程设计说明书撰写的参考模板。1. 数据库课程设计机票预订系统.doc一张 ER 图决定成绩不是界面看到“数据库课程设计机票预订系统.doc”这个文件名基本不用猜这是一份课设交付物你要交的是 Word 报告、建库 SQL、配套程序和一份能自圆其说的设计说明。数据库课程设计爱拿机票预订出题是因为它比图书管理多了一圈真实业务里最关键的复杂度——库存扣减和订单状态流转。老师不会只看你“能不能跑”多数会顺着 ER 图、存储过程、事务隔离一路追问。这个题目适合两类人正在补课设、想一次过的人以及想把建表能力升级成“事务、锁、触发器都讲得清”的初级开发。下面按“需求 → 表结构 → 事务与触发器 → JDBC 落地 → 排错 → 验证”的顺序讲代码以 MySQL 作为数据库、Java 作为示例语言。2. 从需求到表结构ER 模型、三范式与机票预订系统的 7 张核心表数据库课程设计评分时最先被翻开的一定是 ER 图不是代码量。机票预订系统的 ER 图如果只画出“航班、订单、用户”三个框答辩时基本撑不过两分钟反过来只要实体和关系能讲清楚后面的表结构、触发器都是顺水推舟。2.1 先把业务边界画清楚预订、支付、出票、退票落在哪些实体上不要把“订票”理解成一个动作。一次完整预订至少包含用户选航班、选舱位、生成订单、支付、出票、退票。每个动作背后对应的数据对象完全不同。用户选航班时读的是航班和舱位生成订单时要写订单主表和订单明细支付时要写支付记录并且把订单状态从“待支付”改成“已支付”出票后票的状态要能单独查询退票后舱位的已售数要减回去订单状态再变一次。这些状态流转如果全塞在一张表里过两天你自己都看不懂。我一般会先画实体再画关系乘客与订单是一对多订单与订单明细是一对多航班与舱位是一对多舱位与订单明细是多对一。这里最容易画错的地方是“订单明细要不要存在”。如果一张订单能买两张机票那订单主表和机票之间就是一对多必须拆出明细表否则就违反第一范式。2.2 7 张核心表航班、舱位、订单、订单明细、乘机人、支付、日志不建“用户表”而是把乘客表同时当作登录账号表课程设计阶段完全够用。七张核心表分别是表名作用关键字段passenger乘机人/登录用户id, username, password_hash, real_name, id_no, phoneflight航班主数据id, flight_no, departure_city, arrival_city, departure_time, arrival_time, base_price, statuscabin舱位与库存id, flight_id, cabin_class, total_seats, sold_seats, priceorders订单主表id, order_no, passenger_id, status, total_amount, created_at, paid_atorder_items订单明细id, order_id, flight_id, cabin_id, passenger_name, passenger_id_no, item_price, ticket_statuspayment支付记录id, order_id, amount, pay_method, pay_status, pay_timeoperation_log操作日志id, table_name, record_id, action, old_status, new_status, operator_id, op_time下面这一段建库脚本可以直接在 MySQL 里执行写的是 InnoDB 引擎字符集用 utf8mb4避免中文和生僻字出问题。CREATE DATABASE IF NOT EXISTS air_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE air_ticket; CREATE TABLE passenger ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, real_name VARCHAR(50) NOT NULL, id_no VARCHAR(18) NOT NULL, phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), UNIQUE KEY uk_id_no (id_no) ) ENGINEInnoDB; CREATE TABLE flight ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(10) NOT NULL, departure_city VARCHAR(50) NOT NULL, arrival_city VARCHAR(50) NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, base_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0取消 2延误, UNIQUE KEY uk_flight_no (flight_no), KEY idx_departure_time (departure_time) ) ENGINEInnoDB; CREATE TABLE cabin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_id BIGINT NOT NULL, cabin_class VARCHAR(10) NOT NULL COMMENT Y经济舱 C公务舱 F头等舱, total_seats INT NOT NULL, sold_seats INT NOT NULL DEFAULT 0, price DECIMAL(10,2) NOT NULL, UNIQUE KEY uk_flight_cabin (flight_id, cabin_class), CONSTRAINT fk_cabin_flight FOREIGN KEY (flight_id) REFERENCES flight(id) ) ENGINEInnoDB; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, passenger_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已退票 4已取消, total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_passenger (passenger_id), CONSTRAINT fk_orders_passenger FOREIGN KEY (passenger_id) REFERENCES passenger(id) ) ENGINEInnoDB; CREATE TABLE order_items ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, flight_id BIGINT NOT NULL, cabin_id BIGINT NOT NULL, passenger_name VARCHAR(50) NOT NULL, passenger_id_no VARCHAR(18) NOT NULL, item_price DECIMAL(10,2) NOT NULL, ticket_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未出票 1已出票 2已退票, KEY idx_order (order_id), KEY idx_flight (flight_id), CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_items_flight FOREIGN KEY (flight_id) REFERENCES flight(id), CONSTRAINT fk_items_cabin FOREIGN KEY (cabin_id) REFERENCES cabin(id) ) ENGINEInnoDB; CREATE TABLE payment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_method VARCHAR(20) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未支付 1成功 2失败, pay_time DATETIME NULL, UNIQUE KEY uk_order_id (order_id), CONSTRAINT fk_payment_order FOREIGN KEY (order_id) REFERENCES orders(id) ) ENGINEInnoDB; CREATE TABLE operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(50) NOT NULL, record_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, old_status TINYINT NULL, new_status TINYINT NULL, operator_id BIGINT NOT NULL, op_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_record (table_name, record_id) ) ENGINEInnoDB;金额字段用DECIMAL(10,2)而不是FLOAT是因为浮点数在累加和比较时会出精度问题机票价格需要精确到分字符串统一用VARCHAR并给足长度身份证号用VARCHAR(18)而不是BIGINT因为身份证号不是用来做算术运算的而且前面可能带X。flight.status这类能穷举的字段用TINYINT写 COMMENT 说明每个数字含义这是答辩时老师非常容易问到的点。2.3 范式与约束为什么订单明细要单独成表而不是一个字符串有人偷懒把两张票直接写进订单的remark字段“张三/李四/身份证号”。这样看似少一张表但随后所有查询都会变成噩梦统计航班卖了多少张票要解析字符串退一张票要改字符串多订一张要拼接字符串。第一范式要求字段不可再分这条约束不是理论摆设它是为了让你后面的 SQL 能写下去。第二范式要求非主键字段完全依赖于主键。订单明细表能独立出来就是因为“乘机人、航班、舱位、价格”都依赖订单明细这一行的 id而不是订单主表 id。第三范式则提醒你不要把航班名称冗余到订单明细里航班名称可以从 flight 表 JOIN 出来真要做历史快照也只冗余“当时的价格”这一个字段并且要明确写上冗余的理由。我一般还会在乘机人表上额外加一个 CHECK 约束只允许 18 位身份证号。MySQL 8.0 以上对 CHECK 约束支持得比较可靠低版本上它会被忽略。如果你不确定当前 MySQL 版本是否执行 CHECK可以在程序端校验两条路都做最稳。3. 让库存真正“可卖”订票事务、存储过程与触发器的最小实现表结构建好了接下来要解决的是“订票”这个动作为什么能保证不错。很多课程设计做到这里就断了Java 里先查余票再执行 INSERT看起来没问题但两个浏览器同时下单就会超卖。数据库并发这一关必须在数据库层解决。3.1 订票流程的前后顺序先锁库存还是先写订单正确的顺序是先锁定舱位行再判断余票最后写订单和明细。如果先 INSERT 订单再更新库存另一个事务可能在你的订单写完前也插入成功等两边提交库存已经超卖。数据库里的行锁解决的是这个窗口期问题不是让你在业务代码里用synchronized去锁。用SELECT ... FOR UPDATE把舱位这一行锁住之后第二个事务再执行同样的语句就会阻塞直到第一个事务提交或回滚。这是在课程设计里演示“数据库并发锁”最直接的方式也是老师最想看到的细节。3.2 一个能跑的最小订票存储过程含事务与行锁下面这个存储过程是整套方案的核心适合放在课设报告里作为“核心数据库逻辑”一节DELIMITER $$ CREATE PROCEDURE book_ticket( IN p_order_no VARCHAR(32), IN p_passenger_id BIGINT, IN p_flight_id BIGINT, IN p_cabin_class VARCHAR(10), IN p_passenger_name VARCHAR(50), IN p_passenger_id_no VARCHAR(18) ) BEGIN DECLARE v_cabin_id BIGINT; DECLARE v_total_seats INT DEFAULT 0; DECLARE v_sold_seats INT DEFAULT 0; DECLARE v_price DECIMAL(10,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; SELECT id, total_seats, sold_seats, price INTO v_cabin_id, v_total_seats, v_sold_seats, v_price FROM cabin WHERE flight_id p_flight_id AND cabin_class p_cabin_class FOR UPDATE; IF v_cabin_id IS NULL THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT cabin not found; END IF; IF v_sold_seats v_total_seats THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT no seat available; END IF; UPDATE cabin SET sold_seats sold_seats 1 WHERE id v_cabin_id; INSERT INTO orders(order_no, passenger_id, status, total_amount) VALUES (p_order_no, p_passenger_id, 0, v_price); INSERT INTO order_items(order_id, flight_id, cabin_id, passenger_name, passenger_id_no, item_price) VALUES (LAST_INSERT_ID(), p_flight_id, v_cabin_id, p_passenger_name, p_passenger_id_no, v_price); COMMIT; END$$ DELIMITER ;这个存储过程做了三件关键事。第一在事务里用SELECT ... FOR UPDATE锁住对应舱位行锁住之后其他会话的同类订票请求会排队。第二用IF v_sold_seats v_total_seats判断余票若已满则回滚并抛出业务错误而不是返回“成功但没票”。第三库存、订单、明细三张表的写入在同一个事务里要么全部提交要么全部回滚。参数p_cabin_class传的是Y、C、F这样的舱位代码p_order_no建议用“日期时间随机数”生成比如20250101123000 4位随机数避免主键冲突也能在演示时看出是哪一秒下的单。LAST_INSERT_ID()取的是刚插入订单的自增主键用来写明细表不必先查一遍。3.3 触发器在课设里的正确用法航班状态变更与日志审计存储过程把订票这个“写操作”扛下来了触发器可以负责另一件事状态变更审计。下面这个触发器会在订单状态被修改时自动往 operation_log 里插一条记录。CREATE TRIGGER trg_order_status_log AFTER UPDATE ON orders FOR EACH ROW BEGIN INSERT INTO operation_log(table_name, record_id, action, old_status, new_status, operator_id) VALUES (orders, OLD.id, update, OLD.status, NEW.status, OLD.passenger_id); END;触发器适合做这种“旁路日志”但不适合放太重的业务规则。比如“余票不足禁止下单”这种判断如果放在触发器里报错信息会变得很难排查触发器里的错误在应用层捕获时错误堆栈往往指向触发器内部而不是你的 Java 代码。所以我的习惯是核心数据一致性交给存储过程或应用层事务触发器只做审计、统计字段更新这类辅助工作。4. 把 SQL 接到应用层JDBC 连接 MySQL 的增删改查最小闭环课设报告里写“采用 MVC 架构”但老师真正检查的是数据库增删改查是不是走了参数化查询、事务有没有提交、连接有没有关。先别纠结框架把 JDBC 这层跑通后面换 MyBatis 都是顺手的事。4.1 从数据库到 Java最小 JDBC 连接与 CRUD我用 Java 写一个最简连接类只做一件事按城市查航班。这个例子覆盖了建连接、写 SQL、执行查询、处理结果集、关闭资源五个步骤。import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; public class FlightDao { private static final String URL jdbc:mysql://localhost:3306/air_ticket ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/Shanghai useSSLfalseallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD your_password; public void findFlightsByCity(String city) { String sql SELECT flight_no, departure_city, arrival_city, departure_time, arrival_time, base_price FROM flight WHERE departure_city ?; try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, city); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { System.out.println( rs.getString(flight_no) rs.getString(departure_city) - rs.getString(arrival_city) rs.getTimestamp(departure_time) ); } } } catch (Exception e) { e.printStackTrace(); } } }这段代码里有两个容易被课设老师点名的细节。一是PreparedStatement的参数用?占位然后用setString传入这能防止 SQL 注入也让 MySQL 可以缓存执行计划。二是 try-with-resources 写法Connection、PreparedStatement、ResultSet全部自动关闭很多翻车现场就是只关了 Connection结果连接池里连接耗尽程序跑半小时后卡死。4.2 连接参数与时区为什么查出来总是差 8 小时或报时区错误连接串里的参数不是随便抄的。serverTimezoneAsia/Shanghai指定了数据库会话时区如果不指定MySQL 8.0 的 JDBC 驱动会直接报错提示The server time zone value ... is unrecognized。useSSLfalse表示本地开发不启用 SSL 加密避免每次连接多一次握手allowPublicKeyRetrievaltrue是为了配合 MySQL 8.0 默认的caching_sha2_password认证方式不设置时客户端可能报Public Key Retrieval is not allowed。如果项目里用连接池常见选择是 HikariCP。它的最小配置一般是这样jdbcUrljdbc:mysql://localhost:3306/air_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernameroot passwordyour_password maximumPoolSize5 connectionTimeout30000maximumPoolSize别一下开 50课程设计本机演示 5 个连接完全够开太多反而让 MySQL 频繁创建线程。真遇到慢查询先看 SQL 和索引不是加连接池大小。4.3 演示场景订单查询与余票查询如何验证表结构设计表结构设计得好不好看两条业务 SQL 就能判断。第一条是查航班余票SELECT f.flight_no, c.cabin_class, c.total_seats, c.sold_seats, c.total_seats - c.sold_seats AS available_seats FROM flight f JOIN cabin c ON c.flight_id f.id WHERE f.flight_no CA1234;这条 SQL 验证了 flight 与 cabin 的关联也验证了余票不是靠“订单字符串解析”算出来的。第二条是查一个乘客的全部订单SELECT o.order_no, o.status, o.total_amount, i.passenger_name, i.item_price FROM orders o JOIN order_items i ON o.id i.order_id WHERE o.passenger_id 1 ORDER BY o.created_at DESC;能跑通这两条说明外键关系、一对多拆分、状态字段设计都没问题。答辩时直接展示这两条 SQL 的执行结果比展示十个界面截图更有说服力。5. 避坑与排查数据库课程设计里最常见的 5 个翻车点再规范的方案落到自己电脑上也会遇到一堆“玄学”问题。这一章把数据库课程设计里最常见的 5 个翻车点按“现象 → 原因 → 解决”列出来都是能直接照做的排查路径。5.1 外键约束失败cannot delete or update a parent row现象删除航班时MySQL 报错Cannot delete or update a parent row: a foreign key constraint fails。原因order_items 表外键指向 flight 表只要这张航班下存在订单明细删除航班就会触发外键保护。这其实是 InnoDB 在保护你的数据不是 Bug。解决业务上“取消航班”应该用状态字段把flight.status改成 0而不是 DELETE。如果确实要物理删除必须先删除子表数据再删父表。课设答辩时建议主动讲“为什么这里不能直接删航班”这能体现你对数据完整性的理解。5.2 并发超售两个会话同时订最后一张票现象一个航班放出 10 个经济舱座位卖了 12 张订单。原因应用层先查余票再执行 INSERT两个查询同时发现“还剩 1 张”然后各自完成下单没有锁超卖必然发生。解决用第 3 章的存储过程方案SELECT ... FOR UPDATE锁住舱位行或者退一步在 UPDATE 时加条件UPDATE cabin SET sold_seats sold_seats 1 WHERE id ? AND sold_seats total_seats;然后检查UPDATE影响行数为 0 说明没抢到座位回滚整个事务。这样即使不写存储过程也能在应用层控制住超卖。5.3 时间字段变 1970-01-01 或查出的小时数差 8 小时现象Java 控制台里打印的departure_time比数据库里少 8 小时偶尔还会出现1970-01-01这种诡异日期。原因MySQL 的TIMESTAMP类型在存储时会转换成 UTCJDBC 驱动的默认时区如果和数据库时区不一致转换结果就会偏移。DATETIME不依赖时区但如果连接串里没写serverTimezone驱动仍可能用 JVM 默认时区去解析。至于1970-01-01多半是结果集里取的时间字段实际为 NULL而框架把它默认成了new Date(0)。解决表里存储时间统一用DATETIME连接串固定加上serverTimezoneAsia/ShanghaiJava 侧的变量类型用LocalDateTime不要用java.util.Date避免隐式时区转换。出现 NULL 时单独判断不要直接格式化。5.4 数据库死锁Deadlock found when trying to get lock现象并发跑订票或退票逻辑时MySQL 报Deadlock found when trying to get lock; try restarting transaction。原因两个事务以不同顺序加锁。比如事务 A 先锁 orders 再锁 cabin事务 B 先锁 cabin 再锁 orders两边互相等对方释放锁数据库检测到循环等待后会选择回滚其中一个事务。解决让所有事务按相同顺序加锁。在存储过程里固定“先锁 cabin再插 orders最后插 order_items”基本能避开。应用层拿到死锁异常后不要直接报错给用户捕获SQLException并判断错误码40001重试一次。课设里能写出这个重试逻辑会让老师觉得你真正处理过生产问题。5.5 ER 图与真实表结构对不上现象Word 文档里画的 ER 图很漂亮但把你提供的 SQL 导入 MySQL 后字段对不上、外键根本不存在。原因画 ER 图用的是 Visio 或手绘建表 SQL 又是另一套两边没有同步。答辩时老师导入你的 SQL一眼就能看出不一致。解决用SHOW CREATE TABLE orders;或DESC orders;查看真实表结构然后用数据库工具反向生成 ER 图。Navicat、MySQL Workbench、DBeaver 这类工具都有“反向工程”功能直接从数据库生成 ER 图再截图贴进 doc。这一步比手动画图省时间还能保证报告与代码完全一致。6. 进阶收尾用 EXPLAIN、并发模拟和失败演示把课设做成答辩级课程设计做到能跑只是及格线真正拉开差距的是验证过程。这三个收尾动作不需要额外写几千行代码却能让你的报告和答辩提升一个档次。第一个动作是用EXPLAIN看一条查询有没有走索引。比如在 order_items 上查某航班的订单明细EXPLAIN SELECT * FROM order_items WHERE flight_id 123;只要key列不是 NULL说明命中了idx_flight索引如果出现Using filesort就说明排序字段缺索引。报告里放一两张 EXPLAIN 截图能直接证明你关注查询性能而不是只会 SELECT *。第二个动作是模拟并发订票。开两个 MySQL 会话同时执行CALL book_ticket(...)正常情况下第二个会话会阻塞在SELECT ... FOR UPDATE上等第一个提交后才继续。把这个现象截图放进去再配一句“验证了行锁对超卖的抑制作用”这就是数据库并发最直观的证明。第三个动作是主动演示失败场景。答辩时先演示“余票不足下单失败”再查询订单表和库存表证明失败时数据没有被污染有条件的话再演示一次死锁重试逻辑。很多学生对系统讲解只挑成功路径走老师一提问就露怯。报错处理和回滚本身就是系统设计的一部分主动展示反而显得扎实。我自己当初做课设时也吃过亏ER 图画得好看但没跑通并发答辩现场老师开了两个页面同时下单库存直接变负数。后来养成的习惯是每个写操作都写“正常路径 失败路径”两套演示先在文件里写清楚预期现象再上机跑。这套习惯后来带到生产环境救了不少次发布。希望帮到你。本文还有配套的精品资源点击获取