VB.NET 数据库事务处理入门:确保多步操作一致完成

VB.NET 数据库事务处理入门:确保多步操作一致完成

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 应用来说,事务的基本结构并不复杂:打开连接、开始事务、在同一事务中执行相关命令、全部成功后提交,任何关键步骤失败则回滚。真正需要仔细设计的,是事务覆盖范围、并发条件、数据库约束以及数据库之外的副作用。把这些边界明确下来,才能让多步操作在失败和并发情况下仍保持可预期的一致性。

© 版权声明

相关文章