☰
C#火车订票系统源码拆解:余票扣减与事务控制实战
2026/11/6 2:10:36 网站建设 项目流程

简介:这份资源是面向C#初学者与.NET课程实践者的火车订票系统完整源码工程,以窗体应用形式呈现,可用于练习GUI交互、事件驱动编程与数据库操作,帮助读者把课堂语法落到一个可运行的小型业务系统上。压缩包共128个文件、约1.85MB,以50个cs源文件与24个resx、24个resources资源文件为主体,配合sln解决方案、csproj工程文件及少量jpg界面素材,构成可直接在Visual Studio中打开的完整项目结构。已有1437人学习下载,说明其在同类练手项目中具备一定参考价值。源码覆盖用户注册登录、车次增删改查、订票退票改签等模块,并涉及SQL Server数据存取、事务与并发控制、异常捕获及SQL注入防范等要点,读者可据此梳理窗体间跳转关系、业务状态流转与数据库表设计思路,作为课程设计或毕业设计的起步骨架。

1. 从一张“抢票失败”的截图说起:这个 C# 火车订票系统到底能干什么

去年帮一个做职业培训的朋友整理教学素材,他给我看学员交上来的作业截图:一个 WinForms 窗口,左边车次列表,右边座位图,点“预订”弹出一个对话框填身份证号,然后数据库里多一条订单记录。界面丑得很有年代感,但逻辑是通的。这份 C# 火车订票系统源码就是这类东西的完整版——它不是一个玩具 Demo,而是一套把“车次查询、余票扣减、订单生成、退票回滚”串起来的桌面端业务系统,技术栈是 C# + WinForms + SQL Server,典型的课程设计/毕设级工程,但业务闭环是完整的。

适合谁看?如果你手上正好有一份类似的 C# 火车订票系统源码要跑起来交差,或者想拿它当模板改成一个酒店预订、影院选座的系统,这篇拆解就是给你写的。我会按“先跑通、再看懂、最后能改”的顺序讲,重点放在余票扣减这种真正容易翻车的地方,而不是把每个窗体的控件名念一遍。

2. 环境准备与首次运行:把 SQL Server 和 WinForms 项目拉起来

2.1 技术栈与运行环境清单

这套系统的技术选型非常“教科书”,但对新手友好,因为依赖少、坑也相对集中。先把环境对齐,后面 90% 的报错都能避免。

组件版本建议说明
操作系统Windows 10/11WinForms 只能在 Windows 上跑,别想着跨平台
.NET Framework4.7.2 或 4.8老项目常见目标框架,用 Visual Studio 打开会自动提示
开发工具Visual Studio 2019/2022社区版够用,装“.NET 桌面开发”工作负载
数据库SQL Server 2012 及以上Express 版即可,实例名默认SQLEXPRESS
数据库驱动System.Data.SqlClient.NET Framework 内置,不需要额外装 NuGet 包

这里有个血泪经验:很多人拿到源码直接双击.sln,结果提示“找不到目标框架”。原因通常是本机只装了 .NET 6/8 的 SDK,没装 .NET Framework 开发组件。解决办法是在 Visual Studio Installer 里勾选“.NET Framework 4.x 开发工具”,而不是去改项目文件里的 TargetFramework——改了会连带一堆引用失效。

2.2 数据库还原与连接字符串配置

项目一般会带一个.sql脚本或者.bak备份文件。我一般优先用脚本,因为备份文件的版本兼容性更差。在 SSMS 里新建一个数据库(比如叫TrainTicketDB),然后执行建表和初始数据脚本。

-- 建库(如果脚本里没有) CREATE DATABASE TrainTicketDB; GO USE TrainTicketDB; GO -- 核心表结构示意:车次表 CREATE TABLE TrainInfo ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo VARCHAR(10) NOT NULL, -- 车次号,如 G1234 StartStation VARCHAR(50) NOT NULL, EndStation VARCHAR(50) NOT NULL, DepartTime DATETIME NOT NULL, ArriveTime DATETIME NOT NULL, TotalSeats INT NOT NULL, -- 总座位数 RemainSeats INT NOT NULL -- 余票数,扣减的关键字段 ); -- 订单表 CREATE TABLE TicketOrder ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, -- 订单号,业务唯一 TrainId INT NOT NULL, PassengerName VARCHAR(50) NOT NULL, IdCard VARCHAR(18) NOT NULL, SeatNo VARCHAR(10) NULL, OrderTime DATETIME DEFAULT GETDATE(), Status TINYINT DEFAULT 0 -- 0 已订 1 已退 );

建完表,回到 Visual Studio,找到App.config里的连接字符串。这是新手最容易卡住的一步,因为不同机器的 SQL Server 实例名不一样。

<!-- App.config --> <configuration> <connectionStrings> <!-- Data Source 改成你自己的实例名;LocalDB 用户可写 (localdb)\MSSQLLocalDB --> <add name="TrainConn" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=TrainTicketDB;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

参数说明:Data Source是实例地址,.\SQLEXPRESS表示本机默认 Express 实例;Initial Catalog是数据库名;Integrated Security=True表示用 Windows 身份验证,省去账号密码。如果你的 SQL Server 开了混合验证,也可以换成User Id=sa;Password=你的密码。改完记得重新生成解决方案,因为连接字符串是编译进配置文件的。

2.3 首次运行的正确姿势与验证点

按 F5 启动后,先别急着点“预订”。我习惯按这个顺序验证:先看车次列表能不能加载出来(验证数据库连通和查询语句),再查一次余票(验证字段映射),最后走一遍下单(验证写入和扣减)。如果第一步列表就是空的,八成是初始数据没插进去,或者查询条件里写死了某个日期而你的数据日期对不上。

// 车次查询的典型写法,注意用参数化避免拼接 public DataTable GetTrains(string start, string end) { string sql = @"SELECT TrainId, TrainNo, StartStation, EndStation, DepartTime, ArriveTime, RemainSeats FROM TrainInfo WHERE StartStation = @start AND EndStation = @end ORDER BY DepartTime"; using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand(sql, conn)) using (var adapter = new SqlDataAdapter(cmd)) { cmd.Parameters.AddWithValue("@start", start); cmd.Parameters.AddWithValue("@end", end); var dt = new DataTable(); adapter.Fill(dt); return dt; } }

这段代码没什么花活,但有两个细节值得说:using包住连接和命令对象,保证异常时也能释放;AddWithValue虽然方便,但在生产里对varchar字段可能引起隐式转换,教学项目无所谓,二次开发时建议改成Add("@start", SqlDbType.VarChar).Value = start。跑通这一步,说明环境链路是通的,可以进入下一章看核心逻辑了。

3. 核心模块拆解:余票扣减、订单生成与事务控制

3.1 余票扣减为什么必须放在事务里

这是整个系统最值得看的部分,也是我见过最多人写错的地方。很多学员的写法是:先SELECT RemainSeats查出来,判断大于 0,然后UPDATE TrainInfo SET RemainSeats = RemainSeats - 1。单机演示没问题,但只要两个人同时点,就会出现超卖——两个请求都读到余票 1,都判断通过,最后余票变成 -1。这就是典型的并发问题,靠“加个 if 判断”是解决不了的。

正确做法是把“判断 + 扣减”合并成一条原子 SQL,让数据库自己保证一致性:

// 原子扣减:只有余票大于 0 才会更新成功 public bool TryDeductSeat(int trainId, SqlConnection conn, SqlTransaction tran) { string sql = @"UPDATE TrainInfo SET RemainSeats = RemainSeats - 1 WHERE TrainId = @trainId AND RemainSeats > 0"; using (var cmd = new SqlCommand(sql, conn, tran)) { cmd.Parameters.Add("@trainId", SqlDbType.Int).Value = trainId; int affected = cmd.ExecuteNonQuery(); return affected == 1; // 返回 0 说明余票不足,扣减失败 } }

逻辑说明:WHERE RemainSeats > 0是这道锁的关键,数据库在执行 UPDATE 时会对该行加锁,两个并发请求会串行化,第二个请求看到的是扣减后的值,affected为 0 就说明没抢到。参数trainId定位车次,conn和tran由外层传入,保证扣减和订单写入在同一个事务里。这种写法比SELECT ... FOR UPDATE更简洁,SQL Server 下也足够可靠。

3.2 订单生成与事务提交的完整链路

扣减只是半条命,订单写入失败必须回滚,否则会出现“票扣了但没订单”的黑匣子状态。下面是一个完整的下单方法,把扣减、插入订单、提交串起来:

public string CreateOrder(int trainId, string name, string idCard) { using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 原子扣减余票 if (!TryDeductSeat(trainId, conn, tran)) throw new InvalidOperationException("余票不足"); // 2. 生成订单号并写入订单 string orderNo = "T" + DateTime.Now.ToString("yyyyMMddHHmmss") + new Random().Next(1000, 9999); string sql = @"INSERT INTO TicketOrder (OrderNo, TrainId, PassengerName, IdCard, Status) VALUES (@no, @tid, @name, @id, 0)"; using (var cmd = new SqlCommand(sql, conn, tran)) { cmd.Parameters.AddWithValue("@no", orderNo); cmd.Parameters.AddWithValue("@tid", trainId); cmd.Parameters.AddWithValue("@name", name); cmd.Parameters.AddWithValue("@id", idCard); cmd.ExecuteNonQuery(); } tran.Commit(); // 两步都成功才提交 return orderNo; } catch { tran.Rollback(); // 任一步失败整体回滚,余票恢复 throw; } } } }

参数说明:trainId是车次主键,name和idCard是乘客信息。订单号用“时间戳 + 随机数”拼接,教学场景够用,但严格来说有极小概率重复,二次开发时建议改成数据库序列或 GUID。tran.Rollback()放在 catch 里是后悔药——只要扣减成功但插入失败,回滚会把余票加回去,不会留下脏数据。这段代码是整个系统的心脏,看懂它,退票逻辑就是它的逆操作:把RemainSeats + 1、订单Status置 1,同样包在事务里。

3.3 数据访问层的组织方式

项目里通常有一个DBHelper或SqlHelper类,把连接、执行、查询封装起来,窗体代码只调方法。这种分层虽然朴素,但比把 SQL 全写在按钮点击事件里强太多。我一般会保留它的ExecuteNonQuery、ExecuteDataTable、ExecuteScalar三个方法,够覆盖 95% 的场景。窗体层只负责收集控件值、做非空校验,业务判断(余票、重复下单)全部下沉到数据层或服务层。这样你后面想换成 Web API 或者加一个移动端,业务逻辑不用重写。

4. 窗体结构与交互流程:从查询到出票的界面链路

4.1 主要窗体与职责划分

这套系统的窗体数量不多,但职责要分清,否则改起来会互相牵连。常见结构是:登录窗体、主窗体(含车次查询和列表)、订票窗体、订单管理窗体。主窗体负责查询条件收集和结果展示,订票窗体只负责乘客信息录入和调用下单方法,订单窗体做退票和状态展示。

// 主窗体查询按钮的典型事件处理 private void btnSearch_Click(object sender, EventArgs e) { string start = cmbStart.Text.Trim(); string end = cmbEnd.Text.Trim(); if (string.IsNullOrEmpty(start) || string.IsNullOrEmpty(end)) { MessageBox.Show("请选择出发站和到达站"); return; } // 调用数据层,绑定到 DataGridView dgvTrains.DataSource = trainService.GetTrains(start, end); }

这段代码的价值在于“校验前置”:空值判断放在查询之前,避免把空参数传给数据库。cmbStart和cmbEnd是下拉框,数据一般从TrainInfo里SELECT DISTINCT出来,这样站点不会写死。dgvTrains绑定DataTable后,列名和字段名自动对应,省去手动赋值。

4.2 订票窗体的参数传递与回填

订票窗体打开时,需要把选中的车次信息传过去。常见做法是通过构造函数传trainId和车次号,而不是用全局变量——全局变量在多次打开窗体时容易串数据,这是踩过的坑。

// 主窗体打开订票窗体 private void btnBook_Click(object sender, EventArgs e) { if (dgvTrains.CurrentRow == null) return; int trainId = Convert.ToInt32(dgvTrains.CurrentRow.Cells["TrainId"].Value); string trainNo = dgvTrains.CurrentRow.Cells["TrainNo"].Value.ToString(); using (var frm = new BookForm(trainId, trainNo)) { if (frm.ShowDialog() == DialogResult.OK) { btnSearch.PerformClick(); // 订票成功后刷新余票 } } }

ShowDialog()是模态打开,用户必须处理完订票窗才能回主界面,避免同时操作多个车次导致状态混乱。DialogResult.OK作为成功信号,主窗体收到后重新查询,余票数字立刻更新,用户能直观看到扣减效果。这个“订完刷新”的小细节,很多作业里漏掉,导致界面显示的还是旧余票,演示时很尴尬。

4.3 退票流程与状态机

退票不是删记录,而是改状态加回余票。订单表里的Status字段就是简易状态机:0 已订、1 已退。退票时先校验当前状态,防止重复退票。

public bool RefundOrder(string orderNo) { using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 查出订单对应的车次,且必须是未退状态 string q = "SELECT TrainId FROM TicketOrder WHERE OrderNo=@no AND Status=0"; int trainId; using (var cmd = new SqlCommand(q, conn, tran)) { cmd.Parameters.AddWithValue("@no", orderNo); var obj = cmd.ExecuteScalar(); if (obj == null) return false; // 订单不存在或已退 trainId = Convert.ToInt32(obj); } // 余票加回 string u1 = "UPDATE TrainInfo SET RemainSeats=RemainSeats+1 WHERE TrainId=@tid"; // 订单状态置为已退 string u2 = "UPDATE TicketOrder SET Status=1 WHERE OrderNo=@no"; // 两条 UPDATE 在同一事务执行,省略命令对象创建 tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }

ExecuteScalar返回第一行第一列,这里用来快速判断订单是否存在且可退。两条 UPDATE 必须同事务,否则可能出现“票加回去了但订单还是已订”的不一致。状态机虽然只有两个状态,但它是后面加“改签”功能的基础,二次开发时值得保留这个设计。

5. 避坑与排查:跑不起来时先看这几条

5.1 连接数据库报“网络相关或特定于实例的错误”

现象:启动就弹异常,提示找不到 SQL Server 实例或无法连接。原因通常是连接字符串里的实例名和本机不一致,或者 SQL Server 服务没启动。解决:打开“服务”确认SQL Server (SQLEXPRESS)正在运行;在 SSMS 里看实际实例名;把Data Source改成localhost或(localdb)\MSSQLLocalDB逐个试。别急着改代码,先确认数据库本身能连上。

5.2 余票出现负数或超卖

现象:并发点单或者快速连点,余票变成 -1。原因就是前面说的“先查后改”写法,判断和扣减不在一个原子操作里。解决:换成UPDATE ... WHERE RemainSeats > 0并检查受影响行数,同时把按钮在请求期间禁用,防止用户连点。界面层的防抖只是辅助,数据库层的原子性才是根本。

5.3 订单写入了但余票没扣

现象:订单表有记录,车次余票没变。原因多半是扣减和插入用了不同的连接或没包事务,或者扣减的WHERE条件没命中(比如trainId传错)。解决:确认两步共用同一个SqlConnection和SqlTransaction;在扣减后打印affected行数;检查trainId是否从DataGridView正确取值,而不是取了行索引。

5.4 中文显示成乱码或问号

现象:车次站名、乘客姓名存进去变成???。原因:数据库字段排序规则不是中文,或者连接字符串没指定字符集。解决:建库时选Chinese_PRC_CI_AS,字段用NVARCHAR而不是VARCHAR,参数化时用SqlDbType.NVarChar。这个坑在换机器演示时特别容易翻车,因为不同机器的默认排序规则可能不同。

5.5 发布到别的电脑后连不上数据库

现象:本机跑得好好的,拷到同学电脑上就报连接失败。原因:对方没装 SQL Server,或者没还原数据库,或者连接字符串还指向你的实例名。解决:把建库脚本和初始数据一起打包,写一个简单的部署说明;连接字符串改成可配置,首次运行时让用户填实例名。教学项目最容易忽略部署环节,但这恰恰是“能不能跑起来”的最后一公里。

6. 二次开发改造:把订票系统改成你自己的业务模板

拿到这套源码,多数人不是原样交差,而是想改成自己的题目。我的建议是先动数据层,再动界面,最后加功能,顺序反了会越改越乱。

第一个改造点是抽象出“资源”概念。火车订票的本质是“有限资源 + 时间 + 用户占用”,把TrainInfo换成RoomInfo(酒店)、SessionInfo(影院场次),把RemainSeats换成RemainRooms或RemainSeats,扣减逻辑一行不用改。我一般会建一个IBookable接口,定义TryDeduct和Rollback两个方法,让不同业务表去实现,这样以后加新业务不用复制粘贴事务代码。

第二个改造点是订单号生成。原来的“时间戳 + 随机数”在单机演示够用,但如果你要模拟多终端,建议改成“前缀 + 日期 + 数据库序列”,或者直接用Guid.NewGuid().ToString("N")。改的时候注意订单号字段长度,VARCHAR(32)放 GUID 刚好,别到时候截断报错。

第三个改造点是加一个简单的日志表。把每次下单、退票的成功失败都记一条,字段包括时间、操作类型、订单号、结果。排查问题时这张表比断点调试还快,尤其是演示现场出问题,翻日志能立刻定位是余票不足还是数据库超时。

改造方向改动位置工作量风险点
换业务领域表结构 + 实体类中字段语义要对齐,别漏改查询条件
加改签功能订单表 + 事务方法中高涉及“退旧订新”,必须一个事务
换数据库DBHelper + SQL 方言高自增主键、日期函数差异大
加用户登录新增用户表 + 会话低密码别明文存,至少加个哈希

验证改造是否成功,我习惯跑三条用例:正常下单余票减一、余票为零时下单失败且不产生订单、下单后立即退票余票恢复且状态正确。这三条过了,基本业务就稳了。最后说个习惯:从那以后我每次改这类带事务的代码,都会先把“扣减失败”和“插入失败”两个分支各手动触发一次,确认回滚真的生效,再往下写新功能。希望帮到你。

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

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

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

立即咨询