C#电影院售票系统实战:三层架构与数据库并发锁座设计
简介一套基于C#实现的电影院售票系统完整项目面向计算机专业学生或需要毕业设计、课程设计代码方案的学习者覆盖从用户登录、选座购票到后台管理的完整票务业务适合用来综合演练桌面应用开发。压缩包共包含692个文件以C#源码文件、Windows Forms页面及aspx网页文件为主同时含有大量gif、png、jpg界面素材、CSS与JS前端资源以及数据库文件和配置说明整体约13.81MB结构上兼顾了前后台代码与演示素材。目前已有90人学习/下载。项目知识点涵盖C#面向对象编程、Windows Forms界面设计、ADO.NET数据访问、数据绑定与控件交互、用户认证与权限控制、异常处理机制等同时附有运行环境配置说明便于本地搭建调试是一份可用于答辩讲解和二次开发的完整参考资料。1. 为什么一套C#电影院售票系统值得你从头敲一遍每年毕业设计答辩现场总能看到几套“电影院售票系统”翻车选座能选、下单报错两个窗口同时卖同一张票数据库里座位状态乱成一团。这类项目看着不起眼但把C#、数据库事务、UI联动和并发处理全串起来了是练手性价比很高的题目。这套系统要解决的核心问题是把电影的场次、座位、订单、支付状态管清楚保证一张票不会被卖两次。适合用C#入门不久、想拿一个完整项目练三层架构和数据库设计的学习者也适合需要交一个课程设计或毕业设计成品的学生。真正动手写一遍比背一百道C#面试题管用。2. 先把架子搭稳C#三层架构与数据库选型2.1 为什么选WinForms而非WPF或Web见过不少同学一上来就纠结UI框架。这里直接说结论如果是毕业设计和课程设计首选WinForms。原因有三。一是资料最全从GridView绑定到报表打印随便一搜就是一堆能跑的案例C#初学者遇到问题最容易找到现成答案二是调试直观窗体上拖控件、双击事件就能把交互逻辑理清楚不用在前端路由和HTTP请求里绕弯子三是这套系统的业务本来就在一个局域网或者单机上跑用不上Web那套部署方案。WPF不是不行它的数据绑定和样式确实更现代但学习成本会分摊到XAML、DataTemplate、MVVM上。如果只有两周时间写一个课设这些额外成本容易变成翻车点。至于ASP.NET Core Web项目适合想顺便练前后端的同学但答辩时你要多解释一堆接口和跨域问题票务核心逻辑反而讲不透。2.2 数据库选型SQL Server还是SQLite数据库选择也不能拍脑袋。如果是课程设计老师通常希望看到SQL Server或者MySQL这种“正经数据库”因为要建表、写存储过程、设主外键。这几种里SQL Server在Windows下部署最省事Visual Studio自带连接功能附加数据库文件就能跑是最常见的做法。SQLite也可以它的优点是不需要安装服务一个.db文件扛起整个系统适合演示环境极其有限的场景。但缺点也明显并发写锁机制弱多个窗口同时卖票时容易出现“数据库被锁定”的提示而且少了作业、存储过程这些企业级功能答辩时被问到“你怎么保证数据一致性”会少一些可讲的素材。我一般建议目标环境是老师电脑、需要当场演示的用SQL Server LocalDB或Express版如果只是自己练手SQLite足够。2.3 三层架构的工程拆分和数据访问层写法常见的做法是建三个项目UI层窗体、BLL层业务逻辑、DAL层数据访问外加一个Models层放实体类。UI层只管收集用户输入和展示结果BLL层管订票、退票、查场次这些规则DAL层只写SQL和数据库交互。这样拆的好处是往后想换成MySQL只需要改DAL层。DAL层最常见的落法是写一个SqlHelper类封装连接、执行SQL、返回DataTable的操作。下面是极简版本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 int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(_connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(sql, conn)) { if (paras ! null) cmd.Parameters.AddRange(paras); return cmd.ExecuteNonQuery(); } } } // 执行查询返回DataTable public static DataTable ExecuteDataTable(string sql, params SqlParameter[] paras) { using (SqlConnection conn new SqlConnection(_connStr)) { using (SqlDataAdapter da new SqlDataAdapter(sql, conn)) { if (paras ! null) da.SelectCommand.Parameters.AddRange(paras); DataTable dt new DataTable(); da.Fill(dt); return dt; } } } }这段代码的逻辑是每次操作打开一个新连接用完自动释放避免连接泄漏。注意ExecuteDataTable里用了SqlDataAdapter它会在内部自己打开和关闭连接所以不需要显式Open。参数paras用来防止SQL注入凡是拼接用户输入的地方都必须走参数化不能把字符串直接拼进SQL。连接字符串放在App.config里通过ConfigurationManager读取这是很多初学者会忽略的点。用Visual Studio创建项目后在App.config里加一段connectionStrings add nameCinemaDB connectionStringData Source.;Initial CatalogCinemaDB;Integrated SecurityTrue providerNameSystem.Data.SqlClient / /connectionStringsInitial Catalog对应当前数据库名Integrated SecurityTrue表示用Windows身份认证登录不需要填账号密码。如果换到别的机器只需要改Data Source指向目标数据库服务器代码一行都不用动。这里值得记住连接字符串里常用的还有User ID和Password但那是SQL Server混合认证模式用的课设环境一般用不上。把SqlHelper写好之后DAL层其他类就都是“拼SQL 调SqlHelper”的模板了。BLL层调用DAL层UI层调用BLL层整个数据流是单向的出了问题顺着调用链一层层找就行。3. 数据库设计售出一张票需要哪几张表撑着3.1 影片、影厅、场次三张基础表怎么建很多人的第一版数据库只有“影片表”和“订单表”结果到场次排期时傻眼了同一部电影一天放三场每场在不同的厅票价还不一样。这就暴露了表结构设计的核心问题场次才是售票的最小单位影片不是。基础表一般拆三张影片表存电影名称、时长、上映日期影厅表存厅名和行列数场次表存哪部电影、哪个厅、什么时间放、卖多少钱。下面是建表SQLCREATE TABLE Movie ( MovieId INT IDENTITY(1,1) PRIMARY KEY, MovieName NVARCHAR(50) NOT NULL, Duration INT NOT NULL, -- 影片时长单位分钟 Price DECIMAL(6,2) NOT NULL -- 基础票价 ); CREATE TABLE CinemaHall ( HallId INT IDENTITY(1,1) PRIMARY KEY, HallName NVARCHAR(20) NOT NULL, RowCount INT NOT NULL, -- 影厅座位行数 ColCount INT NOT NULL -- 影厅座位列数 ); CREATE TABLE SessionPlan ( SessionId INT IDENTITY(1,1) PRIMARY KEY, MovieId INT NOT NULL, HallId INT NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NOT NULL, Price DECIMAL(6,2) NOT NULL, -- 场次实际票价允许和影片基础票价不同 CONSTRAINT FK_Session_Movie FOREIGN KEY (MovieId) REFERENCES Movie(MovieId), CONSTRAINT FK_Session_Hall FOREIGN KEY (HallId) REFERENCES CinemaHall(HallId) );这里有两个容易被忽略的点。第一场次表里冗余了Price字段而不是每次都去查影片表。因为同一步电影晚场票价比早场贵是常态冗余这个字段能让售票查询少一次联表查询。第二EndTime也不是必须的但有了它后面做“同一影厅同一时间段不能排两场”的冲突检查就非常方便。3.2 座位表为什么要按场次复制座位座位是这套系统里最容易设计错的地方。常见做法是给影厅存一份座位表订单存“几排几号”这种做法单机演示没问题但一旦遇到“同一个厅的座位不同场次卖出状态不同”就麻烦了。正确做法是为每一个场次生成一份独立的座位记录。CREATE TABLE Seat ( SeatId INT IDENTITY(1,1) PRIMARY KEY, SessionId INT NOT NULL, -- 属于哪个场次 HallId INT NOT NULL, -- 冗余影厅ID方便统计 RowNo INT NOT NULL, ColNo INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0空闲 1锁定 2已售 LockOrderId INT NULL, -- 锁定订单ID用于恢复 CONSTRAINT FK_Seat_Session FOREIGN KEY (SessionId) REFERENCES SessionPlan(SessionId), CONSTRAINT UQ_Seat_Session UNIQUE (SessionId, RowNo, ColNo) );每次创建场次时程序按影厅的RowCount和ColCount循环插入座位N行的厅就生成N条Seat记录。这个设计的好处是座位状态天然归属于场次不同场次的同一物理座位互不干扰坏处是数据量会随场次数量增长但一个电影院一天十几个场次完全不是问题。Status字段用TINYINT0空闲、1锁定、2已售。锁定意思是用户选座后还没付款这时候座位得先占住否则别人就买了。LockOrderId记录是谁锁的方便超时后释放座位。3.3 订单表用状态机处理支付和退票订单表是系统的账本字段设计直接决定业务逻辑好不好写。推荐用订单号、场次、座位、金额、状态、创建时间这几个核心字段CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(20) NOT NULL UNIQUE, SessionId INT NOT NULL, SeatId INT NOT NULL, MovieName NVARCHAR(50) NOT NULL, -- 冗余影片名列表页不联表 TotalPrice DECIMAL(6,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已退票 3已失效 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Order_Session FOREIGN KEY (SessionId) REFERENCES SessionPlan(SessionId), CONSTRAINT FK_Order_Seat FOREIGN KEY (SeatId) REFERENCES Seat(SeatId) );订单状态的流转是这套系统的业务核心选座成功创建订单状态是0支付成功后改成1用户退票且系统允许时改成2同时把对应Seat的Status恢复成0如果用户锁座后超时未支付订单标记3座位释放。每一次状态变化都对应一段明确的SQL更新而不是散落在UI代码里随手改。这里还要提一个设计决策订单表和Seat表之间用SeatId直接关联而不是存“几排几号”。原因是SeatId能唯一定位到某场次的某个座位查询“这个座位现在属于哪个订单”只需要一条语句如果存排号列号还得带上场次ID三重匹配麻烦且容易出错。4. 核心售票链路从锁座到出票的完整实现4.1 锁座一条UPDATE语句解决并发抢座售票系统最容易翻车的地方就是两个窗口同时卖同一张票。最简单的错误写法是这样的先SELECT查看座位状态如果空闲就UPDATE成已售。两个用户同时SELECT都看到空闲然后都UPDATE结果一张票卖给两个人。正确处理是用“条件更新”代替“先查后改”public bool LockSeat(int sessionId, int rowNo, int colNo, int orderId) { string sql UPDATE Seat SET Status 1, LockOrderId OrderId WHERE SessionId SessionId AND RowNo RowNo AND ColNo ColNo AND Status 0; int rows SqlHelper.ExecuteNonQuery(sql, new SqlParameter(SessionId, sessionId), new SqlParameter(RowNo, rowNo), new SqlParameter(ColNo, colNo), new SqlParameter(OrderId, orderId)); return rows 1; }这段代码的关键在WHERE子句里的Status 0。数据库执行UPDATE时会先锁定匹配行再判断状态所以即使两个线程同时执行这条SQL也只有第一个能成功更新一行第二个受影响行数为0。返回的rows等于1才说明锁座成功等于0说明座位已经被别人占了。这里用到的知识点是数据库的行级锁和原子更新比什么花哨的并发控制都可靠。C#多线程里经常提到的lock语句在这里反而不适用因为两个售票窗口可能是两台机器上的两个进程C#的lock只能管住同一个进程内的线程。4.2 下单把锁座和创建订单放进一个事务锁座成功之后还要插入订单记录。这里有个细节如果锁座成功但插入订单失败座位会永远卡在锁定状态。所以这两步必须放在同一个SqlTransaction里要么都成功要么都回滚。BLL层的典型写法是这样public bool CreateOrder(int sessionId, int rowNo, int colNo, decimal price, string movieName) { string connStr ConfigurationManager.ConnectionStrings[CinemaDB].ConnectionString; using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran conn.BeginTransaction()) { try { // 第一步锁座条件更新保证不超卖 string lockSql UPDATE Seat SET Status 1, LockOrderId OrderId WHERE SessionId SessionId AND RowNo RowNo AND ColNo ColNo AND Status 0; // 生成订单号比如T20240521加自增序号 string orderNo GenerateOrderNo(); SqlCommand lockCmd new SqlCommand(lockSql, conn, tran); lockCmd.Parameters.AddWithValue(SessionId, sessionId); lockCmd.Parameters.AddWithValue(RowNo, rowNo); lockCmd.Parameters.AddWithValue(ColNo, colNo); lockCmd.Parameters.AddWithValue(OrderId, orderId); if (lockCmd.ExecuteNonQuery() ! 1) { throw new Exception(座位已被他人锁定); } // 第二步插入订单 string insertSql INSERT INTO Orders(OrderNo, SessionId, SeatId, MovieName, TotalPrice, Status) VALUES(OrderNo, SessionId, (SELECT SeatId FROM Seat WHERE SessionId SessionId AND RowNo RowNo AND ColNo ColNo), MovieName, Price, 0); SqlCommand insertCmd new SqlCommand(insertSql, conn, tran); insertCmd.Parameters.AddWithValue(OrderNo, orderNo); // 其他参数类似省略 insertCmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); return false; } } } }代码逻辑分两步先条件更新锁座影响行数不是1就抛异常回滚再插入订单。注意插入订单时用子查询从Seat表取SeatId避免前端传一个不可信的座位ID进来。参数全部通过AddWithValue传入这是SqlCommand最基础的用法也是防止SQL注入的标准姿势。注意AddWithValue虽然方便但遇到NULL值和NVARCHAR长度不匹配时容易出玄学错误。更严谨的做法是用new SqlParameter(参数名, SqlDbType.Int)来显式指定类型和长度。在课设阶段AddWithValue够用但心里要清楚这个边界。4.3 退票、场次查询和C#委托在UI联动里的作用出票之后另一个高频业务是退票。退票的核心不是删订单而是改状态public bool Refund(int orderId) { string sql BEGIN TRAN; UPDATE Orders SET Status 2 WHERE OrderId OrderId AND Status 1; UPDATE Seat SET Status 0, LockOrderId NULL WHERE LockOrderId OrderId; COMMIT; ; // 在SqlHelper里执行 return SqlHelper.ExecuteNonQuery(sql, new SqlParameter(OrderId, orderId)) 0; }这段SQL把订单状态改成已退票同时把该订单锁定的座位释放。两个UPDATE用事务包起来避免出现“票退了座位没释放”的情况。这里要注意UPDATE Orders那行带了Status 1条件防止重复退票。除了数据库操作UI联动也是这套系统里很有讲头的部分。选座页面上用户点击一个空座位座位按钮变灰同时下方订单面板显示影片名、场次时间和票价——这种联动用C#委托和事件来做非常顺手。主窗体定义一个座位选中事件子控件触发事件订单面板订阅事件刷新内容。C#里event本质上是委托的封装理解了这个后面做任何多窗体通知都一通百通。5. 避坑最容易让售票系统在答辩时翻车的五个细节5.1 座位状态在界面显示时对不上数据库现象是两个窗口同时开着选座界面A窗口把座位卖了B窗口界面里还是空的。这是正常现象因为界面是启动时加载的静态数据。但也有人把它当bug改越改越乱。原因是程序只查了一次座位表之后没有任何刷新机制。解决方法是选座成功后用委托或Timer重新刷新一次DataGridView或者在刷新时带上DateTime.Now参数让SQL只查当前状态。这里的关键是区分“界面缓存”和“数据库状态”不要把两者混为一谈。5.2 AddWithValue的NVARCHAR长度玄学现象是订单号明明有20位插入数据库后变成一堆空格或者查询报“将截断字符串或二进制数据”。原因是AddWithValue在参数未显式指定长度时会和数据库列定义不一致。解决方法是构造函数里先取数据长度或者直接用Parameters.Add(new SqlParameter(OrderNo, SqlDbType.NVarChar, 20))。这是个很小的细节但翻车概率极高尤其是订单号、电话这种定长字符串。5.3 日期时间过滤用了Between但漏了当天现象是查“今天”的场次结果晚上10点的场次查不出来。原因是Between and在SQL Server里的边界语义是包含头尾但如果前端把DateTime截断成yyyy-MM-dd就会把当天最后一场的时间归零导致该场次落在区间外。解决方法是不要用Between用和两段比较日期加1天作为上界。这条是对付“时间类查询”的通用套路。5.4 座位锁定后没有释放现象是用户选座不支付关掉窗口座位一直灰着。原因是没有做超时释放。解决方法是加一个“订单待支付超过5分钟自动失效”的定时任务或者退而求其次在座位表加LastLockTime字段查询时SQL里带上“锁定时间超过N分钟视为空闲”。虽然不如后台任务干净但课设演示够用。5.5 数据库文件附加失败、连接报错现象是换了一台电脑附加.mdf时报“无法打开物理文件”或者登录时提示“用户登录失败”。原因是文件权限不足或者连接字符串里的登录模式不对。解决方法是右键数据库文件给“Everyone”读权限或者在App.config里改用LocalDB的Data Source(LocalDB)\MSSQLLocalDB写法。答辩现场换机器是常态提前在目标机器上跑一遍启动脚本是血泪经验。6. 进阶验证用多线程并发脚本证明系统不会超卖6.1 50个线程抢同一个座位的冒烟测试到这一步基础功能基本写完了。但如果你有精力值得写一个并发冒烟测试开50个线程同时抢同一个座位看最终订单数是不是1。这比任何口头解释都有说服力。代码可以单独建一个控制台项目引用BLL层int success 0; int fail 0; object lockObj new object(); ManualResetEventSlim readyEvent new ManualResetEventSlim(false); int readyThreads 0; for (int i 0; i 50; i) { Thread t new Thread(() { // 所有线程就绪后同时开抢 Interlocked.Increment(ref readyThreads); readyEvent.Wait(); OrderService service new OrderService(); bool ok service.CreateOrder(1001, 5, 8, 45.00m); if (ok) Interlocked.Increment(ref success); else Interlocked.Increment(ref fail); }); t.Start(); } readyEvent.Set(); Thread.Sleep(3000); Console.WriteLine($成功 {success} 单失败 {fail} 单);理论上输出必须是成功1单、失败49单。如果出现了成功多于1单说明锁座逻辑有问题回到第4章检查条件更新是否正确。这是验证“数据一致性”最直接的证据。6.2 日志记录和后续扩展方向除了并发验证建议再加一个简单的日志类把订票、退票、异常都写到一个日志文件里。用StreamWriter追加写就行不用上什么重量级框架。这样才能在出问题时快速定位是哪个环节挂了而不是开着Visual Studio一点点断点。后续想往深了做可以从这几个方向扩展加入会员积分系统、用Queue处理高并发订票请求、引入存储过程把关键事务从C#挪到数据库端。但无论扩展多少核心的锁座和订单状态机都不要动那是整套系统的地基。我自己的习惯是写完一个模块先跑一遍并发冒烟再开始写下一个。这个习惯帮我避开了很多线上才会暴露的坑。如果你正在做C#电影院售票系统把这一套流程走完项目质量会比多数同龄人的成品高一个台阶希望帮到你。本文还有配套的精品资源点击获取