C# WinForms火车订票系统:事务锁防超卖与订单状态机实战
简介:面向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 的隐式事务好排查得多。
分层是经典三层,引用关系单向,不允许反向依赖:
| 层 | 典型类型 | 职责 | 不该做的事 |
|---|---|---|---|
| 表现层 Forms | MainForm、TicketQueryForm、OrderForm | 收集输入、展示结果、做基础校验 | 拼 SQL、开事务 |
| 业务层 BLL | TicketService、OrderService、UserService | 业务规则、事务边界、状态流转 | 直接碰控件 |
| 数据层 DAL | SqlHelper、TrainRepository、OrderRepository | 参数化命令、结果映射、连接管理 | 判断业务合法性 |
| 模型 | Train、TicketOrder、CreateOrderRequest | 数据传输 | 持有连接对象 |
有个常见误解是把「三层」理解成三个文件夹。真正的分界线是事务边界落在哪一层:这套项目里,开事务的地方必须在 BLL,DAL 只负责执行命令和返回受影响行数。一旦让 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 version="1.0" encoding="utf-8"?> <configuration> <connectionStrings> <!-- Data Source 写实例名:本机默认实例用 . 或 (local), Express 版用 .\SQLEXPRESS,LocalDB 用 (localdb)\MSSQLLocalDB --> <add name="TrainDb" providerName="System.Data.SqlClient" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=TrainTicket;Integrated Security=SSPI;MultipleActiveResultSets=True;Application Name=TrainTicketApp;" /> </connectionStrings> <appSettings> <!-- 待支付订单保留分钟数,业务层读这个值算 ExpireTime --> <add key="PayTimeoutMinutes" value="15" /> </appSettings> </configuration>各参数的实际含义:Integrated Security=SSPI表示走 Windows 身份验证,省掉账号密码;如果必须用 SQL 账号,换成User ID=xxx;Password=xxx,但别用 sa,也别把密码提交进仓库。MultipleActiveResultSets=True允许同一个连接上并发多个 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→C,A 到 C 之间只有 100 个二等座。乘客甲买了 A→C,占掉一个座;乘客乙此时想买 B→C,他其实可以坐这个座位在 B 到 C 的那一段——但如果库存是一个全局数字,甲已经把它减掉了,乙就买不到,运力白白浪费。反过来如果不去减,甲乙会同时认为自己有座,上车打架。
工程上有三种处理方式,复杂度递增:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 整段扣减 | 一张票扣一站全程库存 | 实现最简单 | 区间运力浪费严重 |
| 分段库存 | 每个相邻站区间各存一条库存 | 查询快 | 表行数按区间数平方增长,退票要回滚多段 |
| 座位级占用 | 记录每张票的起讫站序号,冲突时判区间重叠 | 最准,支持选座 | 查询要扫描同车同日订单 |
这套项目默认走的是第一种,够跑通也够演示。要往真实业务靠,我一般改成第三种,判定条件就一行区间重叠公式:
-- 判断某段已售票 [FromStop, ToStop) 是否与请求区间冲突 -- 冲突条件:已有票的起点在请求区间内,或终点跨过请求起点 WHERE o.FromStop < @toStop AND o.ToStop > @fromStop这个开闭区间的写法把「同站换乘」自然排除了:甲的票是 1→3,乙请求 2→3,3 > 2且1 < 3成立,冲突;乙请求 3→5,3 > 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的情况也要处理,库存表里没有这一行时子查询结果是 NULL,ISNULL包在外层之外,最外层还得防一手。
提示:这个查询在高并发下会被反复执行,
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 Dictionary<byte, byte[]> AllowedTransitions = new() { { 0, new byte[] { 1, 4 } }, // 待支付 → 已支付 / 已取消 { 1, new byte[] { 2, 3 } }, // 已支付 → 已出票 / 已退票 { 2, new byte[] { 3 } }, // 已出票 → 已退票 { 3, Array.Empty<byte>() }, // 终态 { 4, Array.Empty<byte>() } // 终态 };超时释放是个定时任务,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.DateTime,SQL 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 下单成功了,我的订单页还是旧数据
现象:数据确实插进库了,切到订单列表页刷新还是老的几行,关掉重开窗口才正常。
原因:窗体绑的是List<T>,查询后虽然拿到了新集合,但没有重新赋给DataGridView.DataSource;或者用了BindingSource却没调ResetBindings。List<T>不实现INotifyCollectionChanged,界面根本不知道数据变了。
解决:最省事的写法是先解绑再绑:
// 强制重绑,让 DataGridView 丢掉旧的数据视图 gridOrders.DataSource = null; gridOrders.DataSource = orderList;要长期维护就改成BindingList<T>或者维护一个BindingSource,增删改都走BindingSource的方法,界面自动跟着动。
5.5 后台线程更新界面,直接抛「线程间操作无效」
现象:定时释放任务里查完库,顺手更新了一下状态栏的Label.Text,程序弹异常崩掉,提示「从不是创建控件的线程访问它」。
原因:WinForms 控件有线程亲和性,只能在创建它的那根 UI 线程上操作。Timer回调、Task.Run里的代码都跑在线程池线程上。
解决:跨线程更新走Invoke或BeginInvoke:
if (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.QuerySingle<int>(sql, new { trainId, travelDate })一行搞定,SQL 还是你写的那条,锁提示也照旧生效,改造成本基本只有映射部分。
| 改造点 | 现状 | 目标 |
|---|---|---|
| 数据访问 | SQL 写在窗体里 | 全部收敛到 Repository |
| 事务边界 | 分散在多个方法 | 只在TicketService开 |
| 状态流转 | 直接赋Status | 走状态机校验方法 |
| 并发验证 | 无 | 集成测试覆盖抢票场景 |
从那以后我每次接这类带库存的交易型项目,都强制先把「并发抢最后一张票」的测试写出来再动业务代码,跑不通就不往下写。少这一遍,后面全是补不完的坑。希望帮到你。
本文还有配套的精品资源,点击获取