PAYATHON 2026

WCF 中 TransactionScope 间歇性抛出事务未释放错误

支付阿杰

明确结论

这类异常通常不是因为 TransactionScope 没有执行 Dispose(),而是事务在循环执行期间已经超时、回滚或进入不可用状态,关联的 SqlConnection 或 LINQ to SQL DataContext 却仍在执行后续 SQL。

示例代码还缺少 scope.Complete()。如果不调用它,离开 using 时事务必然回滚。不过,这通常不会直接引发当前异常。排查时更应关注以下情况:

  • 整个循环处于一个持续时间过长的事务中。
  • SQL 阻塞、锁等待或服务器负载导致事务偶尔超时。
  • DataContext 或 SqlConnection 生命周期过长,事务结束后仍被重复使用。
  • 数据访问层捕获数据库异常后继续执行,导致后续命令使用已经失效的事务。
  • 多个连接或资源管理器导致事务升级为分布式事务,而新旧服务器的 MSDTC 或网络配置不同。

不建议只修改 machine.config 来掩盖问题。应先查明事务为什么结束,再决定是缩短事务、分批提交、优化 SQL,还是适当调整超时。

为什么问题是间歇性的

事务是否超时取决于实际耗时,与文件行数没有直接对应关系。一个 200 行的文件也可能比 2,000 行的文件处理得更慢,例如:

  • 某些数据命中了锁竞争严重的记录或索引页。
  • 不同参数生成了不同的执行计划。
  • 某些行触发了额外查询、触发器、级联更新或约束检查。
  • 同期运行着其他批处理、报表、备份或维护任务。
  • 新数据库的索引、统计信息、兼容级别或硬件配置与旧服务器不同。
  • 应用处理每一行时都会重新创建连接、调用存储过程或执行多条 SQL。
  • 多个请求并发处理文件,相互持锁和等待。

同一个文件有时成功、有时失败,恰好符合锁等待、系统负载波动或事务超时的表现,不能据此排除超时。

异常通常按以下过程发生:

  1. TransactionScope 创建环境事务。
  2. SqlConnection 自动加入该事务。
  3. 某条 SQL 因阻塞或执行缓慢,使事务超过有效期,或者内部错误导致事务回滚。
  4. 代码捕获或忽略了之前的异常,继续处理下一行。
  5. 下一条 SQL 仍通过原来的连接执行。
  6. SqlClient 发现连接关联的事务已经结束,但事务对象尚未清理完成,于是抛出当前异常。

因此,堆栈中显示的 SaveTramsPayment 不一定是最初导致事务失效的位置。它可能只是后续调用中第一个发现事务已经不可用的方法。

首先修正事务代码

所有数据库操作成功后,必须调用 Complete():

var options = new TransactionOptions
{
    IsolationLevel = IsolationLevel.ReadCommitted,
    Timeout = TimeSpan.FromMinutes(5)
};

using (var scope = new TransactionScope(
    TransactionScopeOption.Required,
    options))
{
    foreach (var row in Rows)
    {
        Invoice invoice =
            (Invoice)CreateObjectWithConstantData(
                typeof(Invoice),
                doc,
                applicationName);

        invoice =
            (Invoice)FillObjectWithUserData(
                invoice,
                row,
                -1,
                -1,
                string.Empty);

        invoice.InvoiceNumber =
            InvoiceDBImpl.SaveInvoice(
                invoice,
                processFileRequest.RunId);

        if (invoice.InvoiceNumber == Guid.Empty)
        {
            throw new DataAccessException(
                string.Format(
                    Messages.ErrorSavingInvoice,
                    invoice.ReceiptId,
                    invoice.ProductID));
        }
    }

    scope.Complete();
}

Complete() 表示允许提交事务。如果循环中抛出异常,程序就不会执行到 Complete(),随后 Dispose() 会回滚事务。

显式设置超时比依赖服务器默认配置更清晰,但具体数值应根据实际耗时确定,不能盲目调大。事务持续时间越长,持锁时间通常也越长,可能进一步加重并发阻塞。

不要在事务失效后继续执行

检查 PerformOperation、SaveInvoice、SavePayment 等数据访问方法,确认其中是否存在类似逻辑:

try
{
    // Execute SQL
}
catch (Exception ex)
{
    Log(ex);
    // 然后继续执行
}

在事务中发生可能使事务失效的数据库异常后,不应继续使用原来的 DataContext 或连接。应让异常向外传播,使当前 TransactionScope 立即退出并回滚:

try
{
    ProcessRows();
}
catch (SqlException ex)
{
    LogDatabaseError(ex);
    throw;
}
catch (TransactionException ex)
{
    LogTransactionError(ex);
    throw;
}

如果业务允许跳过失败行,就应为每行或每批数据使用独立事务,不能继续使用同一个已经失败的事务。

缩小事务范围

把数千行数据和多张表的写入全部放进一个事务,风险通常较高。事务运行得越久,出现超时、锁等待或死锁的概率就越大。

如果业务不要求整个文件绝对原子提交,可以分批处理:

const int batchSize = 100;

foreach (var batch in Rows.Chunk(batchSize))
{
    var options = new TransactionOptions
    {
        IsolationLevel = IsolationLevel.ReadCommitted,
        Timeout = TimeSpan.FromMinutes(2)
    };

    using (var scope = new TransactionScope(
        TransactionScopeOption.RequiresNew,
        options))
    {
        foreach (var row in batch)
        {
            SaveRow(row);
        }

        scope.Complete();
    }
}

Chunk 需要相应版本的 .NET。旧版 .NET Framework 可以使用 Skip / Take,也可以自行编写分批方法。

如果业务要求“整个文件要么全部成功,要么全部失败”,可以考虑:

  • 先将文件内容批量写入 staging table。
  • 使用 SqlBulkCopy,减少逐行处理产生的数据库往返。
  • 在数据库中通过一个存储过程完成校验和正式表写入。
  • 减少事务内的文件解析、对象转换、日志写入和远程调用。

事务中只应保留真正需要原子性的数据库操作。文件解析和数据校验可以在进入 TransactionScope 前完成。

正确管理 DataContext 和连接

LINQ to SQL 的 DataContext 不应作为 WCF service 的共享字段长期复用,也不能由多个请求并发使用。建议为每个操作或每个事务单独创建并释放一个实例:

using (var scope = new TransactionScope(
    TransactionScopeOption.Required,
    options))
using (var dc = new TramsDBDataContext(connectionString))
{
    foreach (var row in Rows)
    {
        SaveInvoice(dc, row);
    }

    scope.Complete();
}

需要重点检查:

  • SystemDataContext 是否缓存了静态 DataContext。
  • WCF service 是否采用单例实例,导致并发请求共享连接或上下文。
  • PerformOperation 是否保存并反复使用同一个 DataContext。
  • 某个方法是否手动打开连接,却没有在异常路径中关闭或释放。
  • 事务结束后是否仍在使用事务内创建的连接。
  • 同一个事务中是否交替打开了多个数据库连接。

不要通过关闭连接池来解决这个问题。连接池通常不是根因,正确释放 DataContext、SqlConnection 和事务对象才是重点。

区分两种超时

排查时要区分以下两种超时:

  • SqlCommand.CommandTimeout:单条 SQL 命令允许执行的时间。
  • TransactionScope timeout:整个事务允许存活的总时间,包括所有命令、锁等待和应用代码耗时。

即使每条 SQL 都没有超过 CommandTimeout,大量命令的累计耗时仍可能超过事务超时。

如果需要调整事务超时,应在代码中明确配置,并确认运行环境允许使用该值。某些 .NET Framework 环境还受机器级最大事务超时限制。如果代码中请求的超时超过机器级上限,实际值仍可能被截断。因此,需要对比新旧 Web server 的以下配置:

  • machine.config 中与事务有关的配置。
  • .NET Framework 和 CLR 版本。
  • 应用程序池位数及其加载的配置文件。
  • WCF transaction timeout 设置。
  • 应用配置中的连接字符串和数据库目标。

修改 machine.config 会影响机器上的其他应用,而且通常需要重启相关进程,不应作为排查的第一步。

检查是否发生事务升级

如果一个 TransactionScope 使用了多个物理连接、多个数据库或其他事务资源,事务可能升级为 MSDTC 分布式事务。具体是否升级,取决于 .NET、SQL Server 版本和连接使用方式,需要结合实际环境确认。

迁移服务器后,应检查:

  • Web server 和 DB server 上是否启用了 MSDTC。
  • 网络 DTC 访问、防火墙和身份验证方式是否配置正确。
  • Windows Event Viewer 中是否有 MSDTC 错误。
  • 多次打开的连接是否使用完全相同的连接字符串。
  • 数据访问方法是否每次保存数据时都创建新连接。

如果业务只访问一个 SQL Server 数据库,最好在同一事务中复用一个生命周期清晰的连接或 DataContext,避免不必要的事务升级。

如何定位最初原因

不要只记录最终出现的 InvalidOperationException。每次数据库操作失败时,都应记录最早出现的异常,以及事务状态和耗时:

var stopwatch = Stopwatch.StartNew();

try
{
    using (var scope = new TransactionScope(
        TransactionScopeOption.Required,
        options))
    {
        LogTransaction(Transaction.Current);

        ProcessRows();

        scope.Complete();
    }
}
catch (Exception ex)
{
    var transaction = Transaction.Current;

    Log.Error(
        ex,
        "Processing failed. Elapsed={Elapsed}, TransactionId={TransactionId}, Status={Status}",
        stopwatch.Elapsed,
        transaction?.TransactionInformation.LocalIdentifier,
        transaction?.TransactionInformation.Status);

    throw;
}

还应记录:

  • 文件 ID、Run ID 和当前行号。
  • 每个主要数据库调用的开始时间和耗时。
  • SQL 异常编号及完整的内部异常。
  • 实际生效的事务超时和命令超时。
  • WCF 请求的开始和结束时间。
  • 是否有多个请求并发处理同一文件或同一业务实体。
  • 当前服务器、数据库和连接字符串标识,但不能记录密码。

数据库侧应重点查看:

  • 阻塞链和长时间运行的事务。
  • 死锁报告。
  • 存储过程的实际执行计划。
  • 索引和统计信息是否与旧服务器一致。
  • SQL Server Extended Events 中记录的超时、错误和 deadlock 事件。
  • 迁移后数据库兼容级别、参数和资源配置是否发生变化。

如果异常总是在接近某个固定时长时出现,例如接近 60 秒或某个机器级限制,事务超时的可能性很高。如果每次耗时差异很大,则应继续检查连接复用、异常被吞掉、事务升级和并发问题。

推荐处理顺序

  1. 补上 scope.Complete()。
  2. 确保事务内部发生的异常不会被吞掉,随后继续执行。
  3. 确保每个 WCF 请求单独创建并释放 DataContext,不要跨请求共享。
  4. 记录整个事务和各数据库调用的准确耗时。
  5. 从 SQL Server 侧定位阻塞、死锁和慢查询。
  6. 对比新旧服务器的 .NET、WCF、SQL Server、索引、统计信息和 MSDTC 配置。
  7. 尽可能改为批量写入或分批事务。
  8. 只有确认正常业务确实需要更长时间后,才适当提高事务超时。

增加超时可能会暂时降低异常频率。但如果根因是锁竞争、低效的逐行写入或错误的连接生命周期,更长的事务只会持锁更久。超时设置应纳入容量设计,不能作为此类异常的唯一修复手段。

备注:内容仅供参考。