☰
C#电影院售票系统课程设计:从三表建模到座位并发控制一次讲透
2026/10/8 23:24:47 网站建设 项目流程

简介:基于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 name="CinemaDb" connectionString="Data Source=.;Initial Catalog=CinemaDb;User ID=sa;Password=123456;" providerName="System.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 List<Button> seatButtons = new List<Button>(); 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); } } }

List<Button>是 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(*),返回 0;B 执行SELECT COUNT(*),也返回 0;A 执行 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。用户下单时只需要一条 UPDATE:

string 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 本机跑得好好的,拷到别人电脑上连不上数据库

现象:在自己电脑上一切正常,把项目拷到演示机,运行到登录就报“建立到服务器的连接时发生错误。原因通常有三个:连接字符串写死了localhost;SQL Server 实例名不同,自己机器是默认实例,对方机器是 SQLEXPRESS;身份验证用的是Integrated Security=True,换机器后 Windows 账户对不上。

排查顺序很重要,先确认对方 SQL Server 的服务在跑,再确认实例名,最后确认登录名密码。逐个对照检查:

表现原因排查动作
连接报错,Login failed for userSQL Server 只开了 Windows 身份验证用 sa 登录,开启混合模式
找不到服务器或实例名对方实例名是 SQLEXPRESS连接串改为Data Source=.\\SQLEXPRESS
数据库不存在没有在目标机器上执行建库脚本重新执行建表 SQL,而不是只拷 exe

5.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 冗余票价才圆回来。这段经历教会我一件事:设计上的偷懒,最后都会变成答辩时的窟窿。希望帮到你。

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

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

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

立即咨询