.NETにおけるトランザクション管理
.NETアプリケーションにおけるデータ整合性を保つためのトランザクション管理について解説します。System.Transactionsを使用した暗黙的なトランザクションから、ADO.NETやEntity Framework Coreを使用した明示的なトランザクションまで、主要な実装パターンと注意点を網羅します。
トランザクションの基本
トランザクションとは、複数の操作を一つの論理的な作業単位として扱い、**すべて成功するか、すべて失敗するか(All or Nothing)**を保証する仕組みです。ACID特性(Atomicity, Consistency, Isolation, Durability)を満たすことが求められます。
1. TransactionScope (System.Transactions)
System.Transactions.TransactionScope は、.NETで最も推奨される汎用的なトランザクション管理方法です。リソースマネージャー(SQL Serverなど)に対して暗黙的にトランザクションを管理します。
基本的な使い方
using ブロックでスコープを定義し、最後に Complete() を呼び出すことでコミットされます。Complete() が呼ばれずにブロックを抜けると、自動的にロールバックされます。
using System.Transactions;
public void TransferMoney(int fromAccountId, int toAccountId, decimal amount)
{
// TransactionScopeを作成
// デフォルトの分離レベルは Serializable なので注意が必要(後述)
using (var scope = new TransactionScope())
{
// データベース操作1: 出金
_accountRepository.Withdraw(fromAccountId, amount);
// データベース操作2: 入金
_accountRepository.Deposit(toAccountId, amount);
// トランザクションをコミット
scope.Complete();
}
}
非同期コードでの注意点 (Async/Await)
.NET 4.5.1 以降および .NET Core / .NET 5+ では、非同期メソッドで TransactionScope を使用する場合、TransactionScopeAsyncFlowOption.Enabled を指定する必要があります。これを指定しないと、await の後に別のスレッドで処理が再開された際、トランザクションコンテキストが引き継がれず、意図しない動作(トランザクション外での実行など)を引き起こす可能性があります。
public async Task TransferMoneyAsync(int fromAccountId, int toAccountId, decimal amount)
{
// 非同期フローを有効にする
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
await _accountRepository.WithdrawAsync(fromAccountId, amount);
await _accountRepository.DepositAsync(toAccountId, amount);
scope.Complete();
}
}
分離レベルの指定
TransactionScope のデフォルトの分離レベルは Serializable(直列化可能)です。これは最も厳格なレベルですが、ロックの競合が発生しやすく、パフォーマンスに影響を与える可能性があります。多くの場合、ReadCommitted が実用的です。
var options = new TransactionOptions
{
IsolationLevel = IsolationLevel.ReadCommitted,
Timeout = TimeSpan.FromMinutes(1)
};
using (var scope = new TransactionScope(TransactionScopeOption.Required, options))
{
// ...
scope.Complete();
}
2. Entity Framework Core トランザクション
EF Core は、SaveChanges() 呼び出し時に自動的にトランザクションを作成・コミット・ロールバックします。しかし、複数の SaveChanges を一つのトランザクションにまとめたい場合は、明示的なトランザクション制御が必要です。
IDbContextTransaction の使用
using var context = new MyDbContext();
// トランザクション開始
using var transaction = context.Database.BeginTransaction();
try
{
context.Users.Add(new User { Name = "Alice" });
context.SaveChanges();
context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/alice" });
context.SaveChanges();
// コミット
transaction.Commit();
}
catch (Exception)
{
// ロールバック(usingブロックを抜ける際にも自動で行われるが、明示的に書くことも可能)
// transaction.Rollback();
throw;
}
TransactionScope との併用
EF Core は TransactionScope にも対応しています。すでに TransactionScope 内で実行されている場合、EF Core はそのトランザクションに参加します。
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
using var context = new MyDbContext();
context.Users.Add(newUser);
await context.SaveChangesAsync();
// 他のDB操作やサービス呼び出し...
scope.Complete();
}
3. ADO.NET トランザクション (SqlTransaction)
SqlConnection などの低レベルAPIを使用する場合、BeginTransaction メソッドを使用します。
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
// トランザクション開始
using (var transaction = connection.BeginTransaction())
{
try
{
var command = connection.CreateCommand();
command.Transaction = transaction; // コマンドにトランザクションを紐付ける必須操作
command.CommandText = "INSERT INTO ...";
command.ExecuteNonQuery();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
ベストプラクティスと注意点
1. トランザクションの期間は短く保つ
トランザクション中はデータベースのリソース(ロックなど)を保持し続けます。長時間実行される処理(外部API呼び出しや複雑な計算など)はトランザクションの外に出し、DB操作のみをトランザクション内に含めるようにします。
2. 適切な分離レベルを選択する
- ReadCommitted: 一般的なWebアプリケーションで推奨。コミットされたデータのみ読み取る。
- Serializable: 最も安全だが、デッドロックやタイムアウトのリスクが高い。
- Snapshot: 読み取り一貫性を提供するが、tempdbの使用量が増える(SQL Serverの場合)。
3. 分散トランザクション (MSDTC) の回避
複数の異なるデータベース(例: SQL Server A と SQL Server B)にまたがる TransactionScope は、Windows環境では MSDTC (Microsoft Distributed Transaction Coordinator) に昇格する可能性があります。クラウド環境やコンテナ環境では MSDTC の利用が難しい場合があるため、可能な限り同一DB内でのトランザクションに留めるか、Sagaパターンなどの結果整合性アプローチを検討してください。
※ .NET Core / .NET 5以降では、Windows以外での分散トランザクションのサポートは限定的です。
4. 接続管理
TransactionScope を使用する場合、スコープ内でデータベース接続を開き、スコープ内で閉じることが推奨されます。これにより、接続が自動的にトランザクションに参加します。
// 良い例
using (var scope = new TransactionScope(...))
{
using (var conn = new SqlConnection(...)) // スコープ内でOpen
{
conn.Open();
// ...
}
scope.Complete();
}