:數(shù)據(jù)庫設(shè)計(jì)與事務(wù)并發(fā)控制實(shí)戰(zhàn))
簡介這是一套面向 C# 初學(xué)者與課程設(shè)計(jì)學(xué)習(xí)者的 WinForm 影院售票管理系統(tǒng)完整源碼基于 VS2012 與 SQL Server 2012 開發(fā)重點(diǎn)解決窗體程序多模塊協(xié)作與數(shù)據(jù)庫連接的實(shí)際問題。壓縮包共 93 個(gè)文件、約 4.13MB以 40 個(gè) cs 源碼文件為核心配合 15 個(gè) resx 與 15 個(gè) resources 資源文件承載界面布局另有 mdf 與 ldf 數(shù)據(jù)庫文件可直接附加使用sln 與 csproj 便于整體打開編譯config、ico、manifest 等則支撐程序配置與運(yùn)行。系統(tǒng)涵蓋登錄、影片類型、放映廳、售票、用戶管理等多個(gè)窗體模塊并配有 Model 實(shí)體層與 DBHelper 數(shù)據(jù)訪問類目錄結(jié)構(gòu)清晰適合對(duì)照學(xué)習(xí)分層組織與窗體間數(shù)據(jù)傳遞。目前已有 1405 人學(xué)習(xí)下載可作為課程設(shè)計(jì)參考或二次開發(fā)基礎(chǔ)幫助讀者快速理解售票業(yè)務(wù)流程與數(shù)據(jù)庫附加配置節(jié)省從零搭建框架的時(shí)間。1. 從一張排片表說起影院售票系統(tǒng)到底要解決什么影院售票這件事表面看是「選座—付款—出票」三步真做起來卻處處是坑同一場次兩個(gè)窗口同時(shí)賣最后一張票、會(huì)員卡余額和現(xiàn)金混合支付怎么記賬、退票后座位什么時(shí)候釋放、排片表和影廳座位數(shù)對(duì)不上導(dǎo)致超賣。這些問題不是靠界面做得漂亮能解決的根子在數(shù)據(jù)庫設(shè)計(jì)和事務(wù)邊界上。這套 C# Winform 影院售票管理系統(tǒng)面向的正是這類場景它把影片、影廳、場次、座位、訂單、會(huì)員、票券這幾類實(shí)體拆清楚用 Winform 做前臺(tái)操作界面用關(guān)系型數(shù)據(jù)庫承載業(yè)務(wù)狀態(tài)適合拿來做課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)也適合作為中小型影院業(yè)務(wù)原型的參考底稿。數(shù)據(jù)庫齊全意味著表結(jié)構(gòu)、字段約束、初始數(shù)據(jù)都到位讀者拿到后能直接跑通「排片—選座—下單—退票」這條主鏈路而不是對(duì)著一堆空表猜字段含義。2. 需求與數(shù)據(jù)模型七張核心表撐起售票主鏈路2.1 業(yè)務(wù)角色與功能邊界劃分先把角色理清楚后面所有表結(jié)構(gòu)和界面都圍繞角色展開。這套系統(tǒng)通常劃分三類使用者售票員前臺(tái)窗口負(fù)責(zé)選場次、選座、收款、打印票、影院管理員維護(hù)影片信息、影廳座位布局、排片計(jì)劃、會(huì)員規(guī)則、系統(tǒng)維護(hù)者賬號(hào)權(quán)限、數(shù)據(jù)備份、日志查看。角色不同能看到的界面和能改的數(shù)據(jù)就不同這一點(diǎn)在 Winform 里一般通過登錄后加載不同的主窗體來實(shí)現(xiàn)而不是把所有按鈕都擺出來再靠禁用灰化。功能邊界上售票主鏈路必須閉環(huán)影片上架 → 生成場次綁定影廳與放映時(shí)間→ 場次對(duì)應(yīng)座位狀態(tài)初始化 → 選座鎖座 → 生成訂單 → 支付完成出票 → 退票釋放座位。會(huì)員體系是旁路增強(qiáng)會(huì)員注冊(cè)、儲(chǔ)值、積分、折扣價(jià)購票。報(bào)表是事后統(tǒng)計(jì)日票房、場次上座率、影片排行。把這三塊分開是為了避免把「賣票」和「統(tǒng)計(jì)」寫進(jìn)同一個(gè)事務(wù)里那樣鎖競爭會(huì)非常難看。2.2 核心數(shù)據(jù)表設(shè)計(jì)與字段約束數(shù)據(jù)庫齊全是這套資源最實(shí)在的價(jià)值。下面給出核心表的結(jié)構(gòu)字段名和類型按常見做法給出讀者可對(duì)照自己手上的腳本調(diào)整。-- 影片表一部影片一條記錄狀態(tài)控制是否可排片 CREATE TABLE Movie ( MovieId INT IDENTITY(1,1) PRIMARY KEY, Title NVARCHAR(100) NOT NULL, DurationMin INT NOT NULL CHECK (DurationMin 0), -- 片長排片時(shí)算結(jié)束時(shí)間 Poster NVARCHAR(200) NULL, Status TINYINT NOT NULL DEFAULT 1 -- 1上架 0下架 ); -- 影廳表記錄廳名與總座位數(shù)座位布局另表存 CREATE TABLE Hall ( HallId INT IDENTITY(1,1) PRIMARY KEY, HallName NVARCHAR(50) NOT NULL, RowCount INT NOT NULL, ColCount INT NOT NULL ); -- 場次表影片 影廳 開始時(shí)間結(jié)束時(shí)間由片長推算 CREATE TABLE Session ( SessionId INT IDENTITY(1,1) PRIMARY KEY, MovieId INT NOT NULL REFERENCES Movie(MovieId), HallId INT NOT NULL REFERENCES Hall(HallId), StartTime DATETIME NOT NULL, BasePrice DECIMAL(10,2) NOT NULL CHECK (BasePrice 0), Status TINYINT NOT NULL DEFAULT 1 -- 1可售 0停售 ); -- 座位表每個(gè)影廳的物理座位行號(hào)列號(hào)唯一 CREATE TABLE Seat ( SeatId INT IDENTITY(1,1) PRIMARY KEY, HallId INT NOT NULL REFERENCES Hall(HallId), RowNo INT NOT NULL, ColNo INT NOT NULL, CONSTRAINT UQ_Seat UNIQUE (HallId, RowNo, ColNo) ); -- 場次座位狀態(tài)表某場次某座位的實(shí)時(shí)狀態(tài)鎖座與售票都改這里 CREATE TABLE SessionSeat ( SessionSeatId INT IDENTITY(1,1) PRIMARY KEY, SessionId INT NOT NULL REFERENCES Session(SessionId), SeatId INT NOT NULL REFERENCES Seat(SeatId), Status TINYINT NOT NULL DEFAULT 0, -- 0可售 1已鎖 2已售 OrderId INT NULL, CONSTRAINT UQ_SessionSeat UNIQUE (SessionId, SeatId) ); -- 訂單表一次交易一條金額與支付方式落庫 CREATE TABLE TicketOrder ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, MemberId INT NULL, TotalAmount DECIMAL(10,2) NOT NULL, PayMethod TINYINT NOT NULL, -- 1現(xiàn)金 2會(huì)員卡 3混合 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), Status TINYINT NOT NULL DEFAULT 0 -- 0待支付 1已支付 2已退 ); -- 訂單明細(xì)一單多票記錄具體場次座位與單價(jià) CREATE TABLE OrderItem ( ItemId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL REFERENCES TicketOrder(OrderId), SessionSeatId INT NOT NULL REFERENCES SessionSeat(SessionSeatId), Price DECIMAL(10,2) NOT NULL );這套表結(jié)構(gòu)的關(guān)鍵設(shè)計(jì)點(diǎn)在SessionSeat座位狀態(tài)不是掛在Seat上而是按場次單獨(dú)存一份。原因很直接——同一個(gè)物理座位在不同場次的狀態(tài)互不影響上午場賣掉了不影響下午場。UQ_SessionSeat唯一約束是防超賣的第一道閘配合事務(wù)里的條件更新能擋住并發(fā)重復(fù)售出。OrderItem與SessionSeat通過SessionSeatId關(guān)聯(lián)退票時(shí)按明細(xì)回滾座位狀態(tài)賬目和座位兩邊對(duì)得上。提示SessionSeat的數(shù)據(jù)量等于「場次數(shù) × 每廳座位數(shù)」排片一多增長很快。通常的做法是場次創(chuàng)建時(shí)批量插入該場次的座位狀態(tài)行而不是查詢時(shí)臨時(shí)拼。2.3 技術(shù)選型為什么是 Winform 加關(guān)系型數(shù)據(jù)庫選 Winform 不是因?yàn)樗露且驗(yàn)樗鼘?duì)這類桌面收銀場景足夠直接窗體拖控件、DataGridView 綁數(shù)據(jù)、打印票據(jù)調(diào)本地打印機(jī)開發(fā)周期短答辯演示時(shí)雙擊 exe 就能跑不用配 Web 服務(wù)器。數(shù)據(jù)訪問層一般用 ADO.NET 手寫 SQL 或輕量 ORM事務(wù)控制顯式寫在業(yè)務(wù)層比全自動(dòng) ORM 更容易講清楚「鎖座和下單在同一個(gè)事務(wù)里」這件事。數(shù)據(jù)庫選 SQL Server 或 MySQL 都行腳本里IDENTITY是 SQL Server 寫法換 MySQL 改成AUTO_INCREMENT即可字段類型對(duì)應(yīng)調(diào)整。這套組合的取舍是犧牲跨平臺(tái)和遠(yuǎn)程訪問能力換來部署簡單和事務(wù)邊界清晰對(duì)課設(shè)和中小影院原型是劃算的。3. 總體架構(gòu)與模塊實(shí)現(xiàn)從鎖座到出票的代碼落地3.1 分層結(jié)構(gòu)與項(xiàng)目目錄組織Winform 項(xiàng)目最容易寫成一鍋粥所有按鈕事件里直接拼 SQL改一個(gè)字段要翻十個(gè)窗體。負(fù)責(zé)任的做法是分三層UI窗體與控件事件只負(fù)責(zé)收集輸入和展示結(jié)果、BLL業(yè)務(wù)邏輯鎖座、計(jì)價(jià)、退票規(guī)則都在這里、DAL數(shù)據(jù)訪問只做增刪改查和事務(wù)封裝。目錄上對(duì)應(yīng)Forms/、Services/、DataAccess/三個(gè)文件夾實(shí)體類放Models/。窗體事件里不出現(xiàn) SQL 字符串這是硬規(guī)矩否則后期加一個(gè)「會(huì)員折扣」要改遍所有下單入口。// Services/TicketService.cs —— 業(yè)務(wù)層只依賴 DAL 接口不碰窗體控件 public class TicketService { private readonly ITicketRepository _repo; public TicketService(ITicketRepository repo) _repo repo; // 鎖座并生成待支付訂單整個(gè)過程在一個(gè)數(shù)據(jù)庫事務(wù)內(nèi) public OrderResult LockSeats(int sessionId, Listint seatIds, int? memberId) { using (var tran _repo.BeginTransaction()) { try { // 條件更新只有仍為可售(0)的座位才會(huì)被改成已鎖(1) int affected _repo.LockSessionSeats(sessionId, seatIds, tran); if (affected ! seatIds.Count) { tran.Rollback(); return OrderResult.Fail(部分座位已被他人鎖定請(qǐng)重新選座); } var order _repo.CreateOrder(sessionId, seatIds, memberId, tran); tran.Commit(); return OrderResult.Ok(order); } catch { tran.Rollback(); throw; } } } }這段代碼的重點(diǎn)不在語法而在affected ! seatIds.Count這個(gè)判斷。鎖座用的是UPDATE ... WHERE Status 0數(shù)據(jù)庫返回受影響行數(shù)如果少于請(qǐng)求座位數(shù)說明有人搶先鎖了其中某個(gè)座整單回滾。這比「先查再改」可靠得多后者在并發(fā)下必然翻車。BeginTransaction由 DAL 提供保證鎖座和建單要么都成要么都不成不會(huì)出現(xiàn)座位鎖了訂單沒建的臟狀態(tài)。3.2 選座鎖座的事務(wù)實(shí)現(xiàn)與并發(fā)控制鎖座的 SQL 是整套系統(tǒng)的心臟單獨(dú)拎出來講。下面是對(duì)應(yīng) DAL 層的實(shí)現(xiàn)// DataAccess/TicketRepository.cs public int LockSessionSeats(int sessionId, Listint seatIds, SqlTransaction tran) { // 參數(shù)化 IN 列表避免拼接 SQL 注入 var paramNames seatIds.Select((_, i) s i).ToArray(); string sql $ UPDATE SessionSeat SET Status 1 WHERE SessionId sid AND SeatId IN ({string.Join(,, paramNames)}) AND Status 0; -- 只鎖可售座位已鎖已售的不動(dòng) using (var cmd new SqlCommand(sql, _conn, tran)) { cmd.Parameters.AddWithValue(sid, sessionId); for (int i 0; i seatIds.Count; i) cmd.Parameters.AddWithValue(paramNames[i], seatIds[i]); return cmd.ExecuteNonQuery(); // 返回實(shí)際鎖到的行數(shù) } }參數(shù)說明sid是場次主鍵s0...sn是座位主鍵列表Status 0是樂觀鎖條件。返回值就是實(shí)際鎖成功的座位數(shù)業(yè)務(wù)層拿它和請(qǐng)求數(shù)比對(duì)。這里有個(gè)血淚經(jīng)驗(yàn)IN列表參數(shù)化必須動(dòng)態(tài)生成參數(shù)名不能把座位 ID 直接拼進(jìn)字符串否則既慢又有注入風(fēng)險(xiǎn)。另外鎖座后要設(shè)一個(gè)超時(shí)釋放機(jī)制通常的做法是訂單表加ExpireTime字段后臺(tái)定時(shí)任務(wù)把超時(shí)未支付的訂單置為取消并回滾座位狀態(tài)否則用戶鎖了座不付款座位就永遠(yuǎn)卡住了。3.3 計(jì)價(jià)、支付與退票的狀態(tài)流轉(zhuǎn)計(jì)價(jià)規(guī)則要集中在一處別散落在各個(gè)窗體。常見規(guī)則是基礎(chǔ)價(jià)來自場次BasePrice會(huì)員按等級(jí)打折兒童票或早場有固定減免混合支付時(shí)先扣會(huì)員卡余額再收現(xiàn)金。把這些寫成PriceCalculator類輸入場次、座位、會(huì)員信息輸出明細(xì)金額下單和退票都調(diào)它保證前后一致。退票的狀態(tài)流轉(zhuǎn)是另一個(gè)容易寫亂的地方。訂單狀態(tài)0待支付 → 1已支付 → 2已退退票時(shí)要做三件事訂單置 2、OrderItem對(duì)應(yīng)座位狀態(tài)從 2 改回 0、會(huì)員卡余額按規(guī)則退回。這三步必須在同一事務(wù)里且退票前要校驗(yàn)場次是否已開場——通常開場前若干分鐘停止退票這個(gè)閾值做成配置項(xiàng)。狀態(tài)機(jī)寫清楚后面加「改簽」功能才有地方掛。3.4 會(huì)員與報(bào)表模塊的落地要點(diǎn)會(huì)員模塊核心是余額和積分的原子更新。充值、扣款都用UPDATE Member SET Balance Balance amt WHERE MemberId id AND Balance amt 0這種帶條件的寫法避免并發(fā)下扣成負(fù)數(shù)。積分按消費(fèi)金額比例累加規(guī)則參數(shù)化別寫死。報(bào)表模塊不參與售票事務(wù)走獨(dú)立查詢。日票房按TicketOrder.CreateTime分組求和上座率用「已售座位數(shù) / 場次總座位數(shù)」影片排行按場次匯總票房倒序。報(bào)表查詢通常直接寫 SQL 視圖或存儲(chǔ)過程DataGridView 綁定結(jié)果集即可不要在內(nèi)存里循環(huán)幾萬條訂單做聚合。4. 避坑與排查這類系統(tǒng)最容易翻車的五個(gè)點(diǎn)4.1 超賣兩個(gè)窗口賣出同一個(gè)座現(xiàn)象是同一場次同一座位出現(xiàn)兩條已支付訂單。原因多半是鎖座用了「先 SELECT 判斷再 UPDATE」兩個(gè)請(qǐng)求同時(shí)查到可售就都往下走。解決辦法是把判斷塞進(jìn) UPDATE 的 WHERE 條件里用受影響行數(shù)裁決配合事務(wù)和UQ_SessionSeat唯一約束兜底。上線前用兩個(gè)客戶端同時(shí)點(diǎn)同一座位壓一遍能復(fù)現(xiàn)就說明沒改對(duì)。4.2 座位狀態(tài)卡死鎖了座卻沒訂單現(xiàn)象是座位顯示已鎖但訂單列表里找不到。原因通常是鎖座成功、建單失敗后沒有回滾或者程序崩潰在兩步之間。解決靠事務(wù)保證原子性再加超時(shí)釋放任務(wù)掃描Status1且關(guān)聯(lián)訂單超時(shí)未支付的記錄。排查時(shí)直接查SessionSeat里Status1且OrderId IS NULL或訂單狀態(tài)為待支付超時(shí)的行。4.3 排片時(shí)間沖突同一影廳兩場重疊現(xiàn)象是同一影廳相鄰場次時(shí)間重疊觀眾進(jìn)場撞車。原因是排片時(shí)只校驗(yàn)了開始時(shí)間沒算結(jié)束時(shí)間。解決辦法是排片保存前查該影廳是否已有場次滿足新開始 舊結(jié)束 AND 新結(jié)束 舊開始有則拒絕。結(jié)束時(shí)間由StartTime DurationMin加清場緩沖算出緩沖做成配置。4.4 金額精度折扣算完差幾分錢現(xiàn)象是對(duì)賬時(shí)訂單總額和明細(xì)之和對(duì)不上。原因是double參與金額計(jì)算或四舍五入時(shí)機(jī)不一致。解決是金額一律用decimal折扣計(jì)算統(tǒng)一在PriceCalculator里做先算明細(xì)再匯總舍入規(guī)則固定為銀行家舍入或統(tǒng)一保留兩位別各處各算。4.5 并發(fā)下會(huì)員余額錯(cuò)亂現(xiàn)象是會(huì)員卡余額扣多了或扣成負(fù)數(shù)。原因是讀余額、判斷、寫回三步分開執(zhí)行。解決是用帶條件的原子 UPDATE把余額校驗(yàn)寫進(jìn) WHERE受影響行數(shù)為 0 就提示余額不足。所有涉及余額變動(dòng)的地方都走同一個(gè)倉儲(chǔ)方法不允許旁路直接改表。5. 驗(yàn)收與答辯怎么證明這套系統(tǒng)真的能賣票驗(yàn)收不是看界面能點(diǎn)而是看邊界條件下數(shù)據(jù)對(duì)不對(duì)。我一般按下面這張清單逐項(xiàng)過每項(xiàng)都要求給出可復(fù)現(xiàn)的操作步驟和預(yù)期結(jié)果驗(yàn)收項(xiàng)操作步驟通過標(biāo)準(zhǔn)主鏈路閉環(huán)排片→選座→下單→支付→出票訂單、明細(xì)、座位狀態(tài)三處一致并發(fā)防超賣兩客戶端同時(shí)鎖同一座僅一方成功另一方收到明確提示鎖座超時(shí)釋放鎖座后不支付等待超時(shí)座位自動(dòng)回到可售訂單置取消退票回滾已支付訂單退票訂單置已退、座位釋放、余額按規(guī)則退回排片沖突同廳排重疊場次系統(tǒng)拒絕并提示沖突場次金額對(duì)賬混合支付下單后核對(duì)訂單總額等于明細(xì)之和余額扣減正確報(bào)表準(zhǔn)確造若干訂單后查日票房與手工匯總一致答辯時(shí)最容易被問的是「你怎么保證不超賣」和「事務(wù)邊界在哪」?;卮鸩灰掣拍钪苯哟蜷_LockSessionSeats那段代碼指著WHERE Status 0和受影響行數(shù)判斷講再補(bǔ)一句唯一約束兜底比講十分鐘理論管用。另一個(gè)高頻問題是「如果支付到一半斷電怎么辦」答案就是事務(wù)加超時(shí)釋放任務(wù)把異常路徑講清楚比只講正常流程更能體現(xiàn)你想過邊界。演示順序也有講究先跑一遍正常購票讓評(píng)委看到完整鏈路再故意制造一次并發(fā)沖突展示防護(hù)最后演示退票和報(bào)表。別一上來就翻代碼先讓系統(tǒng)跑起來再回頭講關(guān)鍵實(shí)現(xiàn)。數(shù)據(jù)庫腳本、建表語句、初始數(shù)據(jù)要提前在演示機(jī)上跑通別現(xiàn)場導(dǎo)庫翻車。從那以后我每次交付這類系統(tǒng)都強(qiáng)制自己按驗(yàn)收表走一遍尤其是并發(fā)和超時(shí)兩項(xiàng)寧可多花半小時(shí)壓測也不在演示現(xiàn)場賭運(yùn)氣。希望這份拆解能幫到你把手上這套影院售票系統(tǒng)真正跑通、講明白。本文還有配套的精品資源點(diǎn)擊獲取