WCF 中 TransactionScope 间歇性抛出事务未释放错误
明确结论
这类异常通常不是因为 TransactionScope 没有执行 Dispose(),而是事务在循环执行期间已经超时、回滚或进入不可用状态,关联的 SqlConnection 或 LINQ to SQL DataContext 却仍在执行后续 SQL。
示例代码还缺少 scope.Complete()。如果不调用它,离开 using 时事务必然回滚。不过,这通常不会直接引发当前异常。排查时更应关注以下情况:
- 整个循环处于一个持续时间过长的事务中。
- SQL 阻塞、锁等待或服务器负载导致事务偶尔超时。
DataContext或SqlConnection生命周期过长,事务结束后仍被重复使用。- 数据访问层捕获数据库异常后继续执行,导致后续命令使用已经失效的事务。
- 多个连接或资源管理器导致事务升级为分布式事务,而新旧服务器的 MSDTC 或网络配置不同。
不建议只修改 machine.config 来掩盖问题。应先查明事务为什么结束,再决定是缩短事务、分批提交、优化 SQL,还是适当调整超时。
为什么问题是间歇性的
事务是否超时取决于实际耗时,与文件行数没有直接对应关系。一个 200 行的文件也可能比 2,000 行的文件处理得更慢,例如:
- 某些数据命中了锁竞争严重的记录或索引页。
- 不同参数生成了不同的执行计划。
- 某些行触发了额外查询、触发器、级联更新或约束检查。
- 同期运行着其他批处理、报表、备份或维护任务。
- 新数据库的索引、统计信息、兼容级别或硬件配置与旧服务器不同。
- 应用处理每一行时都会重新创建连接、调用存储过程或执行多条 SQL。
- 多个请求并发处理文件,相互持锁和等待。
同一个文件有时成功、有时失败,恰好符合锁等待、系统负载波动或事务超时的表现,不能据此排除超时。
异常通常按以下过程发生:
TransactionScope创建环境事务。SqlConnection自动加入该事务。- 某条 SQL 因阻塞或执行缓慢,使事务超过有效期,或者内部错误导致事务回滚。
- 代码捕获或忽略了之前的异常,继续处理下一行。
- 下一条 SQL 仍通过原来的连接执行。
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 命令允许执行的时间。TransactionScopetimeout:整个事务允许存活的总时间,包括所有命令、锁等待和应用代码耗时。
即使每条 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 秒或某个机器级限制,事务超时的可能性很高。如果每次耗时差异很大,则应继续检查连接复用、异常被吞掉、事务升级和并发问题。
推荐处理顺序
- 补上
scope.Complete()。 - 确保事务内部发生的异常不会被吞掉,随后继续执行。
- 确保每个 WCF 请求单独创建并释放
DataContext,不要跨请求共享。 - 记录整个事务和各数据库调用的准确耗时。
- 从 SQL Server 侧定位阻塞、死锁和慢查询。
- 对比新旧服务器的 .NET、WCF、SQL Server、索引、统计信息和 MSDTC 配置。
- 尽可能改为批量写入或分批事务。
- 只有确认正常业务确实需要更长时间后,才适当提高事务超时。
增加超时可能会暂时降低异常频率。但如果根因是锁竞争、低效的逐行写入或错误的连接生命周期,更长的事务只会持锁更久。超时设置应纳入容量设计,不能作为此类异常的唯一修复手段。
备注:内容仅供参考。
