
VB.NET 数据库事务处理入门:确保多步操作一致完成
一笔银行转账,通常至少包含两步:从付款账户扣款,再向收款账户入账。如果第一步成功、第二步失败,数据就会不一致。数据库事务正是用来把这类相关操作组合起来:要么全部完成,要么在失败时撤销尚未提交的修改。
本文以 VB.NET 和 SQL Server 为例,说明事务适合解决什么问题、如何编写可运行的转账代码,以及事务的边界、参数化查询的能力与限制。示例使用 Microsoft.Data.SqlClient,适用于现代 .NET 项目。
一、事务解决什么问题
事务是一组数据库操作的执行单元。应用程序明确开始事务后,执行若干查询或更新,最后选择提交或回滚:
- 提交(Commit):确认事务中的修改,使其成为数据库的正式数据。
- 回滚(Rollback):撤销事务中尚未提交的修改。
事务常见于必须保持整体一致的多步写入,例如转账、创建订单并写入订单明细、扣减库存并记录出库、更新业务状态并写审计记录等。若其中某一步失败,通常不能只留下前面的部分修改。
事务并不会自动判断业务逻辑是否正确。比如转账金额输错了,只要程序没有发现并拒绝,数据库仍可能成功提交。事务负责控制一组数据库操作的整体提交与回滚;业务规则仍需要由程序校验、数据库约束或二者共同保证。
二、准备数据库表
下面的 SQL 创建一个最小账户表。请在测试数据库中执行,再用对应的连接字符串运行后面的示例。表中的余额使用 decimal(18, 2),适合以两位小数记录的金额;实际系统应根据币种和业务要求确定精度。
CREATE TABLE dbo.Accounts
(
AccountId int NOT NULL
CONSTRAINT PK_Accounts PRIMARY KEY,
Balance decimal(18, 2) NOT NULL
CONSTRAINT CK_Accounts_Balance_NonNegative CHECK (Balance >= 0)
);
INSERT INTO dbo.Accounts (AccountId, Balance)
VALUES (1, 1000.00), (2, 250.00);
主键保证账户编号唯一;余额检查约束则阻止负余额写入。数据库约束很重要:程序校验可以提供更友好的提示,但不能取代数据库对数据有效性的最终保护。
三、VB.NET 转账示例
先创建一个 VB.NET 控制台项目,并安装 SQL Server 客户端包:
dotnet new console -lang VB -n TransactionIntro
cd TransactionIntro
dotnet add package Microsoft.Data.SqlClient
将项目中的程序入口代码替换为下面示例。请把连接字符串中的服务器、数据库和身份验证信息改成自己的测试环境配置。示例使用 Windows 集成身份验证;运行账户必须具备相应数据库权限。
Imports System
Imports System.Data
Imports Microsoft.Data.SqlClient
Module Program
Sub Main()
Dim connectionString As String =
"Server=(localdb)\MSSQLLocalDB;" &
"Database=TransactionDemo;" &
"Integrated Security=True;" &
"TrustServerCertificate=True;"
Try
TransferMoney(connectionString, 1, 2, 100D)
Console.WriteLine("转账成功。")
Catch ex As Exception
Console.WriteLine("转账失败:" & ex.Message)
End Try
End Sub
Private Sub TransferMoney(
connectionString As String,
fromAccountId As Integer,
toAccountId As Integer,
amount As Decimal)
If amount <= 0D Then
Throw New ArgumentOutOfRangeException(
NameOf(amount), "转账金额必须大于零。")
End If
If fromAccountId = toAccountId Then
Throw New ArgumentException("付款账户和收款账户不能相同。")
End If
Using connection As New SqlConnection(connectionString)
connection.Open()
Using transaction As SqlTransaction =
connection.BeginTransaction(IsolationLevel.ReadCommitted)
Try
Dim rowsAffected As Integer
' 先扣款。余额条件与更新放在同一条语句中,
' 避免只在应用层读取余额后再判断。
Using debitCommand As New SqlCommand(
"UPDATE dbo.Accounts " &
"SET Balance = Balance - @Amount " &
"WHERE AccountId = @FromAccountId " &
"AND Balance >= @Amount;",
connection, transaction)
Dim amountParameter =
debitCommand.Parameters.Add("@Amount", SqlDbType.Decimal)
amountParameter.Precision = 18
amountParameter.Scale = 2
amountParameter.Value = amount
debitCommand.Parameters.Add(
"@FromAccountId", SqlDbType.Int).Value = fromAccountId
rowsAffected = debitCommand.ExecuteNonQuery()
End Using
If rowsAffected <> 1 Then
Throw New InvalidOperationException(
"付款账户不存在,或余额不足。")
End If
' 再给收款账户入账。
Using creditCommand As New SqlCommand(
"UPDATE dbo.Accounts " &
"SET Balance = Balance + @Amount " &
"WHERE AccountId = @ToAccountId;",
connection, transaction)
Dim amountParameter =
creditCommand.Parameters.Add("@Amount", SqlDbType.Decimal)
amountParameter.Precision = 18
amountParameter.Scale = 2
amountParameter.Value = amount
creditCommand.Parameters.Add(
"@ToAccountId", SqlDbType.Int).Value = toAccountId
rowsAffected = creditCommand.ExecuteNonQuery()
End Using
If rowsAffected <> 1 Then
Throw New InvalidOperationException("收款账户不存在。")
End If
' 只有两步都成功,才确认整个事务。
transaction.Commit()
Catch
' 尽量回滚,并保留原始异常供调用方处理。
Try
transaction.Rollback()
Catch
' 回滚本身也可能因连接或事务状态异常而失败。
End Try
Throw
End Try
End Using
End Using
End Sub
End Module
示例中,Using 负责释放连接、命令和事务对象;BeginTransaction 开始事务;两个 SqlCommand 都显式关联同一个连接和事务;两次更新全部成功后才调用 Commit。若发生异常,则尝试调用 Rollback,随后用 Throw 抛出原始异常,让调用者决定如何记录或提示错误。
付款更新通过 Balance >= @Amount 在数据库内检查余额,并同时完成扣款。这比先读取余额、在 VB.NET 中比较、之后再单独更新更可靠:并发请求可能在“读取”和“更新”之间改变余额。数据库条件更新将检查和修改放进同一条语句中。更新行数不为 1 时,程序不继续入账,而是让事务回滚。
四、参数化查询能做什么,不能做什么
示例使用 SqlParameter 传入金额和账户编号。参数的值会作为数据传递,而不是直接拼接进 SQL 语句,因而能避免把这些值当作 SQL 代码执行。这是处理用户输入和业务值的基本做法,也有助于明确数据类型。
参数化并不意味着所有 SQL 内容都能用参数代替。参数通常用于表达式中的值,例如金额、编号、日期或名称;它不能直接代替表名、列名、关键字或排序方向。下面这种写法不能通过把表名当参数来实现:
SELECT * FROM @TableName
如果确实需要根据用户选择动态表名或排序列,应该通过固定白名单把用户选项映射到预先认可的标识符,再组装 SQL;不能把任意输入直接拼接进去。参数也不会自动处理业务语义,例如 LIKE 搜索中的 % 和 _ 仍是通配符;若业务要求按字面字符匹配,需要另行转义通配符。
使用参数化查询不能代替权限控制、身份认证或输入校验。应用仍应采用最小权限数据库账户,限制可执行的操作;金额等数据还需要按业务规则验证。参数化查询的作用是安全、明确地传递 SQL 值,不是授予或撤销数据库权限。
五、事务的关键边界
1. 事务只覆盖参与该事务的数据库操作
本例中的两条更新使用同一个连接和同一个事务,因此可以一起提交或回滚。如果在事务外另开连接执行写入,或者调用没有加入该事务的数据库代码,该操作通常不受当前事务保护。排查“回滚了但数据还在”时,应检查每条命令是否使用同一连接和事务。
2. 数据库事务不能自动撤销外部副作用
事务不能自动撤回已经发送的邮件、已经调用的第三方接口、已经写入的普通文件,或另一个独立系统中的操作。如果先发邮件再提交数据库,之后数据库回滚,邮件仍可能已经送达;反过来,先提交数据库再通知外部系统,也可能在通知失败时出现状态不同步。
对这类跨系统流程,常见做法是把业务数据和“待发送事件”记录在同一个数据库事务里,再由后台工作进程可靠地发送事件。这类模式通常称为 Outbox。需要跨多个资源管理器的分布式事务时,应单独评估平台、驱动和基础设施的支持,不能把普通的 SqlTransaction 当成跨系统事务。
3. 事务不是长期工作单元
事务持有数据库资源的时间越长,越容易增加锁等待、阻塞和死锁风险。不要在打开事务后执行耗时的用户交互、网络请求或复杂计算。尽量先完成非数据库准备,再快速完成数据库操作并提交。
4. 事务范围和隔离级别是两件事
事务决定哪些操作一起提交或回滚;隔离级别决定并发事务之间可以观察到什么、需要怎样协调。示例选择 SQL Server 的 ReadCommitted。它适用于许多一般业务写入,但具体效果还受数据库配置影响,例如是否启用了基于行版本的读提交隔离。
更高的隔离级别可能减少某些并发读写异常,但也可能增加锁定、等待或死锁。不要为了“更安全”就一律使用最高级别;应根据业务允许的并发行为选择,并通过实际负载测试验证。无论选择哪个级别,关键业务规则仍应有约束或合适的原子更新来保护。
5. 提交结果也需要考虑
如果客户端在 Commit 期间遇到网络中断,应用有可能无法确定数据库到底是否已经提交。此时简单重试整个操作,可能导致重复业务处理。对于订单创建、付款请求等不能重复执行的操作,应设计唯一业务请求编号,并在数据库中施加唯一约束;重试时先根据该编号判断操作是否已完成。这与事务回滚处理是不同的问题。
六、常见错误与改进建议
- 忘记将命令绑定到事务。 使用显式事务时,命令要使用参与该事务的连接和事务对象。示例通过
New SqlCommand(sql, connection, transaction)明确绑定。 - 某一步失败后仍然提交。 在每一步检查结果;失败时抛出异常或显式终止流程,不要忽略错误后继续提交。
- 只在应用中检查余额。 先查余额再更新存在并发窗口。将余额条件放进更新语句,并检查受影响行数;复杂并发规则还需要评估锁和隔离策略。
- 金额使用不合适的数据类型。 金额通常使用
Decimal,并使 VB.NET 参数精度、刻度与数据库列匹配。不要用二进制浮点类型承担需要精确十进制表示的金额计算。 - 拼接用户输入构造 SQL。 对值使用类型明确的参数;动态标识符使用白名单,不要把参数化误解为可以安全拼接任意 SQL。
- 事务过大或过久。 只把需要原子完成的数据库操作放入事务,避免在事务期间等待用户输入或外部服务。
- 吞掉异常或只输出通用错误。 示例重新抛出原始异常。正式应用应在合适层级记录异常详情,同时避免向终端用户泄露连接字符串、敏感数据或内部堆栈。
- 假设回滚总能成功。 连接中断或事务状态异常时,回滚调用也可能失败。回滚失败不应掩盖最初的数据库错误;同时应记录必要信息,便于后续确认数据状态。
七、何时不必显式创建事务
单条 SQL 语句本身通常由数据库作为一个原子操作执行,许多数据库也会为单条语句提供隐式事务处理。若业务只有一条原子更新,例如示例中的条件扣款本身,通常不必仅为这一条语句再写显式事务。但当扣款和入账必须一起成功时,显式事务就有必要。
也可以把多步业务逻辑封装在数据库存储过程中,并由存储过程负责事务边界。无论事务放在 VB.NET 还是数据库端,核心原则相同:相关写入必须参与同一个明确的事务,失败路径必须停止后续操作,业务约束不能只依赖调用方的善意。
八、实践检查清单
- 明确哪些数据库操作必须一起提交,避免把无关操作塞进同一事务。
- 所有相关命令都使用同一个连接和事务。
- 为输入值使用类型明确的参数;动态标识符使用白名单。
- 使用数据库约束和条件更新保护关键业务规则,并检查受影响行数。
- 确保异常路径不会继续执行提交,并妥善处理回滚失败。
- 根据并发需求选择隔离级别,控制事务持续时间。
- 为可能重试的业务操作设计幂等机制,避免网络错误造成重复执行。
对 VB.NET 应用来说,事务的基本结构并不复杂:打开连接、开始事务、在同一事务中执行相关命令、全部成功后提交,任何关键步骤失败则回滚。真正需要仔细设计的,是事务覆盖范围、并发条件、数据库约束以及数据库之外的副作用。把这些边界明确下来,才能让多步操作在失败和并发情况下仍保持可预期的一致性。




