简介:一份面向数据库课程设计、毕业设计及期末大作业的火车售票系统完整工程包,适合需要快速复现或扩展开发的学生开发者。包内提供可运行源码、工程文件及配套设计报告参考,核心基于C#实现,包含数据库脚本(mdf/ldf)、界面资源与图形素材,覆盖登录、查询、订票、退票等典型业务模块,可直接部署运行并在此基础上二次开发。资源共165个文件,以cs源码、resources资源、配置文件及exe可执行程序为主,另含数据库文件、ico图标和说明文档,压缩包大小18.14MB,结构清晰、模块分明,便于按需查阅。目前已有189人学习下载,项目经过调试运行,功能稳定,可复现复刻。对于课程设计或初期项目立项,这套代码与报告能提供完整的业务逻辑参考和界面实现思路,同时可借鉴其数据库表设计与分层架构,有效节省搭建时间。
1. 火车售票系统课程设计:数据库设计才是评分的关键
拿到这份火车售票系统课程设计工程时,很多人第一反应是打开 csproj 文件找入口类,但这类项目评审真正看的是 ER 图、事务处理和数据库设计文档,而不是界面。我拆过不少类似作业,有一个很反直觉的结论:那些被评优的火车售票系统,SQL 脚本往往比代码更完整,尤其是在余票扣减和退票回滚上用了事务与行锁,而不是简单的 UPDATE。本文适合正在做数据库课程设计、毕业设计,或者想用 MySQL 复刻一个可运行售票系统的同学。我会从表结构设计讲到事务并发,再落到 C# 工程如何对接,保证你能照着一套思路把它跑起来。
2. 从需求到 ER 模型:车次、座位、订单与乘客怎么落表
2.1 实体识别:先列业务名词,再画关系
火车售票系统常见的实体有:车次、车厢、座位、车站、订单、乘客。我一般让学生先用一句话描述业务流程:乘客选择一个车次,在某个乘车日期下查看剩余座位,提交订单后系统锁座并扣减余票。这句话里的每个名词几乎都是一个表,每个动词都是一条外键关系。
2.1.1 车次与车厢为什么分开存
车次包含始发站、终点站、发车时间、到达时间、票价、车次编号。而车厢隶属于某个车次,会有车厢号、座位类型(一等座、二等座、硬卧)、座位数量。如果你把车厢字段直接塞进车次表,就会产生大量重复数据,比如一趟车有 8 节车厢,车次信息反复存 8 遍,更新发车时间时就要改 8 条记录。分开存的好处通过 JOIN 查询可以随时拼出完整车次信息,也方便后续扩展不同车厢不同票价。
2.1.2 座位与订单的关系要设计成可追溯
座位表需要记录车次ID、车厢ID、座位号、座位类型。但同一趟车每天都会发车,座位本身是物理资源,订单中记录的是某年某月某日某车次的某个座位。所以订单和座位之间还要通过一个乘车日期字段来关联。常见做法是座位表只存车次和物理位置,订单表里冗余一个乘车日期,再与座位表做联合唯一约束,防止同一天同一座位被重复售卖。
2.2 实体关系与基数:哪些是 1 对多,哪些是多对多
车次与车站之间是多对多关系,一个车次经停多个车站,一个车站也服务多个车次。大部分课程设计会把经停信息单独建一张表,而不是在车次表里放一串站名。字段至少包括:车次ID、车站序号、到达时间、离开时间、停靠时长。如果你把站点用逗号拼在车次表里,后续查“从 A 到 B 有哪些车次”会写得非常痛苦,全表扫描加字符串匹配,性能差且容易出错。
乘客与订单是 1 对多,一个乘客可以下多张订单,但一张订单对应一个乘客。为了照顾学生作业的演示场景,订单表里保存乘客 ID、购票数量、总价、下单时间、订单状态。订单与座位则是多对多,一张订单可以包含多个座位,一个座位也只能归属一个订单,所以中间表命名为订单明细表,每行代表一个座位的销售记录。
2.3 建表 DDL:字段类型与约束的取舍
下面是一份基于 MySQL 的建表脚本,对应课程设计中最常见的精简版模型。注意我在订单明细表上加了联合唯一约束,这是防止重复购票的关键。
CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL UNIQUE, start_station VARCHAR(50) NOT NULL, end_station VARCHAR(50) NOT NULL, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, ticket_price DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE carriage ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, carriage_no INT NOT NULL, seat_type ENUM('二等座','一等座','硬卧','软卧') NOT NULL, seat_count INT NOT NULL, FOREIGN KEY (train_id) REFERENCES train(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, carriage_id INT NOT NULL, seat_no VARCHAR(10) NOT NULL, seat_type ENUM('二等座','一等座','硬卧','软卧') NOT NULL, FOREIGN KEY (carriage_id) REFERENCES carriage(id), UNIQUE KEY uk_carriage_seat (carriage_id, seat_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE passenger ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL UNIQUE, phone VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, passenger_id INT NOT NULL, train_id INT NOT NULL, travel_date DATE NOT NULL, order_status ENUM('待支付','已支付','已退票','已取消') DEFAULT '待支付', total_amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (passenger_id) REFERENCES passenger(id), FOREIGN KEY (train_id) REFERENCES train(id), KEY idx_train_date (train_id, travel_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, seat_id INT NOT NULL, passenger_id INT NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (seat_id) REFERENCES seat(id), UNIQUE KEY uk_seal_travel (seat_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这份脚本里值得说明的是order_detail表的唯一键uk_seal_travel,它直接约束了同一个物理座位只能出现在一条订单明细中。由于订单表已经包含了travel_date,同一张座位在不同日期是可以重复销售的,所以这里按seat_id做唯一约束,而不是和travel_date联合。如果你把travel_date冗余到 order_detail 里,再对(seat_id, travel_date)建唯一索引,同样可行,代价是录入时要多维护一个字段。
orders表上的复合索引idx_train_date (train_id, travel_date)是为了加速“查某天某车次余票”的常见查询。这个索引后续在分析余票查询性能时还会用到。工程里常用的“余票”概念,其实可以拆成两段:一段是物理座位总数,另一段是已被订单占用的座位数,二者相减就是余票。
3. 余票查询、购票与退票:事务里最容易丢分的三个步骤
3.1 余票查询:基于区间与乘车日期的统计
余票不能用一张单独的表存一个数字,因为同一车次的余票在不同日期是不同的。常见做法是从订单明细反推:某车次在某个乘车日期已经卖出了哪些座位,再用座位总数减去已售数量。查询 SQL 大致如下:
SELECT t.train_no, c.seat_type, COUNT(s.id) AS total_seat, COALESCE(od.sold_count, 0) AS sold_seat, COUNT(s.id) - COALESCE(od.sold_count, 0) AS remain_seat FROM train t JOIN carriage c ON c.train_id = t.id JOIN seat s ON s.carriage_id = c.id LEFT JOIN ( SELECT d.seat_id FROM order_detail d JOIN orders o ON o.id = d.order_id WHERE o.train_id = 1 AND o.travel_date = '2025-06-01' AND o.order_status IN ('待支付','已支付') ) od ON od.seat_id = s.id WHERE t.id = 1 GROUP BY t.train_no, c.seat_type;这个 SQL 用LEFT JOIN把已售座位集合与全部座位集合做对比,COALESCE处理没有订单时的空值。需要注意order_status必须过滤掉已退票和已取消的订单,否则退票后余票不会恢复。如果你在课程设计说明里只写了“余票数 = 总数 - 订单数”而没考虑状态,答辩时很容易被老师追问。
3.2 购票事务:先锁行再插入,避免超卖
购票的核心问题是并发下不能卖出同一个座位。很多学生写的代码是先查询剩余座位,再插入订单,最后更新余票。这在单线程操作下没问题,一旦两个请求同时查到同一个空座位,就会重复售卖。解决办法是让插入操作直接触碰行锁,或者对关键行加锁。我推荐用事务配合唯一索引来做最后防线。
START TRANSACTION; INSERT INTO orders (order_no, passenger_id, train_id, travel_date, order_status, total_amount) VALUES ('202506010001', 1001, 1, '2025-06-01', '待支付', 88.00); INSERT INTO order_detail (order_id, seat_id, passenger_id) SELECT LAST_INSERT_ID(), s.id, 1001 FROM seat s WHERE s.carriage_id = 1 AND s.seat_no = '03A' AND NOT EXISTS ( SELECT 1 FROM order_detail d WHERE d.seat_id = s.id ); IF ROW_COUNT() = 0 THEN ROLLBACK; ELSE COMMIT; END IF;这里的关键点是第二个INSERT里的NOT EXISTS子查询。它会在插入前再次确认该座位没有被任何订单明细引用。由于order_detail.seat_id上有唯一约束,即使两个事务同时执行到这里,也只有一个能插入成功,另一个会报唯一键冲突,应用层只需要捕获这个异常并提示座位已被占。LAST_INSERT_ID()在事务内取到的是当前连接的订单 ID,不会因为其他用户的插入而混乱。
如果你后续要维护已购买量字段,可以在同一个事务里更新,而不是额外写一条UPDATE语句。记得事务里所有 SQL 都必须针对同一数据库连接执行,否则LAST_INSERT_ID()拿到的值可能不是你期望的那条订单。
3.3 退票回滚:状态更新与库存恢复的顺序
退票逻辑比购票更容易在细节上丢分。常见错误是直接删除订单记录,这会导致后续查历史报表时无法追溯。正确的做法是把订单状态改成“已退票”,同时从order_detail中删除对应的座位占用记录。这里有顺序问题:先删除明细,再更新订单状态,或者反过来,都必须放在同一个事务里,但顺序会影响外键检查行为。
START TRANSACTION; DELETE FROM order_detail WHERE order_id = 5001; UPDATE orders SET order_status = '已退票' WHERE id = 5001 AND order_status IN ('待支付','已支付'); IF ROW_COUNT() = 1 THEN COMMIT; ELSE ROLLBACK; END IF;ROW_COUNT()在这里用来判断更新是否命中。如果订单已经处于已退票状态,更新会返回 0 行影响,事务回滚,同时连带删除操作也一起回滚,保证了消费记录不会丢失。有些同学会把 delete 放后面,先改状态再删明细,逻辑上也能跑通,但如果删除失败,状态已经被改成已退票,数据库状态就不一致了。所以我的习惯是删除明细前置,用ROW_COUNT()做最后的确认。
4. 索引、锁与连接池:把课程设计讲到性能层面
4.1 复合索引设计与最左前缀原则
在课程设计答辩中,“你的查询为什么快”是一个高频问题。火车售票系统中的核心查询是按车次、乘车日期和站点区间过滤。对应的索引不能只建在单个字段上,需要理解复合索引的最左前缀。比如idx_train_date (train_id, travel_date)可以覆盖“查某车次某天”的查询,但如果单独查某一天的跨车次余票,这个索引就帮不上忙。
我一般会在orders表上再加一组索引idx_date_train (travel_date, train_id),让两个字段互换顺序。这样既支持“某车次某天”,也支持“某天全部车次”的浏览场景。两个索引听起来冗余,但在查询模式固定的课程设计项目里,代价可以接受。需要注意的是不要盲目给所有字段加索引,因为每次插入订单明细时都要同步维护索引,写入性能会下降。
4.2 InnoDB 行锁与事务隔离级别
如果使用 MySQL,默认存储引擎 InnoDB 的行锁只有在命中索引时才会生效。比如退票业务中的UPDATE orders SET order_status = '已退票' WHERE id = 5001,主键查询可以直接锁住这一行。但如果你写成WHERE order_no = 'xxx',并且order_no上有唯一索引,那也能锁行;如果没索引,InnoDB 会升级为表锁,整个订单表在事务期间都无法写入。课程设计里务必要给order_no建唯一索引,不仅是为了业务上防止重复订单号,更是为了让行锁真正落到一行上。
事务隔离级别建议使用默认的 REPEATABLE READ。MySQL 的 InnoDB 在 REPEATABLE READ 下通过间隙锁解决了一部分幻读问题,而购票场景中的NOT EXISTS插入已经通过唯一索引保证了最终正确性。不要为了演示性能去改成 READ UNCOMMITTED,因为那会读到其他事务尚未提交的座位状态,导致余票展示异常。
| 场景 | 推荐索引 | 锁范围 | 说明 |
|---|---|---|---|
| 按车次+日期查余票 | (train_id, travel_date) | 索引范围锁 | 命中复合索引,锁多行但可控 |
| 订单号唯一查验 | (order_no) | 唯一索引行锁 | 防止重复订单,锁定单行 |
| 按座位查明细 | (seat_id) | 唯一索引行锁 | 配合唯一约束防止重复售卖 |
| 按乘客查历史 | (passenger_id) | 普通索引行锁 | 过滤历史订单,避免全表扫描 |
4.3 连接池参数:超出课程设计范围的加分项
大多数课程设计只写了数据库连接字符串,但如果你在文档里加上连接池参数,会让项目看起来完整得多。以常见的 MySQL 连接池配置为例,Initial Pool Size控制启动时创建的连接数,Max Pool Size控制上限,Connection Lifetime控制连接重置周期。需要注意连接池中的连接如果长期闲置,MySQL 服务端的wait_timeout会断开连接,此时从池中取出的连接会抛异常。解决方式是设置连接空闲检查,很多 ORM 框架自带心跳检测,但原生 ADO.NET 里需要自己处理。
string connStr = "server=127.0.0.1;port=3306;database=train_db;uid=root;pwd=123456;"; connStr += "Pooling=true;Min Pool Size=2;Max Pool Size=20;"; connStr += "Connection Lifetime=300;Connection Reset=true;";Min Pool Size=2保证了常用查询不会被频繁建连,Max Pool Size=20限制了高峰期的连接占用,避免数据库连接数被耗尽。Connection Reset=true会让连接在归还池时重置状态,防止上一个事务的事务状态泄漏到下一个使用者。这些参数具体值不需要照抄,但要理解它们关系到数据库并发锁和数据库死锁出现的概率。
5. 对接 C# 前台工程:从连接字符串到参数化查询的完整闭环
拿到资源包里的工程文件后,我建议先找app.config或web.config中的连接字符串,确认指向的是 MySQL 还是 SQL Server。火车售票系统课程设计最常见的是 C# + MySQL 组合,也有部分使用 SQL Server。下面的代码基于 ADO.NET,可以平移到你的工程中。
5.1 三层结构中的数据库访问层
在工程里,数据库操作集中在 DAL 层。一个规范的连接字符串通常会加上CharSet=utf8mb4,否则插入生僻字或表情符号时会出现乱码。我见过很多同学把数据库连接字符串直接写在按钮点击事件里,每次操作都 new 一个连接,这样虽然能跑,但无法体现数据库连接池的复用。
public static DataTable ExecuteQuery(string sql, params MySqlParameter[] parameters) { using (var conn = new MySqlConnection(connectionString)) using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.AddRange(parameters); var adapter = new MySqlDataAdapter(cmd); var dt = new DataTable(); adapter.Fill(dt); return dt; } }我用using包裹连接和命令对象,保证方法结束后连接自动关闭并归还连接池。MySqlParameter数组用于传递查询参数,直接拼接 SQL 是很容易被注入的,这也是答辩时老师最爱问的点。
5.2 购票接口必须开显式事务
DAL 层的单条 SQL 不能保证业务完整性。购票需要同时插入订单表和订单明细表,这两步必须使用同一个MySqlTransaction。常见的做法是让事务在业务层开启,然后传入连接对象,或者在 DAL 层提供一个专门方法。
using (var conn = new MySqlConnection(connectionString)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { string sqlOrder = @"INSERT INTO orders (order_no, passenger_id, train_id, travel_date, order_status, total_amount) VALUES (@no, @pid, @tid, @date, '待支付', @amount);"; MySqlCommand cmdOrder = new MySqlCommand(sqlOrder, conn, tx); cmdOrder.Parameters.AddWithValue("@no", orderNo); // 注入其他参数... cmdOrder.ExecuteNonQuery(); long orderId = cmdOrder.LastInsertedId; string sqlDetail = @"INSERT INTO order_detail (order_id, seat_id, passenger_id) SELECT @oid, id, @pid FROM seat WHERE carriage_id = @carriageId AND seat_no = @seatNo AND NOT EXISTS (SELECT 1 FROM order_detail WHERE seat_id = seat.id);"; MySqlCommand cmdDetail = new MySqlCommand(sqlDetail, conn, tx); cmdDetail.Parameters.AddWithValue("@oid", orderId); int affected = cmdDetail.ExecuteNonQuery(); if (affected == 0) { tx.Rollback(); throw new Exception("座位已被占用"); } tx.Commit(); } catch { tx.Rollback(); throw; } } }注意cmdOrder.LastInsertedId是在同一连接和事务内取到的自增 ID。如果你中途关闭连接,这个属性就失效了。事务在try中要么全部提交,要么回滚,不会出现订单存在但明细丢失的问题。
5.3 验证并发场景的快速方法
课程设计交付前,我建议用一个简单的并发脚本验证是否超卖。打开两个 MySQL 命令行窗口,手动模拟两个事务同时执行购票插入,观察其中一个是否会报唯一键冲突。在应用层可以写一个多线程测试程序,同时发起 10 个购票请求,然后检查订单明细表中同一个seat_id是否只有一个订单。
# 模拟同时购票,实际项目建议用 jmeter 或并发工具 for i in $(seq 1 20); do mysql -uroot -p123456 train_db -e " START TRANSACTION; INSERT INTO order_detail (order_id, seat_id, passenger_id) SELECT 99999 + $i, 1, 1001 FROM seat WHERE id = 1; COMMIT;" 2>&1 | grep -E "Duplicate|ERROR" || echo "success $i"; done这个脚本连续往同一个座位插入 20 次,预期只有一次成功,其余都会因为唯一约束报错。你在课程设计报告中放上这段脚本的执行结果截图,比任何文字描述都有说服力。最终你会发现,整个火车售票系统的数据库设计,最难的部分不是建表,而是在事务边界和并发控制上把每一个细节都闭环。
本文还有配套的精品资源,点击获取