简介一份基于C#与SQL Server的火车订票系统完整项目面向初学C#窗体编程及数据库开发的读者用于理解订票、退票、改签、车次管理等核心业务流程并掌握WinForms事件驱动设计与SQL数据操作。压缩包共128个文件以cs源码文件为主辅以resx资源、resources编译资源、jpg界面素材、exe可执行程序及sln工程文件等整体约1.85MB目录结构清晰方便直接打开编译与二次修改。目前已有1437人学习/下载适合课程设计、毕业设计或项目实训参考。通过该项目可获得整套窗体源码、界面设计文件、数据库交互逻辑及运行程序覆盖用户模块、车次管理模块和订单管理模块的实现细节并包含常见错误处理与异常捕获写法可帮助读者快速搭建同类订票系统提升C#与SQL Server的工程实践能力。1. C#火车订票系统不是查个数据库那么简单C#火车订票系统听起来就是把车次存进数据库、让用户查一查订一订但真正实现过的人都知道这个项目的核心难点根本不在增删改查而在余票扣减的一致性两个用户同时抢最后一张票系统凭什么只让一个人成功订单生成到一半网络闪断余票和订单怎么保持一致说到底订票系统是一个典型的并发写密集型业务系统能用 C# 把它做扎实你对事务、锁、连接池、异步 UI 的理解都会上一个台阶。这篇文章面向正在做课程设计的学生、想练手完整业务闭环的初级开发者和需要把内部订票需求落地的工程师带你从技术选型、数据库设计一路走到并发压测验证。2. 技术选型与三层架构先决定系统长什么样再写代码开始写业务代码之前先回答三个问题系统给谁用、部署在哪里、谁来维护。火车订票系统在 C# 生态里有三种常见形态选型直接决定你后面要写多少界面代码、能不能方便地换数据库、以及项目写到一半会不会因为架构问题返工。2.1 三种形态WinForms、WPF、ASP.NET Core 怎么选如果你做的是课程设计或公司内部的小工具最常见的选择是 WinForms。它拖控件就能出界面DataGridView 绑个 DataTable 就能展示车次列表开发速度最快答辩演示也直观。缺点同样明显界面样式老旧部署时目标机器要装对应版本的 .NET Desktop Runtime而且窗体代码很容易越写越臃肿最后 SQL 全部拼在按钮点击事件里调试一次要重启一次窗口。如果你对界面有要求可以考虑 WPF。WPF 的绑定和样式机制更适合做出复杂的座位选择界面但 MVVM、依赖属性、触发器这些东西的学习成本对新手不太友好一个订票系统的主要工作量会从业务逻辑转移到界面框架上时间容易失控。如果想做一个真正能上线、手机和网页都能访问的系统我建议选择 ASP.NET Core WebAPI 加一个简单的前端页面。这种形态没有 WinForms 那样的窗体生命周期业务层以 Controller 为入口天然适合对接小程序或网页客户端。缺点是得额外维护一套前端工作量会大不少。三者对比我一般这样看形态适用场景界面开发速度部署成本我的建议WinForms课程设计、内部工具快拖控件即可需装 Desktop Runtime练手首选WPF复杂界面、座位图慢需先学 MVVM需装 Desktop Runtime对 UI 有执念再选ASP.NET Core WebAPI线上系统、多端访问需额外写前端只需 .NET Runtime简历/生产推荐我的建议比较直接如果你的目标是快速跑通整个业务闭环、把精力放在事务和并发上选 WinForms后面章节的代码也按 WinForms 来写如果你是想拿这个项目做简历上的代表作选 ASP.NET Core业务层代码几乎可以平移过去。还有一个隐藏因素数据库。WinForms 项目最常见的搭配是 SQL Server LocalDB 或 Express部署简单如果机器上没装 SQL Server也可以换成 SQLite但 SQLite 的并发写锁比 SQL Server 严格得多后面章节讲的并发方案要相应调整。2.2 三层架构拆分UI、BLL、DAL 的职责边界不管选哪种界面形态我都强烈建议把项目拆成三层。一个典型的解决方案结构是这样TrainBooking.sln ├── TrainBooking.Model # 实体类ScheduleInfo、OrderInfo ├── TrainBooking.DAL # 数据访问层只负责 SQL 和数据库交互 ├── TrainBooking.BLL # 业务逻辑层余票扣减、下单、退票规则 ├── TrainBooking.UI # WinForms 界面只负责展示和用户输入 └── TrainBooking.Common # 连接字符串、密码哈希、订单号生成等公共方法这样拆有实打实的好处。第一业务规则可以单测。比如退票逻辑你不需要打开窗体直接调用 BLL 的 CancelOrder 方法就能验证状态流转。第二换界面不换业务。昨天用 WinForms明天想改成 WebAPIUI 层扔掉BLL 和 DAL 原封不动。第三连接字符串和 SQL 不会散落在各处换一台数据库服务器只改一个配置文件。我见过太多把数据库操作直接写在 Form 里的项目一个按钮点击事件里几百行代码查询、更新、弹窗全混在一起。分层之后DAL 只负责拿到参数、执行 SQL、返回结果BLL 只负责判断业务规则UI 只负责把用户输入传给 BLL 再绑定结果。出了问题可以按层排查界面表现异常查 UI业务规则不对查 BLL数据不对查 SQL不用再从几十个事件方法里翻找。实体类在 DAL 和 BLL 之间传递数据。以车次信息为例namespace TrainBooking.Model; public class ScheduleInfo { public int ScheduleId { get; set; } public string TrainNo { get; set; } string.Empty; public string StartStation { get; set; } string.Empty; public string EndStation { get; set; } string.Empty; public TimeSpan DepartTime { get; set; } public TimeSpan ArriveTime { get; set; } public byte SeatType { get; set; } public int RemainingCount { get; set; } public int TotalCount { get; set; } }这里 DepartTime 和 ArriveTime 用 TimeSpan 而不是 DateTime因为车次表里存的是每天几点发车这个重复性时刻不是某个具体时间点。很多初学者在这里用 DateTime后面查票时还得反复做日期拼接纯属给自己挖坑。连接字符串放在 Common 层的一个静态类里读取 App.config 或 appsettings.json换服务器时只改一处。另外密码不要在数据库里明文存储注册时用加盐哈希后存 Hash 列这是订票系统的底线。.NET 的文档不建议新代码继续用 MD5练手项目至少也得用 SHA256 加盐比大部分课程设计已经强了。三层架构的代价是多写一层代码但对订票系统这个业务复杂度来说这点代价非常值得。后面章节里所有代码我都按这个分层来组织你可以在自己的项目里直接照抄这个结构。3. 数据库设计五张核心表与余票扣减的关键约束数据库设计是订票系统最容易返工的部分。返工不是因为表不够多而是因为一开始没想清楚余票这个数据模型。把余票拆成独立的表而不是在车次表里放一个 RemainingCount 字段这是整个设计的第一个关键决策。3.1 核心表结构用户、车次、排班、余票、订单怎么建先看建表脚本我把一个最小可用的订票系统拆成了五张表CREATE TABLE Users ( UserId INT IDENTITY PRIMARY KEY, Username NVARCHAR(50) NOT NULL UNIQUE, PasswordHash NVARCHAR(64) NOT NULL, -- 哈希后的密码不存明文 RealName NVARCHAR(50) NOT NULL, IdCardNo NVARCHAR(18) NOT NULL ); CREATE TABLE Trains ( TrainId INT IDENTITY PRIMARY KEY, TrainNo NVARCHAR(20) NOT NULL UNIQUE, -- 车次号如 G1234 TrainType TINYINT NOT NULL, -- 1高铁 2动车 3普速 StartStation NVARCHAR(50) NOT NULL, EndStation NVARCHAR(50) NOT NULL ); CREATE TABLE Schedules ( ScheduleId INT IDENTITY PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), DepartDate DATE NOT NULL, -- 只存日期 DepartTime TIME NOT NULL, -- 每天的发车时刻 ArriveTime TIME NOT NULL, CONSTRAINT UQ_Schedule UNIQUE (TrainId, DepartDate) ); CREATE TABLE SeatInventory ( InventoryId INT IDENTITY PRIMARY KEY, ScheduleId INT NOT NULL REFERENCES Schedules(ScheduleId), SeatType TINYINT NOT NULL, -- 1二等座 2一等座 3硬卧 4软卧 TotalCount INT NOT NULL, RemainingCount INT NOT NULL, CONSTRAINT UQ_Inventory UNIQUE (ScheduleId, SeatType) ); CREATE TABLE Orders ( OrderId INT IDENTITY PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL UNIQUE, -- 对外订单号 UserId INT NOT NULL REFERENCES Users(UserId), ScheduleId INT NOT NULL REFERENCES Schedules(ScheduleId), SeatType TINYINT NOT NULL, TicketCount INT NOT NULL, Status TINYINT NOT NULL DEFAULT 1, -- 1已支付 2已退票 3已过期 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CancelTime DATETIME NULL );有几个设计点值得说清楚。Trains 和 Schedules 分开是因为一趟列车如 G1234每天都在跑如果车次直接带日期同一趟车每天都要复制一份始发站、终到站、车次号这些不变信息数据冗余而且改一个站名要改几百行。Schedules 表用 (TrainId, DepartDate) 唯一约束从数据库层面杜绝同一趟车同一天被重复录入人工维护车次数据时这个约束特别有用。SeatInventory 是余票的核心。为什么单独建表而不是在 Schedules 上挂一个 RemainingCount因为一趟车有多种席别二等座、一等座、硬卧、软卧的库存互相独立拆成行才能各自扣减、各自加锁。RemainingCount 落在独立的行上数据库行锁才能精确控制并发。Orders 表则承担交易凭证的角色OrderNo 对外唯一客户退款、改签都靠这个号码定位。索引方面查询车次的典型条件是始发站、到达站、出发日期所以 Trains 加一个 (StartStation, EndStation) 联合索引Schedules 加一个 DepartDate 索引Orders 的 OrderNo 已经由 UNIQUE 约束自动带索引。如果用户要查历史订单给 Orders.UserId 加一个非聚集索引否则按用户查订单会扫全表。3.2 余票扣减为什么先查再改一定会超卖余票扣减是订票系统里最容易出事的一段代码。新手最常见的写法是两步走先 SELECT 余票判断大于 0 再 UPDATE 减一。这个逻辑单用户下没问题一旦两个用户同时买最后一张票就会同时读到 RemainingCount 等于 1同时通过判断同时执行 UPDATE最终结果是两个人下单成功余票变成 -1。这就是典型的超卖。正确的做法是让扣减变成一个原子操作把判断和更新合并到一条 SQL 里UPDATE SeatInventory SET RemainingCount RemainingCount - count WHERE ScheduleId scheduleId AND SeatType seatType AND RemainingCount count这条语句在 SQL Server 里执行时会对命中的行加排他锁两个并发事务同时执行时第二个事务会一直等到第一个提交或回滚然后重新读取行数据发现 RemainingCount 已经不足更新影响行数为 0。代码只需要判断 ExecuteNonQuery 的返回值等于 0 就是库存不足等于 1 才是扣减成功。这里有一个隐含的坑UPDATE 语句自带锁但 SELECT 和 UPDATE 分开写的话SELECT 默认不加排他锁读到的可能是已提交的旧值。所以要么像我上面这样把判断放进 UPDATE 的 WHERE要么给 SELECT 加 WITH (UPDLOCK) 提示强制加更新锁。前者简单直接推荐优先用。注意判断余票是否扣减成功唯一可靠信号是 ExecuteNonQuery 的返回值不要依赖扣减前先查一次的旧值。返回值 0 时事务里后续步骤不应继续执行必须回滚并返回失败。另外一个容易被忽略的问题是扣减顺序。在一个订单事务里先扣余票再插订单比反过来的死锁概率低。因为所有事务都按同样的顺序拿锁就不容易出现两个事务各自持有一把锁又等对方释放锁的循环等待。记住这个原则多个资源要按固定顺序加锁。最后再加一道数据库防线给 SeatInventory 建一个CHECK (RemainingCount 0)约束万一业务层有漏洞数据库层面还能兜底这个约束的成本几乎可以忽略。4. 核心功能落地查票、下单、退票的代码实现这一章把三个核心业务操作完整写出来。代码里用的都是 SqlClient 的同步方法WinForms 下我会在界面层用异步方式调用避免卡界面具体见 5.4 的坑。所有方法都按 BLL 层来设计不依赖任何控件类型。4.1 查票参数化查询与 DataTable 绑定查票接口的输入是出发站、到达站、出发日期输出是符合条件的车次和对应席别余票。SQL 需要关联 Trains、Schedules、SeatInventory 三张表public DataTable QueryTickets(string fromStation, string toStation, DateTime departDate) { const string sql SELECT s.ScheduleId, t.TrainNo, t.StartStation, t.EndStation, s.DepartTime, s.ArriveTime, i.SeatType, i.RemainingCount FROM Schedules s INNER JOIN Trains t ON s.TrainId t.TrainId INNER JOIN SeatInventory i ON s.ScheduleId i.ScheduleId WHERE t.StartStation fromStation AND t.EndStation toStation AND s.DepartDate departDate ORDER BY s.DepartTime, i.SeatType; using var conn new SqlConnection(_connString); using var cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(fromStation, fromStation); cmd.Parameters.AddWithValue(toStation, toStation); cmd.Parameters.AddWithValue(departDate, departDate.Date); var adapter new SqlDataAdapter(cmd); var table new DataTable(); adapter.Fill(table); return table; }关键点有三处。第一所有查询条件都用 SqlParameter 传参绝不能拼字符串。拼字符串的后果是用户输入特殊内容时SQL 语句被注入改写轻则查询异常重则数据被删。第二departDate 传参前调用 .Date 把时分秒清零。如果界面用日期控件取到的值往往带当前时间直接用会导致 DepartDate 是 2024-05-01 00:00:00 但参数是 2024-05-01 14:30:00等值匹配永远查不到数据。第三函数返回 DataTable 是 WinForms 场景下的便捷做法直接赋值给 DataGridView.DataSource 就能显示省去手工逐行赋值。有一个细节要提醒AddWithValue 在某些版本的 SQL Server 驱动下可能把参数类型推断为 NVARCHAR导致查询优化器生成不了好的执行计划。如果你的系统数据量大、查询慢可以改成显式类型声明cmd.Parameters.Add(new SqlParameter(departDate, SqlDbType.Date) { Value departDate.Date });cmd.Parameters.Add(new SqlParameter(departDate, SqlDbType.Date) { Value departDate.Date });这个写法明确告诉数据库参数是 DATE 类型避免隐式转换导致索引失效。项目规模小的时候不必较真但如果出现同一 SQL 每次都在编译的现象先检查这里。4.2 下单事务包裹的原子扣减与订单生成下单是订票系统的核心事务必须保证余票扣减和订单插入要么一起成功要么一起回滚。我用 SqlTransaction 把两步包起来public bool BookTicket(int userId, int scheduleId, byte seatType, int count, out string orderNo) { orderNo string.Empty; using var conn new SqlConnection(_connString); conn.Open(); using var tx conn.BeginTransaction(); try { // 第一步原子扣减余票扣不动直接失败 const string updateSql UPDATE SeatInventory SET RemainingCount RemainingCount - count WHERE ScheduleId scheduleId AND SeatType seatType AND RemainingCount count; using (var updateCmd new SqlCommand(updateSql, conn, tx)) { updateCmd.Parameters.AddWithValue(count, count); updateCmd.Parameters.AddWithValue(scheduleId, scheduleId); updateCmd.Parameters.AddWithValue(seatType, seatType); if (updateCmd.ExecuteNonQuery() 0) { tx.Rollback(); return false; // 余票不足或车次不存在 } } // 第二步生成订单号插入订单 orderNo GenerateOrderNo(); const string insertSql INSERT INTO Orders (OrderNo, UserId, ScheduleId, SeatType, TicketCount, Status) VALUES (orderNo, userId, scheduleId, seatType, count, 1); using (var insertCmd new SqlCommand(insertSql, conn, tx)) { insertCmd.Parameters.AddWithValue(orderNo, orderNo); insertCmd.Parameters.AddWithValue(userId, userId); insertCmd.Parameters.AddWithValue(scheduleId, scheduleId); insertCmd.Parameters.AddWithValue(seatType, seatType); insertCmd.Parameters.AddWithValue(count, count); insertCmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); throw; } }这段代码有两个重要设计。第一扣减余票和插入订单共用同一个连接和同一个事务这是为了保证一致性。如果不用事务扣减成功但插入订单时断电余票就莫名其妙少了反过来订单插入成功但扣减失败就会出现空头订单。第二余票扣减放在事务最前面所有请求都按同一顺序拿锁降低死锁概率。ExecuteNonQuery 返回 0 时立即回滚返回 false调用方可以提示余票不足。注意这里把支付和下单合并成一步是为了聚焦库存一致性。真实系统通常先建待支付订单支付回调确认后再把状态改成已支付。那个状态流转的原则与此处完全一致——状态更新必须带当前状态条件。订单号的生成方法我一般这样写private static string GenerateOrderNo() { var now DateTime.Now; return now.ToString(yyyyMMddHHmmssfff) Random.Shared.Next(1000, 9999); }时间戳加随机数的组合在演示项目里够用但你要知道它的局限同一毫秒内多个请求理论上有碰撞可能。生产环境我建议用全局自增序列或 Redis INCR 生成订单号避免撞号或者干脆让数据库自增主键对外暴露虽然不好看但保证唯一。4.3 退票状态守卫与余票回补退票比下单容易但同样有陷阱。最常见的错误是直接 UPDATE Orders SET Status 2 然后回补余票完全没有检查订单当前状态。如果用户把退票请求发两次或者管理端代退后用户自己又退了一次余票会被回补两次系统里凭空多出票来。正确的退票要先做状态守卫只允许把已支付的订单改成已退票public bool CancelOrder(string orderNo) { using var conn new SqlConnection(_connString); conn.Open(); using var tx conn.BeginTransaction(); try { // 第一步状态守卫只有 Status 1 的订单能退 const string updateOrderSql UPDATE Orders SET Status 2, CancelTime GETDATE() WHERE OrderNo orderNo AND Status 1; int affected; using (var cmd new SqlCommand(updateOrderSql, conn, tx)) { cmd.Parameters.AddWithValue(orderNo, orderNo); affected cmd.ExecuteNonQuery(); } if (affected 0) { tx.Rollback(); return false; // 订单不存在或已处于不可退状态 } // 第二步按订单的席别和数量回补余票 const string restoreSql UPDATE i SET i.RemainingCount i.RemainingCount o.TicketCount FROM SeatInventory i INNER JOIN Orders o ON i.ScheduleId o.ScheduleId AND i.SeatType o.SeatType WHERE o.OrderNo orderNo; using (var cmd new SqlCommand(restoreSql, conn, tx)) { cmd.Parameters.AddWithValue(orderNo, orderNo); cmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); throw; } }UPDATE Orders 的 WHERE 条件里带 Status 1 就是幂等守卫。第一次退票成功订单状态变成 2第二次执行同样的语句WHERE 匹配不到行影响行数为 0直接返回失败余票也不会二次回补。这个模式在支付回调、退款、改签等所有状态流转场景里都适用。回补余票的 SQL 用 UPDATE FROM JOIN 语法从 Orders 中取回 TicketCount 和 SeatType与 SeatInventory 按 ScheduleId 和 SeatType 关联。注意赋值方向左边 RemainingCount 加的是订单里的票数而不是固定加 1。一张订单可能包含多张票按固定值回补必然出错。5. 踩坑与排查订票系统最容易翻车的五个场景这一章整理的是我在类似项目里真实踩过、也帮别人排查过的五个高频问题。每一个都按现象 → 原因 → 解决三段式展开你可以直接对照排查。5.1 并发超卖最后一张票被卖给了三个人现象用多线程模拟 50 个用户同时抢同一车次的最后 1 张票数据库里出现了多张成功订单余票变成负数。原因业务代码先 SELECT 余票再 UPDATE两个请求同时读到余票为 1同时通过判断各自生成订单。这是典型的检查与动作分离导致的竞态条件。解决把判断合并进 UPDATE 语句用影响行数判断是否成功代码见 4.2 的 BookTicket。另外给 SeatInventory 建CHECK (RemainingCount 0)约束作为最后一道防线万一业务层有漏洞数据库层面还能兜底。这个约束成本极低强烈建议加上。5.2 连接池耗尽只 Open 不 Dispose 的后果现象程序运行一段时间后操作数据库时报错连接池已满或超时时间已到尚未从池中获取连接。原因SqlConnection 打开后没有释放。连接池默认最大连接数是 100每次连接用完后如果只是 Close 而没有 Dispose异常路径提前 return 时连接就永远留在池里很快把连接池占满。解决所有 SqlConnection 的创建一律用 using 声明。C# 的 using 会自动调用 Dispose等价于把连接归还连接池。如果项目里有手写 DBHelper 的老代码见到 new SqlConnection 后面没有 using 或没有 Close 的一律改掉。这是一个可以在代码评审时快速核查的点用一个正则就能扫出来。5.3 日期格式化陷阱查当天车票却查不到现象用户在界面上选了 2024-05-01查询结果为空但数据库里明明有当天车次。原因最常见的是参数类型不匹配。日期选择控件的值带时分秒比如 2024-05-01 14:30:00而 Schedules.DepartDate 列是 DATE 类型等值比较时数据库把参数隐式转换实际变成 2024-05-01 00:00:00 与 2024-05-01 14:30:00 比较自然不相等。解决传入参数前统一使用 departDate.Date 把时间清零参数显式声明为 SqlDbType.Date。查询条件坚持用参数化不要日期 ToString 后拼进 SQL。遇到当天查不到但前一天能查到的诡异问题第一反应就去看日期参数的值十有八九是这里。5.4 UI 线程假死同步查询阻塞窗口现象点击查询按钮后窗口标题出现未响应过几秒才恢复期间窗口无法拖动、无法关闭。原因WinForms 的按钮点击事件跑在 UI 线程上同步执行数据库查询时UI 线程被阻塞消息循环无法处理重绘和鼠标消息。数据量一大阻塞时间就越明显。解决把数据库调用放到后台线程用 async/await 回到 UI 线程更新界面private async void btnQuery_Click(object sender, EventArgs e) { btnQuery.Enabled false; try { var from txtFrom.Text.Trim(); var to txtTo.Text.Trim(); var date datePicker.Value.Date; var table await Task.Run(() _ticketService.QueryTickets(from, to, date)); dataGridView1.DataSource table; } catch (Exception ex) { MessageBox.Show(查询失败 ex.Message); } finally { btnQuery.Enabled true; } }这里用到 Task.Run 把同步的 DAL 方法丢到线程池执行await 之后自动回到 UI 线程更新 DataGridView。按钮在查询期间禁用避免用户重复点击叠加请求。注意async void 只能用在事件处理器这类没有调用方的场景普通方法一律返回 Task否则异常会直接逃逸到同步上下文导致进程崩溃。5.5 重复下单双击一次生成两个订单现象用户双击确认按钮或者网络慢时连点多次数据库里同一个用户对同一个车次生成了两条订单。原因按钮点击事件没有防重后端也没有幂等判断。前端禁用按钮只能挡住正常人挡不住双击事件和网络重试。解决前端在提交后立即禁用按钮同时在数据库层面加一道唯一约束。下单前由调用方生成一个请求标识Orders 表增加 RequestId 列并建唯一索引第一次插入成功第二次插入同样的 RequestId 会因为唯一约束被拒。事务里可以先查这个 RequestId 是否已存在存在就直接返回已有订单不存在才走完整的扣减流程。双重防护之下重复点击的后果被彻底堵死。6. 并发压测验证你的余票扣减真的不会超卖写完了业务代码不能直接就说系统没问题。订票系统这种并发业务验证手段就是模拟大量用户同时抢票看最终订单数和余票数是否对得上。我会写一个小的控制台压测程序直接调用 BLL 层的方法绕开 UIstatic async Task Main(string[] args) { var service new TicketService(your_connection_string); // 假设数据库里某车次 ScheduleId1001SeatType1RemainingCount1 const int userId 1; const int scheduleId 1001; const byte seatType 1; int success 0, fail 0; var tasks new ListTask(); for (int i 0; i 200; i) { tasks.Add(Task.Run(() { bool ok service.BookTicket(userId, scheduleId, seatType, 1, out _); if (ok) Interlocked.Increment(ref success); else Interlocked.Increment(ref fail); })); } await Task.WhenAll(tasks); Console.WriteLine($成功 {success}失败 {fail}); }这个压测有几个要点。第一200 个任务并发调用同一个 BLL 方法直接测核心事务逻辑不掺杂界面因素。第二使用 Interlocked 累加结果因为 int 的 在并发下不是原子的直接写 success 统计会丢数据。第三压测前手动把余票改为 1压测后验证成功数是否恰好为 1。如果成功数大于 1说明你的扣减逻辑还有竞态条件回到 3.2 检查。压测结果正常后再进一步观察锁等待。在 SQL Server 上执行 sp_who2 或查看 sys.dm_tran_locks确认同一时刻只有少量会话处于等待锁状态。如果等待会话特别多说明事务持有锁的时间过长优先检查 4.2 里事务内有没有多余的查询操作把查询移出事务能显著缩短持锁时间。对于更高要求的场景可以再用 WITH (UPDLOCK) 写一个备用方案事务内先 SELECT 余票并加更新锁阻塞其他事务的更新直到当前事务结束然后再做业务判断和 UPDATE。它适合业务判断比较复杂、没法塞进一条 UPDATE 的场景代价是持锁时间更长。我的习惯是能用原子 UPDATE 就用原子 UPDATEUPDLOCK 只做兜底。压测做到这个程度你的订票系统在并发扣减这一关就站得住脚了。我自己的习惯是把这个压测代码留在项目里每次改完数据库或事务逻辑就跑一遍余票从 1 变成 0、成功数永远是 1才敢放心把系统交给业务方使用。写代码容易真正让人放心的是验证过边界条件的代码。希望这篇笔记能帮你把 C# 火车订票系统做扎实也少走我当年走过的那些弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?