☰
C#影院系统实战:选座锁座并发控制与SQL Server事务处理
2026/10/19 5:31:38 网站建设 项目流程

简介:本资源是一套基于C#开发的电影院售票系统完整项目源码,专为计算机专业本科生毕业设计与课程设计打造,覆盖桌面应用开发、数据库管理与用户交互全流程实践。项目采用Windows Forms构建图形界面,集成SQL Server/SQLite等数据库,运用ADO.NET实现数据增删改查,并包含用户登录、权限控制、座位选票、异常处理及多线程优化等核心功能模块,助力学习者系统掌握面向对象编程、GUI开发与工程化调试能力。压缩包共692个文件,含83个C#业务逻辑文件(.cs)、79个ASPX页面、75个JS前端脚本、60个PNG与56个JPG界面资源,以及DB数据库文件、配置文件和UI图标资源等,整体13.81MB,结构清晰、模块解耦度高,便于分层学习与二次开发。目前已有91人下载学习,附带环境配置说明与完整目录组织,开箱即可编译运行,是理论落地与项目复现的优质实践范例。

1. 这不是又一个“登录+增删改查”Demo:一个真能跑通选座、锁座、出票、退票全流程的C#影院系统,毕业答辩前3天我靠它救回了毕设进度

去年带某高校课程设计时,A同学在答辩前72小时突然发现手写的“电影院系统”连座位图都只是静态图片——点击没反应,选座不记录,更别说并发锁座和订单回滚。他翻遍B站和某乎,90%的所谓“完整项目”只到“显示电影列表+弹窗提示购票成功”,数据库里压根没生成任何订单记录。而这个基于C#的Windows Forms影院系统,是少有的把真实业务闭环做扎实的:从用户登录→场次筛选→动态渲染可售座位网格→鼠标点击实时高亮+加锁→支付模拟→生成唯一票号→后台自动更新余票与状态→管理员端可查退票流水并触发库存回滚。它不炫技,不堆UI动效,但每个按钮背后都有事务控制、异常捕获、数据绑定刷新、线程安全校验。适合真正想拿高分、想搞懂“桌面应用怎么落地成产品”的本科生——尤其当你被导师问“如果两个用户同时点同一个座位,你怎么保证不超卖?”时,你能打开Form1.cs里那段带lock(_seatLock)的代码,指着UpdateSeatStatus()方法里的BEGIN TRANSACTION和ROLLBACK说清楚。

2. 从解压到运行:Visual Studio 2019+SQL Server LocalDB环境搭建实录(含三处易错路径配置)

2.1 环境依赖清单与版本对齐逻辑

本项目明确适配Visual Studio 2019(Community版及以上)和SQL Server 2019 Express LocalDB。为什么不是VS2022?因为项目中.csproj文件里<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>硬编码了.NET Framework 4.7.2,而VS2022默认新建项目已倾向.NET 6+,强行升级会导致System.Data.SqlClient引用冲突。LocalDB而非完整SQL Server实例,是因为它零配置、免服务安装,启动命令sqllocaldb start "MSSQLLocalDB"即可用,完美匹配课程设计轻量部署需求。数据库文件为CinemaDB.mdf,位于解压后目录的Database\子文件夹下——注意,不是根目录,也不是bin\Debug\,这是新手第一处卡点。

2.2 数据库附加操作:必须用LocalDB实例名,不能用“.”或“(local)”

打开SQL Server Management Studio (SSMS),连接服务器名填:(localdb)\MSSQLLocalDB(注意斜杠是反斜杠\,不是正斜杠/)。右键【数据库】→【附加】→点击【添加】,定位到解压目录下的Database\CinemaDB.mdf。此时SSMS会自动识别日志文件CinemaDB_log.ldf,但关键一步:检查右侧“当前数据库文件路径”栏,所有路径必须以C:\...开头,不能出现..\..\相对路径。若显示.\CinemaDB.mdf,点击该行末尾的文件夹图标,手动重选绝对路径。否则附加后查询会报错:“无法打开物理文件…操作系统错误5”。

2.3 应用程序配置文件(App.config)连接字符串修正

打开项目根目录下的App.config,找到<connectionStrings>节点内name="CinemaDBConnectionString"的connectionString属性。原始值通常是:

Data Source=.;AttachDbFilename=|DataDirectory|\Database\CinemaDB.mdf;Integrated Security=True;Connect Timeout=30

这行在调试时会失败——因为|DataDirectory|默认指向bin\Debug\,但.mdf实际在Database\子目录。必须改为绝对路径:

Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=C:\Your\Full\Path\To\Unzipped\Folder\Database\CinemaDB.mdf;Integrated Security=True;Connect Timeout=30

提示:路径中的反斜杠\在XML里无需转义,但复制粘贴时务必确认没有变成中文全角符号。建议用记事本另存为UTF-8无BOM格式再覆盖。

2.4 启动项目前的编译检查:三个关键引用是否就位

在VS中右键解决方案→【还原NuGet包】。重点检查以下三项是否在“引用”列表中存在且无黄色感叹号:

  • System.Data.SqlClient(v4.8.5,非Microsoft.Data.SqlClient)
  • System.Windows.Forms(.NET Framework内置,若缺失说明目标框架选错)
  • System.Drawing.Common(v4.7.0,用于座位图渲染)
    若System.Data.SqlClient报错,右键该项目→【管理NuGet包】→切换到【浏览】→搜索System.Data.SqlClient→安装4.8.5版本(最新版5.x不兼容Framework 4.7.2)。

3. 核心业务流程拆解:从用户点击“选座”到数据库写入订单的七步链路

3.1 座位网格动态渲染:DataGridView + 自定义CellPainting事件

座位选择界面(SeatSelectionForm.cs)使用DataGridView控件承载座位表。关键不在控件本身,而在CellPainting事件重绘逻辑:

private void dgvSeats_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex >= 0 && e.ColumnIndex >= 0 && e.Value != null) { string status = e.Value.ToString(); using (SolidBrush brush = new SolidBrush(status == "0" ? Color.Green : status == "1" ? Color.Red : Color.Gray)) { e.Graphics.FillRectangle(brush, e.CellBounds); e.Paint(e.ClipBounds, DataGridViewPaintParts.All & ~DataGridViewPaintParts.ContentForeground); e.Handled = true; } } }

这里status取值逻辑来自数据库Seats表的Status字段:0=可售,1=已售,2=锁定(临时占用)。注意:e.Value直接绑定数据库字段,未做空值判断会崩溃——所以实际代码中需加if (e.Value == DBNull.Value || string.IsNullOrEmpty(e.Value.ToString())) return;。

3.2 锁座与防超卖:基于数据库行级锁的双重校验

当用户点击座位单元格,触发dgvSeats_CellClick事件,核心逻辑在LockSeat(int row, int col)方法:

public bool LockSeat(int row, int col) { string connStr = ConfigurationManager.ConnectionStrings["CinemaDBConnectionString"].ConnectionString; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction trans = conn.BeginTransaction()) { try { // 第一步:SELECT ... FOR UPDATE(SQL Server用WITH (UPDLOCK, ROWLOCK)) string checkSql = "SELECT Status FROM Seats WHERE RowNum = @Row AND ColNum = @Col AND ShowTimeID = @ShowID WITH (UPDLOCK, ROWLOCK)"; using (SqlCommand cmd = new SqlCommand(checkSql, conn, trans)) { cmd.Parameters.AddWithValue("@Row", row); cmd.Parameters.AddWithValue("@Col", col); cmd.Parameters.AddWithValue("@ShowID", _currentShowTimeID); object result = cmd.ExecuteScalar(); if (result == null || result.ToString() != "0") throw new Exception("座位已被占用或不可用"); } // 第二步:UPDATE锁定状态 string updateSql = "UPDATE Seats SET Status = '2' WHERE RowNum = @Row AND ColNum = @Col AND ShowTimeID = @ShowID"; using (SqlCommand cmd = new SqlCommand(updateSql, conn, trans)) { cmd.Parameters.AddWithValue("@Row", row); cmd.Parameters.AddWithValue("@Col", col); cmd.Parameters.AddWithValue("@ShowID", _currentShowTimeID); cmd.ExecuteNonQuery(); } trans.Commit(); return true; } catch { trans.Rollback(); return false; } } } }

参数说明:@Row/@Col是座位行列号(整数),@ShowID是当前场次ID(GUID字符串)。WITH (UPDLOCK, ROWLOCK)确保其他事务无法读取或修改该行,避免幻读;trans.Rollback()是后悔药,任何环节失败立即释放锁。

3.3 订单生成与票号规则:时间戳+场次哈希+随机数三段式

支付成功后调用CreateOrder(),票号生成逻辑在TicketGenerator.cs:

public static string GenerateTicketNumber(Guid showTimeId, int seatCount) { string timePart = DateTime.Now.ToString("yyMMddHHmmss"); // 240515142301 string hashPart = BitConverter.ToString(MD5.HashData(Encoding.UTF8.GetBytes(showTimeId.ToString()))).Substring(0, 8); // 8A2F1E7B string randPart = new Random().Next(1000, 9999).ToString(); // 5623 return $"{timePart}-{hashPart}-{randPart}"; }

此规则保证全局唯一性:时间精度到秒,场次哈希消除重复ID风险,随机数防预测。票号存入Orders.TicketNumber字段,同时Orders.Seats字段以JSON数组存储所选座位坐标(如[{"Row":3,"Col":5},{"Row":3,"Col":6}]),便于后续退票时精准解锁。

4. 避坑:五个让答辩老师当场皱眉的典型问题与血泪修复方案

4.1 现象:启动后登录界面空白,F12开发者工具显示“无法加载资源:net::ERR_CONNECTION_REFUSED”

原因:项目混入了ASP.NET Web Forms残留文件(如qtuserlog.ascx、alipay_notify.asp等),但主程序是WinForms,这些.ascx/.asp文件根本不会被编译,却在App.config或某个隐藏配置里被误引用为登录验证地址。
解决:在VS解决方案资源管理器中,右键所有.ascx、.ashx、.asp文件→【属性】→将“生成操作”从“内容”改为“无”。再全局搜索qtuserlog、alipay等关键词,删除App.config中所有<appSettings>里含这些词的键值对。

4.2 现象:管理员登录后,电影列表DataGridView显示“System.Data.DataRowView”而非片名

原因:DataGridView的DataSource直接绑定了DataTable,但未设置AutoGenerateColumns = false,导致自动生成列时调用DataRowView.ToString()而非DataRow["MovieName"]。
解决:在AdminForm.cs的LoadMovies()方法末尾添加:

dgvMovies.AutoGenerateColumns = false; dgvMovies.Columns["MovieName"].DataPropertyName = "MovieName"; // 显式绑定 dgvMovies.Columns["Duration"].DataPropertyName = "Duration"; dgvMovies.Columns["ReleaseDate"].DataPropertyName = "ReleaseDate";

4.3 现象:多用户同时选座时,偶尔出现两个用户都“锁座成功”,但最终只有一张订单入库

原因:LockSeat()方法中SELECT ... WITH (UPDLOCK)未加HOLDLOCK(即SERIALIZABLE),在高并发下可能因索引扫描范围不同导致锁粒度扩大,引发死锁或漏锁。
解决:将锁查询语句改为:

SELECT Status FROM Seats WITH (UPDLOCK, HOLDLOCK) WHERE RowNum = @Row AND ColNum = @Col AND ShowTimeID = @ShowID

HOLDLOCK确保事务结束前一直持有锁,彻底杜绝竞态条件。

4.4 现象:退票后,座位状态变回“0”(可售),但该场次总票房金额未扣减

原因:RefundOrder()方法只更新Seats.Status和Orders.Status,遗漏了Movies.TotalBoxOffice字段的更新。
解决:在RefundOrder()事务内追加SQL:

string updateBoxOffice = "UPDATE Movies SET TotalBoxOffice = TotalBoxOffice - @Amount WHERE MovieID IN (SELECT MovieID FROM ShowTimes WHERE ShowTimeID = @ShowID)"; using (SqlCommand cmd = new SqlCommand(updateBoxOffice, conn, trans)) { cmd.Parameters.AddWithValue("@Amount", order.Amount); cmd.Parameters.AddWithValue("@ShowID", order.ShowTimeID); cmd.ExecuteNonQuery(); }

4.5 现象:Visual Studio调试时断点无效,代码始终跳过SeatSelectionForm.cs中的逻辑

原因:项目属性→【生成】→“平台目标”设为Any CPU,但LocalDB驱动要求x64或x86明确指定。Any CPU在64位系统上可能加载32位驱动失败,导致ADO.NET连接静默失败,后续代码不执行。
解决:右键项目→【属性】→【生成】→“平台目标”改为x64(若本地SQL Server是64位)或x86(若用32位LocalDB)。保存后清理解决方案→重新生成。

5. 管理员后台深度用法:三类高频操作与数据一致性验证技巧

5.1 场次批量导入:用Excel模板规避手动录入错误

管理员新增场次时,避免逐条填写《阿凡达2》《流浪地球3》等长片名。项目附带Template\ShowTime_Import_Template.xlsx,含四列:MovieID(电影表主键)、TheaterID(影厅ID)、StartTime(格式2024-05-15 14:30:00)、Price(数字)。导入逻辑在AdminForm.cs的btnImportShowTimes_Click:

// 使用EPPlus库读取Excel(已预装NuGet包) using (var package = new ExcelPackage(new FileInfo(filePath))) { var worksheet = package.Workbook.Worksheets[0]; for (int row = 2; row <= worksheet.Dimension.End.Row; row++) { Guid movieId = Guid.Parse(worksheet.Cells[row, 1].Value.ToString()); int theaterId = Convert.ToInt32(worksheet.Cells[row, 2].Value); DateTime startTime = DateTime.Parse(worksheet.Cells[row, 3].Value.ToString()); decimal price = Convert.ToDecimal(worksheet.Cells[row, 4].Value); // 插入前校验MovieID是否存在 if (!MovieExists(movieId)) continue; // 跳过无效电影ID InsertShowTime(movieId, theaterId, startTime, price); } }

关键校验:MovieExists()查询Movies表确保MovieID有效,防止因Excel手误填错ID导致INSERT失败中断整个导入。

5.2 座位状态强制同步:当UI显示与数据库不一致时的终极排查表

若用户反馈“明明看到座位是绿色,点击却提示已售”,可能是缓存或锁未释放。执行以下SQL校验三表一致性:

校验维度SQL语句期望结果异常处理
单座位状态SELECT Status FROM Seats WHERE RowNum=3 AND ColNum=5 AND ShowTimeID='xxx'返回0(可售)或1(已售)或2(锁定)若返回NULL,执行UPDATE Seats SET Status='0' WHERE ...重置
场次余票数SELECT COUNT(*) FROM Seats WHERE ShowTimeID='xxx' AND Status='0'应等于ShowTimes.AvailableSeats字段值不等则执行UPDATE ShowTimes SET AvailableSeats = [COUNT] WHERE ShowTimeID='xxx'
订单关联性SELECT o.TicketNumber, s.RowNum, s.ColNum FROM Orders o JOIN OrderDetails od ON o.OrderID=od.OrderID JOIN Seats s ON od.SeatID=s.SeatID WHERE o.ShowTimeID='xxx'每行RowNum/ColNum必须在Seats表中存在且Status='1'发现Status!='1'的记录,手动UPDATE Seats SET Status='1' WHERE SeatID=...

5.3 并发压力测试:用批处理脚本模拟10用户抢同一场次

不用JMeter,用Windows批处理+curl最简验证锁机制:

@echo off setlocal enabledelayedexpansion set SHOW_ID=8a2f1e7b-1234-5678-90ab-cdef12345678 for /l %%i in (1,1,10) do ( start "" curl -X POST "http://localhost:5000/api/lockseat" -H "Content-Type: application/json" -d "{\"showId\":\"%SHOW_ID%\",\"row\":3,\"col\":5}" )

注意:此脚本需先在项目中启用简易HTTP API(SimpleHttpServer.cs已内置,监听5000端口)。运行后检查数据库Seats.Status——应只有1条记录变为2,其余9次请求返回{"success":false,"message":"座位已被占用"}。若出现多条Status='2',说明锁失效,需回查WITH (UPDLOCK, HOLDLOCK)是否遗漏。

从那以后我每次帮学生调毕设,只要涉及“多用户操作同一资源”,都会强制走一遍SELECT ... WITH (UPDLOCK, HOLDLOCK)的锁校验+事务回滚链路,并用批处理脚本压测3轮。不是信不过代码,是信不过自己没想全边界——毕竟答辩现场,老师问“如果100人同时点第3排第5座,你的系统会怎样?”,你得答得出来,而不是打开任务管理器看CPU。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询