訂票系統(tǒng)實(shí)戰(zhàn):并發(fā)控制與事務(wù)一致性設(shè)計(jì))
簡(jiǎn)介一份基于C#與SQL Server的火車(chē)訂票系統(tǒng)完整項(xiàng)目面向初學(xué)C#窗體編程及數(shù)據(jù)庫(kù)開(kāi)發(fā)的讀者用于理解訂票、退票、改簽、車(chē)次管理等核心業(yè)務(wù)流程并掌握WinForms事件驅(qū)動(dòng)設(shè)計(jì)與SQL數(shù)據(jù)操作。壓縮包共128個(gè)文件以cs源碼文件為主輔以resx資源、resources編譯資源、jpg界面素材、exe可執(zhí)行程序及sln工程文件等整體約1.85MB目錄結(jié)構(gòu)清晰方便直接打開(kāi)編譯與二次修改。目前已有1437人學(xué)習(xí)/下載適合課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)或項(xiàng)目實(shí)訓(xùn)參考。通過(guò)該項(xiàng)目可獲得整套窗體源碼、界面設(shè)計(jì)文件、數(shù)據(jù)庫(kù)交互邏輯及運(yùn)行程序覆蓋用戶(hù)模塊、車(chē)次管理模塊和訂單管理模塊的實(shí)現(xiàn)細(xì)節(jié)并包含常見(jiàn)錯(cuò)誤處理與異常捕獲寫(xiě)法可幫助讀者快速搭建同類(lèi)訂票系統(tǒng)提升C#與SQL Server的工程實(shí)踐能力。1. C#火車(chē)訂票系統(tǒng)不是查個(gè)數(shù)據(jù)庫(kù)那么簡(jiǎn)單C#火車(chē)訂票系統(tǒng)聽(tīng)起來(lái)就是把車(chē)次存進(jìn)數(shù)據(jù)庫(kù)、讓用戶(hù)查一查訂一訂但真正實(shí)現(xiàn)過(guò)的人都知道這個(gè)項(xiàng)目的核心難點(diǎn)根本不在增刪改查而在余票扣減的一致性?xún)蓚€(gè)用戶(hù)同時(shí)搶最后一張票系統(tǒng)憑什么只讓一個(gè)人成功訂單生成到一半網(wǎng)絡(luò)閃斷余票和訂單怎么保持一致說(shuō)到底訂票系統(tǒng)是一個(gè)典型的并發(fā)寫(xiě)密集型業(yè)務(wù)系統(tǒng)能用 C# 把它做扎實(shí)你對(duì)事務(wù)、鎖、連接池、異步 UI 的理解都會(huì)上一個(gè)臺(tái)階。這篇文章面向正在做課程設(shè)計(jì)的學(xué)生、想練手完整業(yè)務(wù)閉環(huán)的初級(jí)開(kāi)發(fā)者和需要把內(nèi)部訂票需求落地的工程師帶你從技術(shù)選型、數(shù)據(jù)庫(kù)設(shè)計(jì)一路走到并發(fā)壓測(cè)驗(yàn)證。2. 技術(shù)選型與三層架構(gòu)先決定系統(tǒng)長(zhǎng)什么樣再寫(xiě)代碼開(kāi)始寫(xiě)業(yè)務(wù)代碼之前先回答三個(gè)問(wèn)題系統(tǒng)給誰(shuí)用、部署在哪里、誰(shuí)來(lái)維護(hù)?;疖?chē)訂票系統(tǒng)在 C# 生態(tài)里有三種常見(jiàn)形態(tài)選型直接決定你后面要寫(xiě)多少界面代碼、能不能方便地?fù)Q數(shù)據(jù)庫(kù)、以及項(xiàng)目寫(xiě)到一半會(huì)不會(huì)因?yàn)榧軜?gòu)問(wèn)題返工。2.1 三種形態(tài)WinForms、WPF、ASP.NET Core 怎么選如果你做的是課程設(shè)計(jì)或公司內(nèi)部的小工具最常見(jiàn)的選擇是 WinForms。它拖控件就能出界面DataGridView 綁個(gè) DataTable 就能展示車(chē)次列表開(kāi)發(fā)速度最快答辯演示也直觀。缺點(diǎn)同樣明顯界面樣式老舊部署時(shí)目標(biāo)機(jī)器要裝對(duì)應(yīng)版本的 .NET Desktop Runtime而且窗體代碼很容易越寫(xiě)越臃腫最后 SQL 全部拼在按鈕點(diǎn)擊事件里調(diào)試一次要重啟一次窗口。如果你對(duì)界面有要求可以考慮 WPF。WPF 的綁定和樣式機(jī)制更適合做出復(fù)雜的座位選擇界面但 MVVM、依賴(lài)屬性、觸發(fā)器這些東西的學(xué)習(xí)成本對(duì)新手不太友好一個(gè)訂票系統(tǒng)的主要工作量會(huì)從業(yè)務(wù)邏輯轉(zhuǎn)移到界面框架上時(shí)間容易失控。如果想做一個(gè)真正能上線(xiàn)、手機(jī)和網(wǎng)頁(yè)都能訪(fǎng)問(wèn)的系統(tǒng)我建議選擇 ASP.NET Core WebAPI 加一個(gè)簡(jiǎn)單的前端頁(yè)面。這種形態(tài)沒(méi)有 WinForms 那樣的窗體生命周期業(yè)務(wù)層以 Controller 為入口天然適合對(duì)接小程序或網(wǎng)頁(yè)客戶(hù)端。缺點(diǎn)是得額外維護(hù)一套前端工作量會(huì)大不少。三者對(duì)比我一般這樣看形態(tài)適用場(chǎng)景界面開(kāi)發(fā)速度部署成本我的建議WinForms課程設(shè)計(jì)、內(nèi)部工具快拖控件即可需裝 Desktop Runtime練手首選WPF復(fù)雜界面、座位圖慢需先學(xué) MVVM需裝 Desktop Runtime對(duì) UI 有執(zhí)念再選ASP.NET Core WebAPI線(xiàn)上系統(tǒng)、多端訪(fǎng)問(wèn)需額外寫(xiě)前端只需 .NET Runtime簡(jiǎn)歷/生產(chǎn)推薦我的建議比較直接如果你的目標(biāo)是快速跑通整個(gè)業(yè)務(wù)閉環(huán)、把精力放在事務(wù)和并發(fā)上選 WinForms后面章節(jié)的代碼也按 WinForms 來(lái)寫(xiě)如果你是想拿這個(gè)項(xiàng)目做簡(jiǎn)歷上的代表作選 ASP.NET Core業(yè)務(wù)層代碼幾乎可以平移過(guò)去。還有一個(gè)隱藏因素?cái)?shù)據(jù)庫(kù)。WinForms 項(xiàng)目最常見(jiàn)的搭配是 SQL Server LocalDB 或 Express部署簡(jiǎn)單如果機(jī)器上沒(méi)裝 SQL Server也可以換成 SQLite但 SQLite 的并發(fā)寫(xiě)鎖比 SQL Server 嚴(yán)格得多后面章節(jié)講的并發(fā)方案要相應(yīng)調(diào)整。2.2 三層架構(gòu)拆分UI、BLL、DAL 的職責(zé)邊界不管選哪種界面形態(tài)我都強(qiáng)烈建議把項(xiàng)目拆成三層。一個(gè)典型的解決方案結(jié)構(gòu)是這樣TrainBooking.sln ├── TrainBooking.Model # 實(shí)體類(lèi)ScheduleInfo、OrderInfo ├── TrainBooking.DAL # 數(shù)據(jù)訪(fǎng)問(wèn)層只負(fù)責(zé) SQL 和數(shù)據(jù)庫(kù)交互 ├── TrainBooking.BLL # 業(yè)務(wù)邏輯層余票扣減、下單、退票規(guī)則 ├── TrainBooking.UI # WinForms 界面只負(fù)責(zé)展示和用戶(hù)輸入 └── TrainBooking.Common # 連接字符串、密碼哈希、訂單號(hào)生成等公共方法這樣拆有實(shí)打?qū)嵉暮锰?。第一業(yè)務(wù)規(guī)則可以單測(cè)。比如退票邏輯你不需要打開(kāi)窗體直接調(diào)用 BLL 的 CancelOrder 方法就能驗(yàn)證狀態(tài)流轉(zhuǎn)。第二換界面不換業(yè)務(wù)。昨天用 WinForms明天想改成 WebAPIUI 層扔掉BLL 和 DAL 原封不動(dòng)。第三連接字符串和 SQL 不會(huì)散落在各處換一臺(tái)數(shù)據(jù)庫(kù)服務(wù)器只改一個(gè)配置文件。我見(jiàn)過(guò)太多把數(shù)據(jù)庫(kù)操作直接寫(xiě)在 Form 里的項(xiàng)目一個(gè)按鈕點(diǎn)擊事件里幾百行代碼查詢(xún)、更新、彈窗全混在一起。分層之后DAL 只負(fù)責(zé)拿到參數(shù)、執(zhí)行 SQL、返回結(jié)果BLL 只負(fù)責(zé)判斷業(yè)務(wù)規(guī)則UI 只負(fù)責(zé)把用戶(hù)輸入傳給 BLL 再綁定結(jié)果。出了問(wèn)題可以按層排查界面表現(xiàn)異常查 UI業(yè)務(wù)規(guī)則不對(duì)查 BLL數(shù)據(jù)不對(duì)查 SQL不用再?gòu)膸资畟€(gè)事件方法里翻找。實(shí)體類(lèi)在 DAL 和 BLL 之間傳遞數(shù)據(jù)。以車(chē)次信息為例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因?yàn)檐?chē)次表里存的是每天幾點(diǎn)發(fā)車(chē)這個(gè)重復(fù)性時(shí)刻不是某個(gè)具體時(shí)間點(diǎn)。很多初學(xué)者在這里用 DateTime后面查票時(shí)還得反復(fù)做日期拼接純屬給自己挖坑。連接字符串放在 Common 層的一個(gè)靜態(tài)類(lèi)里讀取 App.config 或 appsettings.json換服務(wù)器時(shí)只改一處。另外密碼不要在數(shù)據(jù)庫(kù)里明文存儲(chǔ)注冊(cè)時(shí)用加鹽哈希后存 Hash 列這是訂票系統(tǒng)的底線(xiàn)。.NET 的文檔不建議新代碼繼續(xù)用 MD5練手項(xiàng)目至少也得用 SHA256 加鹽比大部分課程設(shè)計(jì)已經(jīng)強(qiáng)了。三層架構(gòu)的代價(jià)是多寫(xiě)一層代碼但對(duì)訂票系統(tǒng)這個(gè)業(yè)務(wù)復(fù)雜度來(lái)說(shuō)這點(diǎn)代價(jià)非常值得。后面章節(jié)里所有代碼我都按這個(gè)分層來(lái)組織你可以在自己的項(xiàng)目里直接照抄這個(gè)結(jié)構(gòu)。3. 數(shù)據(jù)庫(kù)設(shè)計(jì)五張核心表與余票扣減的關(guān)鍵約束數(shù)據(jù)庫(kù)設(shè)計(jì)是訂票系統(tǒng)最容易返工的部分。返工不是因?yàn)楸聿粔蚨喽且驗(yàn)橐婚_(kāi)始沒(méi)想清楚余票這個(gè)數(shù)據(jù)模型。把余票拆成獨(dú)立的表而不是在車(chē)次表里放一個(gè) RemainingCount 字段這是整個(gè)設(shè)計(jì)的第一個(gè)關(guān)鍵決策。3.1 核心表結(jié)構(gòu)用戶(hù)、車(chē)次、排班、余票、訂單怎么建先看建表腳本我把一個(gè)最小可用的訂票系統(tǒng)拆成了五張表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, -- 車(chē)次號(hào)如 G1234 TrainType TINYINT NOT NULL, -- 1高鐵 2動(dòng)車(chē) 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, -- 每天的發(fā)車(chē)時(shí)刻 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, -- 對(duì)外訂單號(hào) 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已過(guò)期 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CancelTime DATETIME NULL );有幾個(gè)設(shè)計(jì)點(diǎn)值得說(shuō)清楚。Trains 和 Schedules 分開(kāi)是因?yàn)橐惶肆熊?chē)如 G1234每天都在跑如果車(chē)次直接帶日期同一趟車(chē)每天都要復(fù)制一份始發(fā)站、終到站、車(chē)次號(hào)這些不變信息數(shù)據(jù)冗余而且改一個(gè)站名要改幾百行。Schedules 表用 (TrainId, DepartDate) 唯一約束從數(shù)據(jù)庫(kù)層面杜絕同一趟車(chē)同一天被重復(fù)錄入人工維護(hù)車(chē)次數(shù)據(jù)時(shí)這個(gè)約束特別有用。SeatInventory 是余票的核心。為什么單獨(dú)建表而不是在 Schedules 上掛一個(gè) RemainingCount因?yàn)橐惶塑?chē)有多種席別二等座、一等座、硬臥、軟臥的庫(kù)存互相獨(dú)立拆成行才能各自扣減、各自加鎖。RemainingCount 落在獨(dú)立的行上數(shù)據(jù)庫(kù)行鎖才能精確控制并發(fā)。Orders 表則承擔(dān)交易憑證的角色OrderNo 對(duì)外唯一客戶(hù)退款、改簽都靠這個(gè)號(hào)碼定位。索引方面查詢(xún)車(chē)次的典型條件是始發(fā)站、到達(dá)站、出發(fā)日期所以 Trains 加一個(gè) (StartStation, EndStation) 聯(lián)合索引Schedules 加一個(gè) DepartDate 索引Orders 的 OrderNo 已經(jīng)由 UNIQUE 約束自動(dòng)帶索引。如果用戶(hù)要查歷史訂單給 Orders.UserId 加一個(gè)非聚集索引否則按用戶(hù)查訂單會(huì)掃全表。3.2 余票扣減為什么先查再改一定會(huì)超賣(mài)余票扣減是訂票系統(tǒng)里最容易出事的一段代碼。新手最常見(jiàn)的寫(xiě)法是兩步走先 SELECT 余票判斷大于 0 再 UPDATE 減一。這個(gè)邏輯單用戶(hù)下沒(méi)問(wèn)題一旦兩個(gè)用戶(hù)同時(shí)買(mǎi)最后一張票就會(huì)同時(shí)讀到 RemainingCount 等于 1同時(shí)通過(guò)判斷同時(shí)執(zhí)行 UPDATE最終結(jié)果是兩個(gè)人下單成功余票變成 -1。這就是典型的超賣(mài)。正確的做法是讓扣減變成一個(gè)原子操作把判斷和更新合并到一條 SQL 里UPDATE SeatInventory SET RemainingCount RemainingCount - count WHERE ScheduleId scheduleId AND SeatType seatType AND RemainingCount count這條語(yǔ)句在 SQL Server 里執(zhí)行時(shí)會(huì)對(duì)命中的行加排他鎖兩個(gè)并發(fā)事務(wù)同時(shí)執(zhí)行時(shí)第二個(gè)事務(wù)會(huì)一直等到第一個(gè)提交或回滾然后重新讀取行數(shù)據(jù)發(fā)現(xiàn) RemainingCount 已經(jīng)不足更新影響行數(shù)為 0。代碼只需要判斷 ExecuteNonQuery 的返回值等于 0 就是庫(kù)存不足等于 1 才是扣減成功。這里有一個(gè)隱含的坑UPDATE 語(yǔ)句自帶鎖但 SELECT 和 UPDATE 分開(kāi)寫(xiě)的話(huà)SELECT 默認(rèn)不加排他鎖讀到的可能是已提交的舊值。所以要么像我上面這樣把判斷放進(jìn) UPDATE 的 WHERE要么給 SELECT 加 WITH (UPDLOCK) 提示強(qiáng)制加更新鎖。前者簡(jiǎn)單直接推薦優(yōu)先用。注意判斷余票是否扣減成功唯一可靠信號(hào)是 ExecuteNonQuery 的返回值不要依賴(lài)扣減前先查一次的舊值。返回值 0 時(shí)事務(wù)里后續(xù)步驟不應(yīng)繼續(xù)執(zhí)行必須回滾并返回失敗。另外一個(gè)容易被忽略的問(wèn)題是扣減順序。在一個(gè)訂單事務(wù)里先扣余票再插訂單比反過(guò)來(lái)的死鎖概率低。因?yàn)樗惺聞?wù)都按同樣的順序拿鎖就不容易出現(xiàn)兩個(gè)事務(wù)各自持有一把鎖又等對(duì)方釋放鎖的循環(huán)等待。記住這個(gè)原則多個(gè)資源要按固定順序加鎖。最后再加一道數(shù)據(jù)庫(kù)防線(xiàn)給 SeatInventory 建一個(gè)CHECK (RemainingCount 0)約束萬(wàn)一業(yè)務(wù)層有漏洞數(shù)據(jù)庫(kù)層面還能兜底這個(gè)約束的成本幾乎可以忽略。4. 核心功能落地查票、下單、退票的代碼實(shí)現(xiàn)這一章把三個(gè)核心業(yè)務(wù)操作完整寫(xiě)出來(lái)。代碼里用的都是 SqlClient 的同步方法WinForms 下我會(huì)在界面層用異步方式調(diào)用避免卡界面具體見(jiàn) 5.4 的坑。所有方法都按 BLL 層來(lái)設(shè)計(jì)不依賴(lài)任何控件類(lèi)型。4.1 查票參數(shù)化查詢(xún)與 DataTable 綁定查票接口的輸入是出發(fā)站、到達(dá)站、出發(fā)日期輸出是符合條件的車(chē)次和對(duì)應(yīng)席別余票。SQL 需要關(guān)聯(lián) 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; }關(guān)鍵點(diǎn)有三處。第一所有查詢(xún)條件都用 SqlParameter 傳參絕不能拼字符串。拼字符串的后果是用戶(hù)輸入特殊內(nèi)容時(shí)SQL 語(yǔ)句被注入改寫(xiě)輕則查詢(xún)異常重則數(shù)據(jù)被刪。第二departDate 傳參前調(diào)用 .Date 把時(shí)分秒清零。如果界面用日期控件取到的值往往帶當(dāng)前時(shí)間直接用會(huì)導(dǎo)致 DepartDate 是 2024-05-01 00:00:00 但參數(shù)是 2024-05-01 14:30:00等值匹配永遠(yuǎn)查不到數(shù)據(jù)。第三函數(shù)返回 DataTable 是 WinForms 場(chǎng)景下的便捷做法直接賦值給 DataGridView.DataSource 就能顯示省去手工逐行賦值。有一個(gè)細(xì)節(jié)要提醒AddWithValue 在某些版本的 SQL Server 驅(qū)動(dòng)下可能把參數(shù)類(lèi)型推斷為 NVARCHAR導(dǎo)致查詢(xún)優(yōu)化器生成不了好的執(zhí)行計(jì)劃。如果你的系統(tǒng)數(shù)據(jù)量大、查詢(xún)慢可以改成顯式類(lèi)型聲明cmd.Parameters.Add(new SqlParameter(departDate, SqlDbType.Date) { Value departDate.Date });cmd.Parameters.Add(new SqlParameter(departDate, SqlDbType.Date) { Value departDate.Date });這個(gè)寫(xiě)法明確告訴數(shù)據(jù)庫(kù)參數(shù)是 DATE 類(lèi)型避免隱式轉(zhuǎn)換導(dǎo)致索引失效。項(xiàng)目規(guī)模小的時(shí)候不必較真但如果出現(xiàn)同一 SQL 每次都在編譯的現(xiàn)象先檢查這里。4.2 下單事務(wù)包裹的原子扣減與訂單生成下單是訂票系統(tǒng)的核心事務(wù)必須保證余票扣減和訂單插入要么一起成功要么一起回滾。我用 SqlTransaction 把兩步包起來(lái)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 { // 第一步原子扣減余票扣不動(dòng)直接失敗 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; // 余票不足或車(chē)次不存在 } } // 第二步生成訂單號(hào)插入訂單 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; } }這段代碼有兩個(gè)重要設(shè)計(jì)。第一扣減余票和插入訂單共用同一個(gè)連接和同一個(gè)事務(wù)這是為了保證一致性。如果不用事務(wù)扣減成功但插入訂單時(shí)斷電余票就莫名其妙少了反過(guò)來(lái)訂單插入成功但扣減失敗就會(huì)出現(xiàn)空頭訂單。第二余票扣減放在事務(wù)最前面所有請(qǐng)求都按同一順序拿鎖降低死鎖概率。ExecuteNonQuery 返回 0 時(shí)立即回滾返回 false調(diào)用方可以提示余票不足。注意這里把支付和下單合并成一步是為了聚焦庫(kù)存一致性。真實(shí)系統(tǒng)通常先建待支付訂單支付回調(diào)確認(rèn)后再把狀態(tài)改成已支付。那個(gè)狀態(tài)流轉(zhuǎn)的原則與此處完全一致——狀態(tài)更新必須帶當(dāng)前狀態(tài)條件。訂單號(hào)的生成方法我一般這樣寫(xiě)private static string GenerateOrderNo() { var now DateTime.Now; return now.ToString(yyyyMMddHHmmssfff) Random.Shared.Next(1000, 9999); }時(shí)間戳加隨機(jī)數(shù)的組合在演示項(xiàng)目里夠用但你要知道它的局限同一毫秒內(nèi)多個(gè)請(qǐng)求理論上有碰撞可能。生產(chǎn)環(huán)境我建議用全局自增序列或 Redis INCR 生成訂單號(hào)避免撞號(hào)或者干脆讓數(shù)據(jù)庫(kù)自增主鍵對(duì)外暴露雖然不好看但保證唯一。4.3 退票狀態(tài)守衛(wèi)與余票回補(bǔ)退票比下單容易但同樣有陷阱。最常見(jiàn)的錯(cuò)誤是直接 UPDATE Orders SET Status 2 然后回補(bǔ)余票完全沒(méi)有檢查訂單當(dāng)前狀態(tài)。如果用戶(hù)把退票請(qǐng)求發(fā)兩次或者管理端代退后用戶(hù)自己又退了一次余票會(huì)被回補(bǔ)兩次系統(tǒng)里憑空多出票來(lái)。正確的退票要先做狀態(tài)守衛(wèi)只允許把已支付的訂單改成已退票public bool CancelOrder(string orderNo) { using var conn new SqlConnection(_connString); conn.Open(); using var tx conn.BeginTransaction(); try { // 第一步狀態(tài)守衛(wèi)只有 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; // 訂單不存在或已處于不可退狀態(tài) } // 第二步按訂單的席別和數(shù)量回補(bǔ)余票 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 就是冪等守衛(wèi)。第一次退票成功訂單狀態(tài)變成 2第二次執(zhí)行同樣的語(yǔ)句WHERE 匹配不到行影響行數(shù)為 0直接返回失敗余票也不會(huì)二次回補(bǔ)。這個(gè)模式在支付回調(diào)、退款、改簽等所有狀態(tài)流轉(zhuǎn)場(chǎng)景里都適用?;匮a(bǔ)余票的 SQL 用 UPDATE FROM JOIN 語(yǔ)法從 Orders 中取回 TicketCount 和 SeatType與 SeatInventory 按 ScheduleId 和 SeatType 關(guān)聯(lián)。注意賦值方向左邊 RemainingCount 加的是訂單里的票數(shù)而不是固定加 1。一張訂單可能包含多張票按固定值回補(bǔ)必然出錯(cuò)。5. 踩坑與排查訂票系統(tǒng)最容易翻車(chē)的五個(gè)場(chǎng)景這一章整理的是我在類(lèi)似項(xiàng)目里真實(shí)踩過(guò)、也幫別人排查過(guò)的五個(gè)高頻問(wèn)題。每一個(gè)都按現(xiàn)象 → 原因 → 解決三段式展開(kāi)你可以直接對(duì)照排查。5.1 并發(fā)超賣(mài)最后一張票被賣(mài)給了三個(gè)人現(xiàn)象用多線(xiàn)程模擬 50 個(gè)用戶(hù)同時(shí)搶同一車(chē)次的最后 1 張票數(shù)據(jù)庫(kù)里出現(xiàn)了多張成功訂單余票變成負(fù)數(shù)。原因業(yè)務(wù)代碼先 SELECT 余票再 UPDATE兩個(gè)請(qǐng)求同時(shí)讀到余票為 1同時(shí)通過(guò)判斷各自生成訂單。這是典型的檢查與動(dòng)作分離導(dǎo)致的競(jìng)態(tài)條件。解決把判斷合并進(jìn) UPDATE 語(yǔ)句用影響行數(shù)判斷是否成功代碼見(jiàn) 4.2 的 BookTicket。另外給 SeatInventory 建CHECK (RemainingCount 0)約束作為最后一道防線(xiàn)萬(wàn)一業(yè)務(wù)層有漏洞數(shù)據(jù)庫(kù)層面還能兜底。這個(gè)約束成本極低強(qiáng)烈建議加上。5.2 連接池耗盡只 Open 不 Dispose 的后果現(xiàn)象程序運(yùn)行一段時(shí)間后操作數(shù)據(jù)庫(kù)時(shí)報(bào)錯(cuò)連接池已滿(mǎn)或超時(shí)時(shí)間已到尚未從池中獲取連接。原因SqlConnection 打開(kāi)后沒(méi)有釋放。連接池默認(rèn)最大連接數(shù)是 100每次連接用完后如果只是 Close 而沒(méi)有 Dispose異常路徑提前 return 時(shí)連接就永遠(yuǎn)留在池里很快把連接池占滿(mǎn)。解決所有 SqlConnection 的創(chuàng)建一律用 using 聲明。C# 的 using 會(huì)自動(dòng)調(diào)用 Dispose等價(jià)于把連接歸還連接池。如果項(xiàng)目里有手寫(xiě) DBHelper 的老代碼見(jiàn)到 new SqlConnection 后面沒(méi)有 using 或沒(méi)有 Close 的一律改掉。這是一個(gè)可以在代碼評(píng)審時(shí)快速核查的點(diǎn)用一個(gè)正則就能掃出來(lái)。5.3 日期格式化陷阱查當(dāng)天車(chē)票卻查不到現(xiàn)象用戶(hù)在界面上選了 2024-05-01查詢(xún)結(jié)果為空但數(shù)據(jù)庫(kù)里明明有當(dāng)天車(chē)次。原因最常見(jiàn)的是參數(shù)類(lèi)型不匹配。日期選擇控件的值帶時(shí)分秒比如 2024-05-01 14:30:00而 Schedules.DepartDate 列是 DATE 類(lèi)型等值比較時(shí)數(shù)據(jù)庫(kù)把參數(shù)隱式轉(zhuǎn)換實(shí)際變成 2024-05-01 00:00:00 與 2024-05-01 14:30:00 比較自然不相等。解決傳入?yún)?shù)前統(tǒng)一使用 departDate.Date 把時(shí)間清零參數(shù)顯式聲明為 SqlDbType.Date。查詢(xún)條件堅(jiān)持用參數(shù)化不要日期 ToString 后拼進(jìn) SQL。遇到當(dāng)天查不到但前一天能查到的詭異問(wèn)題第一反應(yīng)就去看日期參數(shù)的值十有八九是這里。5.4 UI 線(xiàn)程假死同步查詢(xún)阻塞窗口現(xiàn)象點(diǎn)擊查詢(xún)按鈕后窗口標(biāo)題出現(xiàn)未響應(yīng)過(guò)幾秒才恢復(fù)期間窗口無(wú)法拖動(dòng)、無(wú)法關(guān)閉。原因WinForms 的按鈕點(diǎn)擊事件跑在 UI 線(xiàn)程上同步執(zhí)行數(shù)據(jù)庫(kù)查詢(xún)時(shí)UI 線(xiàn)程被阻塞消息循環(huán)無(wú)法處理重繪和鼠標(biāo)消息。數(shù)據(jù)量一大阻塞時(shí)間就越明顯。解決把數(shù)據(jù)庫(kù)調(diào)用放到后臺(tái)線(xiàn)程用 async/await 回到 UI 線(xiàn)程更新界面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(查詢(xún)失敗 ex.Message); } finally { btnQuery.Enabled true; } }這里用到 Task.Run 把同步的 DAL 方法丟到線(xiàn)程池執(zhí)行await 之后自動(dòng)回到 UI 線(xiàn)程更新 DataGridView。按鈕在查詢(xún)期間禁用避免用戶(hù)重復(fù)點(diǎn)擊疊加請(qǐng)求。注意async void 只能用在事件處理器這類(lèi)沒(méi)有調(diào)用方的場(chǎng)景普通方法一律返回 Task否則異常會(huì)直接逃逸到同步上下文導(dǎo)致進(jìn)程崩潰。5.5 重復(fù)下單雙擊一次生成兩個(gè)訂單現(xiàn)象用戶(hù)雙擊確認(rèn)按鈕或者網(wǎng)絡(luò)慢時(shí)連點(diǎn)多次數(shù)據(jù)庫(kù)里同一個(gè)用戶(hù)對(duì)同一個(gè)車(chē)次生成了兩條訂單。原因按鈕點(diǎn)擊事件沒(méi)有防重后端也沒(méi)有冪等判斷。前端禁用按鈕只能擋住正常人擋不住雙擊事件和網(wǎng)絡(luò)重試。解決前端在提交后立即禁用按鈕同時(shí)在數(shù)據(jù)庫(kù)層面加一道唯一約束。下單前由調(diào)用方生成一個(gè)請(qǐng)求標(biāo)識(shí)Orders 表增加 RequestId 列并建唯一索引第一次插入成功第二次插入同樣的 RequestId 會(huì)因?yàn)槲ㄒ患s束被拒。事務(wù)里可以先查這個(gè) RequestId 是否已存在存在就直接返回已有訂單不存在才走完整的扣減流程。雙重防護(hù)之下重復(fù)點(diǎn)擊的后果被徹底堵死。6. 并發(fā)壓測(cè)驗(yàn)證你的余票扣減真的不會(huì)超賣(mài)寫(xiě)完了業(yè)務(wù)代碼不能直接就說(shuō)系統(tǒng)沒(méi)問(wèn)題。訂票系統(tǒng)這種并發(fā)業(yè)務(wù)驗(yàn)證手段就是模擬大量用戶(hù)同時(shí)搶票看最終訂單數(shù)和余票數(shù)是否對(duì)得上。我會(huì)寫(xiě)一個(gè)小的控制臺(tái)壓測(cè)程序直接調(diào)用 BLL 層的方法繞開(kāi) UIstatic async Task Main(string[] args) { var service new TicketService(your_connection_string); // 假設(shè)數(shù)據(jù)庫(kù)里某車(chē)次 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}); }這個(gè)壓測(cè)有幾個(gè)要點(diǎn)。第一200 個(gè)任務(wù)并發(fā)調(diào)用同一個(gè) BLL 方法直接測(cè)核心事務(wù)邏輯不摻雜界面因素。第二使用 Interlocked 累加結(jié)果因?yàn)?int 的 在并發(fā)下不是原子的直接寫(xiě) success 統(tǒng)計(jì)會(huì)丟數(shù)據(jù)。第三壓測(cè)前手動(dòng)把余票改為 1壓測(cè)后驗(yàn)證成功數(shù)是否恰好為 1。如果成功數(shù)大于 1說(shuō)明你的扣減邏輯還有競(jìng)態(tài)條件回到 3.2 檢查。壓測(cè)結(jié)果正常后再進(jìn)一步觀察鎖等待。在 SQL Server 上執(zhí)行 sp_who2 或查看 sys.dm_tran_locks確認(rèn)同一時(shí)刻只有少量會(huì)話(huà)處于等待鎖狀態(tài)。如果等待會(huì)話(huà)特別多說(shuō)明事務(wù)持有鎖的時(shí)間過(guò)長(zhǎng)優(yōu)先檢查 4.2 里事務(wù)內(nèi)有沒(méi)有多余的查詢(xún)操作把查詢(xún)移出事務(wù)能顯著縮短持鎖時(shí)間。對(duì)于更高要求的場(chǎng)景可以再用 WITH (UPDLOCK) 寫(xiě)一個(gè)備用方案事務(wù)內(nèi)先 SELECT 余票并加更新鎖阻塞其他事務(wù)的更新直到當(dāng)前事務(wù)結(jié)束然后再做業(yè)務(wù)判斷和 UPDATE。它適合業(yè)務(wù)判斷比較復(fù)雜、沒(méi)法塞進(jìn)一條 UPDATE 的場(chǎng)景代價(jià)是持鎖時(shí)間更長(zhǎng)。我的習(xí)慣是能用原子 UPDATE 就用原子 UPDATEUPDLOCK 只做兜底。壓測(cè)做到這個(gè)程度你的訂票系統(tǒng)在并發(fā)扣減這一關(guān)就站得住腳了。我自己的習(xí)慣是把這個(gè)壓測(cè)代碼留在項(xiàng)目里每次改完數(shù)據(jù)庫(kù)或事務(wù)邏輯就跑一遍余票從 1 變成 0、成功數(shù)永遠(yuǎn)是 1才敢放心把系統(tǒng)交給業(yè)務(wù)方使用。寫(xiě)代碼容易真正讓人放心的是驗(yàn)證過(guò)邊界條件的代碼。希望這篇筆記能幫你把 C# 火車(chē)訂票系統(tǒng)做扎實(shí)也少走我當(dāng)年走過(guò)的那些彎路。本文還有配套的精品資源點(diǎn)擊獲取