news 2026/10/29 23:42:23

C#火车订票系统:SQL Server事务行锁防超卖与区间库存设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#火车订票系统:SQL Server事务行锁防超卖与区间库存设计

简介:面向C#与SQL Server初学者及课程设计需求的一套火车订票系统窗体项目源码,围绕用户注册登录、车次维护、订票退票改签等典型业务,提供从WinForm界面交互到数据库读写的完整实践场景,适合用来理解事件驱动编程、事务处理与多用户并发控制。压缩包共128个文件、约1.85MB,以50个cs源代码、24组resx/resources窗体资源及少量jpg界面截图、exe可执行程序与sln解决方案为主,另含缓存和调试文件,基本保留可编译运行的工程结构。已有1437人学习下载。读者可据此梳理窗体结构、功能模块划分与数据库访问层实现,参考异常捕获、输入校验和防SQL注入等处理思路,并对照车次管理、订单状态变更与并发控制逻辑完成课程设计、项目复现或二次开发;也可观察WinForm控件布局、资源文件组织与解决方案配置方式,积累调试和排错经验。

1. 从一个提交就崩的订票窗口说起:这套 C# 火车订票系统能解决什么

前阵子帮人看一个课设,界面做得挺像回事,车次列表一刷就出来,可几个人同时点“提交订单”,余票直接变成 -2,订单还多出来一条。问题不在 C# 语法,而在下单那几十行没有事务、没有行锁。这套 C# 火车订票系统就是冲着这种真实毛病来的:C# WinForms 桌面端配 SQL Server,覆盖车次查询、区间余票、下单、退票改签、订单管理和后台车次维护,数据访问走 ADO.NET 三层结构。它适合两类人,一类是要交一个能当场演示并发不超卖的课程设计,另一类是想把 ADO.NET 事务、数据绑定、SQL 索引这些散点知识串成一条完整链路的初中级开发者。往下读,你能拿到表结构、下单事务写法和几个必踩的坑。

2. 数据库设计:七张表撑起车次、区间库存与订单

订票系统写不好,八成是表先设计歪了。很多人上来就一张Ticket表,车次、座位、乘客、金额全塞一行,查询时靠字符串拼车站名,等到要算“这趟车 A 到 C 还剩几张”就彻底卡住。合理的拆法是按“静态字典 → 运行图 → 库存 → 交易”四层分:车站和车次是静态的,经停站是运行图,座位库存按日期和区间滚动,订单和票是交易结果。下面这套结构是我在几个类似项目里反复用过的版本,字段不算多,但每条约束都有对应的业务含义。

2.1 表结构与关系:从车站字典到票明细

先把实体关系说清楚,再贴脚本。Stations存车站字典,站名唯一,避免“北京西”和“北京西站”两条记录。Trains存车次本身,只保留车次号、类型、始发终到站,不存具体日期。TrainStops是运行图,一列车按StopOrder顺序挂多个站,每个站记到达时刻、发车时刻和累计里程,区间票价和运行时长都从这里算。SeatInventory是库存表,按车次 + 乘车日期 + 席别 + 起止站序记账,这是防止超卖的核心。Orders和Tickets是一对多,一个订单可以带多个乘客,每张票有独立座位号和状态,退票时改的是票的状态而不是删行。

-- 车站字典:站名唯一,防止同站多条记录导致联表出笛卡尔积 CREATE TABLE Stations ( StationId INT IDENTITY(1,1) PRIMARY KEY, StationName NVARCHAR(50) NOT NULL, CityCode NVARCHAR(20) NULL, CONSTRAINT UQ_Station_Name UNIQUE (StationName) ); -- 车次主表:只描述车次,不描述某一天 CREATE TABLE Trains ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo NVARCHAR(20) NOT NULL, -- 如 G1234 TrainType NVARCHAR(20) NOT NULL, -- 高铁/动车/普快 StartStationId INT NOT NULL REFERENCES Stations(StationId), EndStationId INT NOT NULL REFERENCES Stations(StationId), IsActive BIT NOT NULL DEFAULT 1 ); -- 运行图:StopOrder 从 1 开始递增,票价和时长都靠它算 CREATE TABLE TrainStops ( StopId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL REFERENCES Trains(TrainId), StationId INT NOT NULL REFERENCES Stations(StationId), StopOrder INT NOT NULL, ArriveTime TIME(0) NULL, -- 始发站为空 DepartTime TIME(0) NULL, -- 终到站为空 DurationFromStart INT NOT NULL, -- 距始发站的分钟数,跨天不算负数 PriceFromStart DECIMAL(10,2) NOT NULL, -- 距始发站累计票价 CONSTRAINT UQ_Train_StopOrder UNIQUE (TrainId, StopOrder) );

这里有个设计取舍值得说:到发时刻用TIME(0)加一个DurationFromStart整型分钟数,而不是直接存DATETIME。原因很简单,同一趟车每天跑,日期是变量,时刻才是常量;跨天车次(23:50 发、次日 06:00 到)用DATETIME相减会得到负数,用累计分钟数就永远单调递增,算时长、排时刻表都不会翻车。票价同理,存“距始发站累计票价”,任意两站之间的票价就是两个累计值相减,改票价只需要改一处。

2.2 区间库存怎么记:一个座位卖给两段行程的关键

库存表是全篇最需要想清楚的地方。假设一列车 8 节车厢,A→B→C→D,一个座位在 A→B 段卖掉了,B→C 段理论上还能再卖一次,这就是区间复用。课程设计里最常见的简化是整段库存:SeatInventory(TrainId, TravelDate, SeatType, TotalCount, SoldCount),只记“这趟车这天这个席别还剩几张”,不管乘客从哪上从哪下。这种做法实现快,但一趟车只能卖一批人,浪费运力,跟真实订票系统差得远。

要做区间复用,就得按“起止站序”组合记账,一条库存记录对应一个区间段:

CREATE TABLE SeatInventory ( InventoryId BIGINT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL, TravelDate DATE NOT NULL, SeatType NVARCHAR(20) NOT NULL, -- 二等座/一等座/硬卧 FromStopOrder INT NOT NULL, ToStopOrder INT NOT NULL, TotalCount INT NOT NULL, SoldCount INT NOT NULL DEFAULT 0, RowVersion ROWVERSION, -- 乐观锁备用 CONSTRAINT CK_Seat_Range CHECK (FromStopOrder < ToStopOrder), CONSTRAINT CK_Seat_Count CHECK (SoldCount >= 0 AND SoldCount <= TotalCount) );

判断某段行程还能不能卖,核心是“新行程区间与已售区间是否重叠”。重叠判断写成一个条件就是NOT (ToStopOrder <= @From OR FromStopOrder >= @To),两端相接不算重叠,A→B 和 B→C 可以共存。查询时把所有与目标区间重叠的库存行加起来算已售,再拿总座位数减,得到的就是这段行程的可售余票。这个思路不是唯一解,也有项目用座位级位图或者按站打标记,但对中小规模数据来说,区间记账加唯一索引已经够用,查询也直观。

-- 同一车次、同一天、同席别、同区间只能有一条库存记录 CREATE UNIQUE INDEX IX_Seat_Unique ON SeatInventory(TrainId, TravelDate, SeatType, FromStopOrder, ToStopOrder); -- 查询某段行程的余票:重叠区间的已售数量之和 SELECT @Total = SUM(TotalCount), @Sold = SUM(SoldCount) FROM SeatInventory WHERE TrainId = @TrainId AND TravelDate = @TravelDate AND SeatType = @SeatType AND NOT (ToStopOrder <= @FromOrder OR FromStopOrder >= @ToOrder);

提示:SoldCount <= TotalCount这条 CHECK 约束是最后一道防线,业务层就算漏了判断,数据库也会拒绝写入,比事后修数据省事得多。

2.3 订单、票与索引:交易表怎么建

订单和票分开,是为了支持一个订单多个乘客。订单存聚合信息(总金额、状态、下单时间),票存明细(乘客、座位号、票状态)。订单状态用整型枚举而不是字符串,0 待支付 / 1 已支付 / 2 已退票 / 3 已取消 / 4 已改签,状态流转只能按固定方向走,不允许从已退票回到已支付。

CREATE TABLE Orders ( OrderId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, -- 业务单号,如 20240517123045001 UserId INT NOT NULL, TrainId INT NOT NULL, TravelDate DATE NOT NULL, FromOrder INT NOT NULL, ToOrder INT NOT NULL, SeatType NVARCHAR(20) NOT NULL, TotalAmount DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, CreateTime DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_Order_No UNIQUE (OrderNo) ); CREATE TABLE Tickets ( TicketId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderId BIGINT NOT NULL REFERENCES Orders(OrderId), PassengerName NVARCHAR(50) NOT NULL, IdCardNo VARCHAR(18) NOT NULL, SeatNo NVARCHAR(10) NULL, -- 如 08车12F Status TINYINT NOT NULL DEFAULT 0 -- 0 有效 1 已退 2 已改签 ); -- 用户查自己订单,按时间倒序,这个索引必须建 CREATE INDEX IX_Order_User_Time ON Orders(UserId, CreateTime DESC); -- 按车次和日期统计上座率 CREATE INDEX IX_Order_Train_Date ON Orders(TrainId, TravelDate);

索引不是越多越好。Orders上如果给每个外键都建单列索引,插入订单时维护成本会上去,实际高频查询就两个方向:按用户查、按车次日期统计,建这两个复合索引足够。身份证号这种敏感字段,真实项目里要脱敏存储或者加密,课设阶段至少别在日志里打印完整号码,这个习惯早点养成没坏处。

3. 核心链路拆解:从余票查询到下单扣库存的一次事务

表建好只是开始,真正决定系统能不能扛住并发的是下单那条链路。这一章按调用顺序拆:先有数据访问底座,再有余票查询,最后是带行锁的下单事务。三块代码放到一起看,你会发现防超卖的关键不在逻辑多复杂,而在UPDLOCK和事务边界有没有放对位置。

3.1 数据访问底座:DbHelper 与参数化查询

三层架构里,数据访问层最忌讳两件事:连接字符串硬编码、SQL 字符串拼接。前者换台机器就要改代码重编译,后者一个输入框就能把表删了。我一般会写一个极薄的DbHelper,只做三件事:开连接、执行查询返回DataTable、执行增删改返回影响行数,所有参数走SqlParameter,连接一律using包裹,保证异常时也能归还连接池。

public static class DbHelper { // 从 App.config 的 connectionStrings 节点读取,换环境只改配置 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["TrainDb"].ConnectionString; public static SqlConnection OpenConnection() { var conn = new SqlConnection(ConnStr); conn.Open(); return conn; } // 查询:返回 DataTable,适合直接绑 DataGridView public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn = OpenConnection()) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); var dt = new DataTable(); using (var adapter = new SqlDataAdapter(cmd)) adapter.Fill(dt); return dt; } } // 增删改:返回受影响行数,调用方据此判断成功与否 public static int Execute(string sql, params SqlParameter[] ps) { using (var conn = OpenConnection()) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); return cmd.ExecuteNonQuery(); } } }

参数说明:ConnStr来自配置文件,格式是Data Source=.;Initial Catalog=TrainDb;Integrated Security=True,用 Windows 身份验证就不用把账号密码写进配置。Query返回DataTable是为了图省事,界面层直接绑DataGridView不用转换;如果项目要往大了做,这里换成返回List<T>配合 Dapper 更好,但课设阶段没必要引入额外依赖。Execute不负责事务,事务由业务层自己开连接控制,这样DbHelper保持无状态,复用起来不会互相干扰。

3.2 余票查询:联表 + 区间重叠判断

查询页面上用户看到的是“车次号、出发站、到达站、发车时间、历时、余票、票价”,这些字段分散在Trains、TrainStops、SeatInventory三张表里,一条 SQL 联出来最省事。下面这条按起止站序查车次,用自连接把同一趟车的两个站各取一行,再子查询算余票:

DECLARE @From INT, @To INT; -- 由界面传入的站序,来自用户选择的车站 SELECT t.TrainNo, s1.StationName AS FromStation, s2.StationName AS ToStation, s1.DepartTime, s2.DurationFromStart - s1.DurationFromStart AS DurationMinutes, s2.PriceFromStart - s1.PriceFromStart AS Price, (SELECT SUM(TotalCount) - SUM(SoldCount) FROM SeatInventory si WHERE si.TrainId = t.TrainId AND si.TravelDate = @TravelDate AND si.SeatType = @SeatType AND NOT (si.ToStopOrder <= s1.StopOrder OR si.FromStopOrder >= s2.StopOrder) ) AS RemainCount FROM Trains t JOIN TrainStops s1 ON s1.TrainId = t.TrainId AND s1.StopOrder = @From JOIN TrainStops s2 ON s2.TrainId = t.TrainId AND s2.StopOrder = @To WHERE t.IsActive = 1 AND s1.StopOrder < s2.StopOrder;

这段 SQL 的逻辑是:先定位同一趟车上的两个站,用StopOrder保证上车站在前、下车站在后;历时和票价都用累计值相减,跨天车次也不会算错;余票子查询沿用了上一章的区间重叠条件,只统计和目标区间相交的库存行。参数@TravelDate是DATE类型,别传成带时分秒的字符串,否则2024-05-18 08:00:00和2024-05-18在某些排序规则下匹配不上,这是新手很容易忽略的细节。数量大时可以给SeatInventory加(TrainId, TravelDate, SeatType)的前缀索引,让子查询走索引扫描而不是全表。

3.3 下单事务:UPDLOCK 行锁挡住并发超卖

真正会出问题的只有这一段。两个用户同时看到“余票 1”,同时点提交,如果两个事务都先SELECT再UPDATE,读到的都是 1,各自减一,结果卖出两张票,库存变 -1。解决办法是在读库存时就加UPDLOCK和ROWLOCK,让第二个请求在SELECT阶段排队,直到第一个事务提交或回滚。

public BookingResult Book(int userId, int trainId, DateTime travelDate, int fromOrder, int toOrder, string seatType, int count) { using (var conn = DbHelper.OpenConnection()) using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 1. 加行锁读库存:UPDLOCK 是更新锁,ROWLOCK 限定行级 // 并发请求会在这里等待,避免读到同一份余票 const string lockSql = @" SELECT SUM(TotalCount) AS TotalCount, SUM(SoldCount) AS SoldCount FROM SeatInventory WITH (UPDLOCK, ROWLOCK) WHERE TrainId = @TrainId AND TravelDate = @Date AND SeatType = @SeatType AND NOT (ToStopOrder <= @From OR FromStopOrder >= @To);"; int total = 0, sold = 0; using (var cmd = new SqlCommand(lockSql, conn, tran)) { cmd.Parameters.AddWithValue("@TrainId", trainId); cmd.Parameters.AddWithValue("@Date", travelDate); cmd.Parameters.AddWithValue("@SeatType", seatType); cmd.Parameters.AddWithValue("@From", fromOrder); cmd.Parameters.AddWithValue("@To", toOrder); using (var reader = cmd.ExecuteReader()) { if (reader.Read()) { total = reader["TotalCount"] == DBNull.Value ? 0 : Convert.ToInt32(reader["TotalCount"]); sold = reader["SoldCount"] == DBNull.Value ? 0 : Convert.ToInt32(reader["SoldCount"]); } } } // 2. 业务校验:余票不足直接回滚,不进入后续写操作 if (total - sold < count) { tran.Rollback(); return BookingResult.Fail("余票不足,请选择其他车次或席别"); } // 3. 扣库存:按区间展开扣减,这里简化为扣目标区间那一条 const string updateSql = @" UPDATE SeatInventory SET SoldCount = SoldCount + @Count WHERE TrainId = @TrainId AND TravelDate = @Date AND SeatType = @SeatType AND FromStopOrder = @From AND ToStopOrder = @To;"; using (var cmd = new SqlCommand(updateSql, conn, tran)) { cmd.Parameters.AddWithValue("@Count", count); cmd.Parameters.AddWithValue("@TrainId", trainId); cmd.Parameters.AddWithValue("@Date", travelDate); cmd.Parameters.AddWithValue("@SeatType", seatType); cmd.Parameters.AddWithValue("@From", fromOrder); cmd.Parameters.AddWithValue("@To", toOrder); if (cmd.ExecuteNonQuery() == 0) throw new InvalidOperationException("库存区间不存在,检查库存初始化"); } // 4. 写订单主表,OrderNo 用时间戳 + 随机数,唯一索引兜底 string orderNo = DateTime.Now.ToString("yyyyMMddHHmmss") + new Random().Next(100, 999); long orderId; const string insertOrder = @" INSERT INTO Orders(OrderNo, UserId, TrainId, TravelDate, FromOrder, ToOrder, SeatType, TotalAmount, Status) OUTPUT INSERTED.OrderId VALUES(@OrderNo, @UserId, @TrainId, @Date, @From, @To, @SeatType, @Amount, 1);"; using (var cmd = new SqlCommand(insertOrder, conn, tran)) { cmd.Parameters.AddWithValue("@OrderNo", orderNo); cmd.Parameters.AddWithValue("@UserId", userId); cmd.Parameters.AddWithValue("@TrainId", trainId); cmd.Parameters.AddWithValue("@Date", travelDate); cmd.Parameters.AddWithValue("@From", fromOrder); cmd.Parameters.AddWithValue("@To", toOrder); cmd.Parameters.AddWithValue("@SeatType", seatType); cmd.Parameters.AddWithValue("@Amount", 0m); // 金额由票价接口计算后回填 orderId = Convert.ToInt64(cmd.ExecuteScalar()); } tran.Commit(); return BookingResult.Success(orderId, orderNo); } catch (Exception ex) { // 任何一步失败整单回滚,库存和订单不会出现半截状态 tran.Rollback(); return BookingResult.Fail("下单失败:" + ex.Message); } } }

参数和逻辑说明:IsolationLevel.ReadCommitted是 SQL Server 默认级别,配合UPDLOCK已经能挡住大部分并发场景,不需要上Serializable,后者锁范围大容易死锁。UPDLOCK的含义是“我准备更新这行,别人别来读”,ROWLOCK强制行级而不是页级,减少无关阻塞。第 2 步的余票校验必须在同一个事务里做,如果放到事务外面先查后开事务,加锁就白加了。第 4 步用OUTPUT INSERTED.OrderId一次拿到自增主键,比再查一次SCOPE_IDENTITY()少一次往返。整个方法返回业务结果对象而不是抛异常给界面,界面层只负责弹提示,不参与事务决策。

3.4 退票与状态机:别用 DELETE 了事

退票不是删订单,是把票状态改成已退、把库存加回去。状态机要简单可枚举:订单从待支付到已支付,已支付可以退票或改签,已退票和已取消是终态。退票逻辑和下单对称,同一个事务里先锁库存、加回SoldCount、更新票状态,任何一步失败整体回滚。

// 退票核心:锁库存 -> 回加数量 -> 改票状态,三步同事务 const string restoreSql = @" UPDATE SeatInventory SET SoldCount = SoldCount - @Count WHERE TrainId = @TrainId AND TravelDate = @Date AND SeatType = @SeatType AND FromStopOrder = @From AND ToStopOrder = @To;"; const string updateTicketSql = @" UPDATE Tickets SET Status = 1 WHERE OrderId = @OrderId AND Status = 0;";

这里有个血泪经验:UPDATE Tickets一定要带AND Status = 0,否则重复点退票按钮会把已退的票再退一次,库存越退越多。前端按钮置灰只是体验层,数据库层的状态条件才是真正的后悔药。

4. WinForms 界面层:查询面板、DataGridView 与三层怎么落地

桌面端订票系统的好处是界面和业务都在一个进程里,调试直观;坏处是很容易写成“窗体里塞满 SQL”,几百行代码堆在按钮事件里,改一个查询要翻半天。这一章讲三层的目录怎么切、界面怎么绑数据、配置怎么管,最后说登录和权限两条入口怎么分流。

4.1 三层目录结构与职责边界

我习惯按UI / BLL / DAL / Models四个目录分,不追求严格的分层框架,但要守住一条线:窗体里不出现 SQL 字符串,DAL 里不出现MessageBox。具体职责是这样:

TrainTicketSystem/ ├── UI/ # 窗体:只做展示、收集输入、调用 BLL │ ├── FrmLogin.cs │ ├── FrmMain.cs │ ├── FrmQuery.cs │ ├── FrmOrder.cs │ └── FrmAdmin.cs ├── BLL/ # 业务:校验、事务编排、状态流转 │ ├── BookingService.cs │ ├── RefundService.cs │ └── TrainQueryService.cs ├── DAL/ # 数据访问:SQL、参数、DataTable │ ├── DbHelper.cs │ ├── TrainRepository.cs │ ├── OrderRepository.cs │ └── SeatRepository.cs ├── Models/ # 实体:Train、Order、Ticket、Passenger │ ├── TrainInfo.cs │ └── BookingResult.cs └── App.config # 连接字符串、票价规则等配置

UI只依赖BLL,BLL依赖DAL和Models,DAL依赖Models和DbHelper,依赖方向单向,不出现循环引用。这样改查询 SQL 只动DAL,改业务规则只动BLL,界面层的DataGridView列名变了也不影响后面两层。小项目里有人觉得这层壳是多余的,但一旦需求变成“加一个学生票折扣”,你就知道规则集中在BLL有多省事。

4.2 查询面板与 DataGridView 数据绑定

查询面板由几个ComboBox(出发站、到达站、席别)加DateTimePicker(乘车日期)和一个查询按钮组成,结果用DataGridView展示。绑定时别在代码里一列一列Add,用AutoGenerateColumns = false配合设计器里定义好的列,字段名和 SQL 别名对齐,改查询不用改界面代码。

private void btnQuery_Click(object sender, EventArgs e) { // 1. 前置校验:出发站和到达站不能相同,站序由 BLL 负责换算 if (cmbFrom.SelectedValue == null || cmbTo.SelectedValue == null) { MessageBox.Show("请选择出发站和到达站"); return; } if (Convert.ToInt32(cmbFrom.SelectedValue) == Convert.ToInt32(cmbTo.SelectedValue)) { MessageBox.Show("出发站和到达站不能相同"); return; } try { var service = new TrainQueryService(); // 2. 返回 DataTable 直接绑定,列名与 DataGridView 的 DataPropertyName 对应 dgvResult.DataSource = service.Query( Convert.ToInt32(cmbFrom.SelectedValue), Convert.ToInt32(cmbTo.SelectedValue), dtpDate.Value.Date, cmbSeatType.Text); lblCount.Text = $"共 {dgvResult.Rows.Count} 趟车"; } catch (Exception ex) { // 3. 界面层只负责提示,不吞异常细节到日志以外的位置 MessageBox.Show("查询失败:" + ex.Message, "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); } }

参数说明:cmbFrom.SelectedValue绑的是StationId,DisplayMember是站名,这样用户看到中文、代码拿到主键,不用做字符串匹配。dtpDate.Value.Date取日期部分,去掉时分秒,避免前面提到的DATE字段匹配问题。DataGridView的AutoSizeColumnsMode设成Fill,窗口拉伸时列宽自适应,比固定宽度耐看。行双击事件里取dgvResult.CurrentRow.Cells["TrainNo"].Value,跳转到下单窗体时把车次和站序一起带过去,别只传车次号再查一次库。

4.3 配置与连接字符串管理

连接字符串放App.config,用connectionStrings节点,代码里通过ConfigurationManager读。发布时把TrainTicketSystem.exe.config一起带走,换机器只改配置文件,不用重新编译。除了连接串,票价折扣、退票手续费比例、每单最大乘客数这类会变的参数也建议放配置里,别写死在常量类中。

<configuration> <connectionStrings> <add name="TrainDb" connectionString="Data Source=.;Initial Catalog=TrainDb;Integrated Security=True;MultipleActiveResultSets=False" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <add key="MaxPassengersPerOrder" value="5" /> <add key="RefundFeeRate" value="0.05" /> </appSettings> </configuration>

MultipleActiveResultSets保持False,逼着自己别在一个连接上同时开两个DataReader,那会带来很难查的连接状态问题。连接串里不写账号密码,用集成身份验证,打包给别人时对方只要有本机 SQL Server 实例和对应数据库权限就能跑,省去解释密码的麻烦。

4.4 登录与权限:普通用户和管理员两条入口

登录窗体校验通过后,把UserId和Role存到一个静态的CurrentUser类里,主窗体根据角色决定显示哪些菜单。普通用户看到查询、我的订单、退票;管理员额外看到车次维护、库存初始化、订单统计。权限判断只在打开窗体时做一次不够,关键操作在BLL里再校验一次角色,防止有人绕过界面直接调用服务方法。

public static class CurrentUser { public static int UserId { get; set; } public static string UserName { get; set; } public static string Role { get; set; } // "Admin" / "User" public static bool IsAdmin => Role == "Admin"; } // 主窗体加载时按角色控制菜单可见性 private void FrmMain_Load(object sender, EventArgs e) { menuAdmin.Visible = CurrentUser.IsAdmin; lblUser.Text = $"当前用户:{CurrentUser.UserName}"; }

密码存储别用明文。课设阶段至少做一次加盐哈希,SHA256(salt + password)存两列,登录时用同样方式算一遍比对。Role用字符串比用0/1可读性好,但要在BLL里做白名单校验,只允许"Admin"和"User"两个值。

5. 常见问题排查:五个真实翻车现场

这一章全是踩过的坑,按“现象 → 原因 → 解决”写。有些是代码问题,有些是环境问题,共同点是第一次遇到都想不通,事后看又特别简单。建议在跑通项目之前先把这几条过一遍,能省下不少排查时间。

5.1 余票扣成负数,订单还多出一条

现象:压测时开 20 个线程同时下单,最后SoldCount大于TotalCount,RemainCount显示负数,订单表却一条不少。

原因:下单方法先SELECT余票判断,再UPDATE扣减,两次操作之间没有锁,也没有放在同一个事务里。两个线程读到同一份余票,各自判断“还有 1 张”,然后都执行了扣减。这在新手写的订票系统里几乎是标配错误。

解决:把SELECT和UPDATE放进同一个SqlTransaction,查询语句加WITH (UPDLOCK, ROWLOCK),让第二个请求在读取阶段排队。另外给SeatInventory加上CHECK (SoldCount <= TotalCount)约束,业务层万一漏了判断,数据库直接拒绝,不会产生脏数据。

5.2 跨天车次历时算成负数

现象:查询 23:50 发车、次日 06:10 到达的车次,历时显示-1060 分钟,票价正常。

原因:DepartTime和ArriveTime都存成TIME类型,跨天时到达时刻小于发车时刻,直接相减就得到负数。用DATEDIFF对两个TIME值做差也是同样的结果。

解决:在运行图里加DurationFromStart字段,存距始发站的累计分钟数,历时永远用两个累计值相减,跟时刻是否跨天无关。如果一定要用时刻算,判断到达小于发车时给到达时间加 24 小时再减,但这样每个查询都要写分支,不如累计分钟数干净。

5.3 连接池耗尽,报“超时时间已到”

现象:系统跑一段时间后所有数据库操作都变慢,最后报“超时时间已到。在从池中获取连接之前超时时间已过”,重启程序又好了。

原因:某几个方法里SqlConnection开了没关,或者SqlDataReader用完没Dispose,连接一直悬着不归还池子。常见写法是var conn = new SqlConnection(...); conn.Open();之后中间抛异常,Close那行永远执行不到。

解决:所有连接、命令、读取器一律包在using里,异常路径也能释放。DbHelper里就用using把资源生命周期限定在方法内,调用方拿到的DataTable已经和连接脱钩,不存在连接泄漏。改完用 SQL Server 的sys.dm_exec_sessions看连接数是否稳定,能直观验证。

5.4 金额用 double 导致对账差一分钱

现象:订单总金额显示256.29999999999998,两张票分开买是256.30,合起来对不上,退款时差额算出来是-0.0000000001。

原因:float和double是二进制浮点数,0.1 + 0.2在计算机里不等于0.3。金额参与加减乘除,误差会累积放大,涉及退款就更乱。

解决:金额统一用decimal,数据库列用DECIMAL(10,2),C# 里用decimal类型,AddWithValue传参时显式指定SqlDbType.Decimal并设置精度,避免驱动推断成浮点。涉及除法的地方用Math.Round(value, 2, MidpointRounding.AwayFromZero)明确舍入规则,别依赖默认行为。

5.5 打包到别人机器连不上数据库

现象:自己机器上跑得好好的,拷到同学电脑上点查询就报“在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误”。

原因:连接串里写的是Data Source=.或者特定实例名,对方机器上 SQL Server 实例名不同,或者根本没启用 TCP/IP 协议。另外数据库文件没有一起部署,Initial Catalog指向的库不存在。

解决:部署包里带上建库脚本和初始数据脚本,用Data Source=localhost或.\SQLEXPRESS按对方环境改App.config。给对方一份三步操作说明:附加数据库、改连接串、启动程序。别把sa账号密码写进配置,用 Windows 身份验证省掉账号问题的排查。

6. 二次开发:加候补队列、改签和并发压测的改造路径

项目跑通之后,多数人第一个想加的功能是候补。思路不复杂:新建WaitList表,字段是车次、日期、区间、席别、用户、排队序号、状态;退票成功时在同一事务里查最早的一条候补,有就给它占座并生成订单,没有就正常回加库存。关键点是退票和候补出票必须原子,退票事务里先锁库存,再查候补队列,最后决定是回加库存还是直接转成新订单,中间不能有提交空档,否则会出现“票退了、候补没出、库存也没加”的黑洞。候补队列按CreateTime加自增序号排序,别用时间戳单独排,同一秒内多条会乱序。

改签本质是“退旧票 + 订新票”,放在同一个事务里做。先按新行程锁库存、扣减、生成新订单,再回滚旧订单的库存占用、把旧票状态置为已改签。差额计算用新票价 - 旧票价,正数补款、负数退款,补退金额记在订单备注字段里。这里容易翻车的地方是改签目标车次和原车次是同一趟、同一区间,只是换席别,库存扣减和回加会落在同一条记录上,顺序不能反,否则中间态会出现SoldCount短暂超过TotalCount触发 CHECK 约束。稳妥做法是先扣新席别、再回加旧席别,两条记录不同,互不干扰。

验证不超卖最直接的办法是写个多线程压测。用Parallel.For开 50 个线程,抢同一趟车同一个区间 10 张票,断言成功数等于 10、SoldCount等于 10、没有异常订单。压测前把库存初始化脚本单独抽出来,每次跑完重置数据,别在脏数据上重复跑。

// 压测片段:50 个并发抢 10 张票,验证不超卖 int success = 0; Parallel.For(0, 50, i => { var svc = new BookingService(); // 每次新建,模拟独立请求 var r = svc.Book(userId: 1, trainId: 1, travelDate: new DateTime(2024, 5, 18), fromOrder: 1, toOrder: 3, seatType: "二等座", count: 1); if (r.Success) Interlocked.Increment(ref success); }); Console.WriteLine($"成功 {success} 单,预期 10 单"); // 压测后核对:这两条必须同时成立,否则说明锁或事务有问题 // SELECT SoldCount FROM SeatInventory WHERE TrainId=1 AND TravelDate='2024-05-18' // SELECT COUNT(*) FROM Orders WHERE TrainId=1 AND TravelDate='2024-05-18'

如果结果大于 10,先查下单方法是不是漏了UPDLOCK;如果小于 10 但没有报错,查是不是库存初始化时区间没配对,查询条件把可售区间过滤掉了。压测跑通一次,再把IsolationLevel从ReadCommitted往上调一档试试,观察是否出现死锁,就知道当前锁粒度是否合理。那次压测发现余票变负数之后,我每次给订票类项目加新功能,都会先把并发脚本跑一遍再提交代码,哪怕是改一个退票按钮的文案。希望这些能帮到你,少走一遍我踩过的路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 23:42:04

营销技能完整框架:从用户路径到增长操盘手实战指南

刚入行那会儿&#xff0c;我以为营销就是"发内容、投广告、做活动"&#xff0c;谁嗓门大谁就赢。后来带团队、做项目、独立操盘&#xff0c;踩过的坑一个接一个&#xff0c;才慢慢想明白&#xff1a;营销技能从来不是单点能力&#xff0c;而是一套组合拳。这套拳打得…

作者头像 李华
网站建设 2026/10/29 23:41:05

电力线通信信道建模:多径回波、衰减与Matlab仿真

简介&#xff1a;面向电力线通信学习与仿真需求&#xff0c;这份资料围绕PLC信道建模提供一套轻量级Matlab实现与配套说明&#xff0c;适合通信工程学生、研究人员以及从事智能电网、智能家居载波通信分析的工程师参考。内容聚焦多径传播与噪声环境两条主线&#xff0c;可用于复…

作者头像 李华
网站建设 2026/10/29 23:39:56

Web开发实战:任务管理应用开发指南

1. Web开发第三次作业解析与实战指南作为从事Web开发教学多年的从业者&#xff0c;我经常需要设计适合学生进阶练习的第三次作业内容。这个阶段的作业通常需要综合运用HTML、CSS和JavaScript的基础知识&#xff0c;完成一个具备完整交互功能的前端项目。下面我将分享一个典型的…

作者头像 李华
网站建设 2026/10/29 23:37:54

等腰三角形全解析:性质、判定与避坑指南

等腰三角形这个知识点&#xff0c;几乎是每个人学几何时最早接触、也最容易“以为自己会了”的内容之一。我在带新人和做课程辅导的过程中反复验证过一个现象&#xff1a;大多数人对“等边对等角”倒背如流&#xff0c;可一旦题目里出现辅助线、外角、或者需要自己判定等腰的场…

作者头像 李华
网站建设 2026/10/29 23:34:53

YOLOv10n-WFU摄像头目标检测系统解析与优化

1. 项目背景与核心价值YOLOv10n-WFU摄像头目标检测系统是当前计算机视觉领域最前沿的实时检测方案之一。作为YOLO系列的最新迭代版本&#xff0c;v10n在保持YOLO家族实时性优势的基础上&#xff0c;通过神经网络架构优化和训练策略改进&#xff0c;显著提升了小目标检测精度。我…

作者头像 李华
网站建设 2026/10/29 23:34:42

进制转换实战:二进制、八进制、十进制、十六进制手算与工程应用

前几天调一个界面配色&#xff0c;设计稿上写着#3A7BD5&#xff0c;我顺手把它丢进代码里当色值用。旁边一位刚入行的同事问了一句&#xff1a;“这个 D5 到底是多大&#xff1f;”我愣了一下才反应过来&#xff0c;他问的不是颜色&#xff0c;而是这个十六进制字符串背后代表的…

作者头像 李华