
简介面向C#与SQL Server初学者、课程设计及毕业设计人群的火车订票系统源码包围绕窗体应用、用户交互与数据库管理展开可用于学习GUI布局、事件驱动编程及订单业务状态流转。压缩包共128个文件、约1.85MB以50个cs源文件承载窗体与业务逻辑配合resx、resources窗体资源及sln、csproj解决方案配置另有exe、dll等编译产物和少量jpg预览图便于直接打开工程梳理结构。已有1437人学习下载适合作为中小型桌面项目的参考样例。读者可从中了解用户注册登录与密码修改、车次增删改查、订票退票改签等模块的组织方式并观察SQL Server数据操作、事务与并发控制、异常捕获及输入校验在代码中的落地从而把C#窗体开发和数据库应用知识串联起来。1. 火车订票系统真正的难点不是画界面是那一次扣减很多人第一次做 C# 火车订票系统都会把时间花在 WinForms 拖控件上——车次列表、席别下拉、下单按钮两天就能摆出一个像模像样的界面。真正让项目在演示环节翻车的往往是这么一幕两个人同时点下预订同一张硬卧余票从 1 变成 0再刷一次变成了 -1。界面没报错数据库也没报错订单还生成成功了。这份 C# SQL Server 火车订票系统资源价值不在于它有几个窗体、几种席别而在于它把余票扣减这条链路做对了座位库存单独建表、扣减带上条件与锁、下单写订单和扣库存放在同一个事务里。它适合三类人课程设计要交差的同学、想补一补 ADO.NET 事务这块短板的初级开发、以及需要一套能跑通查询—下单—退票闭环的练手项目的人。下面按数据库、数据访问层、业务主链路、踩坑、验证五个层次拆开讲。2. 数据库先立住七张表与三种余票模型怎么选这个系统的骨架全在 SQL Server 里C# 只是它的操作面板。表结构设计错了后面写再多代码都是给错误打补丁。2.1 七张表撑起一条完整的订票链路一套能跑通订票、退票、改签的最小表集合我一般会拆成下面七张。注意这里刻意把车次和经停站分开这是后面做区间查询的前提。表名作用关键字段数据量级教学/中小场景Users用户与管理员UserId、UserName、PwdHash、Salt、Role、Phone千级Trains车次主表TrainId、TrainNo、TrainType、StartStation、EndStation百级TrainStops经停站与时刻StopId、TrainId、Seq、StationName、ArriveTime、DepartTime、Mileage千级SeatInventory按日期的席别库存InventoryId、TrainId、RunDate、SeatType、Price、Total、Remain万级Orders订单主表OrderId、OrderNo、UserId、TrainId、RunDate、FromStation、ToStation、Status、Amount十万级OrderItems乘客与座位明细ItemId、OrderId、PassengerName、IdCard、SeatNo、TicketPrice十万级RefundLog退票/改签流水RefundId、OrderId、Fee、RefundAmount、OperateAt万级一个容易被忽略的点TrainStops 里的 Seq 不能省。区间查询完全靠上车站的 Seq 小于下车站的 Seq来判断方向没有这个序号反方向的同站名线路会串在一起。2.2 建表脚本把约束写进数据库别写进代码注释下面这段是核心三张表的建表语句重点看 CHECK、唯一索引和金额类型。约束放在数据库层比在 C# 里写 if 判断可靠得多——绕过 UI 直接连库改数据的人永远比你想的多。-- 车次经停表Seq 决定区间方向DepartTime 只存时分秒 CREATE TABLE TrainStops ( StopId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), Seq INT NOT NULL, StationName NVARCHAR(32) NOT NULL, ArriveTime TIME(0) NULL, DepartTime TIME(0) NULL, Mileage INT NOT NULL DEFAULT 0, CONSTRAINT UQ_Stop UNIQUE (TrainId, Seq), CONSTRAINT CK_Seq CHECK (Seq 0) ); -- 站名查询走这个索引顺序是 站名 - 车次 - 序号 CREATE INDEX IX_TrainStops_Station ON TrainStops(StationName, TrainId, Seq); -- 席别库存一个车次 一个运行日 一种席别 一行 CREATE TABLE SeatInventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), RunDate DATE NOT NULL, SeatType NVARCHAR(16) NOT NULL, -- 二等座/一等座/硬卧/软卧 Price DECIMAL(10,2) NOT NULL, Total INT NOT NULL, Remain INT NOT NULL, RowVersion ROWVERSION, CONSTRAINT UQ_Inv UNIQUE (TrainId, RunDate, SeatType), CONSTRAINT CK_Remain CHECK (Remain 0 AND Remain Total), CONSTRAINT CK_Price CHECK (Price 0) ); -- 订单主表OrderNo 是业务单号不是自增主键 CREATE TABLE Orders ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(24) NOT NULL, UserId INT NOT NULL REFERENCES Users(UserId), TrainId INT NOT NULL REFERENCES Trains(TrainId), RunDate DATE NOT NULL, FromStation NVARCHAR(32) NOT NULL, ToStation NVARCHAR(32) NOT NULL, SeatType NVARCHAR(16) NOT NULL, SeatCount INT NOT NULL, Amount DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, ExpireAt DATETIME2(0) NULL, CreatedAt DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_OrderNo UNIQUE (OrderNo), CONSTRAINT CK_SeatCount CHECK (SeatCount BETWEEN 1 AND 5) );几个参数值得单独说。Price 用 DECIMAL(10,2) 而不是 FLOAT票价算手续费时不会出现 0.30000000000000004 这种数。Remain 上的 CHECK 是最后一道防线哪怕事务逻辑写漏了数据库也会直接把负数挡回去报错而不是默默存下去。RowVersion 是给乐观并发用的后面第 6 章会讲怎么拿它做校验。RunDate 用 DATE 而不是 DATETIME是个刻意的选择运行日和发车时刻是两回事混在一个字段里跨天车次一定出问题。2.3 余票三种模型字段扣减、区间拆分、区段位图这是火车订票系统和普通电商库存最大的区别。一件商品扣一件就完了一张票却要覆盖上车站到站这一整段区间中间任何一段被占用这段票就卖不出去。模型存储方式优点代价适用A 总量扣减车次日期席别 一行Remain 减 1实现最简单一条 UPDATE 搞定卖始发—终点会挤占中间短途票区间配额不准课程设计、单一直达线路B 区间拆分按 OD 对建行n 个站产生 n(n-1)/2 行区间精确扣减逻辑直观行数随站数平方增长10 个站就是 45 行/席别/天中小规模、站数少的线路C 区段位图每个座位存一个位掩码标记占用的区段精确且省空间能做座位级复用需要位运算SQL 里写起来费劲真实售票系统、座位级复用这份资源走的是模型 A这也是绝大多数教学项目和中小系统的现实选择。它的代价必须提前知道一列车 500 个二等座如果按总量扣减卖了 100 张始发—终点中间上车的旅客就买不到了哪怕座位在这段区间其实是空的。如果要做准常见做法是把模型 A 和 B 混着用热门长区间单独建一行库存做配额其余走总量扣减或者在最粗的粒度上只在 TrainStops 里限制每站预留票额用一张 StationQuota 表控制每个上车站最多放出多少票。前者改动小后者更接近真实调度思路看你项目的答辩要求到哪一步。3. C# 数据访问层连接、参数化与事务边界数据库定完之后C# 这边所有代码都围绕 ADO.NET 三个对象转SqlConnection、SqlCommand、SqlTransaction。这一层写歪了前面表设计得再漂亮也白搭。3.1 连接字符串与 SqlConnection 的生命周期连接字符串里最容易出错的是把 Pooling 关掉或者用错认证方式。一个可用的模板长这样// App.config / appsettings.json 里存别硬编码在 .cs 文件里 public static class Db { // TrustServerCertificateTrue 仅本地开发用生产环境应配置有效证书 public const string ConnStr Server.;DatabaseTrainTicketing;User Idapp_user;Password***; Poolingtrue;Min Pool Size2;Max Pool Size100;Connect Timeout15; TrustServerCertificateTrue;; public static SqlConnection Open() { var conn new SqlConnection(ConnStr); conn.Open(); return conn; } }关键认知SqlConnection 的 Close/Dispose 归还的是逻辑连接物理 TCP 连接留在池子里复用。所以频繁开关连接会影响性能这个说法在 ADO.NET 里不成立正确的写法恰恰是即用即关// 正确using 保证异常路径也会归还连接 using (var conn Db.Open()) using (var cmd new SqlCommand(SELECT COUNT(*) FROM Trains, conn)) { var count (int)cmd.ExecuteScalar(); }我见过最典型的翻车写法是把 SqlConnection 存成窗体级的静态字段全程序共用一个。单用户演示毫无问题一旦两个人同时操作第二个人的命令就会报已有打开的与此 Command 相关联的 DataReader。连接不是稀缺资源连接池才是连接池里的连接数由 Max Pool Size 控制不是由你 new 了几个 SqlConnection 控制。3.2 参数化查询拼字符串省的十分钟排查要花一天登录框是 SQL 注入的重灾区。下面这段错误写法在用户名里输入 OR 11就能直接以第一个用户身份登进去// 错误示范字符串拼接永远不要这么写 var sql SELECT UserId, Role FROM Users WHERE UserName txtUser.Text AND PwdHash Hash(txtPwd.Text) ;正确做法是参数化并且显式指定 SqlDbType 和长度public static (int userId, string role)? Login(string userName, string pwdHash) { const string sql SELECT UserId, Role FROM Users WHERE UserName name AND PwdHash pwd; using var conn Db.Open(); using var cmd new SqlCommand(sql, conn); // 显式给长度和类型避免 AddWithValue 推断成 nvarchar(4000) 导致索引失效 cmd.Parameters.Add(name, SqlDbType.NVarChar, 32).Value userName; cmd.Parameters.Add(pwd, SqlDbType.VarChar, 64).Value pwdHash; using var rd cmd.ExecuteReader(); if (!rd.Read()) return null; return (rd.GetInt32(0), rd.GetString(1)); }参数说明UserName 在表里是 NVARCHAR(32)参数就必须给 NVarChar 和 32。如果用AddWithValue(name, userName)ADO.NET 会按字符串实际长度推断第一次传 3 个字符SQL Server 就缓存一个 nvarchar(3) 的执行计划后面传 20 个字符的查询会退化甚至触发重编译。字段是 VARCHAR 就给 VarChar别一把梭全用 NVarChar隐式转换会让索引直接失效。密码存 PwdHash 而不是明文加盐用 Salt 字段独立存哈希算法常见做法是 PBKDF2 或 BCrypt别用 MD5——这不是安全意识问题是答辩时会被追问的点。3.3 下单事务扣余票和写订单必须同生共死这是整个项目的心脏。一次下单要动三张表SeatInventory 扣减、Orders 插主单、OrderItems 插乘客明细。任何一步失败前面做的都得撤回去否则就会出现票扣了但没订单或者有订单但没扣票。public static (bool ok, string msg) PlaceOrder(OrderDto dto) { using var conn Db.Open(); using var tran conn.BeginTransaction(IsolationLevel.ReadCommitted); try { // 第一步条件扣减。把够不够和扣不扣压进同一条 UPDATE const string sqlDeduct UPDATE SeatInventory WITH (UPDLOCK, ROWLOCK) SET Remain Remain - cnt WHERE TrainId tid AND RunDate date AND SeatType seat AND Remain cnt;; using (var cmd new SqlCommand(sqlDeduct, conn, tran)) { cmd.Parameters.Add(cnt, SqlDbType.Int).Value dto.SeatCount; cmd.Parameters.Add(tid, SqlDbType.Int).Value dto.TrainId; cmd.Parameters.Add(date, SqlDbType.Date).Value dto.RunDate; cmd.Parameters.Add(seat, SqlDbType.NVarChar, 16).Value dto.SeatType; int affected cmd.ExecuteNonQuery(); if (affected 0) { tran.Rollback(); return (false, 余票不足或席别不存在); } } // 第二步写订单主表OrderNo 由时间戳 随机后缀生成 string orderNo DateTime.Now.ToString(yyyyMMddHHmmss) Random.Shared.Next(1000, 9999); // ... 插入 Orders 与 OrderItems 的代码同理全部挂 tran tran.Commit(); return (true, orderNo); } catch (SqlException ex) { // 547 是外键冲突2601/2627 是唯一索引冲突 tran.Rollback(); return (false, $下单失败{ex.Number}); } }逻辑说明分三层。UPDLOCK 是更新锁它保证两个并发事务不会同时读到 Remain1 然后各自去减——后到的事务会等前一个提交再重新判断Remain cnt。ROWLOCK 是给优化器的提示让它优先行锁而不是页锁减少并发时的锁冲突范围。Remain cnt写在 WHERE 里而不是先 SELECT 出来在 C# 里判断是为了让判断和扣减在数据库里原子完成中间没有空隙给别的会话插队。参数上date 传 DateTime 但字段是 DATEADO.NET 会自动截断时分秒这里给 SqlDbType.Date 更明确也避免某些版本下隐式转换走不到索引。SeatCount 上限 5 在表上已经用 CHECK 限死C# 里再校验一次只是为了让报错更友好。Rollback 放在 catch 里但要注意如果 Commit 已经执行成功再调 Rollback 会抛 InvalidOperationException。常见做法是把 Commit 作为 try 块的最后一句Rollback 只处理异常路径。4. 订票主链路区间查询、状态机与退改签差额数据库和数据访问层就位后业务逻辑反而是最薄的一层。真正需要想清楚的是三件事怎么把从 A 站到 B 站翻译成一条 SQL、订单状态怎么流转、退票改签的钱怎么算。4.1 车次查询区间匹配靠自连接索引要按站名打头经停表的结构决定了区间查询必须自连接同一趟车上车站一条记录下车站另一条记录且前者的 Seq 小于后者。-- 查询某天从 from 到 to 的所有车次 SELECT t.TrainNo, t.TrainType, s1.DepartTime AS FromDepart, s2.ArriveTime AS ToArrive, s2.Mileage - s1.Mileage AS Distance, i.SeatType, i.Price, i.Remain FROM TrainStops s1 JOIN TrainStops s2 ON s1.TrainId s2.TrainId AND s1.Seq s2.Seq JOIN Trains t ON t.TrainId s1.TrainId JOIN SeatInventory i ON i.TrainId t.TrainId AND i.RunDate date WHERE s1.StationName from AND s2.StationName to AND i.Remain 0 ORDER BY s1.DepartTime;s1.Seq s2.Seq这个条件不能少少了会把反方向的车次也捞出来。IX_TrainStops_Station 索引的列顺序是 StationName → TrainId → Seq正是因为 WHERE 里第一个等值条件是站名把选择性最高、过滤后行数最少的列放最前面后面的连接才能走索引查找而不是扫描。一个性能细节这条 SQL 一次返回所有席别前端按 TrainNo 分组展示即可。如果分成先查车次再逐个查席别10 个车次就是 11 次往返在局域网里感觉不出来一旦放到有延迟的环境就是明显的卡顿。4.2 订单状态机五个状态两条超时路径状态字段用 TINYINT 存数字比存中文字符串好维护但一定要有一张对照表或者枚举否则三个月后自己都看不懂 3 是什么。状态值含义可执行动作进入条件0待支付支付、取消下单成功写入 ExpireAt1已支付出票、退票支付回调成功2已出票退票、改签出票完成写入座位号3已退票无终态退票成功回补库存4已取消无终态超时未支付或用户主动取消超时取消是必须做的用户下了单不付款库存被占着别人买不了。实现方式是在 Orders 上写 ExpireAt下单时间 15 分钟然后起一个后台任务轮询-- 每 30 秒跑一次取消超时未支付订单并回补库存 UPDATE o SET o.Status 4 OUTPUT deleted.Remain FROM Orders o WHERE o.Status 0 AND o.ExpireAt SYSDATETIME();回补库存要和状态变更放在同一个事务里否则会出现订单取消了但票没回来。教学项目里常见做法是用 C# 的 System.Threading.Timer 每 30 秒调一次存储过程生产环境更稳的是 SQL Server Agent 作业。注意这个作业和用户下单事务之间会有锁竞争回补时的 UPDATE 同样要带 UPDLOCK并且按 InventoryId 排序更新避免死锁。4.3 退票手续费与改签差额DECIMAL 和舍入方式别搞错退票手续费按开车前时间阶梯计算这是业务规则里唯一需要小心数值的地方。// 阶梯费率开车前 8 天以上 5%48 小时以上 5%24-48 小时 10%24 小时内 20% public static decimal CalcRefundFee(decimal amount, DateTime departAt, DateTime now) { var span departAt - now; decimal rate span.TotalHours switch { 192 0.05m, 48 0.05m, 24 0.10m, _ 0.20m }; // 手续费四舍五入到分用 AwayFromZero 而不是默认的银行家舍入 return Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero); }参数说明amount 是原票价departAt 是发车时刻注意是 DATETIME 不是 DATEnow 传服务器时间而不是客户端时间——客户端时间可以被改教学项目里这个漏洞很常见。Math.Round 默认用的是银行家舍入MidpointRounding.ToEven2.5 会舍成 2票价场景下用户会觉得少收了或者多收了显式指定 AwayFromZero 更符合直觉。改签的处理逻辑是退旧买新的合并版先校验新席别有票扣新库存、回补旧库存、更新订单的车次与席别字段差额 新价 - 旧价正数让用户补负数退回。所有步骤同样在一个事务里。这里有个坑改签后 OrderNo 要不要变常见做法是不变另起一张 OrderChangeLog 记录变更历史否则对账时会对不上。5. 常见问题排查超卖、跨天、连接池和乱码这几条都是我实际调试这套系统时踩出来的按现象 → 原因 → 解决记下来遇到同样的报错可以对着查。5.1 余票被扣成负数界面还显示下单成功现象并发下单压测时SeatInventory.Remain 出现 -1、-2但每一单的 Orders 记录都完整。原因扣减逻辑写成了先 SELECT 查余票C# 判断大于 0再 UPDATE 减 1。这两条语句之间没有任何锁两个会话可能同时读到 Remain1同时判断通过然后各自减 1。这就是典型的检查与使用分离。解决把判断并进 UPDATE 的 WHERE 里见 3.3 的 sqlDeduct并对目标行加 UPDLOCK。更彻底的做法是给 Remain 加 CHECK 约束兜底一旦出现负数直接抛 547 错误让问题在测试阶段就暴露而不是上线后被用户发现。5.2 凌晨发车的车次查不到或者日期对不上现象查 3 月 1 日从某站出发的车次一班 00:30 发车的车次查不出来或者查出来了但库里的 RunDate 存的是 3 月 2 日。原因把发车时刻和运行日混在一个 DATETIME 字段里。跨天车次的运行日应该归属始发日而不是发车日凌晨 0 点到 4 点之间的车次通常算前一天的运行日。解决RunDate 用 DATE 单独存运行日TrainStops 里只存 TIME。查询时用s1.DepartTime startTime AND s1.DepartTime endTime过滤时段运行日按DepartTime 04:00 ? RunDate.AddDays(-1) : RunDate的规则归属。这个4 点分界是行业惯例教学项目里可以按需调整。5.3 运行一段时间后报超时时间已到但尚未从池中获取连接现象单机测试正常多人同时操作或者反复刷新列表后开始报连接池超时重启程序又好了。原因SqlConnection 或 SqlDataReader 没有释放连接只借不还。最常见的是在方法里 new 了连接中间抛异常没有 finally 或 using 兜底。另一个隐蔽原因是用了ExecuteReader()却没设CommandBehavior.CloseConnectionreader 不关连接就一直被占着。解决所有连接、命令、reader 一律用 using 包裹DataReader 要么读完要么显式 Close排查阶段可以在 SSMS 里跑SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE is_user_process 1看当前会话数如果持续上涨就是泄漏。真要强制回收SqlConnection.ClearAllPools()能救急但它会断开所有连接只能当调试手段。5.4 站名显示成问号或者按中文站名查不到数据现象车次列表里北京南显示成??或者 WHERE StationName 北京南 查出来零行但表里明明有数据。原因数据库排序规则是 Latin1 或者字段类型用了 VARCHAR 而不是 NVARCHAR中文字符在传输过程中被截断。另一个原因是 C# 里拼 SQL 时字符串没加 N 前缀北京南会被当成 VARCHAR 字面量。解决所有存中文的字段统一用 NVARCHAR(n)建库时排序规则选 Chinese_PRC_CI_AS 或对应语言版本参数化查询走 SqlDbType.NVarChar 就不会有字面量前缀问题这也是坚持参数化查询的额外好处。5.5 订单插入报外键冲突或者事务卡住不返回现象下单时报INSERT 语句与 FOREIGN KEY 约束冲突或者程序卡在 Commit 上不返回SSMS 里能看到大量 LCK_M_X 等待。原因前者通常是 TrainId 或 UserId 传了 0 或者不存在的值前端下拉框没选中时默认值是 0。后者是事务开启后中间某条语句抛了异常但没走 Rollback 分支事务一直挂着占锁。解决所有外键字段在 C# 侧做非零校验UI 层下拉框强制选择事务统一写成 try-catch-rollback 结构并在 catch 里记录 ex.Number547 是外键、2601/2627 是唯一索引、1205 是死锁牺牲者。遇到 1205 不要盲目重试先看两个事务的加锁顺序是否相反——库存表和订单表的更新顺序在全程序里必须一致。6. 用脚本造五千条数据把并发真跑一遍功能写完不等于做对验证这一步很多人省了答辩时被问两个人同时买怎么办就开始支支吾吾。我的做法是造一批数据直接用多线程压一遍。先造库存和订单基础数据用系统表做笛卡尔积生成序列比写循环快得多-- 为未来 30 天、20 个车次、4 种席别各造一行库存每行 100 张票 INSERT INTO SeatInventory (TrainId, RunDate, SeatType, Price, Total, Remain) SELECT t.TrainId, DATEADD(DAY, d.n, CAST(GETDATE() AS DATE)), s.SeatType, s.Price, 100, 100 FROM Trains t CROSS JOIN (SELECT TOP 30 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n FROM sys.all_objects) d CROSS JOIN (VALUES (二等座, 553.00), (一等座, 933.00), (硬卧, 322.00), (软卧, 498.00)) s(SeatType, Price) WHERE NOT EXISTS (SELECT 1 FROM SeatInventory i WHERE i.TrainId t.TrainId AND i.RunDate DATEADD(DAY, d.n, CAST(GETDATE() AS DATE)) AND i.SeatType s.SeatType);这段的关键在 NOT EXISTS 做幂等重复执行不会插重。CROSS JOIN 的写法比游标快一个数量级5000 行以内基本一秒出结果。然后用 C# 起 200 个并发任务每个任务抢同一行库存的 1 张票int success 0, fail 0; Parallel.For(0, 200, new ParallelOptions { MaxDegreeOfParallelism 32 }, i { var r PlaceOrder(new OrderDto { TrainId 1, RunDate DateTime.Today.AddDays(1), SeatType 二等座, SeatCount 1, UserId 1 }); if (r.ok) Interlocked.Increment(ref success); else Interlocked.Increment(ref fail); }); Console.WriteLine($成功 {success}失败 {fail});跑完对照三条success fail 应该等于 200SELECT Remain FROM SeatInventory WHERE ...应该等于 100 - successOrders 里 Status0 的行数应该等于 success。三个数对不上说明事务边界或者锁有问题回到 5.1 重新查。我一般还会再跑一轮扣到 0 为止的测试把 Total 改成 5起 50 个并发正确结果应该是 5 成功 45 失败Remain 恰好为 0绝不能是负数。这一轮比上面那轮更能暴露问题因为它一定会触发余票不足的分支能验证 Rollback 路径写没写对。从那以后我每次交付这类带库存扣减的项目都会先跑一遍这个 5 张票 50 并发的用例跑通了才敢让人点按钮。库存这东西没有后悔药负数一旦落库后面所有报表都是错的。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。