简介面向数据库课程设计、毕业设计、期末大作业及工程实训场景的火车售票系统完整项目包基于C#与.NET环境开发包含可运行的源码、Visual Studio解决方案及数据库文件适合有一定编程基础、需要快速复现或在此基础上扩展功能的学习者。压缩包共165个文件大小约18.14MB涵盖cs源码、resources资源、cache编译缓存、exe可执行文件、pdb调试符号、resx界面资源、mdf/ldf数据库文件、sln工程文件以及pdf设计报告等整体目录结构按工程规范组织便于对照模块学习和二次开发。目前已有189人浏览学习。项目代码经过测试运行功能完备可直接复现复刻内置设计报告可借鉴其数据库表设计、界面交互与业务逻辑实现也可基于现有模块继续扩展余票查询、订单管理等功能。下载后建议先阅读说明文件并结合工程结构了解依赖配置从而快速进入开发状态。1. 数据库课程设计为什么火车售票系统是每届都绕不开的题“数据库课程设计 火车售票系统.zip”这个题我见过太多次了。它不像商城、博客那么套路化核心是余票、订单、车次三张表之间那点并发博弈做深一点能学到事务隔离级别和行锁做浅一点至少能把 ER 图和触发器讲明白。很多同学拿到压缩包第一反应是找现成代码但答辩老师问一句“你事务没提交会怎样”就露馅。这篇按我实际带过的做法从建表到存储过程再到最容易踩的并发坑带你把这个题做出能讲清楚的深度。2. 从需求到表结构先分清车次、票、座位的关系2.1 车次、库存与订单先分清“一辆车”和“一次发车”火车售票系统的需求拆出来其实就三类查车次、买车票、退票改签。大多数关系模式是四个实体车次、库存、订单、用户。很多同学会把“座位”单独建表一对一关联订单然后发现维护成本特别高。常见做法是把座位聚合到余票数量上按硬座、硬卧、软卧三种票种维护剩余数。这样的好处是售票时只需要对一行库存做“扣减”操作不需要扫描几百个座位去找哪一个是空的。做完这个取舍ER 图上一共四个实体关系也说得清楚。设计表时有一个关键点车次表本身不存“日期”。同一次列车每天开所以库存表里必须同时有车次号和出发日期。从三范式角度讲train_stock 的业务唯一性就是 (train_no, travel_date, seat_type)如果只用一个自增 id 做主键报表统计时可能出现重复数据。我会把这三个字段做唯一键同时用自增 id 作为物理主键。这样既方便外键引用也保证了业务上不重复。“改签”如果做了不要硬改订单主键。常见做法是原订单置“已退票”再生成一张新订单保留历史记录。这一点在答辩时很加分因为它体现了数据审计意识而不只是会写增删改查。2.2 建表 SQL第一版能直接跑的库这里给你一套我常用的 MySQL 建表脚本字符集直接上 utf8mb4避免后面对中文数数据发愁CREATE DATABASE train_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE train_ticket; CREATE TABLE train ( train_no VARCHAR(20) PRIMARY KEY COMMENT 车次号如 G123, train_name VARCHAR(50) NOT NULL COMMENT 列车名称, start_station VARCHAR(30) NOT NULL COMMENT 始发站, end_station VARCHAR(30) NOT NULL COMMENT 终点站, depart_time TIME NOT NULL COMMENT 发车时间, arrive_time TIME NOT NULL COMMENT 到达时间 ) ENGINEInnoDB; CREATE TABLE train_stock ( id INT AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(20) NOT NULL, travel_date DATE NOT NULL, seat_type VARCHAR(10) NOT NULL COMMENT hard_seat/hard_sleeper/soft_sleeper, total_tickets INT NOT NULL COMMENT 该票种总票数, remain_tickets INT NOT NULL COMMENT 当前余票数, price DECIMAL(8,2) NOT NULL COMMENT 该票种单价, UNIQUE KEY uq_train_date_seat (train_no, travel_date, seat_type), CONSTRAINT fk_stock_train FOREIGN KEY (train_no) REFERENCES train(train_no) ) ENGINEInnoDB; CREATE TABLE user_account ( user_id INT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL, id_card VARCHAR(30) NOT NULL COMMENT 证件号唯一, UNIQUE KEY uq_id_card (id_card) ) ENGINEInnoDB; CREATE TABLE ticket_order ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, train_no VARCHAR(20) NOT NULL, travel_date DATE NOT NULL, seat_type VARCHAR(10) NOT NULL, ticket_count INT NOT NULL DEFAULT 1, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未支付 1已支付 2已退票 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user_account(user_id) ) ENGINEInnoDB;这套表结构里train_stock 是核心表。它把“某车次在某天某票种还剩多少票”作为一行数据购票时直接锁这一行后面所有并发控制都在它上面做。ticket_order 的 order_status 用数字而不是字符串方便后续扩展状态机也省存储空间。price 放到 train_stock 而不是 train 表是因为不同票种价格不同同一车次不同日期也可能有浮动价。这里有个值得注意的地方ticket_order 没有对 train_stock 建外键。订单不指向某条库存记录而是指向“车次日期票种”这个业务维度。如果强行加外键到 train_stock.id反而会导致退票时库存变化后订单外键说不清。课程设计里这个点经常被老师追问建议提前想好说法。2.3 约束与外键哪些该加哪些会给自己挖坑主键和唯一键的取舍要明确。train_stock 的 UNIQUE KEY 负责业务唯一自增 id 只负责物理定位。train 表用业务车次号当主键是因为车次号本身全局唯一没必要再造一个 id。user_account 的 id_card 加唯一索引避免同一个证件号注册多个账号。外键要不要建要看你的使用场景。train_stock 对 train 建外键保证了不会出现某趟列车的库存记录乱飘。但这也意味着你想删一条车次记录时如果它还有库存或订单数据库会直接拒绝。有些同学为了演示删除功能把外键全部删掉结果数据完整性没法讲。我的做法是保留外键删除业务用“逻辑删除”完成也就是给 train 表加一个 is_active 字段默认 1删除车次时更新为 0查车次列表默认只查 is_active 1。这样既演示了删除又不破坏历史订单。约束设计还要注意默认值。order_status 默认 0create_time 默认 CURRENT_TIMESTAMP这些写在 DDL 里应用层就不容易漏字段。课程设计的代码里常见问题就是 insert 时 status 没赋值后面查“已支付订单”永远查不到多半是默认值没建好。3. 售票逻辑落库事务、存储过程与并发控制3.1 先查余票再更新为什么容易翻车最朴素的售票逻辑是查一下剩余票数如果够就 UPDATE然后插入订单。这个思路在单用户测试时没问题但并发一上来就翻车。假设某趟车只剩 2 张票两个用户同时各买 2 张。事务 A 查询到余票 2事务 B 也查询到余票 2A 把余票更新成 0B 也把余票更新成 0。最终两个订单各卖 2 张车票卖超了。更极端的情况是只剩 1 张票两个用户各买 1 张两个事务都读到 1都判断“够”都执行扣减最后余票变成 -1。这个问题不是靠“先更新再查询”能解决的而是因为两个事务在可重复读隔离级别下各自看到的快照里余票都是旧值。数据库并不知道你之前读到的值和当前行最新值之间的因果关系。解决思路有两类。第一类是用 UPDATE 语句自带条件比如UPDATE train_stock SET remain_tickets remain_tickets - 1 WHERE train_no G123 AND travel_date 2025-06-01 AND seat_type hard_seat AND remain_tickets 1;这样语句执行时MySQL 会对命中的行加锁直到事务提交或回滚才释放。第二个事务来执行同样 UPDATE 时会被阻塞等第一个事务提交后它基于最新值重新判断条件就不会再超卖。应用层只需要检查 UPDATE 影响行数是 1 就下单是 0 就说明余票不足。第二类是我更推荐在课程设计中讲的方案先 SELECT ... FOR UPDATE 锁定库存行再做业务判断。它能直观展示“锁等待”的过程答辩演示也更好看。接下来就用存储过程把它封装起来。3.2 用 SELECT FOR UPDATE 锁住余票行买票存储过程我一般把购票逻辑全部放进存储过程应用层只负责传参数和拿返回值。这样事务边界清楚也避免每个客户端语言各自实现一遍扣票逻辑。下面是核心代码DELIMITER $$ CREATE PROCEDURE buy_ticket( IN p_user_id INT, IN p_train_no VARCHAR(20), IN p_travel_date DATE, IN p_seat_type VARCHAR(10), IN p_ticket_count INT, OUT p_order_id BIGINT, OUT p_ret_code INT ) BEGIN DECLARE v_remain INT DEFAULT NULL; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_ret_code -500; END; START TRANSACTION; SELECT remain_tickets INTO v_remain FROM train_stock WHERE train_no p_train_no AND travel_date p_travel_date AND seat_type p_seat_type FOR UPDATE; IF v_remain IS NULL THEN ROLLBACK; SET p_ret_code -2; -- 车次、日期、票种不存在 ELSEIF v_remain p_ticket_count THEN ROLLBACK; SET p_ret_code -1; -- 余票不足 ELSE UPDATE train_stock SET remain_tickets remain_tickets - p_ticket_count WHERE train_no p_train_no AND travel_date p_travel_date AND seat_type p_seat_type; INSERT INTO ticket_order( user_id, train_no, travel_date, seat_type, ticket_count, order_status ) VALUES ( p_user_id, p_train_no, p_travel_date, p_seat_type, p_ticket_count, 1 ); SET p_order_id LAST_INSERT_ID(); SET p_ret_code 0; COMMIT; END IF; END$$ DELIMITER ;这段代码的关键在SELECT ... FOR UPDATE。它会对命中的库存行加排他锁直到事务 COMMIT 或 ROLLBACK 才释放。两个事务同时执行这个存储过程时后者会在 SELECT 处阻塞等前者提交后重新读到新余票再判断是否足够。DECLARE v_remain INT DEFAULT NULL 而不是 DEFAULT 0是为了区分“记录不存在”和“余票为 0”。如果准备用 UPDATE 影响行数判断那 v_remain 初始值是什么影响不大但用 SELECT INTO 时必须小心。这里我还加了 EXIT HANDLER 做异常兜底。任何 SQL 异常都会触发 ROLLBACK并把返回码置为 -500。这样应用层至少能感知到“不是业务失败是系统异常”不会把脏数据留在库里。返回码约定建议全项目统一0 成功-1 余票不足-2 库存记录不存在-500 系统异常。3.3 退票存储过程把余票还回去还要防重复退票退票逻辑比买票简单但有个小坑要防止同一订单被重复退。我习惯先更新订单状态用order_status 1作为条件再检查影响行数。下面是退票过程的简化版本DELIMITER $$ CREATE PROCEDURE refund_ticket( IN p_order_id BIGINT, IN p_user_id INT, OUT p_ret_code INT ) BEGIN DECLARE v_train_no VARCHAR(20); DECLARE v_travel_date DATE; DECLARE v_seat_type VARCHAR(10); DECLARE v_ticket_count INT; DECLARE v_status TINYINT; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_ret_code -500; END; START TRANSACTION; SELECT train_no, travel_date, seat_type, ticket_count, order_status INTO v_train_no, v_travel_date, v_seat_type, v_ticket_count, v_status FROM ticket_order WHERE order_id p_order_id AND user_id p_user_id FOR UPDATE; IF v_status IS NULL THEN ROLLBACK; SET p_ret_code -2; -- 订单不存在 ELSEIF v_status ! 1 THEN ROLLBACK; SET p_ret_code -1; -- 订单不是已支付状态不允许退 ELSE UPDATE ticket_order SET order_status 2 WHERE order_id p_order_id AND order_status 1; UPDATE train_stock SET remain_tickets remain_tickets v_ticket_count WHERE train_no v_train_no AND travel_date v_travel_date AND seat_type v_seat_type; SET p_ret_code 0; COMMIT; END IF; END$$ DELIMITER ;退票这里有一个隐藏并发点如果用一个连接先查订单状态再更新另一个连接同时退同一订单两个都可能读到“已支付”导致重复退。用UPDATE ... WHERE order_status 1并检查影响行数能保证只有第一个事务改成功。此处的 SELECT 加 FOR UPDATE 是为了把订单行锁住防止后面取到的票种、张数在更新过程中被别人改写。锁的顺序也值得提一下。买票是先锁库存行、再插入订单退票是先锁订单行、再更新库存。如果不同事务按不同顺序锁两个资源可能出现死锁。课程设计规模下偶尔死锁一次很难复现但老师问到锁顺序时能答出“尽量所有事务都按同一个资源顺序加锁”会非常加分。4. 查询与统计让车票列表不卡住的索引设计4.1 查车票场景里的慢 SQL买票之前要把车次列表展示给用户常见查询是“查某天从 A 城到 B 城的车显示每种票的余票和价格”。最直白的写法SELECT t.train_no, t.train_name, t.start_station, t.end_station, s.seat_type, s.price, s.remain_tickets FROM train t LEFT JOIN train_stock s ON t.train_no s.train_no WHERE t.start_station A城 AND t.end_station B城 AND s.travel_date 2025-06-01 ORDER BY t.depart_time;如果只造了很少的测试数据这个 SQL 怎么跑都快。但课程设计演示时老师可能会让你插入几万条 train_stock 记录再问为什么列表页变慢。此时你会发现 LEFT JOIN 的驱动顺序可能与你想的相反优化器可能先扫 train_stock再回表查 train导致 travel_date 条件没有走任何索引。我在做这类查询时会先给 train 表加一个按车站查询的索引ALTER TABLE train ADD INDEX idx_start_end (start_station, end_station);这样先通过车站过滤得到少量车次再去关联库存表。库存表这边建索引时要特别注意已有 UNIQUE KEY 的顺序是 (train_no, travel_date, seat_type)它没法高效支撑WHERE travel_date ?这种条件。所以要单独为查询再加一个索引。4.2 联合索引怎么建优先等值条件再考虑排序针对“按日期查余票”这个高频动作我一般会在 train_stock 上建这样的索引ALTER TABLE train_stock ADD INDEX idx_stock_query (travel_date, train_no, seat_type);这个索引的列顺序不是随便写的。查询条件里 travel_date 是等值条件放在最左边train_no 用于和 train 表关联也是等值关联放第二位seat_type 用于展示放第三位。这样索引能最有效地定位到少量行避免对整张库存表做全表扫描。还有一个容易被忽视的点如果要让查询直接从索引返回数据、不回表可以把余票和价格也放进索引。InnoDB 的二级索引叶子节点会存主键 id但不会存其他列所以SELECT remain_tickets, price仍需要回表。在 MySQL 里做一个覆盖索引ALTER TABLE train_stock ADD INDEX idx_stock_cover (travel_date, train_no, seat_type, remain_tickets, price);这样 SELECT 列表中的字段都在索引里执行计划会显示 Using index不需要回表。不过索引列越多插入和更新成本越高。对课程设计来说数据量不大加一个覆盖索引完全够用还能在演示 EXPLAIN 时拿出真实证据。回到最初的查询加上这两个索引后执行计划通常会变成先通过 train 的 idx_start_end 找到 A 城到 B 城的车次集合再用车次号和日期去 stock 表走 idx_stock_cover。如果数据量大还可以用 IN 子查询先取车次再关联但普通场景不需要把 SQL 写复杂。4.3 聚合报表统计区间售票量别用错函数课程设计除了页面展示通常还要做“每日售票情况”报表。报表查询一般是统计某段时间内各车次卖出了多少张票SELECT train_no, DATE(create_time) AS sale_date, SUM(ticket_count) AS sold_tickets FROM ticket_order WHERE order_status 1 AND create_time 2025-06-01 00:00:00 AND create_time 2025-07-01 00:00:00 GROUP BY train_no, DATE(create_time) ORDER BY sold_tickets DESC;这个 SQL 的注意点在 WHERE 条件。我习惯用create_time 起始时间 AND create_time 结束时间而不是BETWEEN避免边界是 7 月 1 日 0 点整时把下个月的订单也算进来。GROUP BY 后面用了 DATE(create_time)这个函数会让索引失效但对报表数据量来说通常可以接受如果老师较真可以在订单表加一个 sale_date 冗余字段插入时单独存日期查询直接按 sale_date 分组。对应索引可以这样建ALTER TABLE ticket_order ADD INDEX idx_order_status_time (order_status, create_time);order_status 是等值条件create_time 是范围条件这个组合是标准的“等值在前、范围在后”。还有一个经验不要在一个表达式字段上建索引去迎合WHERE DATE(create_time) ?应当把范围条件写清楚让索引能走区间扫描。课程设计里把 EXPLAIN 结果截图放进报告再简单说明索引覆盖这个章节的分数基本稳了。5. 课程设计避坑指南余票超卖、字符集和外键顺序5.1 现象余票变成负数余票出现负数是最常见也最致命的错误。现象是并发测几次后train_stock 的 remain_tickets 变成 -1 或更小而订单表里对应的订单早已成功插入。原因就是前面说的“先查后改”没有加锁两个事务同时读到同一个旧余票各自扣减后结果不一致。也可能是存储过程里忘了 START TRANSACTIONSELECT FOR UPDATE 在自动提交模式下根本不持锁。解决方法是双保险。第一在存储过程里用 SELECT ... FOR UPDATE 锁行或者用带remain_tickets p_ticket_count条件的 UPDATE。第二给余票字段加约束兜底。MySQL 8.0 可以给表加 CHECK 约束ALTER TABLE train_stock ADD CONSTRAINT chk_remain_not_negative CHECK (remain_tickets 0);这个约束挡不住超卖但能保证数据不会变成负数。一旦出现负数说明业务逻辑就是错的。我在实际调试时还会在扣减语句后加一行IF ROW_COUNT() 0 THEN ROLLBACK; SET p_ret_code -1; END IF;用 UPDATE 影响行数判断可以少写一次 SELECT 锁也是一个可行方案。5.2 现象中文写入变成“??”或乱码明明数据库表里能看到中文插入的“硬座”却变成问号或者从程序查询出来全是乱码。这个问题几乎每个做课程设计的同学都遇到过而且排查起来很玄学。原因通常是三层不一致数据库字符集不是 utf8mb4、JDBC 连接 URL 没指定编码、存储过程传参时连接字符集不对。建库虽写了 DEFAULT CHARACTER SET utf8mb4但客户端连接如果没告诉服务端自己用什么编码服务端可能用默认 latin1 处理。解决方法是同时检查三处。连接 URL 必须带jdbc:mysql://localhost:3306/train_ticket?useUnicodetruecharacterEncodingutf8存储过程调试时先执行SET NAMES utf8mb4;再用 SHOW FULL COLUMNS FROM train_stock 确认 seat_type 字段的 Collation 是 utf8mb4_unicode_ci。我记得有次排查半天最后发现是建表时表用了 utf8mb4但连接池配置文件里写了 characterEncodingGBK。所以统一标准数据库、表、连接、JDBC URL 全部用 utf8mb4 / UTF-8不要混搭。5.3 现象删除一条车次记录提示外键约束失败演示删除功能时执行 DELETE FROM train WHERE train_no G123数据库直接报 Cannot delete or update a parent row。原因很简单train_stock 表通过外键引用 trainG123 仍有库存记录删不掉是外键在保护数据完整性。很多同学会选择把外键删掉但答辩时老师会问“库存记录和车次对不上怎么办”。我的解决方法是避免物理删除。给 train 表加一个 is_active 字段删除时更新为 0查询时默认只查 is_active 1。这样外键保留演示时删除动作看起来正常历史订单也能对上车次名。如果老师非要求物理删除演示那顺序必须是先删除或逻辑处理相关订单再删除库存记录最后删除车次。最好在存储过程里做这个顺序而不是在应用层分步执行。5.4 现象程序跑几次之后连接池报 Connection is not available这个现象在图形界面程序里特别典型第一次点按钮正常第二次变慢第三次卡死最后控制台报连接超时或连接池被耗尽。原因通常是连接没有关闭。很多同学只关了 Statement没关 ResultSet或者开着事务后迟迟不 COMMIT/ROLLBACK连接一直被占用。数据库连接是宝贵的资源连接池最多给你几个连接全被占住后新的请求只能排队等到超时。解决方法是统一用 try-with-resources或者在 finally 里依次关闭 ResultSet、PreparedStatement、Connection。另外存储过程内部既然已经管理事务应用层就不要画蛇添足地 setAutoCommit(false) 再自己提交否则可能造成事务挂起。排查时可以先看SHOW FULL PROCESSLIST;如果看到大量线程处于 Sleep 状态且对应连接长时间不关闭基本就是代码里连接没释放。这个习惯在课程设计里养成以后工作能省很多事。6. 最后一步用 JDBC 把系统跑起来并验证事务6.1 连接配置与最小可运行片段存储过程写好后我用 Java 做最小验证时代码会保持很短String url jdbc:mysql://localhost:3306/train_ticket ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/Shanghai; try (Connection conn DriverManager.getConnection(url, root, your_password)) { try (CallableStatement cs conn.prepareCall({call buy_ticket(?,?,?,?,?,?,?)})) { cs.setInt(1, 1); cs.setString(2, G123); cs.setDate(3, Date.valueOf(2025-06-01)); cs.setString(4, hard_seat); cs.setInt(5, 2); cs.registerOutParameter(6, Types.BIGINT); cs.registerOutParameter(7, Types.INTEGER); cs.execute(); if (cs.getInt(7) 0) { System.out.println(购票成功订单号 cs.getLong(6)); } else { System.out.println(购票失败返回码 cs.getInt(7)); } } }因为存储过程内部已经执行 COMMIT 或 ROLLBACK这里不需要再手动提交。如果老师要求事务必须由应用层控制那就要把存储过程中的 START TRANSACTION 和 COMMIT 去掉然后在 Java 里用 setAutoCommit(false) 包住整个调用代码量会更大。课程设计建议把事务放在存储过程里理由只有一个逻辑集中不容易漏提交。6.2 并发验证两个客户端演示行锁答辩时最能说明并发问题的方法是开两个 MySQL 客户端窗口同时执行BEGIN; SELECT remain_tickets FROM train_stock WHERE train_no G123 AND travel_date 2025-06-01 AND seat_type hard_seat FOR UPDATE;第一个窗口先执行并保持事务不提交第二个窗口执行同一条 SQL 会卡住。等到第一个窗口执行 ROLLBACK第二个窗口立刻返回结果。这个演示能直观说明 FOR UPDATE 的锁行为也解释了为什么存储过程里查到的余票一定是准确的。进阶验证是写一个多线程 Java 程序让十几个线程同时调 buy_ticket检查最终余票数加上已售订单数是否等于总票数。这个结论可以作为课程设计报告里的重要测试用例。6.3 我收尾时一定会检查的几个点每次交课程设计前我都会花十分钟做这几件事第一执行SELECT * FROM train_stock WHERE remain_tickets 0确认没有负数余票第二执行SHOW FULL PROCESSLIST确认没有长时间挂起的事务第三重复调两次退票存储过程第二次必须返回“订单不是已支付状态”而不是数据异常第四让几个同学同时点购票按钮结束后核对订单总数和余票变化是否吻合。这些点看起来不起眼却是我自己带项目时踩过坑后总结出的“后悔药”。一个火车售票系统功能做再多只要并发数据错了课程设计基本就翻车。反过来能把并发一致性讲清楚哪怕界面朴素一点分数也不会低。希望帮到你。本文还有配套的精品资源点击获取