
简介面向C#与SQL Server学习者这份完整的火车订票系统源码包基于.NET窗体应用实现涵盖用户注册登录、车次管理、订票退票改签等核心模块适合课程设计、毕业设计及自学练手。压缩包共128个文件、约1.85MB主体为50个.cs窗体逻辑与24个.resx界面资源文件另有24个.resources资源、6个.cache编译缓存、5张.jpg界面设计图、3个.exe可运行程序以及.sln/.csproj等工程文件可直接用Visual Studio打开调试。系统以SQL Server为数据存储核心涉及用户、车次、订单数据的增删改查代码中可看到窗体控件事件绑定、数据库连接与SQL语句封装、异常处理及基础安全校验等关键写法。已有1437人学习下载适合希望借助完整项目串起C#面向对象、窗体交互、SQL编程和数据库事务控制等知识点的开发者。1. C#火车订票系统真正难的不是界面而是并发扣票课程设计也好小公司内部订票也好很多人用 C# 拖几个控件、连个数据库就交差了。但只要你把系统放到两台电脑上同时抢最后一张票余票变成负数的那一刻整个项目就翻车了。C# 火车订票系统的本质是一个事务型桌面应用核心难点不在窗体和按钮而在“同一时刻重复扣减余票”时怎么保证数据一致。这篇文章我会按数据模型、订票事务、界面线程、踩坑和进阶技巧的顺序把一套能落地的小型订票系统逐步讲清楚。适合正在做毕设、或者想把 CRUD 原型改成能扛并发压力的开发者照着一路写下来。2. 先把数据模型立住车次、余票和订单的表结构设计2.1 选型SqlServer Express 还是 SQLite我选前者的三个理由C# 做火车订票系统最常被问到的第一个问题是数据库选什么。在我接触过的项目里SQLite 的单机原型很多但真到了多人同时订票的场景SQLite 的锁机制会让你频繁看到database is locked。SQLite 用的是数据库级写锁一个写事务没结束另一个写事务只能干等这在订票这种高并发写场景里基本不可用。SqlServer Express 免费、装完就能用事务、行锁、隔离级别都是完整语义。相比 MySQL它在 Windows 桌面端部署时不用额外配置服务相比 Access它不会在并发写和类型转换上给你挖坑。网上还有不少老教程教“C# 与 Access 连表查询”那个思路在十年前可以现在再捡起来就是给自己找麻烦。我一般直接用 SqlServer Express不需要 Studio命令行sqlcmd就能完成建库建表。第三个理由是周边生态。EF Core、Dapper、SqlConnection 对 SqlServer 的支持都是亲儿子级别的你后来想加个查询分析或者迁移工具链都比 SQLite 顺。当然如果系统真的只是单机演示SQLite 也不是不行但下面讲的并发控制思路建议你在 SQLite 上先做压测再决定。2.2 四张核心表的字段定义与建表 SQL订票系统最少需要四张表用户表、车次表、余票表、订单表。很多初学者把余票直接塞在车次表里只放一个总票数。真这么做后面想支持“不同日期同车次不同余票”就得改表。我把车次信息和余票拆开TrainId 加 SeatDate 作为一个逻辑键这样每天每个车次的库存都是独立行并发更新时锁的粒度也更小。下面是我常用的建表脚本CREATE TABLE Users ( UserId INT IDENTITY PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, PasswordHash NVARCHAR(200) NOT NULL, Salt NVARCHAR(50) NOT NULL, Role INT NOT NULL DEFAULT 0 ); CREATE TABLE Trains ( TrainId INT IDENTITY PRIMARY KEY, TrainNo NVARCHAR(20) NOT NULL, DepartureStation NVARCHAR(50) NOT NULL, ArrivalStation NVARCHAR(50) NOT NULL, DepartureTime DATETIME2 NOT NULL, ArrivalTime DATETIME2 NOT NULL, Price DECIMAL(10,2) NOT NULL ); CREATE TABLE TrainSeats ( Id INT IDENTITY PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), SeatDate DATE NOT NULL, Remaining INT NOT NULL, Version INT NOT NULL DEFAULT 0 ); CREATE TABLE Orders ( OrderId INT IDENTITY PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL UNIQUE, UserId INT NOT NULL REFERENCES Users(UserId), TrainId INT NOT NULL REFERENCES Trains(TrainId), SeatDate DATE NOT NULL, Quantity INT NOT NULL, Status INT NOT NULL DEFAULT 0, CreateTime DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), PayTime DATETIME2 NULL );几个字段值得解释一下TrainSeats.Version是给乐观并发用的版本号。每次成功扣减余票Version都要加 1。UPDATE 时把Version放进 WHERE 条件就能知道数据在读取之后有没有被别人改过。OrderNo设置成唯一约束这是防重复提交的最后一层兜底。就算客户端双击连点第二次插入也会因为唯一键冲突而被数据库拒绝。价格用DECIMAL(10,2)不用FLOAT。火车票价格需要精确计算FLOAT的二进制误差会在累计对账时暴露出来。日期时间用DATETIME2而不是老旧的DATETIME。DATETIME的精度是 3.33 毫秒范围也只到 2079 年没有理由再用它。另外建议给Orders表补一个查询索引CREATE INDEX IX_Orders_UserId_CreateTime ON Orders(UserId, CreateTime DESC);这个索引主要服务“查我的订单”列表避免用户一多就走全表扫描。系统上线初期数据量小看不出差别等订单上了十万条这个索引就是救命稻草。2.3 用 EF Core 还是原生 ADO.NET关键看并发场景在 C# 技术栈里做数据访问最常见的纠结是“用 EF Core 还是 Dapper”。我的判断标准不是流行度而是你能不能控制 UPDATE 语句。EF Core 的乐观并发配置不算复杂但一旦涉及自定义 SQL、行锁、事务隔离级别它生成的语句会让你觉得是个黑匣子。订票系统的核心操作只有两条查余票、扣余票。扣余票这件事必须是一条带条件的原子 UPDATE任何 ORM 在这时候都只是传话筒。为了少一层意外我一般会用 Dapper 或者原生SqlCommandSQL 自己写执行结果自己读。using var conn new SqlConnection(connectionString); var seat await conn.QueryFirstOrDefaultAsyncTrainSeat( SELECT * FROM TrainSeats WHERE TrainId TrainId AND SeatDate SeatDate, new { TrainId trainId, SeatDate date });这段代码本身没问题但注意它查出来的seat是一个内存快照。拿着这个快照去计算“剩余 5 张减 1 张剩 4 张”然后再UPDATE整个行就是经典的“先读后写”并发陷阱。两个请求同时读到 5各自算成 4后写的覆盖先写的实际只扣了一张票的库存却产生了两张订单。所以这里我给一个对照表方式适合场景并发风险EF Core 实体更新后端管理系统、CRUD默认不加锁需要配置并发令牌Dapper 自定义 SQL订票、扣库存等强一致场景可控SQL 自己负责原生 ADO.NET需要精细控制事务和锁时代码量大但最透明如果你的项目已经在用 EF Core也没必要推翻重写可以用ExecuteSqlInterpolatedAsync把原子 UPDATE 以原生 SQL 方式执行同样能达到目的。2.4 给余票加版本号乐观并发的三行 SQL火车订票系统里最怕的不是慢而是超卖。解决超卖有两个方向悲观锁和乐观锁。悲观锁用UPDLOCK直接锁行乐观锁靠版本号在 UPDATE 时校验。我自己的习惯是两条路都走事务里用UPDLOCK锁定行UPDATE 语句里再带一次Version校验双保险。先看乐观锁的 SQLUPDATE TrainSeats SET Remaining Remaining - Quantity, Version Version 1 WHERE TrainId TrainId AND SeatDate SeatDate AND Version Version AND Remaining Quantity;这里的关键是把Remaining Quantity也写进 WHERE。这样数据库在修改前会检查“余票够不够”和“版本对不对”两个条件都满足才更新否则影响行数为 0。对应的 C# 重试逻辑for (int i 0; i 3; i) { var affected await conn.ExecuteAsync( sql, new { TrainId, SeatDate, Quantity, Version seat.Version }); if (affected 0) { return true; } // 版本冲突重新读取最新余票 seat await conn.QueryFirstOrDefaultAsyncTrainSeat(query, new { TrainId, SeatDate }); await Task.Delay(50 * (i 1)); }提示这里不要用“先 SELECT 再 UPDATE”的思路。即使你在同一个事务里Read Committed 隔离级别下两个并发读到的也可能是相同快照最终仍然会互相覆盖。乐观锁的本质是把冲突检测推迟到 UPDATE 时刻靠影响行数判断是否需要重试。重试 3 次还不够说明这段时间写入很密集可以提示用户“系统繁忙请稍后再试”不要让用户一直转圈。3. 用 WinForms 把订票流程跑通查询、锁票、支付和出票3.1 登录与角色权限最少要做的安全防护订票系统总得有个登录否则任何人都能查到别人订单。用户表里我设计了UserName、PasswordHash、Salt和Role四个核心字段。密码绝不能明文存储哪怕这是课程设计我也建议至少做到“随机盐 SHA256 哈希”。public static string HashPassword(string password, string salt) { using var sha SHA256.Create(); var bytes Encoding.UTF8.GetBytes(salt password); var hash sha.ComputeHash(bytes); return Convert.ToBase64String(hash); }登录验证时从数据库读出盐和哈希用同一算法重新计算再比较。这里有一个安全细节不要直接用比较字符串应该用固定时间比较避免通过响应时间差猜密码长度。C# 里可以用CryptographicOperations.FixedTimeEquals。var inputHash HashPassword(password, user.Salt); var storedHash Convert.FromBase64String(user.PasswordHash); var isValid CryptographicOperations.FixedTimeEquals(inputHash, storedHash);这个强度对付内部系统足够了。如果进一步要求可以换 PBKDF2 或 BCrypt但原理不变数据库里存的必须是不可逆的哈希值。3.2 车次查询避免 UI 线程卡死的异步加载写法WinForms 里最常见的翻车写法是在按钮点击事件里直接同步查询数据库。数据量小的时候看不出来一旦SqlConnection.Open和DataTable填充需要几百毫秒窗口就会“假死”标题栏显示“未响应”用户第一反应就是点第二下然后事件重入更卡。正确做法是用async/await配合Task.Run把数据库查询挪到线程池再回到 UI 线程更新控件private async void btnSearch_Click(object sender, EventArgs e) { btnSearch.Enabled false; btnSearch.Text 查询中...; try { var result await Task.Run(() _ticketService.Search(txtFrom.Text.Trim(), txtTo.Text.Trim(), dtpDate.Value)); dataGridView1.DataSource result; } catch (Exception ex) { MessageBox.Show($查询失败{ex.Message}); } finally { btnSearch.Text 查询; btnSearch.Enabled true; } }注意几个参数和写法Task.Run把耗时操作扔到线程池不阻塞 UI 线程。await之后的代码会回到 WinForms 的 UISynchronizationContext所以直接给dataGridView1.DataSource赋值是线程安全的。async void只能用于事件处理器普通方法不要这么写否则异常会直接抛到线程上下文很难捕获。点击查询后立刻禁用按钮这是最简单的防重入手段。如果你的窗体还放了进度条可以在Task.Run之前启动ProgressBar结束后关掉。关于“c# winform 如何更新状态栏与进度条”这个问题只要记住一条铁律不要在工作线程里直接改控件属性一切更新都通过Invoke或await回到 UI 线程。3.3 订票的核心事务先锁票再生成订单订票流程可以拆成四步查余票、锁余票、生成订单、提交事务。最关键的是“锁余票”和“生成订单”必须在同一个数据库事务里否则会出现“订单生成了但余票没扣”或者反过来。推荐在事务里使用UPDATE配合UPDLOCK行锁using var conn new SqlConnection(_connStr); await conn.OpenAsync(); using var tx (SqlTransaction)await conn.BeginTransactionAsync(); try { // 1. 锁定余票行防止并发修改 var seat await conn.QuerySingleOrDefaultAsyncTrainSeat( SELECT * FROM TrainSeats WITH (UPDLOCK, ROWLOCK) WHERE TrainId TrainId AND SeatDate SeatDate, req, tx); if (seat null) { await tx.RollbackAsync(); return 车次或日期不存在; } // 2. 原子扣减余票版本号作为二次校验 var affected await conn.ExecuteAsync( UPDATE TrainSeats SET Remaining Remaining - Quantity, Version Version 1 WHERE Id Id AND Remaining Quantity, new { seat.Id, req.Quantity }, tx); if (affected 0) { await tx.RollbackAsync(); return 余票不足; } // 3. 生成订单 var orderNo GenerateOrderNo(); await conn.ExecuteAsync( INSERT INTO Orders (OrderNo, UserId, TrainId, SeatDate, Quantity, Status, CreateTime) VALUES (OrderNo, UserId, TrainId, SeatDate, Quantity, 0, SYSUTCDATETIME()), new { OrderNo orderNo, req.UserId, req.TrainId, req.SeatDate, req.Quantity }, tx); await tx.CommitAsync(); return $订票成功订单号{orderNo}; } catch { await tx.RollbackAsync(); throw; }这段代码有三个参数细节值得展开WITH (UPDLOCK, ROWLOCK)的意思是只锁定这一行而不是锁住整张表。UPDLOCK告诉 SQL Server“我要更新这行请给我排他锁的资格”ROWLOCK缩小锁粒度避免阻塞其他车次的查询。UPDATE 里没有再把Version放进去因为我前面已经用UPDLOCK锁住了行当前事务是唯一可以改这行的人。如果这时另一个事务也来读它会被阻塞到当前事务结束。BeginTransactionAsync在 .NET 6 之后才可用如果你的项目还在 .NET Framework就用同步的BeginTransaction差别不大。3.4 订单状态机从待支付到已出票的流转订单不能任由代码随意改状态。用一个简单的状态机管理流转能避免很多低级错误比如“已取消的订单还能支付”。C# 里不需要引入专门的状态机库一个switch表达式就够public enum OrderStatus { Pending 0, Paid 1, Issued 2, Canceled 3 } public static bool CanChange(OrderStatus current, OrderStatus next) { return current switch { OrderStatus.Pending next is OrderStatus.Paid or OrderStatus.Canceled, OrderStatus.Paid next OrderStatus.Issued, _ false }; }为什么要自己写状态机因为订单状态是核心业务规则如果散落在各个按钮点击事件里今天这里允许跳转明天那里又允许系统很快就变成一团乱麻。把规则收敛到一个静态方法里测试也好写。改状态时先检查CanChange然后再执行 UPDATEif (!OrderStateMachine.CanChange(order.Status, OrderStatus.Paid)) throw new InvalidOperationException(当前订单状态不允许支付); await conn.ExecuteAsync( UPDATE Orders SET Status Status, PayTime SYSUTCDATETIME() WHERE OrderId OrderId, new { Status (int)OrderStatus.Paid, OrderId order.OrderId });这里要注意状态判断和 UPDATE 最好在同一个事务里否则判断完之后状态可能已经被别的操作改了。简单做法是把 UPDATE 语句加上WHERE Status CurrentStatus影响行数为 0 就知道状态已变化。3.5 用单元测试验证两个并发场景WinForms 界面很难做自动化测试所以我一般把订票核心逻辑抽到一个独立的类库项目里UI 只负责取参数和展示结果。这样可以用 xUnit 直接测试最危险的并发场景。[Fact] public async Task Two_Concurrent_Bookings_Only_One_Succeeds() { // 准备一张只有 1 张余票的车次 await TestData.SeedSeat(1); var request new BookRequest { TrainId 1, SeatDate DateTime.Today, Quantity 1 }; // 两个用户同时订票 var task1 _ticketService.BookAsync(request with { UserId 1 }); var task2 _ticketService.BookAsync(request with { UserId 2 }); await Task.WhenAll(task1, task2); // 断言只有一个成功且余票为 0 Assert.Equal(1, new[] { task1.Result.IsSuccess, task2.Result.IsSuccess }.Count(x x)); Assert.Equal(0, await TestData.GetRemaining(1)); }这个测试需要连真实数据库或者本地 SqlServer Express速度不快但非常值得。它能在你改动事务代码后第一时间告诉你“并发安全”是不是被破坏了。如果测试环境不好建也可以把连接串指到测试库跑完清数据。4. C# 火车订票系统避坑指南并发、事务和界面卡死4.1 超卖两个连接同时读到余票为 1现象系统上线后用户反馈明明下单成功出票时却说票已售罄。后台一看订单数超过了实际票数余票还变成了负数。原因经典“先读后写”。代码里先SELECT Remaining程序里判断Remaining 0然后再UPDATE。两个请求同时读到余票 1都通过判断分别执行 UPDATE于是扣了两次余票变 -1。解决把判断条件写进 UPDATE用一条原子 SQL 完成扣减UPDATE TrainSeats SET Remaining Remaining - 1 WHERE TrainId TrainId AND SeatDate SeatDate AND Remaining 1;影响行数为 0 说明余票不够。这也是我在第 2 章反复强调“不要先 SELECT 再 UPDATE”的原因。4.2 事务隔离级别默认的 Read Committed 并不安全现象在一个事务里先读取余票然后做了一些耗时操作再 UPDATE期间另一个事务提交了扣减当前事务用旧数据覆盖了它。原因Read Committed 隔离级别下普通的SELECT不加锁读取的是已提交快照。你读到的值可能在事务结束前已经被别人合法修改。解决查询加上UPDLOCK提示主动锁定行SELECT * FROM TrainSeats WITH (UPDLOCK, ROWLOCK) WHERE TrainId TrainId AND SeatDate SeatDate这样在事务提交前其他事务无法修改这行。如果不想用悲观锁就回到乐观锁版本号方案二者择一即可别又乐观又悲观混得不清不楚。4.3 界面假死SqlDataReader 阻塞了 UI 线程现象点击查询按钮后窗口马上没反应拖动窗口都拖不动过一会才恢复。原因所有数据库操作都直接跑在 UI 线程上。SqlConnection.Open、ExecuteReader这些都是同步方法会阻塞消息循环。UI 线程被占住窗体自然假死。解决用async/awaitTask.Run把数据库操作挪到后台线程。记住一个死锁陷阱不要在 async 方法里用.Result或.Wait()否则 UI 线程等任务任务等 UI 线程释放直接死锁。// 错误在 UI 线程同步等待 var result Task.Run(() _service.Search(...)).Result; // 正确使用 await 异步等待 var result await Task.Run(() _service.Search(...));4.4 中文乱码参数化查询和 nvarchar 一个都不能少现象订单里的旅客姓名、车站名插入到数据库后变成或者 SQL 语句里拼接的中文条件查不到数据。原因最常见的是两个一是表的列用了varchar而不是nvarchar二是代码里字符串拼接 SQL 时没有加N前缀。varchar存不了 Unicode遇到中文就丢失。解决建表时所有可能包含中文的列一律用nvarchar代码里严禁拼接 SQL全部使用参数化查询// 正确参数化 await conn.ExecuteAsync( SELECT * FROM Trains WHERE DepartureStation Station, new { Station txtStation.Text }); // 错误字符串拼接 await conn.ExecuteAsync( $SELECT * FROM Trains WHERE DepartureStation {txtStation.Text});如果连接的是 MySQL 而不是 SqlServer还要在连接串里加CharSetutf8否则同样会乱码。这个坑在课程设计里几乎人人都会踩一次。4.5 重复提交用户双击“支付”按钮现象用户网络不好点击支付后没反应又点了一下结果生成两个订单、扣了两次钱。原因客户端没有禁用按钮是表面原因深层原因是服务端没有幂等控制。按钮禁用只是“防君子”防不了浏览器重发、断线重试和脚本调用。解决三层兜底。第一层点击后立刻禁用按钮第二层业务上用一个IdempotencyKey比如客户端生成 GUID重复请求带同一个 GUID第三层数据库给IdempotencyKey加唯一约束。第二层初始化流程var bookKey Guid.NewGuid().ToString(); // 同一业务操作只使用同一个 key await conn.ExecuteAsync( INSERT INTO Orders (OrderNo, UserId, TrainId, SeatDate, Quantity, Status, IdempotencyKey) VALUES (OrderNo, UserId, TrainId, SeatDate, Quantity, 0, IdempotencyKey), new { OrderNo orderNo, UserId, TrainId, SeatDate, Quantity, IdempotencyKey bookKey });唯一约束冲突时捕获SqlException如果错误码是 2601 或 2627就说明是重复提交直接返回“订单已提交请勿重复操作”而不是报一个 500 或者程序崩溃。5. 让订票系统能扛住真实压力的三个小技巧课程设计交上去之后如果想让系统真正能用建议再补三个不复杂但收益明显的点。第一个连接串配置。不要每次查询都新建连接SqlServer 本身有连接池但默认参数不一定适合桌面应用。我常用的连接串长这样Server.\SQLEXPRESS;DatabaseTicketDb;Integrated SecurityTrue;MultipleActiveResultSetsTrue;PoolingTrue;Min Pool Size2;Max Pool Size100;MultipleActiveResultSets允许我们在一个连接上同时跑多个结果集配合异步查询能减少连接数。连接池的Min Pool Size设为 2避免刚启动时频繁建连。第二个防重单约束。界面上的按钮禁用只是心理安慰数据库唯一索引才是最后一道闸门。给订单表的IdempotencyKey建唯一索引重复请求直接抛异常你只需要在代码里把异常翻译成友好提示就行。这一步既防用户手滑也防代码 bug。第三个关键操作写日志。订票成功、扣票失败、状态机拒绝这些关键节点至少要落一行文本日志。不用复杂框架写入文件即可private static readonly object LogLock new(); public static void Info(string message) { lock (LogLock) { File.AppendAllText( Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ticket.log), ${DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}{Environment.NewLine}); } }日志不需要记所有查询只记录“谁在什么时候订了什么票、扣了哪趟车的余票、结果成功还是失败”。等哪天用户说自己没订过票查日志比翻数据库快得多。我自己的教训是第一次做完订票系统只想着把界面画好看结果在用户验收时两个人同时点订票余票直接变负数当场翻车。后来把版本号、唯一约束、日志补齐才敢放到真实环境。如果你正在写这个项目希望前面的细节能让你少走这段弯路。希望帮到你。本文还有配套的精品资源点击获取