资讯动态

C#电影院售票系统课程设计:从三表建模到座位并发控制一次讲透

发布时间:2026/10/8 23:24:51 来源:尧图企业网站定制
简介基于C#实现的电影院售票系统是一份面向毕业设计、课程设计的完整实践项目围绕用户注册登录、影片信息管理、场次排片、选座购票、订单支付、后台数据维护等核心业务展开适合正在完成C#/.NET课程作业或毕业设计的学生使用。压缩包共692个文件、约13.81MB主要包含C#源码、ASP.NET页面与用户控件、JavaScript/CSS前端资源、PNG/JPG/GIF界面图片、数据库文件及MP4演示视频目录清晰便于按模块阅读。目前已有90人学习下载内容涉及ADO.NET数据库访问、数据绑定、用户认证与权限管理、多线程购票、异常日志及支付接口对接等知识点并附环境配置说明可帮助快速搭建运行环境、理解系统设计与常见排错方向。通过源码、界面素材和演示视频学习者既能掌握C#与数据库结合的项目开发方法也能为课程验收或毕业答辩准备一份结构完整的参考方案。1. 从课程设计到能演示的售票系统这个 C# 项目到底在做什么答辩现场评委老师经常会追问一句你这个电影院售票系统两个用户同时买最后一排同一个座位怎么保证不超卖这个问题一出来前面演示得多顺都会卡住。这个标题里的 C# 电影院售票系统表面看是个窗口程序实际把数据库建模、界面绑定、事务与行锁、座位状态流转全串在一条购票链路上恰好卡在课程设计和真实项目的分界线上。它适合两类人一类是想把手头这个压缩包跑通并且能在答辩时把原理讲清楚的同学另一类是正准备把它改造成自己的课程设计还发愁从哪里下手的新手。前者需要快速掌握项目骨架后者需要知道改哪里、怎么改才不像直接抄的。我拆这类项目时有个习惯先看数据库怎么建模再看一条购票链路怎么写通然后单独审并发控制最后才看界面。沿着这条路选型、建库、下单、锁座位、避坑、答辩走读一次讲透。2. 先把架子搭对C# 售票系统的分层选型与数据库三表设计拿到任何解压后的源码包先别急着双击 .sln第一步是判断技术栈。绝大多数 C# 电影院售票系统跑在 WinForms 上数据库用 SQL Server。为什么是这套组合为什么不是网页版直接决定了你后面几周的工作量。2.1 WinForms 还是 ASP.NET毕业设计场景我为什么选 WinForms课程设计和毕业设计里WinForms 占绝对主流原因不是什么技术先进性而是演示成本低。双击 exe 就能跑不需要装 IIS、不需要配站点评委坐在屏幕前看你点鼠标就行。ASP.NET WebForms 虽然也是 C#但部署要看 IIS 版本、要处理端口和防火墙答辩现场最怕这种环境问题。WPF 是另一个常被提的选项界面确实现代拖控件也方便问题是它带出来的 MVVM 模式会把简单项目复杂化。很多同学为了用 WPF 硬上 MVVM结果在数据绑定和命令上花的时间比业务逻辑还多。WinForms 不强制 MVVM只要你把数据访问单独拆出来已经比全部写在 Form 里强很多。数据库这边也有用 Access 的单机演示确实能跑但事务和锁的语义很弱老师多问两句并发就露馅。我的建议很直接能用 SQL Server 就别用 Access理由后面并发章节会展开。方案演示成本环境依赖答辩友好度改动成本WinForms SQL Server低双击运行SQL Server 本机实例高界面直观低WPF SQL Server中需要熟悉绑定SQL Server 本机实例中界面加分但难讲中ASP.NET WebForms SQL Server高需要 IISIIS SQL Server低环境易翻车高2.2 三张核心表电影、场次、订单的字段设计与外键关系建库脚本是项目的骨架。我一般会先确认 SQL Server 实例名和身份验证模式再执行脚本避免后面连不上数据库时怀疑人生。常见的最小可跑结构是电影表、场次表、订单表三张再加一张可选的座位状态表。CREATE DATABASE CinemaDb; GO USE CinemaDb; GO CREATE TABLE Movie ( MovieId INT IDENTITY(1,1) PRIMARY KEY, Title NVARCHAR(100) NOT NULL, Genre NVARCHAR(20) NULL, Duration INT NOT NULL, -- 片长单位分钟 ReleaseDate DATE NULL ); CREATE TABLE Session ( SessionId INT IDENTITY(1,1) PRIMARY KEY, MovieId INT NOT NULL REFERENCES Movie(MovieId), HallId INT NOT NULL, StartTime DATETIME NOT NULL, TicketPrice DECIMAL(10,2) NOT NULL, -- 冗余票价跟随场次 ); CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, SessionId INT NOT NULL REFERENCES Session(SessionId), RowNo INT NOT NULL, ColNo INT NOT NULL, Status VARCHAR(10) NOT NULL DEFAULT Paid, -- Paid / Locked CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_Orders_SessionSeat ON Orders(SessionId, RowNo, ColNo);这里有一个很多人直接踩的坑票价到底放电影表还是场次表放电影表看起来省事但影院晚场、早场、会员价都不同票价必须跟随场次才有业务意义。Session 表冗余一个 TicketPrice是在设计阶段就能说清楚的加分点答辩被问“为什么冗余”时能答得理直气壮。Orders 表最核心的约束是索引IX_Orders_SessionSeat它让“查某个场次某个座位是否已售”走索引而不是全表扫。注意当前版本只有三张表座位是否可售完全靠查订单判断没有给座位本身建状态表。这个设计在并发场景有隐患我放在第 4 章专门讲先记住这个伏笔。2.3 连接字符串与 SqlHelper最小可跑的数据库访问层连接字符串写在 App.config 里而不是每个窗体 new 一个 SqlConnection 时写死在代码里。后者改一次库就要全局搜索替换血泪教训。connectionStrings add nameCinemaDb connectionStringData Source.;Initial CatalogCinemaDb;User IDsa;Password123456; providerNameSystem.Data.SqlClient / /connectionStrings数据库访问层通常收敛成一个 SqlHelper 静态类。它不解决业务问题只是把 Connection、Command、DataAdapter 的样板代码统一收口让上面的购票、查询代码保持干净。using System.Configuration; using System.Data; using System.Data.SqlClient; public static class SqlHelper { private static readonly string connStr ConfigurationManager.ConnectionStrings[CinemaDb].ConnectionString; public static DataTable ExecuteDataTable(string sql, params SqlParameter[] parameters) { using (var conn new SqlConnection(connStr)) using (var cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); var adapter new SqlDataAdapter(cmd); var table new DataTable(); adapter.Fill(table); return table; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn new SqlConnection(connStr)) using (var cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } }注意ExecuteDataTable没有显式conn.Open()因为 SqlDataAdapter.Fill 会自动打开关闭连接ExecuteNonQuery必须自己 Open这是初学者最容易漏的一行。params SqlParameter[]让调用方可以写new SqlParameter(sid, 1)直接传参不用手动组装数组也顺便把参数化查询落到了每个调用点上。有些人会把连接配置挪到 appsettings.json 里启动时用 C# json 匹配配置的方式读进来这对 WinForms 项目有点过度设计。App.config 的 connectionStrings 节点就是给 ADO.NET 用的标准位置课程设计不用绕路。3. 写通一条购票链路从选座到出票的 C# 代码骨架架子搭好后目标是让一条完整购票链路跑起来查场次、看座位、选座、下单、刷新座位图。这一章我先按最容易理解的顺序写第三个环节刻意保留一个并发隐患下一章再修。3.1 场次列表加载DataGridView 数据绑定的基本姿势主窗体打开后第一件事是加载今天的场次列表。SQL 里做两张表的关联把电影名、开场时间、影厅、票价一起查出来直接绑定到 DataGridView。string sql SELECT s.SessionId, m.Title AS 电影, s.StartTime AS 开场时间, s.HallId AS 影厅, s.TicketPrice AS 票价 FROM Session s INNER JOIN Movie m ON s.MovieId m.MovieId WHERE m.Title LIKE keyword ORDER BY s.StartTime; var table SqlHelper.ExecuteDataTable( sql, new SqlParameter(keyword, % txtKeyword.Text.Trim() %)); dataGridView1.DataSource table;这里有两个细节值得说。第一LIKE 的参数化不是把%拼进 SQL 字符串而是把%拼进参数值SQL 里只留LIKE keyword。第二SELECT 里直接写中文别名DataGridView 自动生成的列头就是中文省掉几十行设置列头的代码。新手常犯的错是手工拼 SQL 字符串一旦用户输入英文单引号整个查询直接报错参数化能把这些字符问题一起挡掉。绑定后还有一个容易忽略的动作每次重新查询都生成新的 DataTable 再赋给 DataSource不要拿旧 DataTable 去改行。否则界面显示的数据和数据库不同步看起来像“没刷新”。3.2 座位状态读取与 UI 呈现用二维数组映射座位图影厅座位通常用 Panel 动态生成 Button每个按钮的 Tag 存一个 Point 表示行和列。这个做法的核心不是生成按钮而是用二维数组 boolean 把“哪些座位已售”映射到界面上。private ListButton seatButtons new ListButton(); private bool[,] soldSeats; private Point selectedSeat; private void LoadSeats(int sessionId) { string sql SELECT RowNo, ColNo FROM Orders WHERE SessionId sid AND Status IN (Paid, Locked); var table SqlHelper.ExecuteDataTable(sql, new SqlParameter(sid, sessionId)); soldSeats new bool[8, 12]; foreach (DataRow row in table.Rows) { int r Convert.ToInt32(row[RowNo]); int c Convert.ToInt32(row[ColNo]); soldSeats[r, c] true; } for (int r 0; r 8; r) { for (int c 0; c 12; c) { var btn new Button(); btn.Size new Size(35, 35); btn.Tag new Point(r, c); btn.Text ${r 1}-{c 1}; btn.BackColor soldSeats[r, c] ? Color.Gray : Color.LightGreen; btn.Click SeatButton_Click; panelSeats.Controls.Add(btn); seatButtons.Add(btn); } } }ListButton是 C# 泛型最常见的用法把按钮引用收进来后面刷新颜色时直接遍历这个列表改 BackColor不用再遍历 Controls 找控件。座位布局用固定 8 行 12 列是课程设计里最常见的简化如果想让影厅可配置应该在 Session 表上冗余 RowCount 和 ColCount而不是在代码里写死。按钮点击时从 Tag 里取出座位坐标存进selectedSeat字段。注意 Tag 存的是 Point取出来要强转(Point)btn.Tag这是新手经常写错成(int)然后运行时报 InvalidCastException 的地方。3.3 提交订单参数化 SQL 与事务的第一次接触下单逻辑的第一版大多数人的写法是“先查有没有人买没有就插入”。SqlHelper 里还需要补一个 ExecuteScalar 用来取 COUNT 结果。public static object ExecuteScalar(string sql, params SqlParameter[] parameters) { using (var conn new SqlConnection(connStr)) using (var cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteScalar(); } }private void BtnBuy_Click(object sender, EventArgs e) { string checkSql SELECT COUNT(*) FROM Orders WHERE SessionId sid AND RowNo r AND ColNo c AND Status IN (Paid, Locked); object result SqlHelper.ExecuteScalar(checkSql, new SqlParameter(sid, sessionId), new SqlParameter(r, selectedSeat.X), new SqlParameter(c, selectedSeat.Y)); if (Convert.ToInt32(result) 0) { MessageBox.Show(座位已被占用); return; } string insertSql INSERT INTO Orders(SessionId, RowNo, ColNo, Status, CreateTime) VALUES(sid, r, c, Paid, GETDATE()); SqlHelper.ExecuteNonQuery(insertSql, new SqlParameter(sid, sessionId), new SqlParameter(r, selectedSeat.X), new SqlParameter(c, selectedSeat.Y)); MessageBox.Show(出票成功); LoadSeats(sessionId); }这个版本在单机演示里完全正常查一次、插一条、刷新座位图一气呵成。但它有一个致命前提同一时刻只有一个用户在下单。只要有两个客户端同时点同一个座位两个SELECT COUNT(*)都会返回 0两个INSERT都会成功数据库里就出现两张同一座位的已售订单。这就是下一章要解决的超卖问题。4. 锁定座位与余票扣减并发场景下不能只靠 if 判断很多课程设计做到 3.3 那一步就交差了界面能跑、订单能写库老师不追问就到这。但如果老师想听听“并发”你还在讲 if 判断基本就暴露了。这一章把并发这个点补扎实。4.1 经典翻车现场用户 A、B 同时买同一张座会发生什么假设一个场次只剩最后一排 8 排 5 座没人买。用户 A 和用户 B 几乎同时点了购买数据库的执行顺序可能是这样的A 执行SELECT COUNT(*)返回 0B 执行SELECT COUNT(*)也返回 0A 执行 INSERT成功B 执行 INSERT也成功。最终这个座位出现两条 Paid 订单但影厅里只有一个物理座位。问题不在 INSERT而在“检查座位状态”和“写入订单”这两个操作之间没有做任何隔离。A 检查完还没插入之前B 的检查已经开始两个事务都基于同一个“无人购买”的旧快照做判断。数据库事务默认隔离级别下这类先查后写就是典型的竞态条件。4.2 用事务 UPDLOCK 锁定行可复现的 C# 代码第一种修法是把检查和插入放进同一个事务并在查询时加UPDLOCK, HOLDLOCK行锁提示让“这一行”在事务结束前不能被其他事务重复检查。using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { string lockSql SELECT COUNT(*) FROM Orders WITH (UPDLOCK, HOLDLOCK) WHERE SessionId sid AND RowNo r AND ColNo c AND Status IN (Paid, Locked); using (var chkCmd new SqlCommand(lockSql, conn, tx)) { chkCmd.Parameters.AddWithValue(sid, sessionId); chkCmd.Parameters.AddWithValue(r, selectedSeat.X); chkCmd.Parameters.AddWithValue(c, selectedSeat.Y); int count (int)chkCmd.ExecuteScalar(); if (count 0) { tx.Rollback(); return; } } // 插入订单注意所有命令都挂到同一个事务对象 tx 上 string insertSql INSERT INTO Orders(SessionId, RowNo, ColNo, Status, CreateTime) VALUES(sid, r, c, Paid, GETDATE()); using (var insCmd new SqlCommand(insertSql, conn, tx)) { insCmd.Parameters.AddWithValue(sid, sessionId); insCmd.Parameters.AddWithValue(r, selectedSeat.X); insCmd.Parameters.AddWithValue(c, selectedSeat.Y); insCmd.ExecuteNonQuery(); } tx.Commit(); } }UPDLOCK让查询拿到更新锁而不是共享锁HOLDLOCK让这把锁保持到事务结束。第二个事务再查同一行时会被阻塞直到第一个事务提交或回滚然后它看到COUNT(*)变成 1返回“座位已被占用”。但这个方案有个别扭的地方如果一个座位从来没人买过Orders 表里根本没有这行记录锁的对象就不存在。围绕“不存在的行”做锁定语义很模糊实际表现依赖索引和隔离级别属于说不清的那类黑匣子。4.3 座位状态机可用、已锁、已售三种状态的流转所以我在实际项目里更推荐第二种做法预先生成一个座位状态表把每个场次每个座位的状态持久化购买行为从“插入订单”变成“更新状态”用 UPDATE 自带的行锁来保证安全。CREATE TABLE SeatStatus ( SessionId INT NOT NULL, RowNo INT NOT NULL, ColNo INT NOT NULL, Status VARCHAR(10) NOT NULL DEFAULT Available, OrderId INT NULL, PRIMARY KEY (SessionId, RowNo, ColNo) );初始化时给每个场次生成 8×12 行记录状态全部为 Available。用户下单时只需要一条 UPDATEstring sql UPDATE SeatStatus SET Status Paid, OrderId orderId WHERE SessionId sid AND RowNo r AND ColNo c AND Status Available; int affected SqlHelper.ExecuteNonQuery(sql, new SqlParameter(sid, sessionId), new SqlParameter(r, selectedSeat.X), new SqlParameter(c, selectedSeat.Y), new SqlParameter(orderId, orderId)); if (affected 0) { MessageBox.Show(座位刚刚被别人选了); return; }UPDATE 在行级别加排他锁第二个并发的 UPDATE 会阻塞到第一个提交提交后它发现 Status 已经不是 Available影响行数为 0直接走“被别人选了”分支。这比先查再插简单而且锁粒度明确。到这里座位就是一个典型的状态机Available 可售Locked 用户选座中未付款Paid 已付款。常见的超时释放逻辑是Locked 超过 10 分钟没有付款自动改回 Available。用 WinForms 的 Timer 轮询即可private void timer1_Tick(object sender, EventArgs e) { string sql UPDATE SeatStatus SET Status Available, OrderId NULL WHERE Status Locked AND OrderId IN ( SELECT OrderId FROM Orders WHERE CreateTime DATEADD(MINUTE, -10, GETDATE()) ); SqlHelper.ExecuteNonQuery(sql); }这其实就是一套非常典型的 C# 状态机应用三种状态、两个转移条件、一条超时规则。答辩时把这个讲清楚比讲十个窗体切换都有说服力。5. 售票系统避坑清单数据绑定、日期时间和部署的五个踩坑记录这一章是这几年看这类项目的血泪汇总。前面几章讲的是怎么做这一章讲的是做的时候会怎么翻车以及翻车后怎么爬起来。5.1 DataGridView 绑定 DataTable 后界面不刷新现象第一次绑定数据表格有内容筛选条件再查询一次DataGridView 变成空白或显示的还是旧数据。原因直接把 DataTable 赋给 DataSource第二次查询生成了新表但控件没有触发刷新机制视图还停留在旧状态。解决用 BindingSource 中转数据变更后调一次ResetBindings。bindingSource1.DataSource table; bindingSource1.ResetBindings(false);ResetBindings(false)表示只刷新当前绑定的列表不重建整个 BindingSource。在 Form 设计器里把 DataGridView 的 DataSource 指向 bindingSource1之后所有查询都只动 bindingSource1界面始终保持同步。5.2 座位按钮的颜色状态在读库后全部丢失现象点击购票后调用 LoadSeats 刷新座位图结果所有按钮都重新生成了一遍已购座位颜色没变化有些按钮点击事件也失效了。原因刷新函数里panelSeats.Controls.Clear()清掉了所有旧按钮重新 new 出来的 Button 没有重新挂点击事件或者事件虽然挂了但闭包捕获的座位变量不对。解决把按钮生成和状态刷新拆成两个动作。生成只做一次刷新只改颜色和 Enabled不要删除重建。private void RefreshSeatColors() { foreach (var btn in seatButtons) { var point (Point)btn.Tag; btn.BackColor soldSeats[point.X, point.Y] ? Color.Gray : Color.LightGreen; btn.Enabled !soldSeats[point.X, point.Y]; } }5.3 本机跑得好好的拷到别人电脑上连不上数据库现象在自己电脑上一切正常把项目拷到演示机运行到登录就报“建立到服务器的连接时发生错误。原因通常有三个连接字符串写死了localhostSQL Server 实例名不同自己机器是默认实例对方机器是 SQLEXPRESS身份验证用的是Integrated SecurityTrue换机器后 Windows 账户对不上。排查顺序很重要先确认对方 SQL Server 的服务在跑再确认实例名最后确认登录名密码。逐个对照检查表现原因排查动作连接报错Login failed for userSQL Server 只开了 Windows 身份验证用 sa 登录开启混合模式找不到服务器或实例名对方实例名是 SQLEXPRESS连接串改为Data Source.\\SQLEXPRESS数据库不存在没有在目标机器上执行建库脚本重新执行建表 SQL而不是只拷 exe5.4 中文乱码和时间格式在报表里出错现象SQL 里写中文条件查不到数据INSERT 的中文进库变成问号导出的报表时间显示成“2025/1/2 8:05”这种不统一的格式。原因拼接 SQL 时中文以 ANSI 编码传给 SQL Server和数据库排序规则不匹配时间则是因为没有指定格式化字符串由控件默认转换。解决分两头。中文问题用参数化查询统一解决参数值走 NVARCHAR 通道不乱码建库时把排序规则指定为Chinese_PRC_CI_AS兜底。时间问题在绑定列时显式格式化dataGridView1.Columns[开场时间].DefaultCellStyle.Format yyyy-MM-dd HH:mm;5.5 后台线程改控件抛跨线程异常现象用后台线程轮询订单状态在线程里直接写label1.Text 已出票运行时报 InvalidOperationException线程间操作无效。原因WinForms 控件只能在创建它的 UI 线程上访问后台线程直接改控件属性是违法的。解决先判断是否需要跨线程调用再用 Invoke 或 BeginInvoke 切回 UI 线程。写一个通用方法private void UpdateStatusLabel(string text) { if (label1.InvokeRequired) label1.BeginInvoke(new Action(() label1.Text text)); else label1.Text text; }一个反直觉的点是WinForms 的 Timer它的 Tick 事件跑在 UI 线程直接改控件没问题只有System.Timers.Timer或Task.Run里的代码才需要 Invoke。不少人一看到 Timer 就条件反射加 Invoke属于过度防御。6. 答辩前最后的 30 分钟我最常用的演示脚本与走读顺序答辩演示我坚持一条原则先演示后讲代码并且演示里一定要安排一次“抢座失败”。流程是打开一个客户端实例按电影查场次选一个座位下单出票然后打开第二个客户端实例再点同一个座位预期弹窗“座位刚刚被别人选了”。这一步比任何语言都有说服力评委看到的就是你做了并发控制。走读代码时不要从 Form1.cs 开始逐行念我的顺序是Program.cs 入口说明窗体启动方式然后直奔数据访问层展示参数化 SqlHelper再回到购票按钮展示 SeatStatus 的 UPDATE 语句最后带一句超时释放的 Timer。整个走读控制在十分钟内只讲关键点其余一带而过。演示动作预期现象对应代码位置两个实例抢同一座位第二个弹“座位已被占用”SeatStatus 的 UPDATE 语句查晚场场次票价高于早场Session 表冗余 TicketPrice关闭程序重新打开已售座位仍是灰色Orders 表反查座位状态如果还有余力可以在数据库里开 SQL Server Profiler 抓一下那两条 UPDATE 的阻塞记录展示持锁时间。这一步不是必须但能让答辩从“会做”变成“懂原理”。我当年自己第一次做这个题目时把所有票价挂在电影表上结果被老师追问“晚上八点的场次怎么打折”时完全接不上话。后来改成 Session 冗余票价才圆回来。这段经历教会我一件事设计上的偷懒最后都会变成答辩时的窟窿。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑