简介:一份基于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,明天想改成 WebAPI,UI 层扔掉,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 层的方法,绕开 UI:
static async Task Main(string[] args) { var service = new TicketService("your_connection_string"); // 假设数据库里某车次 ScheduleId=1001,SeatType=1,RemainingCount=1 const int userId = 1; const int scheduleId = 1001; const byte seatType = 1; int success = 0, fail = 0; var tasks = new List<Task>(); 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 就用原子 UPDATE,UPDLOCK 只做兜底。
压测做到这个程度,你的订票系统在并发扣减这一关就站得住脚了。我自己的习惯是把这个压测代码留在项目里,每次改完数据库或事务逻辑就跑一遍,余票从 1 变成 0、成功数永远是 1,才敢放心把系统交给业务方使用。写代码容易,真正让人放心的是验证过边界条件的代码。希望这篇笔记能帮你把 C# 火车订票系统做扎实,也少走我当年走过的那些弯路。
本文还有配套的精品资源,点击获取