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