火车售票系统课程设计:数据库表结构与事务并发控制实战 简介一份面向数据库课程设计、毕业设计及期末大作业的火车售票系统完整工程包适合需要快速复现或扩展开发的学生开发者。包内提供可运行源码、工程文件及配套设计报告参考核心基于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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE passenger ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL UNIQUE, phone VARCHAR(20) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这份脚本里值得说明的是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 server127.0.0.1;port3306;databasetrain_db;uidroot;pwd123456;; connStr Poolingtrue;Min Pool Size2;Max Pool Size20;; connStr Connection Lifetime300;Connection Resettrue;;Min Pool Size2保证了常用查询不会被频繁建连Max Pool Size20限制了高峰期的连接占用避免数据库连接数被耗尽。Connection Resettrue会让连接在归还池时重置状态防止上一个事务的事务状态泄漏到下一个使用者。这些参数具体值不需要照抄但要理解它们关系到数据库并发锁和数据库死锁出现的概率。5. 对接 C# 前台工程从连接字符串到参数化查询的完整闭环拿到资源包里的工程文件后我建议先找app.config或web.config中的连接字符串确认指向的是 MySQL 还是 SQL Server。火车售票系统课程设计最常见的是 C# MySQL 组合也有部分使用 SQL Server。下面的代码基于 ADO.NET可以平移到你的工程中。5.1 三层结构中的数据库访问层在工程里数据库操作集中在 DAL 层。一个规范的连接字符串通常会加上CharSetutf8mb4否则插入生僻字或表情符号时会出现乱码。我见过很多同学把数据库连接字符串直接写在按钮点击事件里每次操作都 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; 21 | grep -E Duplicate|ERROR || echo success $i; done这个脚本连续往同一个座位插入 20 次预期只有一次成功其余都会因为唯一约束报错。你在课程设计报告中放上这段脚本的执行结果截图比任何文字描述都有说服力。最终你会发现整个火车售票系统的数据库设计最难的部分不是建表而是在事务边界和并发控制上把每一个细节都闭环。本文还有配套的精品资源点击获取