C#+SQL Server订票系统:五表建模与存储过程防超卖

发布时间:2026/11/2 2:17:36
C#+SQL Server订票系统:五表建模与存储过程防超卖 简介这是一份面向C#与SQL Server入门及课程设计学习者的火车订票系统源码包围绕窗体应用、数据库访问和订票业务展开可用于理解用户登录注册、车次管理、订单处理等模块的实现思路。压缩包为rar格式共128个文件、约1.85MB以50个cs源文件、24个resx与24个resources界面资源为主并含sln解决方案、csproj项目文件及exe、dll等运行依赖便于直接查看和调试项目结构。已有1437人学习适合作为Windows Forms与数据库课程实践参考。读者可从中梳理事件驱动交互、SQL增删改查、事务与并发控制、异常捕获等知识点并借鉴多模块分层组织和业务状态流转设计快速定位窗体与数据操作代码完成课程设计或二次开发。1. 用 C# 与 SQL Server 搭一个能真正跑通的订票系统关键难点不在界面学期末要交一个 C# 火车订票系统多数人卡住的地方不是窗体画不出来而是车次、席别、余票、订单这四样东西怎么在 SQL Server 里表达清楚——表一旦建错后面的查票、扣库存、退票全是补丁改一处崩三处。这套方案把需求拆成五个模块、五张核心表界面用 WinForms数据访问用 ADO.NET 参数化直连扣减余票则收在存储过程里跑通「查票—锁座—下单—退票」这条主线。它适合两类人一类是要交课程设计、毕业设计需要有东西可演示、有逻辑可讲另一类是先拿一份结构完整的基线再往上加自己的业务。下面从建模开始一路写到验收怎么演示。2. 需求拆解与数据建模五张表支撑查询、下单、退票三条主线2.1 从售票窗口的一天反推功能清单先别急着拖控件。把售票员一天的动作写下来查某天从 A 到 B 有哪些车、看某趟车还剩几张、收钱出票、旅客来退票、后台补一条临时加开的车次。对应到系统里就是查询、下单、退票、车次维护四件事注册登录和订单列表只是把它们串起来的线。我一般会把功能清单压到两条线旅客线和管理线。旅客线五个窗体——登录注册、车次查询、填订单、我的订单、退票管理线四个——车次与经停站维护、每日库存开盘、订单查询、简单统计。窗体数量控制在这个量级是因为课程设计和毕业设计的评审看的是「业务闭环是否成立」不是窗体有多少个。九个窗体能讲清一条完整链路二十个窗体反而每一条都讲不透。功能清单定下来之后要立刻做一件事把每个功能落到「读哪张表、写哪张表」。这一步做完表结构基本就自己长出来了不会出现做到一半发现少存了某个字段、回头改表连带改十几个窗体的翻车场面。2.2 五张核心表的字段设计与关系整套库就五张表职责切得很干净表名作用关键字段说明Users账号与角色UserId、UserName、PwdHash、RoleRole 区分旅客(0)与管理员(1)Trains车次基础信息TrainId、TrainNo、TrainType、Status与日期无关只描述「这趟车」TrainStops经停站与时刻TrainId、StationName、StopOrder、DayOffset一站一行DayOffset 处理跨天SeatInventory按日按席别的库存TrainId、RunDate、SeatType、Price、TotalCount、SoldCount余票由后两列算出Orders订单OrderNo、UserId、InventoryId、Amount、Status库存与旅客信息的连接点CREATE TABLE SeatInventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), RunDate DATE NOT NULL, -- 开行日期同车次不同天各一行 SeatType VARCHAR(10) NOT NULL, -- 二等座 / 一等座 / 硬卧 Price DECIMAL(10,2) NOT NULL, -- 价格挂库存上方便按日调价 TotalCount INT NOT NULL, SoldCount INT NOT NULL DEFAULT 0, CONSTRAINT UQ_Inv UNIQUE (TrainId, RunDate, SeatType), CONSTRAINT CK_Inv_Sold CHECK (SoldCount 0 AND SoldCount TotalCount) ); CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, UserId INT NOT NULL REFERENCES Users(UserId), InventoryId INT NOT NULL REFERENCES SeatInventory(InventoryId), PassengerName NVARCHAR(30) NOT NULL, PassengerIdCard VARCHAR(18) NOT NULL, Amount DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已退票 3已取消 CreatedAt DATETIME NOT NULL DEFAULT GETDATE(), PaidAt DATETIME NULL, RefundAt DATETIME NULL );两个设计点值得单独说。第一个价格放在 SeatInventory 而不是 Trains 上同一条线路节假日涨价、淡季打折是常事价格挂在「某天某席别」这一行上改价不影响历史订单订单里再冗余一份 Amount 作为成交快照。第二个TotalCount 与 SoldCount 分开存而不是只存一个「余票」字段。余票是推导值SoldCount 是事实值退票时把 SoldCount 减一就能自然还原不会出现「退票后余票比总数还多」这种玄学问题。CK_Inv_Sold 这条 CHECK 约束不是摆设。它是数据库层的最后一道防线哪怕业务代码写错、并发算错只要是负数或超总量插入更新直接报错。做毕设的时候这条约束在答辩现场很好用——把 SSMS 打开手动执行一条把 SoldCount 改成 TotalCount1 的语句让评审看到数据库自己会拦。2.3 库存与订单状态机怎么配合余票永远由TotalCount - SoldCount算出来绝不单独维护一列「剩余数量」。这一步定死之后所有「还能不能买」「还能不能退」的判断都归到同一个地方订单状态。状态流转只有四条合法路径0→1 支付成功、0→3 主动取消、1→2 退票成功其余一律非法。落地方式不是写一堆if (order.Status 1)而是把状态条件直接写进 UPDATE 的 WHERE 里看ROWCOUNT是不是 1。这个习惯看着小作用很大两个人几乎同时点退票第一条 UPDATE 影响 1 行第二条影响 0 行程序直接告诉用户「订单状态已变更请刷新」。如果靠先查后改中间那段空隙就是事故现场。提示库存表按「整条线路的起点到终点」建行是最省事的做法课程设计够用。真要支持分段售票比如北京到上海中间有人从济南上车库存得按区段建复杂度上一个数量级答辩时主动说明这个简化比被问出来强。3. 分层架构与技术选型WinForms 加 ADO.NET 的三层怎么切3.1 这个项目不建议改用 ORM见过不少同学一上手就引 Entity Framework理由是「显得高级」。结果做到一半卡在迁移脚本、DbContext 生命周期、导航属性懒加载上业务逻辑一行没写。这个项目的核心难点是并发下的库存扣减和事务边界这两件事恰恰需要你亲手写 SQL、亲手看执行计划。ORM 把这些藏起来等于把最该练的部分外包了。技术选型上我推荐界面 WinForms拖控件快答辩演示稳数据访问 ADO.NET SqlCommand参数化查询关键业务用存储过程配置放 App.config。整套东西零第三方依赖换台机器装个 .NET Framework 和 SQL Server 就能跑部署环节不会翻车。反过来说如果你的项目要做 Web 端、要多人协作、要长期迭代那 ORM 确实省事。判断标准很简单这个项目里「SQL 写得好不好」是不是评分点。是就老老实实写 ADO.NET。3.2 解决方案目录与项目引用关系四个项目引用方向单向不回头TrainTicket.sln ├── TrainTicket.Model # 实体类无任何引用谁都能引它 ├── TrainTicket.DAL # 数据访问引用 Model ├── TrainTicket.BLL # 业务规则引用 Model DAL └── TrainTicket.UI # WinForms 窗体引用 Model BLL分层最容易犯的错是 UI 直接引 DAL图省事在窗体里 new 一个 SqlConnection。这样写的后果是业务规则散落在各个按钮的 Click 事件里退票逻辑在三个窗体各写一遍改一处漏两处。规矩定死UI 层不许出现using System.Data.SqlClient一个都不许。评审老师翻代码第一眼就看这个。BLL 层放什么放「下单前检查用户是否被冻结」「退票是否超过开车前 30 分钟」「同一乘客同一车次是否已有未退订单」这类规则。DAL 层只负责把 SQL 发出去、把 DataTable 或实体拿回来不做判断。Model 层是纯数据载体属性加构造不写方法。3.3 DbHelper 收口连接与参数化整个项目只有这一个类碰连接字符串其余地方一律调它public static class DbHelper { // 连接串放 App.config避免硬编码进窗体后被反编译看到 private static readonly string ConnStr ConfigurationManager.ConnectionStrings[TrainDb].ConnectionString; public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) using (var da new SqlDataAdapter(cmd)) { if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); var dt new DataTable(); da.Fill(dt); // SqlDataAdapter 自己开关连接不必手动 Open return dt; } } public static int Execute(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) { if (ps ! null ps.Length 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } }参数说明几个点。ConnStr是 static readonly进程内只读一次改配置要重启这在桌面端是合理的。params SqlParameter[] ps让调用方写起来像变长参数Query(... where TrainNono, new SqlParameter(no, G1234))一行搞定。两个方法都用using连接在方法结束必然归还连接池这是后面第 5 章要讲的「界面假死」的根因所在。App.config 里对应这样一段connectionStrings add nameTrainDb connectionStringServer.;DatabaseTrainTicket;Integrated SecurityTrue; providerNameSystem.Data.SqlClient / /connectionStrings用Integrated SecurityTrue走 Windows 身份验证本机开发最省事。如果答辩机器和开发机器不是同一台记得改成 SQL Server 账号密码并且把Server.换成实际的实例名——这一步是换机演示失败率最高的环节第 5 章会展开。4. 核心链路实现查询、锁定座位、下单、退票的代码实现4.1 余票查询一条 SQL 带出车次、席别与剩余张数查询窗体只有一个按钮背后一条 SQLSELECT t.TrainNo, s1.StationName → s2.StationName AS Route, s1.DepartTime, s2.ArriveTime, i.SeatType, i.Price, i.TotalCount - i.SoldCount AS Remain FROM TrainStops s1 JOIN TrainStops s2 ON s2.TrainId s1.TrainId AND s2.StopOrder s1.StopOrder JOIN Trains t ON t.TrainId s1.TrainId JOIN SeatInventory i ON i.TrainId t.TrainId AND i.RunDate RunDate WHERE s1.StationName From AND s2.StationName To AND t.Status 1 AND i.TotalCount - i.SoldCount 0 ORDER BY s1.DepartTime, i.Price;From、To、RunDate三个参数由界面控件传入。注意s2.StopOrder s1.StopOrder这个条件它保证查到的是「先经过出发站、后经过到达站」的车次否则会出现反向车次也被匹配出来的错误。s1.StationName From这个写法默认一条线路每个站只停一次对课程设计足够如果同一车次重复经停某站得改成按 StopOrder 区间匹配。索引要跟着查询走。车次查询是这套系统里调用最频繁的操作TrainStops上按站名建索引能让数据量上千行之后依然秒开CREATE INDEX IX_Stop_Station ON TrainStops(StationName, StopOrder) INCLUDE (TrainId, DepartTime, ArriveTime); CREATE INDEX IX_Inv_RunDate ON SeatInventory(RunDate, TrainId) INCLUDE (SeatType, Price, TotalCount, SoldCount);INCLUDE里的列是「覆盖列」查询用到它们时不必回表。这两条索引写进建库脚本答辩时如果被问「你怎么做优化的」能指着执行计划说清楚比背概念强得多。结果集用 DataTable 直接绑到 DataGridView列名在 SQL 里就用中文别名界面不用再改一遍列头。这里有个小坑Remain是算出来的DataGridView 里它不可编辑把ReadOnly true设上否则用户能直接改余票数字虽然改不进数据库但演示时看着很尴尬。4.2 下单扣减用存储过程把事务关在数据库里这是整套方案的核心。下单要同时做两件事库存加一、订单插入。中间任何一步失败都必须整体回滚。做法是写一个存储过程用带条件的 UPDATE 完成「检查余票 扣减」这一个原子动作CREATE PROCEDURE usp_CreateOrder UserId INT, InventoryId INT, PassengerName NVARCHAR(30), IdCard VARCHAR(18), OrderNo VARCHAR(32) OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 出错自动回滚省掉手写一堆判断 BEGIN TRY BEGIN TRANSACTION; -- 核心条件成立才扣条件不成立 ROWCOUNT 就是 0天然防超卖 UPDATE SeatInventory SET SoldCount SoldCount 1 WHERE InventoryId InventoryId AND SoldCount 1 TotalCount; IF ROWCOUNT 0 BEGIN ROLLBACK TRANSACTION; THROW 50001, N余票不足, 1; END SET OrderNo REPLACE(CONVERT(VARCHAR(36), NEWID()), -, ); INSERT INTO Orders (OrderNo, UserId, InventoryId, PassengerName, PassengerIdCard, Amount, Status, CreatedAt) SELECT OrderNo, UserId, InventoryId, PassengerName, IdCard, Price, 0, GETDATE() FROM SeatInventory WHERE InventoryId InventoryId; COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() 0 ROLLBACK TRANSACTION; THROW; -- 把原始错误抛给上层别吞掉 END CATCH END为什么这一句 UPDATE 能防超卖因为 UPDATE 在执行时会对目标行加排他锁第二个会话的相同 UPDATE 必须等第一个提交或回滚。等它拿到锁时SoldCount已经变了SoldCount 1 TotalCount这个条件重新判断一次不成立就影响 0 行。整个判断和修改在一条语句里完成中间没有时间窗口所以不需要在 C# 里先 SELECT 再判断。C# 侧调用保持极简public string CreateOrder(int userId, int inventoryId, string name, string idCard) { using (var conn new SqlConnection(DbHelper.ConnStr)) using (var cmd new SqlCommand(usp_CreateOrder, conn)) { cmd.CommandType CommandType.StoredProcedure; cmd.Parameters.Add(UserId, SqlDbType.Int).Value userId; cmd.Parameters.Add(InventoryId, SqlDbType.Int).Value inventoryId; cmd.Parameters.Add(PassengerName, SqlDbType.NVarChar, 30).Value name; cmd.Parameters.Add(IdCard, SqlDbType.VarChar, 18).Value idCard; var p cmd.Parameters.Add(OrderNo, SqlDbType.VarChar, 32); p.Direction ParameterDirection.Output; // 订单号由库生成避免应用层撞号 conn.Open(); cmd.ExecuteNonQuery(); return p.Value.ToString(); } }参数类型必须和存储过程声明一致NVarChar对应NVARCHARVarChar对应VARCHAR。类型不匹配时 SQL Server 会做隐式转换平时看不出问题一旦表数据量大索引直接失效查询从毫秒变成秒级。这个坑我在真实项目里踩过查了两天才定位到是一个IdCard参数写成了NVarChar。订单号用NEWID()去横线生成32 位字符串全局唯一不依赖自增主键。好处是即使将来分库也不用担心撞号坏处是不连续、不好看——如果要给用户展示「第几位下单」这种业务得另建一个流水号表别拿订单号硬凑。4.3 退票与改签先校验状态再回补库存退票的执行顺序和下单相反但骨架一样开事务、改状态、回补库存、提交。CREATE PROCEDURE usp_RefundOrder OrderNo VARCHAR(32), UserId INT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; BEGIN TRANSACTION; -- 只有「已支付」的订单能被退ROWCOUNT 就是最可靠的状态校验 UPDATE Orders SET Status 2, RefundAt GETDATE() WHERE OrderNo OrderNo AND UserId UserId AND Status 1; IF ROWCOUNT 0 BEGIN ROLLBACK TRANSACTION; THROW 50002, N订单不存在或当前状态不允许退票, 1; END UPDATE i SET i.SoldCount i.SoldCount - 1 FROM SeatInventory i JOIN Orders o ON o.InventoryId i.InventoryId WHERE o.OrderNo OrderNo; COMMIT TRANSACTION; ENDUserId一定要放进 WHERE。少了它任何人只要知道订单号就能退别人的票这是毕设里最常见的安全漏洞评审一问就露馅。改签比退票多一步本质是「退旧订新」但顺序不能随便。正确的做法是先按新库存走一次扣减成功之后再回补旧库存并改订单的 InventoryId。反过来的话先退旧库存、再订新库存新库存没票时用户就变成了「票退了但没订上」得给他一个「恢复原订单」的兜底。我倾向于把整个改签放进一个事务里两条 UPDATE 加一条订单更新失败一起回滚用户看到的要么是改签成功要么是原样不动。改签还要处理差价。新席别比旧的贵补差价便宜退差价。金额用DECIMAL(10,2)存差值算完再写回订单的 Amount保留两位小数。别在 C# 里用 double 算完再存浮点误差会让对账差几分钱这类问题排查起来非常难受。5. 避坑与排查订票系统最容易失败的五个地方5.1 并发下单后余票变成负数现象两个人几乎同时买最后一张票两个人都提示下单成功库存表里 SoldCount 比 TotalCount 大 1界面上余票显示 -1。原因下单逻辑写成了「先 SELECT 查余票判断大于 0再 UPDATE 扣减」。查和改是两条独立语句两个会话都能查到余票为 1都通过判断然后各自扣一次。解决把判断塞进 UPDATE 的 WHERE 里用ROWCOUNT判断结果也就是第 4.2 节那个存储过程。同时在库存表上保留CHECK (SoldCount TotalCount)约束当兜底在 Orders 表上给「同一库存 同一身份证」建一个筛选唯一索引从数据层拦住重复购票CREATE UNIQUE INDEX UX_Order_OnePerPassenger ON Orders(InventoryId, PassengerIdCard) WHERE Status IN (0, 1); -- 已退票、已取消的不参与唯一性5.2 连接池被耗光界面卡死现象连续点几十次查询之后界面完全没反应过一会儿抛Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool。原因某个方法里new SqlConnection(...)之后忘了Close()或Dispose()或者查询过程中抛了异常Close()那行被跳过。连接不归还连接池默认上限 100 个用完就排队排队超时就报这个错。解决所有连接一律写在using里包括SqlCommand和SqlDataAdapter。using编译后是 try/finally异常也会执行 Dispose。另外把SqlDataAdapter.Fill用在查询场景它自己管开关连接比手写Open/Close少一半出错机会。排查阶段可以在 SSMS 里跑SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE is_user_process 1数字只涨不跌就是泄漏了。5.3 跨天车次的时间算错一天现象一趟晚上 23:40 发车、第二天 07:20 到达的车次界面上显示的到达时间比发车时间还早或者查询当天的车次查不到。原因时刻只存了TIME没存日期差。23:40 到 07:20 直接相减是负数拿发车日的日期加上到达时刻算出来的是当天 07:20比发车还早。解决TrainStops 上加DayOffset字段表示「相对发车日跨了几天」。到达时间展示时用RunDate.AddDays(DayOffset)组合成完整时间查询条件也按组合后的时间比较。火车订票这类业务里跨天不是边缘情况是常态卧铺车几乎趟趟跨天这一条不能省。5.4 金额用 float对账差几分钱现象票价 553.50退两张应该退 1107.00实际显示 1106.99。原因C# 的 float/double 是二进制浮点0.1 这类十进制小数无法精确表示多次加减之后误差累积。数据库列如果是FLOAT更糟连存进去的值都是近似的。解决从数据库列到 C# 属性到中间变量全链路用DECIMAL(10,2)和decimal。C# 里写decimal price 553.50m;别漏那个m后缀漏了就是 double。除法场景比如按比例退手续费用Math.Round(x, 2, MidpointRounding.AwayFromZero)明确指定舍入方式别用默认的银行家舍入它在.5的边界上行为和直觉不一致。5.5 换一台机器就连接不上数据库现象自己电脑上跑得好好的拷到答辩机器上程序一启动就报「在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误」。原因连接字符串写死了Serverlocalhost或某个具体实例名目标机器上 SQL Server 实例名不同或者用了Integrated SecurityTrue但当前 Windows 账号没有该库的访问权限或者 SQL Server 的 TCP/IP 协议没启用。解决连接串放 App.config换机器只改这一处。部署前按顺序做三项检查——在 SSMS 里用同一套凭据能连上、SELECT VERSION能出版本号、SELECT COUNT(*) FROM Trains能读数据。第三步最容易漏能连上库但没权限读表的情况很常见。如果答辩现场网络受限提前把数据库备份文件.bak拷过去现场还原比连远程库稳妥。6. 验收与答辩六个能演示的场景和一份检查表自己写完不算完得能证明它是对的。我通常准备一张验收表每个场景对应一个具体操作和一个可观察的结果答辩时按顺序走一遍中间任何一步不对都是问题。场景操作预期结果观察点正常下单查票后订一张二等座订单列表出现待支付订单余票减 1SeatInventory.SoldCount最后一张把库存改成只剩 1 张两个客户端同时下单只有一个成功另一个提示余票不足余票不为负重复购买同一身份证再订同一车次被唯一索引拦下提示已购票订单表不新增行退票支付后点退票状态变已退票余票加 1RefundAt 有值非法退票对已退票的订单再点退票提示状态不允许库存不变动跨天车次查一趟 23:40 发车的卧铺到达时间显示次日 07:20DayOffset 生效演示顺序有讲究先展示表结构和数据再走正常流程最后才演示并发和异常。反过来先演示异常评审会以为系统本来就跑不顺。并发那个场景最好用两个客户端窗口并排放让人一眼看到「同时点只有一个成」这比讲十句原理都有效。被问「你这个系统最大的难点是什么」别答「界面做得很漂亮」。答库存扣减的事务边界为什么把判断写进 UPDATE、为什么用存储过程而不是在 C# 里开事务、ROWCOUNT为什么比先查后改可靠。能把这条讲透说明你真读懂了代码而不是拼出来的。被问「还能怎么改进」说分段售票和订单超时自动取消。这两个是这套简化模型天然的边界主动说出来比被追问出来强。血泪经验一条答辩前一定在演示机上完整走一遍流程包括还原数据库、改连接串、跑并发测试。我见过太多在自己机器上完美、在评审机器上因为一个连接串卡住的场面。从那以后我每次交付前都强制走一遍「换机演练」把连接串、备份文件、建库脚本三样东西单独放一个文件夹现场照着来。这套方案的建库脚本、存储过程和窗体源码我整理在一起了拿去按自己的车次数据改一遍就能跑希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询