首页 / 资讯中心 / 文章详情

C# WinForms火车订票系统:事务锁防超卖与订单状态机实战

C# WinForms火车订票系统:事务锁防超卖与订单状态机实战 ★ FEATURED ARTICLE
简介面向C#与SQL Server初学者及课程设计需求者的火车订票系统实战项目以Windows窗体应用形式呈现涵盖用户注册登录、车次增删改查、订票退票改签等核心业务可用于练手、课程作业参考或毕业设计原型搭建。压缩包共128个文件、约1.85MB以50个cs源码文件和24组resx/resources窗体资源为主体另含sln解决方案、csproj工程文件、exe可执行程序及少量jpg界面截图完整保留了Visual Studio项目的目录结构与编译产物便于直接打开调试或按模块研读。已有1437人学习下载说明其在同类教学项目中具有较高参考价值。读者可从中了解窗体事件驱动、数据库连接与SQL语句编写、事务与并发控制、异常捕获及SQL注入防护等实现思路并对照用户、车次、订单三大模块梳理业务状态变更积累从界面交互到数据持久化的完整开发经验。1. 从一次连不上库的调试说起C# 火车订票系统能干什么前段时间帮 A 同学看一份 C# 火车订票系统他卡在第一步解决方案能正常打开F5 一跑就弹「连接数据库失败」折腾了一下午最后发现是 App.config 里的实例名还留着原作者那台机器的名字。这份源码在下载站里通常被归成「课程设计」但它的骨架比标签值钱——车次查询、经停站区间计价、余票计算、锁座下单、退票改签、订单状态流转这套东西是一个完整的交易型桌面应用而不是几个窗体的拼盘。它能干的事很具体管理员维护车次与经停站、配置席别库存乘客按出发到达站和日期查票、选席别下单选座、模拟支付出票、超时未付自动取消、已出票走退票流程。技术栈是 C# WinForms ADO.NET SQL Server纯 BCL 依赖不需要联网拉包。适合两类人一是想把数据库课设做出真实业务味的学生二是想拿一套可跑的交易闭环练手事务、锁和状态机的开发者。2. 工程骨架与技术选型WinForms SQL Server 三层怎么搭2.1 为什么是 WinForms 加手写 SQL而不是 Web 加 ORM先看选型。这套代码跑在 .NET Framework 4.7.2/4.8 上UI 是 WinForms数据访问是 ADO.NET 的 SqlConnection / SqlCommand 直连 SQL Server 2012 及以上Express 版完全够用。没有 Entity Framework没有 WPF也没有任何第三方 UI 库。这个组合在今天看有点朴素但放在实际场景里是合理的。第一编译环境往往没网或者 NuGet 源不可用纯 BCL 依赖意味着 clone 下来就能 build不会卡在还原包上。第二WinForms 拖控件出活快DataGridView 绑一个 List 就是一张列表页课程设计和内部工具不需要为 MVVM 付学费。第三手写 SQL 让执行计划和锁行为都在明面上——后面讲超卖的时候你会发现能直接写WITH (UPDLOCK, ROWLOCK)这件事比 ORM 的隐式事务好排查得多。分层是经典三层引用关系单向不允许反向依赖层典型类型职责不该做的事表现层 FormsMainForm、TicketQueryForm、OrderForm收集输入、展示结果、做基础校验拼 SQL、开事务业务层 BLLTicketService、OrderService、UserService业务规则、事务边界、状态流转直接碰控件数据层 DALSqlHelper、TrainRepository、OrderRepository参数化命令、结果映射、连接管理判断业务合法性模型Train、TicketOrder、CreateOrderRequest数据传输持有连接对象有个常见误解是把「三层」理解成三个文件夹。真正的分界线是事务边界落在哪一层这套项目里开事务的地方必须在 BLLDAL 只负责执行命令和返回受影响行数。一旦让 DAL 自己提交事务下单链路就会变成两个独立事务超卖就是必然的。2.2 解决方案目录每个文件在链路里的位置拿到源码先别急着点运行把目录过一遍心里有张地图后面改哪儿都清楚。路径内容备注TrainTicket.sln解决方案入口VS2017 及以上可直接打开TrainTicket.Forms/全部窗体与用户控件程序启动项在这个项目TrainTicket.Forms/App.config连接字符串、支付超时分钟数换机器必改这里TrainTicket.BLL/业务服务类事务都在这层开TrainTicket.DAL/SqlHelper与各 Repository所有 SQL 集中在这TrainTicket.Models/实体与请求/响应对象无依赖可单独引用db/01_schema.sql建库建表建索引必须先执行db/02_seed.sql车次、经停站、席别库存种子数据后执行db/03_view.sql余票与订单联合视图可选报表页用启动项是TrainTicket.Forms入口在Program.cs里面通常会有一个全局异常兜底Application.ThreadException把未捕获异常写进logs/目录。这个细节值得留着桌面程序崩起来连堆栈都看不到没有日志就是黑匣子。2.3 首次运行建库、改连接串、编译三步走第一步永远是数据库。用 SSMS 或 sqlcmd 按顺序执行两个脚本注意脚本里用了GO分批别整段一次性丢进去。-- db/01_schema.sql 开头部分 IF DB_ID(TrainTicket) IS NULL CREATE DATABASE TrainTicket; GO USE TrainTicket; GO -- 后面是建表语句见第 3 章第二步改连接字符串。打开TrainTicket.Forms/App.config只改Data Source和认证方式别在代码里new SqlConnection(...)硬编码。?xml version1.0 encodingutf-8? configuration connectionStrings !-- Data Source 写实例名本机默认实例用 . 或 (local) Express 版用 .\SQLEXPRESSLocalDB 用 (localdb)\MSSQLLocalDB -- add nameTrainDb providerNameSystem.Data.SqlClient connectionStringData Source.\SQLEXPRESS;Initial CatalogTrainTicket;Integrated SecuritySSPI;MultipleActiveResultSetsTrue;Application NameTrainTicketApp; / /connectionStrings appSettings !-- 待支付订单保留分钟数业务层读这个值算 ExpireTime -- add keyPayTimeoutMinutes value15 / /appSettings /configuration各参数的实际含义Integrated SecuritySSPI表示走 Windows 身份验证省掉账号密码如果必须用 SQL 账号换成User IDxxx;Passwordxxx但别用 sa也别把密码提交进仓库。MultipleActiveResultSetsTrue允许同一个连接上并发多个 Reader主从表联查时不开这个会报「已有打开的与此命令相关联的 DataReader」。Application Name看着没用等你在 SSMS 的sys.dm_exec_sessions里排查谁在占连接就知道它多值钱。第三步编译。项目默认平台目标如果是 x86 而你机器是 64 位某些情况下会因为 SQL Server 客户端组件位数不匹配报错直接在配置管理器里改成 AnyCPU 即可。读取配置的地方记得引System.Configuration// DAL/SqlHelper.cs public static string ConnectionString ConfigurationManager.ConnectionStrings[TrainDb].ConnectionString;3. 数据库设计五张表如何撑起一趟车的全部状态3.1 表结构总览与字段取舍订票系统的复杂度几乎全在数据模型上。界面只是壳真正决定这套代码能不能扛住真实业务的是余票怎么算、座位怎么占、订单状态怎么记。表名作用关键字段量级参考Train车次主档TrainNo、StartStation、EndStation、DepartTime百级TrainStop经停站决定区间计价TrainId、StopOrder、MilesFromStart千级SeatInventory按日期的席别库存TrainId、TravelDate、SeatType、TotalCount、SoldCount万级TicketOrder订单主表OrderNo、FromStop、ToStop、Status、ExpireTime十万级Ticket票明细一单多票OrderId、SeatNo、TicketNo与订单同量级三个字段类型的选择值得单独说。金额一律DECIMAL(10,2)别用FLOAT浮点累加会出现 0.01 的偏差退票算退款时对不上账是血泪经验。乘车日期用DATE而不是DATETIME一旦带上时分秒后面所有按天查询都会变成隐式转换的雷。状态字段用TINYINT配注释比VARCHAR存中文状态省空间也省索引。3.2 建表 SQL约束和索引才是防线的第一层CREATE TABLE dbo.Train ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo VARCHAR(10) NOT NULL, -- G1234 / K802 TrainType TINYINT NOT NULL, -- 1高铁 2动车 3普快 StartStation VARCHAR(30) NOT NULL, EndStation VARCHAR(30) NOT NULL, DepartTime TIME(0) NOT NULL, -- 每日固定发车时刻 ArriveTime TIME(0) NOT NULL, CONSTRAINT UQ_Train_No UNIQUE (TrainNo) ); GO CREATE TABLE dbo.TrainStop ( StopId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL REFERENCES dbo.Train(TrainId), StationName VARCHAR(30) NOT NULL, StopOrder SMALLINT NOT NULL, -- 1 为起点依次递增 ArriveTime TIME(0) NULL, -- 起点没有到达时刻 DepartTime TIME(0) NULL, MilesFromStart INT NOT NULL DEFAULT 0, -- 用于按里程算票价 CONSTRAINT UQ_TrainStop UNIQUE (TrainId, StopOrder) ); GO CREATE TABLE dbo.SeatInventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL, TravelDate DATE NOT NULL, SeatType TINYINT NOT NULL, -- 1商务 2一等 3二等 4硬卧 5硬座 TotalCount INT NOT NULL, SoldCount INT NOT NULL DEFAULT 0, RowVersion ROWVERSION NOT NULL, -- 乐观并发标记 CONSTRAINT UQ_Inventory UNIQUE (TrainId, TravelDate, SeatType), CONSTRAINT CK_Inventory_Count CHECK (SoldCount 0 AND SoldCount TotalCount) ); GO CREATE TABLE dbo.TicketOrder ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo CHAR(20) NOT NULL, UserId INT NOT NULL, TrainId INT NOT NULL, TravelDate DATE NOT NULL, FromStop SMALLINT NOT NULL, ToStop SMALLINT NOT NULL, SeatType TINYINT NOT NULL, SeatCount TINYINT NOT NULL, TotalAmount DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已出票 3已退票 4已取消 CreateTime DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), ExpireTime DATETIME2(0) NOT NULL, CONSTRAINT UQ_Order_No UNIQUE (OrderNo), CONSTRAINT CK_Order_Stop CHECK (ToStop FromStop) ); GO CREATE INDEX IX_Order_User ON dbo.TicketOrder(UserId, CreateTime DESC); CREATE INDEX IX_Order_Expire ON dbo.TicketOrder(Status, ExpireTime); GO CREATE TABLE dbo.Ticket ( TicketId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderId BIGINT NOT NULL REFERENCES dbo.TicketOrder(OrderId), SeatNo VARCHAR(8) NOT NULL, -- 08车12F TicketNo CHAR(16) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, CONSTRAINT UQ_Ticket_No UNIQUE (TicketNo) ); GO逐条解释几个关键约束。CK_Inventory_Count是最后一道防线即使应用层逻辑写错数据库也不允许卖出超过总座数的票违反时直接抛异常回滚比任何代码判断都可靠。RowVersion是 SQL Server 的行版本戳每次更新自动变化用来做乐观并发检测配合后面的事务代码能给用户一个「票已被抢走请重新选择」的友好提示。IX_Order_Expire上的(Status, ExpireTime)组合索引专门服务超时释放任务那个任务每分钟跑一次没索引就是每分钟一次全表扫描。CK_Order_Stop这个检查约束容易被忽略。起讫站序号必须递增否则会出现「从第 5 站买到第 2 站」这种负里程订单票价算出来是负数。业务层当然要校验但数据库这层兜底永远值得加。3.3 余票为什么不能只存一个数字这是整个项目里最容易想错的地方。新手的第一反应是在Train表上加个RemainSeat字段卖一张减一。跑单趟车没问题一旦引入经停站就崩了。假设一趟车 A→B→CA 到 C 之间只有 100 个二等座。乘客甲买了 A→C占掉一个座乘客乙此时想买 B→C他其实可以坐这个座位在 B 到 C 的那一段——但如果库存是一个全局数字甲已经把它减掉了乙就买不到运力白白浪费。反过来如果不去减甲乙会同时认为自己有座上车打架。工程上有三种处理方式复杂度递增方案做法优点代价整段扣减一张票扣一站全程库存实现最简单区间运力浪费严重分段库存每个相邻站区间各存一条库存查询快表行数按区间数平方增长退票要回滚多段座位级占用记录每张票的起讫站序号冲突时判区间重叠最准支持选座查询要扫描同车同日订单这套项目默认走的是第一种够跑通也够演示。要往真实业务靠我一般改成第三种判定条件就一行区间重叠公式-- 判断某段已售票 [FromStop, ToStop) 是否与请求区间冲突 -- 冲突条件已有票的起点在请求区间内或终点跨过请求起点 WHERE o.FromStop toStop AND o.ToStop fromStop这个开闭区间的写法把「同站换乘」自然排除了甲的票是 1→3乙请求 2→33 2且1 3成立冲突乙请求 3→53 3不成立不冲突正好可以在甲下车后接着坐。半开区间的好处就在这里写成会让所有终点站重复计算。4. 核心链路拆解一次下单从点击到出票的完整代码4.1 余票查询参数化 SQL 加上区间统计查询页面上用户选完出发站、到达站、日期、席别点「查询」后端要做的是拿到车次列表对每个车次算出请求区间的剩余票数。核心方法签名一般长这样参数全部走SqlParameter绝不拼字符串。// DAL/InventoryRepository.cs public int GetRemain(int trainId, DateTime travelDate, byte seatType, int fromStop, int toStop) { const string sql SELECT inv.TotalCount - ISNULL(( SELECT SUM(o.SeatCount) FROM dbo.TicketOrder o WHERE o.TrainId inv.TrainId AND o.TravelDate inv.TravelDate AND o.SeatType inv.SeatType AND o.Status IN (0, 1, 2) -- 待支付/已支付/已出票都算占座 AND o.FromStop toStop -- 区间重叠判定 AND o.ToStop fromStop ), 0) AS Remain FROM dbo.SeatInventory inv WHERE inv.TrainId trainId AND inv.TravelDate travelDate AND inv.SeatType seatType;; using (var conn new SqlConnection(SqlHelper.ConnectionString)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(trainId, SqlDbType.Int).Value trainId; cmd.Parameters.Add(travelDate, SqlDbType.Date).Value travelDate.Date; // 只取日期部分 cmd.Parameters.Add(seatType, SqlDbType.TinyInt).Value seatType; cmd.Parameters.Add(fromStop, SqlDbType.SmallInt).Value fromStop; cmd.Parameters.Add(toStop, SqlDbType.SmallInt).Value toStop; conn.Open(); var result cmd.ExecuteScalar(); return result null || result DBNull.Value ? 0 : Convert.ToInt32(result); } }几个要点。状态过滤里必须包含 0待支付因为下单那一刻座位就被占住了如果只算已支付两个用户会在 15 分钟窗口内同时抢到同一张票。travelDate显式指定SqlDbType.Date而不是让 ADO.NET 推断推断成DateTime时2025-06-01 14:30:00会被拿去和DATE列比较结果恒为不成立列表直接空白。ExecuteScalar返回DBNull的情况也要处理库存表里没有这一行时子查询结果是 NULLISNULL包在外层之外最外层还得防一手。提示这个查询在高并发下会被反复执行SeatInventory上的唯一索引UQ_Inventory正好覆盖(TrainId, TravelDate, SeatType)命中索引查找而不是扫描。订单侧的子查询依赖IX_Order_User之外的组合条件量大了要补一个(TrainId, TravelDate, SeatType, Status)的索引。4.2 锁座与下单一个事务包住四步靠受影响行数判胜负这是全项目最不能含糊的一段。流程是占库存 → 生成座位号 → 插订单 → 插票明细四步必须在同一个事务里任何一步失败全部回滚。// BLL/TicketService.cs public OrderResult CreateOrder(CreateOrderRequest req) { using (var conn new SqlConnection(SqlHelper.ConnectionString)) { conn.Open(); using (var tx conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 第 1 步条件更新占库存把并发判断交给数据库 const string sqlInv UPDATE dbo.SeatInventory WITH (UPDLOCK, ROWLOCK) SET SoldCount SoldCount count WHERE TrainId trainId AND TravelDate travelDate AND SeatType seatType AND SoldCount count TotalCount;; // 关键余量判断写进 WHERE using (var cmd new SqlCommand(sqlInv, conn, tx)) { cmd.Parameters.Add(count, SqlDbType.TinyInt).Value req.SeatCount; cmd.Parameters.Add(trainId, SqlDbType.Int).Value req.TrainId; cmd.Parameters.Add(travelDate, SqlDbType.Date).Value req.TravelDate.Date; cmd.Parameters.Add(seatType, SqlDbType.TinyInt).Value req.SeatType; var affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); // 没抢到立刻退出不产生任何脏数据 return OrderResult.NoSeat(所选席别余票不足); } } // 第 2 步生成订单号与座位号插订单 var orderNo OrderNoGenerator.Next(); var expire DateTime.Now.AddMinutes(SqlHelper.PayTimeoutMinutes); const string sqlOrder INSERT INTO dbo.TicketOrder (OrderNo, UserId, TrainId, TravelDate, FromStop, ToStop, SeatType, SeatCount, TotalAmount, Status, ExpireTime) OUTPUT INSERTED.OrderId VALUES (no, uid, tid, date, from, to, st, cnt, amt, 0, expire);; long orderId; using (var cmd new SqlCommand(sqlOrder, conn, tx)) { cmd.Parameters.Add(no, SqlDbType.Char, 20).Value orderNo; // 其余参数略全部类型化赋值 orderId Convert.ToInt64(cmd.ExecuteScalar()); } // 第 3 步按席别分配座位号逐张插 Ticket唯一索引兜底防重 // 第 4 步可选扣优惠券、写操作日志 tx.Commit(); return OrderResult.Ok(orderNo, orderId); } catch (SqlException ex) when (ex.Number 2601 || ex.Number 2627) { tx.Rollback(); return OrderResult.NoSeat(座位已被抢占请重新选择); } catch { tx.Rollback(); throw; } } } }逐句说。WITH (UPDLOCK, ROWLOCK)里的UPDLOCK是核心它在读取目标行时直接加更新锁别的会话想同时更新同一行会被挡住排队而不是都读到旧值再各自升级成排他锁打成死锁。ROWLOCK是减少锁粒度防止 SQL Server 觉得行数多直接升级成页锁甚至表锁把整趟车的下单都卡住。AND SoldCount count TotalCount这半句是整个防超卖的命门。它把「够不够」的判断和执行更新合并成一个原子操作返回 0 就代表条件不满足。所有写成「先 SELECT 余量、C# 里 if 判断、再 UPDATE」的版本在两个人同时点提交时都会翻车因为 SELECT 和 UPDATE 之间存在时间窗口。IsolationLevel.ReadCommitted在这里够用因为并发控制是靠显式的UPDLOCK做的不需要把隔离级别拉到Serializable——拉高了会连查询都互相阻塞吞吐直接掉一个量级。OUTPUT INSERTED.OrderId一次拿回自增主键省掉SELECT SCOPE_IDENTITY()的往返。最后那个catch捕获 2601/2627唯一索引冲突是座位号分配并发撞车时的兜底把异常翻译成业务提示而不是让用户看到一个英文堆栈。注意整段代码里没有任何Thread.Sleep、没有lock关键字。应用层加锁在单机还能糊弄一换多进程部署就彻底失效。锁的位置只能有一个——数据库。4.3 状态机与超时释放15 分钟没付钱座位必须还回去订单状态只有五个值流转规则写死0 待支付 → 1 已支付 → 2 已出票 → 3 已退票0 还可以直接跳到 4 已取消。反向流转一律禁止比如已出票不能回退到待支付。这套状态机建议直接在业务层用一个字典约束死// 允许的状态迁移键是当前状态值是可达状态集合 private static readonly Dictionarybyte, byte[] AllowedTransitions new() { { 0, new byte[] { 1, 4 } }, // 待支付 → 已支付 / 已取消 { 1, new byte[] { 2, 3 } }, // 已支付 → 已出票 / 已退票 { 2, new byte[] { 3 } }, // 已出票 → 已退票 { 3, Array.Emptybyte() }, // 终态 { 4, Array.Emptybyte() } // 终态 };超时释放是个定时任务SQL 一条语句就能筛出该清理的订单-- 每 60 秒执行一次先选出待释放的订单再回减库存 SELECT OrderId, TrainId, TravelDate, SeatType, SeatCount FROM dbo.TicketOrder WHERE Status 0 AND ExpireTime SYSDATETIME();拿到列表后对每一单在一个事务里把Status置 4同时SoldCount SoldCount - count两步同生共死。触发器会带来隐式执行和难调试的问题我不推荐定时任务写在 C# 里日志清楚出错能重试。放在 WinForms 里用System.Windows.Forms.Timer每 60 秒触发一次注意回调里不要直接碰 UI 控件具体原因见第 5 章第 5 条。更稳的做法是查询页在加载时顺带做一次惰性释放双保险。4.4 订单号与取票号能给人念出来的才叫单号订单号规则yyyyMMddHHmmss 4 位进程内自增 2 位机器码总长 20 位正好塞进CHAR(20)。自增用Interlocked.Increment保证线程安全机器码从配置文件读多机部署时不撞号。private static int _seq 0; public static string Next() { var seq Interlocked.Increment(ref _seq) % 10000; // 溢出后回绕靠唯一索引兜底 return ${DateTime.Now:yyyyMMddHHmmss}{seq:D4}{MachineCode}; }不要用Guid.NewGuid().ToString()直接当单号给用户看36 位带横杠客服念一遍能念错三次。取票号同理16 位纯数字更合适生成后由UQ_Ticket_No唯一索引做最终校验撞了就重试一次。这类「人可读 ID」的设计原则是长度固定、字符集受限、校验位可选千万别混进随机大小写。5. 避坑与排查五个让订票系统当场翻车的细节5.1 两个人同时下单最后一个座位卖出了两张票现象本地单开永远正常一用两台机器同时点「提交」订单表多出一条席别库存的SoldCount却只加了一次。原因代码里先SELECT余量在 C# 里if (remain count)判断再执行UPDATE。两个请求几乎同时读到remain 1都判断通过各自更新最后超卖。解决把判断塞进UPDATE ... WHERE SoldCount count TotalCount只看ExecuteNonQuery()返回值再加WITH (UPDLOCK, ROWLOCK)消除锁升级最后用CK_Inventory_Count检查约束兜底。改完之后用 20 个Task并发压一次只应该成功一单。5.2 当天有车次查询列表却是空的现象把日期往后调一天就能查出车选今天什么都没有。数据库里TravelDate明明有今天的记录。原因请求参数传的是DateTime.Now带着时分秒。SqlParameter没指定类型时会推断成SqlDbType.DateTimeSQL Server 比较时把DATE列提升成DATETIME变成2025-06-01 00:00:00 2025-06-01 14:30:00恒不成立。解决赋值时统一.Date服务端明确写cmd.Parameters.Add(travelDate, SqlDbType.Date)。凡是日期列参数类型都要显式指定别偷懒交给推断——这是我踩过最多次的坑之一。5.3 换台电脑就报「未找到或无法访问服务器」现象代码没动拷到另一台机器sqlConnection.Open()直接抛SqlException错误号 26 或 40。原因Data Source写死了原作者那台机器的实例名或者目标机器装了 Express 版但 SQL Server Browser 服务没启动命名实例解析不到也可能是 TCP/IP 协议在配置管理器里被禁用。解决先把连接串的实例名改成相对写法.\SQLEXPRESS或(localdb)\MSSQLLocalDB把连接串挪回App.config而不是硬编码在SqlHelper里。在 SSMS 里能连上、程序连不上八成是协议或防火墙问题去配置管理器把 TCP/IP 和命名管道都打开重启实例服务。5.4 下单成功了我的订单页还是旧数据现象数据确实插进库了切到订单列表页刷新还是老的几行关掉重开窗口才正常。原因窗体绑的是ListT查询后虽然拿到了新集合但没有重新赋给DataGridView.DataSource或者用了BindingSource却没调ResetBindings。ListT不实现INotifyCollectionChanged界面根本不知道数据变了。解决最省事的写法是先解绑再绑// 强制重绑让 DataGridView 丢掉旧的数据视图 gridOrders.DataSource null; gridOrders.DataSource orderList;要长期维护就改成BindingListT或者维护一个BindingSource增删改都走BindingSource的方法界面自动跟着动。5.5 后台线程更新界面直接抛「线程间操作无效」现象定时释放任务里查完库顺手更新了一下状态栏的Label.Text程序弹异常崩掉提示「从不是创建控件的线程访问它」。原因WinForms 控件有线程亲和性只能在创建它的那根 UI 线程上操作。Timer回调、Task.Run里的代码都跑在线程池线程上。解决跨线程更新走Invoke或BeginInvokeif (lblStatus.InvokeRequired) lblStatus.BeginInvoke(new Action(() lblStatus.Text 释放超时订单完成)); else lblStatus.Text 释放超时订单完成;更好的做法是把耗时逻辑写成async方法await之后自动回到 UI 上下文连InvokeRequired都不用写。另外System.Windows.Forms.Timer的Tick本身就在 UI 线程上跑如果你在里面做同步查库界面会卡死——这就是玄学卡顿的常见来源。6. 二次开发把窗体里的 SQL 抽成可测试的订票内核这套代码想往真实项目走第一步不是加功能是把 SQL 从窗体里搬出来。现状是MainForm里散落着SqlCommand改一个查询要翻三个文件。目标是定义ITicketService和IInventoryRepository窗体只依赖接口实现类放 DAL注册在Program.cs里手工注入——不用 IoC 容器构造函数传参就够少一层依赖少一层调试成本。第二步加缓存。车次和经停站是低频变更数据用MemoryCache缓存 5 分钟查询页每次打开都打库完全没必要。但库存绝对不能缓存余票一秒一变缓存等于主动制造超卖。第三步补自动化验证。超卖这种问题靠手点是测不出来的必须并发压[Fact] public async Task 只剩一张票时并发下单只成功一次() { var svc new TicketService(TestDb.ConnectionString); // 20 个用户同时抢同一天的同一个席别 var tasks Enumerable.Range(0, 20).Select(i Task.Run(() svc.CreateOrder(new CreateOrderRequest { TrainId 1, TravelDate new DateTime(2025, 6, 1), SeatType 3, FromStop 1, ToStop 3, SeatCount 1, UserId 1000 i }))); var results await Task.WhenAll(tasks); Assert.Equal(1, results.Count(r r.Success)); // 只允许成功一单 Assert.Equal(1, new InventoryRepository(TestDb.ConnectionString) .GetSoldCount(1, new DateTime(2025, 6, 1), 3)); // 库存只减一次 }这个测试跑通防超卖才算真的落地。测试库每次跑之前用Respawn或者手写 truncate 清一遍否则第二次跑就被唯一索引卡住。再往后是替换数据访问层。如果嫌SqlHelper的参数赋值太啰嗦Dapper 是最低摩擦的替换方案conn.QuerySingleint(sql, new { trainId, travelDate })一行搞定SQL 还是你写的那条锁提示也照旧生效改造成本基本只有映射部分。改造点现状目标数据访问SQL 写在窗体里全部收敛到 Repository事务边界分散在多个方法只在TicketService开状态流转直接赋Status走状态机校验方法并发验证无集成测试覆盖抢票场景从那以后我每次接这类带库存的交易型项目都强制先把「并发抢最后一张票」的测试写出来再动业务代码跑不通就不往下写。少这一遍后面全是补不完的坑。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站