简介:数据库课程设计实验报告围绕火车票售票管理系统展开,是一份面向软件工程专业的完整课程设计文档。系统以Eclipse和MySQL为开发平台,实现车次管理、车票管理、售票、退票、查询及异常处理等核心功能。报告从系统开发平台、数据库规划、需求分析到数据库逻辑与物理设计均有详细说明,包含ER图、数据字典、关系表、索引设计及安全机制,并给出功能模块、界面设计、事务设计和测试运行结果。资源为1个doc文档,大小741KB,内容结构清晰,可直接作为数据库课程设计的参考模板。目前已有1330人学习下载。通过该报告,读者可系统掌握数据库设计的完整流程,学习如何将业务需求转化为关系模型,并借鉴其测试与总结的写作思路,适合正在完成数据库课程设计或需要快速理清系统设计脉络的高校学生参考。
1. 火车票售票管理系统:数据库课程设计里那个“看着简单却最考功底的题目”
很多同学拿到的课程设计题目是“火车票售票管理系统”,第一反应是“建三张表,写几个增删改查”。但真正做起来才发现,老师最喜欢问的是并发下怎么保证不超卖,这背后是数据库课程里最核心的ER建模、范式、事务和锁。这个题目能把课本上的散点知识串成一条完整链路。
这篇文章从这个经典题目出发,讲清楚一套能落地的路径:从需求分析到建表SQL,从购票事务到并发锁,再到避坑和压测。读者可以是正在赶数据库课程设计的学生,也可以是准备答辩前想查缺补漏的人。标题里的实验报告只是载体,真正值钱的是报告背后你能不能把“为什么这样设计”讲明白。下面按顺序拆,每一步都能照着做。
2. 需求到数据模型:ER图、范式和四张核心表的设计
2.1 需求分析怎么落成表结构:用户、车次、订单的边界
一个火车票系统要演示给老师看,需求边界一定要收敛。我一般把范围圈成四个动作:注册登录、查余票、购票、退票。这个边界足够覆盖数据库课程设计要求的增删改查,又不会把精力耗在会员积分、选座这类非数据库核心的点上。四个动作对应四个实体,听上去简单,但有一个坑:实体不是“火车”,而是“车次”,更准确地说,是“某一天的车次”。
为什么不能把余票直接挂在车次表上?因为K1152次列车今天和明天的余票大概率不同,余票必须绑定到“车次+日期”这条动态记录上。很多实验报告把remaining_tickets放在车次表里,老师一眼就能看出你漏了时间维度。所以我多设计了一张发车计划表schedules,它才是余票真正所在的位置。
| 表名 | 职责 | 关键字段 |
|---|---|---|
| users | 用户账号 | id, phone, password_hash, created_at |
| trains | 车次静态信息 | id, train_no, origin, destination, departure_time, arrival_time |
| schedules | 车次+日期的动态余票 | id, train_id, run_date, remaining_tickets, version |
| orders | 购票订单 | id, order_no, user_id, schedule_id, ticket_count, status, created_at |
这四张表是整个系统的骨架。用户对订单一对多,车次对发车计划一对多,发车计划对订单一对多,关系清楚。ER图落地时按三步走:先用矩形画实体,再用菱形画关系,最后把“一”端的主键加到“多”端作为外键。完成之后,把属性里的可重复部分拆掉,比如用户电话号码就直接存一个phone字段,不需要拆区号和座机,因为没有这个业务场景。
订单表的设计我会刻意保持精简。订单明细、乘客身份证、座位号这些都不放进orders,因为课程设计考察的是表结构设计,不是模拟12306。订单里只放user_id、schedule_id、ticket_count和status,status用0正常、1已支付、2已退票三个值,就能支撑演示。
2.2 范式选择与反范式:为什么第三范式还要留冗余字段
四张表初步设计下来,需要做一次正式的范式检查。以orders表为例,主键是自增id,非主键字段order_no、user_id、schedule_id都完全依赖于id,不存在部分依赖,所以满足第二范式。再看有没有传递依赖:user_id能决定username,但order_no不通过username才能确定user_id,所以没有传递依赖,满足第三范式。trains和schedules更干净,本身就是3NF。
实验报告里一定要写这段分析,这是基础分。但只做到3NF还不够,订单列表页是最常见的查询场景,看车次号、出发日期、起终点时,如果严格按范式走,每次都要orders JOIN schedules JOIN trains,一次查询多两次IO。为了解决这个热点,可以在orders表里冗余train_no和run_date两个字段,让订单页只查orders一张表就能展示主要内容。
反范式不是随便加的,我加冗余字段有两个前提:字段来源稳定,且源表有唯一键保证一致性。train_no由车次表定义,run_date是发车计划里已经固定好的日期,两个都不会频繁变更,风险可控。代价是如果某天车次号真被改了,所有冗余了旧车次号的订单都要同步更新,这个在课程设计里可以接受,但要在文档里写清楚。
可执行的做法是:先按3NF画出表,再从热点查询里筛出可以被冗余的字段,最后在建表SQL的字段注释里写明“冗余自trains.train_no,用于减少订单列表JOIN”。这样既展示了范式功底,又展示了性能意识。索引设计也在这一步定基调:users.phone做唯一索引用于登录,orders.order_no做唯一索引用于退票,schedules表的(train_id, run_date)联合唯一索引是余票更新的根。
3. 建库建表与增删改查:把最小可跑订单流写在SQL里
3.1 建库建表脚本:主键、外键与自增列的配置
数据库我用MySQL,字符集选utf8mb4,因为要存中文站名。下面是完整建库建表脚本,直接复制就能跑出最小环境。
CREATE DATABASE train_ticket DEFAULT CHARACTER SET utf8mb4; USE train_ticket; CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL COMMENT '登录手机号', password_hash VARCHAR(64) NOT NULL COMMENT '密码哈希', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ); CREATE TABLE trains ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(10) NOT NULL COMMENT '车次号', origin VARCHAR(50) NOT NULL COMMENT '始发站', destination VARCHAR(50) NOT NULL COMMENT '终点站', departure_time TIME NOT NULL, arrival_time TIME NOT NULL, UNIQUE KEY uk_train_no (train_no) ); CREATE TABLE schedules ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, train_id BIGINT UNSIGNED NOT NULL, run_date DATE NOT NULL COMMENT '发车日期', remaining_tickets INT NOT NULL DEFAULT 0 COMMENT '剩余票数', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_train_date (train_id, run_date), CONSTRAINT fk_sched_train FOREIGN KEY (train_id) REFERENCES trains (id) ); CREATE TABLE orders ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '业务单号', user_id BIGINT UNSIGNED NOT NULL, schedule_id BIGINT UNSIGNED NOT NULL, ticket_count INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已支付 2已退票', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_schedule (schedule_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES users (id), CONSTRAINT fk_order_sched FOREIGN KEY (schedule_id) REFERENCES schedules (id) );这段脚本里有几个参数和设计是刻意的。id全部用BIGINT UNSIGNED AUTO_INCREMENT,课程设计规模下不会溢出,也避免手动分配主键;外键全部用CONSTRAINT显式命名,方便后面删除和定位报错;orders表里对schedule_id建普通索引,既是为了退票时按发车计划查订单,也顺便让外键约束的查询走索引。特别注意schedules表的uk_train_date联合唯一索引,它保证同一天同一车次只有一行余票记录,这是后面扣减余票时防止出现两条互相矛盾的记录的前提。
表建好后,先用几条简单INSERT填充数据,再进入查询环节。注意我这里没用复合主键,而是统一自增单主键,对InnoDB的聚簇索引更友好,外键引用也更简洁。
3.2 核心查询:按车次查余票的SQL写法与索引运用
查余票是演示里最基础也最常被问的SQL。下面这条语句实现了“查某天北京到上海的所有车次及余票”。
SELECT t.train_no, t.origin, t.destination, t.departure_time, t.arrival_time, s.remaining_tickets FROM trains t JOIN schedules s ON s.train_id = t.id WHERE t.origin = '北京' AND t.destination = '上海' AND s.run_date = '2025-06-01' ORDER BY t.departure_time;逻辑说明:先通过trains表筛选出所有符合条件的车次,再与schedules表做连接,取当天余票。这里的连接条件s.train_id = t.id利用了trains.id主键和uk_train_date索引的开头部分。如果数据量不大,用EXPLAIN能看到执行计划会先走trains的索引找到北京到上海的车次,再去schedules按train_id和run_date回表。当数据量大时,建议在trains表加一个(origin, destination, departure_time)联合索引,避免全表扫描。
接着是下单时最核心的两条操作,注意这两条必须配合事务,单独执行会出大问题。
-- 步骤一:插入订单记录 INSERT INTO orders (order_no, user_id, schedule_id, ticket_count, status) VALUES ('T20250601001', 1, 1, 1, 0); -- 步骤二:扣减对应发车计划的余票 UPDATE schedules SET remaining_tickets = remaining_tickets - 1 WHERE id = 1;这里如果不加事务,就会出现“订单插入成功但余票没扣,或者余票扣了但订单没生成”的情况。很多课程设计只演示到这里,老师问“两条语句之间断了怎么办”就答不上来。下一章专门处理这个。
退票的查询则是反方向:
-- 先更新订单状态为已退票 UPDATE orders SET status = 2 WHERE order_no = 'T20250601001'; -- 再把余票加回去 UPDATE schedules SET remaining_tickets = remaining_tickets + 1 WHERE id = 1;同样的,两条操作也要在一个事务里。另外在订单列表页,我用了冗余字段来减少JOIN,但订单详情页还是需要把用户和车次信息带出来,这时连接查询不可避免。一条通用的订单明细SQL如下:
SELECT o.order_no, u.phone, t.train_no, s.run_date, t.origin, t.destination, o.ticket_count, o.status FROM orders o JOIN users u ON u.id = o.user_id JOIN schedules s ON s.id = o.schedule_id JOIN trains t ON t.id = s.train_id WHERE o.user_id = 1;这条SQL的优化点在users.id、schedules.id、trains.id这些主键连接上,InnoDB主键索引自带聚簇性质,所以连接效率足够。如果orders表数据量上涨,就只要确保WHERE条件里o.user_id有索引即可。
4. 事务、并发锁与购票一致性:为什么不能只靠UPDATE
4.1 购票事务:BEGIN TRANSACTION与隔离级别的选择
单条UPDATE自带隐式事务,但购票是“插入订单+扣减余票”两步,必须放在显式事务里。用事务不是为了快,是为了让两个操作要么全成功,要么全失败。下面是一个标准的购票事务写法。
START TRANSACTION; -- 1. 锁住这张发车计划的余票记录 SELECT id, remaining_tickets FROM schedules WHERE id = 1 FOR UPDATE; -- 2. 业务校验:如果余票<=0,ROLLBACK,否则继续 -- 这里用应用程序判断查询结果即可 -- 3. 插入订单 INSERT INTO orders (order_no, user_id, schedule_id, ticket_count, status) VALUES ('T20250601002', 2, 1, 1, 0); -- 4. 扣减余票 UPDATE schedules SET remaining_tickets = remaining_tickets - 1 WHERE id = 1; COMMIT;逻辑说明:SELECT ... FOR UPDATE是对schedules表中id为1的这一行加排他锁,锁一直保持到事务提交或回滚。这段窗口期内,其他事务再执行同一个FOR UPDATE,会被阻塞等待。这样做把“判断余票、插入订单、扣减余票”变成了一个互斥操作,天然防止超卖。注意锁的是schedules行,不是orders表,因为所有购票压力的瓶颈都在余票这一行数据上。
关于隔离级别,MySQL默认是REPEATABLE READ,对新手最友好,但这个场景我更推荐显式设置成READ COMMITTED。原因是购票的核心矛盾是写冲突,而不是同一事务内的重复读一致性。READ COMMITTED下,FOR UPDATE依然能锁行,但减少了间隙锁的范围,死锁概率更低。设置方法:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;这个命令只影响当前会话,不影响全局配置。实验报告里最好截一下SELECT @@transaction_isolation;,然后写清楚为什么选这个级别。还有两个参数值得提:innodb_lock_wait_timeout默认50秒,太长了,演示时两个会话互相等,老师会失去耐心。我一般把它设成5秒:
SET SESSION innodb_lock_wait_timeout = 5;同样,会话级只对当前连接生效。如果要做全局配置,需要改my.cnf,但课程设计环境下建议用会话级避免影响其他同学。
4.2 并发锁:悲观锁与乐观锁在实际购票中的取舍
FOR UPDATE就是悲观锁,核心思路是“先拿锁,再干活”。它的好处是简单,事务提交前别人动不了这一行,不用考虑重试。坏处也很明显,锁持有期间其他事务只能等,吞吐量上不去,而且锁等待超时就会报错。
乐观锁走另一条路:不锁行,而是在更新时用version条件来保证没人改过。实现方式就是在schedules表里定义version字段,更新时把版本号带上。
UPDATE schedules SET remaining_tickets = remaining_tickets - 1, version = version + 1 WHERE id = 1 AND remaining_tickets > 0 AND version = 0;这里version = 0是应用层之前查出来的版本号。执行后,如果影响行数是1,说明没人抢,更新成功;如果影响行数是0,说明版本已经变了或者余票已经不足,应用层要重新查一遍再重试。乐观锁的优点是锁时间极短,只在UPDATE这一瞬间生效,并发能跑得更高;缺点是写冲突频繁时,大量请求会做无用的重试。
这两个方案怎么选?我的判断是:课程设计的火车票系统,冲突率高且并发规模不大,用悲观锁更合适。因为FOR UPDATE的代码逻辑直观,答辩时容易讲清楚;乐观锁的重试机制更像应用层工程,放在数据库报告里反而显得偏离主题。如果硬要在实验报告里展示优化空间,可以把两种方式都写出来,然后比较。
实际操作中,SELECT ... FOR UPDATE有一个常见误用:锁了整张表。比如有人写成SELECT * FROM schedules WHERE run_date = '2025-06-01' FOR UPDATE;,这样会一次性锁多行,并发能力直线下降。正确做法是锁列,WHERE条件精确到主键id,每次只锁一个发车计划的那一行。退票同理,也先锁对应schedule行,再改订单状态,最后余票加回。
5. 避坑与排查:实验报告里最常被扣分的五个数据库问题
5.1 死锁:两个会话互相等待导致购票失败
现象:并发同时购票时,某个会话报错Deadlock found when trying to get lock; try restarting transaction,整个事务被自动回滚。
原因:两个事务没有按同样的顺序锁表或锁行。比如事务A先锁schedules再插入orders,事务B先插入orders再锁schedules,两边互相持有对方想要的锁,就形成了循环等待。
解决:统一全系统的加锁顺序,先锁schedules行,再做订单插入,最后提交。同时把隔离级别降到READ COMMITTED,减少锁范围。如果死锁仍发生,应用层要捕获异常并重试,通常重试一次就能成功。
5.2 脏读/不可重复读:隔离级别设错
现象:一个事务还没提交,另一个事务就查到了它改过的余票,导致界面显示和实际不一致。
原因:会话隔离级别被改成了READ UNCOMMITTED,或者根本没有用事务包裹“查询+更新”的完整流程。
解决:用命令行检查当前隔离级别:
SELECT @@transaction_isolation;如果是READ UNCOMMITTED,改成READ COMMITTED或REPEATABLE READ。同时买票流程里,查询余票的SELECT必须和后续UPDATE放在同一个事务中,不能先开一个SELECT查完数据再开另一个事务更新,中间隔太久容易被其他会话插入数据。
5.3 外键约束失败:删车次时被订单挡住
现象:执行DELETE FROM trains WHERE id = 1;时,报外键约束错误foreign key constraint fails。
原因:schedules表和orders表里还有引用这个车次的数据,外键的默认行为RESTRICT不允许删除。
解决:不要物理删除车次,改成逻辑删除,增加status字段,置0表示停运。删除订单和发车计划同理。课程设计报告里应该写这句话:外键失败不是bug,是数据库在保护引用完整性,所以业务系统里大多用逻辑删除。
5.4 余票出现负数:扣减时没有条件校验
现象:压测时发现某车次余票变成-1,甚至更小。
原因:UPDATE语句只写了remaining_tickets = remaining_tickets - 1,没有在WHERE里加remaining_tickets > 0。两个事务同时读到余票是1,都认为可以卖,都执行扣减,结果变成-1。
解决:在扣减SQL里同时加上余票条件,并检查影响行数。
UPDATE schedules SET remaining_tickets = remaining_tickets - 1 WHERE id = 1 AND remaining_tickets > 0;应用层执行后判断受影响行数,如果是0,就抛出“无余票”异常,不要继续插入订单。这个方案比先SELECT再判断更紧凑,也是后面压测时能保证余票不为负数的关键。
5.5 报告只贴SQL没有设计说明
现象:交上去的实验报告贴满了建表和查询的截图,老师问“为什么order_no要加唯一索引”“为什么schedules要联合唯一索引”,完全答不上来。
原因:把实验报告写成了操作记录,缺少每个键和参数的设计理由。
解决:在建表脚本的关键字段后面加COMMENT,并在报告的索引设计表格里列出索引名、字段和针对场景,比如uk_train_date是针对“某天某车次余票”查询的,idx_schedule是针对“退票时按发车计划找订单”的。设计理由写三五行即可,这比截图更让老师信服。
6. 进阶验证:用存储过程压测并发并写出一份漂亮的实验报告
6.1 用存储过程做并发压测并验证余票
演示用线程模拟并发最麻烦,但如果把购票逻辑封装成一个存储过程,你就能用一个简单的循环脚本压它。下面是我在课程设计里用的存储过程模板。
DELIMITER // CREATE PROCEDURE buy_one_seat( IN p_schedule_id BIGINT, IN p_user_id BIGINT ) proc: BEGIN DECLARE v_remaining INT DEFAULT 0; DECLARE v_order_no VARCHAR(32); START TRANSACTION; SELECT remaining_tickets INTO v_remaining FROM schedules WHERE id = p_schedule_id FOR UPDATE; IF v_remaining <= 0 THEN ROLLBACK; LEAVE proc; END IF; SET v_order_no = CONCAT('T', UNIX_TIMESTAMP(), '_', p_user_id); INSERT INTO orders (order_no, user_id, schedule_id, ticket_count, status) VALUES (v_order_no, p_user_id, p_schedule_id, 1, 0); UPDATE schedules SET remaining_tickets = remaining_tickets - 1 WHERE id = p_schedule_id; COMMIT; END// DELIMITER ;逻辑说明:存储过程的参数p_schedule_id是发车计划id,p_user_id是用户id。它把加锁、查余票、插入订单、扣减余票四个动作都包在服务端,外部调用时无感知。注意v_order_no这里为了演示用了UNIX_TIMESTAMP拼接,严格来说高并发下可能重复,正式系统要换雪花ID,但课程设计足够用。调用方式也很简单:CALL buy_one_seat(1, 1);。
压测前,先把某一张schedule的余票初始化为100,然后开几个终端同时执行多次CALL,结束后查SELECT remaining_tickets FROM schedules WHERE id = 1;,如果结果等于100减去成功调用次数,且查询orders表没有负数余票的记录,说明事务和锁是生效的。这个存储过程放在实验报告里,比一堆截图更能证明你真的理解并发控制。
最后提醒一句,实验报告不是代码说明书,老师更爱看的是你踩坑之后总结出来的边界。我当年在这个题目上翻过最狠的车,就是余票变负数,后来才补上事务和锁。把这段排查过程写进报告,比贴十条运行结果都有说服力。希望帮到你。
本文还有配套的精品资源,点击获取