C# 從程式碼入門 Mysql 資料庫事務

2023-12-13 09:00:27

在業務開發中,使用資料庫事務是必不可少的。而開發中往往會使用各種 ORM 執行資料庫操作,簡化程式碼複雜度,不過,由於各種 ORM 的封裝特性,開發者的使用方式也不一樣,開發者想要了解 ORM 對事務做了什麼處理是比較難的。因此,本文介紹資料庫事務基礎、Ado.net 事務、如何封裝 DbContext ,讀者掌握以後,可以加深對 C# 使用事務的理解,使用各種 ORM 時也會更應手。

生成資料庫資料

為了演示各種事務操作,我們想要先建立 demo 資料,開啟 filldb 官網,根據操作提示生成模擬資料。

filldb 地址: https://filldb.info/dummy/step1

FillDB 是一款免費工具,可快速生成大量 MySql 格式的自定義資料,用於測試軟體和使用亂資料填充資料庫。

然後按照 authors、posts 的順序,點選 Generate ,生成資料庫資料。

因為 posts 有 authors 的外來鍵,因此生成資料的順序是 authors、posts。

最後點選 Export database 匯出 SQL 即可。

然後在資料庫中匯入資料。

為了連線 Mysql 資料庫,這裡使用 MySqlConnector 驅動,請在建立控制檯專案之後,通過 nuget 引入此包。

MySqlConnector 的主要部件和 API 如下:

ADO.NET 型別 說明 非同步方法 同步方法
DbConnection 聯結器 OpenAsync Open
DbConnection BeginTransactionAsync BeginTransaction
DbCommand 執行命令 ExecuteNonQueryAsync ExecuteNonQuery
DbCommand ExecuteReaderAsync ExecuteReader
DbCommand ExecuteScalarAsync ExecuteScalar
DbDataReader 讀取資料 NextResultAsync NextResult
DbDataReader ReadAsync Read
DbTransaction 資料庫事務 CommitAsync Commit
DbTransaction RollbackAsync Rollback

使用同步方法可能會對託管執行緒池產生不利影響,如果沒有正確調優,還會導致速度減慢或鎖定。

Mysql 連線字串設定範例:

const string connectionString = "Server=localhost;Port=3306;User ID=mysqltest;Password=Password123;Database=mysqldb";

或使用 MySqlConnectionStringBuilder 構建連線字串:

var connectionBuilder = new MySqlConnectionStringBuilder()
	{
		Server = "localhost",
		Port = 3306,
		UserID = "mysqltest",
		Password = "Password123",
		Database = "mysqldb"
};
var connectionString = connectionBuilder.ConnectionString;

詳細連線字串設定可以在 https://mysqlconnector.net/connection-options/ 中找到。

為了讓 MysqlConnetor 可以記錄紀錄檔,需要手動設定紀錄檔程式。

完整的 nuget 包如下:

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
    <PackageReference Include="MySqlConnector" Version="2.3.1" />
    <PackageReference Include="MySqlConnector.Logging.Microsoft.Extensions.Logging" Version="2.1.0" />
  </ItemGroup>

設定連線字串、設定紀錄檔、建立資料庫連線,完整程式碼範例如下:

var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = loggerFactory.CreateLogger<Program>();
var dataSourceBuilder = new MySqlDataSourceBuilder(connectionString);
dataSourceBuilder.UseLoggerFactory(loggerFactory);
await using var dataSource = dataSourceBuilder.Build();

using var connection = dataSource.CreateConnection();

經過以上設定之後,我們擁有了模擬資料庫以及基礎程式碼,下面我們來正式學習 MysqlConnetor 和資料庫事務相關的知識。

Mysql 資料庫事務基礎

百度百科:資料庫事務( transaction)是存取並可能操作各種資料項的一個資料庫操作序列,這些操作要麼全部執行,要麼全部不執行,是一個不可分割的工作單位。事務由事務開始與事務結束之間執行的全部資料庫操作組成。

資料庫事務有四個特性:

  • 原子性:原子性是指包含事務的操作要麼全部執行成功,要麼全部失敗回滾。
  • 一致性:一致性指事務在執行前後狀態是一致的。
  • 隔離性:一個事務所進行的修改在最終提交之前,對其他事務是不可見的。
  • 永續性:資料一旦提交,其所作的修改將永久地儲存到資料庫中。

相信大家對資料庫事務都不陌生,因此這裡就不扯淡了,下面來講解不同資料庫事務的特徵。

資料庫的並行一致性問題

雖然資料庫事務可以幫助我們執行資料庫操作、回滾操作,但是資料庫事務並行執行時,事務之間可能會相互干擾,比如髒讀、幻讀等現象,我們使用資料庫事務時,要根據嚴格程度和效能之間相互平衡選擇事務隔離級別。

當多個事務並行執行時,可能會出現以下問題:

髒讀

​ 事務 A 更新了資料,但還沒有提交,這時事務 B 讀取到事務 A 更新後的資料,然後事務 A 回滾了,事務 B 讀取到的資料就成為髒資料了。

不可重複讀

​ 事務 A 對資料進行多次讀取,事務 B 在事務 A 多次讀取的過程中執行了更新操作並提交了,導致事務 A 多次讀取到的資料並不一致。

不可重複讀,特徵是相同的資料,在事務 A 的不同階段讀取的資料不一樣。

幻讀

​ 事務 A 在讀取資料後,事務 B 向事務A讀取的資料中插入了幾條資料,事務 A 再次讀取資料時發現多了幾條資料,和之前讀取的資料不一致。

幻讀,前後資料量不一樣。

丟失修改

​ 事務 A 和事務 B 都對同一個資料進行修改,事務 A 先修改,事務 B 隨後修改,事務 B 的修改覆蓋了事務 A 的修改。

不可重複度和幻讀看起來比較像,它們主要的區別是:在不可重複讀中,發現資料不一致主要是資料被更新了。在幻讀中,發現資料不一致主要是資料增多或者減少了。

資料庫事務的隔離級別

資料庫事務的隔離級別有以下四種,按隔離級別從低到高:

  • 未提交讀:一個事務在提交前,它的修改對其他事務也是可見的。
  • 提交讀:一個事務提交之後,它的修改才能被其他事務看到。
  • 可重複讀:在同一個事務中多次讀取到的資料是一致的。
  • 序列化:需要加鎖實現,會強制事務序列執行。

Ado.net 中使用 System.Data.IsolationLevel 列舉表示以上幾種資料庫事務隔離級別:

	public enum IsolationLevel
	{
        // 未指定
		Unspecified = -1,
        // 不能覆蓋來自更高度隔離的事務的掛起的更改。
		Chaos = 16,
        // 未提交讀,髒讀是可能的,這意味著不會發出共用鎖,也不會使用獨佔鎖。
		ReadUncommitted = 256,
        // 提交讀,在讀取資料時持有共用鎖,以避免髒讀,但是資料可以在事務結束之前更改,從而導致不可重複讀取或幻像資料。
		ReadCommitted = 4096,
        // 可重複讀,鎖被放置在查詢中使用的所有資料上,防止其他使用者更新資料。防止不可重複讀取,但仍然可以使用幻像行。
		RepeatableRead = 65536,
        // 序列化,將在 DataSet 上放置一個範圍鎖,以防止其他使用者在事務完成之前更新資料集或將行插入資料集。
		Serializable = 1048576,
        // 通過儲存一個應用程式可以讀取而另一個應用程式正在修改相同資料的資料版本來減少阻塞。
        // 指示即使重新查詢,也無法從一個事務中看到在其他事務中所做的更改。
		Snapshot = 16777216
	}

資料庫的隔離級別分別可以解決資料庫的髒讀、不可重複讀、幻讀等問題。

隔離級別 髒讀 不可重複讀 幻讀
未提交讀 允許 允許 允許
提交讀 不允許 允許 允許
可重複讀 不允許 不允許 允許
序列化 不允許 不允許 不允許

其實也不必糾結這些問題,可以按照讀寫鎖的情況來理解。

程式設計中由於多個執行緒並行操作兩個字典:

Dictionary<string, string> a;
Dictionary<string, string> b;

第一個問題時,並行操作一個字典時,會出現執行緒並行異常。

所以,我們想要使用並行字典:

	ConcurrentDictionary<string, string> a;
	ConcurrentDictionary<string, string> b;

可是,當 T1 執行緒修改 a 完成,接著修改 b 時,執行緒 T2 把字典 a 修改了。這就導致了資料不一致。

使用讀寫鎖優化,將 a、b 兩個封包在一起:

	ConcurrentDictionary<string, string> a;
	ConcurrentDictionary<string, string> b;

	private static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
	// 讀
	private void Read()
	{
		try
		{
			_lock.EnterReadLock(); 
			// 讀
		}
		catch { }
		finally
		{
			_lock.ExitReadLock();            // 釋放讀取鎖
		}
	}

	// 寫
	public void Write(int key, int value)
	{
		try
		{
			_lock.EnterUpgradeableReadLock();
			_lock.EnterWriteLock();
			// 寫
			_lock.ExitWriteLock();
		}
		catch { }
		finally
		{
			_lock.ExitUpgradeableReadLock();
		}
	}

讀寫鎖的原理很簡單,讀和寫是兩個衝突的操作。當沒有執行緒 時,多個執行緒可以並行 ,此時不會有任何問題。當有一個執行緒 時,既不允許有其它執行緒同時在 ,也不允許其它執行緒同時在 。也就是說, 是可以並行的,但是寫是獨佔的。

序列化

當然對於資料庫事務就複雜了很多。如果要按照讀寫鎖的形式去做,那麼其隔離級別相當於 序列化,整個表都被鎖住,不允許事務並行執行,此時不會有 髒讀不可重複讀幻讀 這些情況。

可是,這樣對於資料庫來說壓力是很大的,會嚴重拖垮資料庫的效能,以及嚴重降低了業務程式的並行量。

當事務 A 只需要修改 id=1,2,3 的資料時,使用 序列化 級別,會鎖住整個表。這樣似乎有點太浪費了。

可重複讀

那麼,我們只需要鎖住事務 A 正在修改的那幾行記錄不就行了嗎?那麼我們把資料庫事務下降一個級別,使用 可重複讀

使用 可重複讀 事務級別,其被鎖住的資料,依然保持安全,也就是不會被其它事務所修改。所以,不會出現 髒讀不可重複讀。但是因為不是鎖住整個表,因此其它事務是可以插入資料的,這就導致了會出現 幻讀。當然,可重複讀 出現的問題,一般來說只需要保證事務中只處理自己想要的資料即可。

可重複讀 導致的 幻讀 問題,比如 A 事務在 筆電 分類下給聯想筆電型號都打 9 折優惠,可是此時 B 事務從 筆電 分類下,增加了幾個理想筆電型號。結果,事務 A 最後一查詢,把 B 事務插入的資料查詢出來了。那麼事務 A 查詢的資料就包含了打折和未打折的資料了。

InnoDB 使用 MVCC 來實現高並行性,並實現了所有 4 個SQL標準隔離級別。InnoDB 預設為 REPEATABLE READ (可重複讀)隔離級別,並且通過間隙鎖(next-key locking)策略來防止在這個隔離級別上的幻讀。InnoDB 不只鎖定在查詢中涉及的行,還會對索引結構中的間隙進行鎖定,以防止幻行被插入。

提交讀

使用範例:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE  pet SET NAME = 'A';
SELECT SLEEP(5);
SELECT * from pet;
COMMIT;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE  pet SET NAME = 'B';
SELECT SLEEP(5);
SELECT * from pet;
COMMIT;

A 事務和 B 事務執行時,大家都對 name 做了修改,但是事務只能看到自己做出的修改,也就是說,B 事務未提交之前,A、B 都修改了資料,但是是隔離的。

A 事務修改了 name = A ,B 事務修改了 name = B ,未提交之前,A、B 事務讀到的分別是 A、B,這沒問題,不會干擾。

但是如果 A 先提交了事務,那麼資料庫的 name 值就為 A,此時 B 事務還沒有提交,B 查詢到的 name = A,這就是不可重複讀。

提交讀 只能保證事務未提交前的資料隔離。當另一個事務提交後,會導致當前事務看到的資料前後不一樣。

未提交讀

這就離譜了。啥也不能保證。

對於資料庫事務的理解,大家倒序建議就比較容易理解了。

BeginTransaction() 和 TransactionScope 的區別

在 C# Ado.net 中,主要有兩種事務使用方式:

// 方式 1:
using var tran = await connection.BeginTransactionAsync();

// 方式 2:
using (TransactionScope transactionScope = new TransactionScope())
{

}

BeginTransaction() 由 IDbConnection 連線物件開啟,只能作用於當前 IDbConnection 。通過呼叫資料庫連線物件的 BeginTransaction() 方法,顯式地啟動了一個資料庫事務,因此與同步方法非同步方法不衝突。

TransactionScope 內部封裝了一些 API,在TransactionScope設定的範圍內,不需要顯式地呼叫 Commit()Rollback() 方法,可以跨 IDbConnection 使用,在非同步方法下使用需要做額外設定。

主要區別在於 BeginTransaction() 是顯式地管理事務,而 TransactionScope 則是在程式設計模型上提供了更為方便的自動事務管理機制。

在 System.Transactions 名稱空間中存在很多與事務相關的程式碼封裝。讀者可以自行了解:

https://learn.microsoft.com/en-us/dotnet/api/system.transactions?view=net-8.0

下面來詳細說明兩種事務開啟方式的使用區別。

BeginTransaction()

先說 BeginTransaction() ,其返回的是 DbTransaction 型別。

BeginTransaction() 開啟事務比較簡單,不過需要手動給 IDbCommand 設定事務屬性。

			await connection.OpenAsync();
           // 先開啟事務,再建立命令
			using var tran = await connection.BeginTransactionAsync();
			using var command = new MySqlCommand()
			{
				Connection = connection,
                // 注意這裡
				Transaction = tran
			};

			try
			{
				command.CommandText = "... ...";
				await command.ExecuteNonQueryAsync();

				if(...)
				{
					await tran.CommitAsync();
				}else
				{
					await tran.RollbackAsync();
				}
			}
			catch (Exception ex)
			{
				await tran.RollbackAsync();
                logger.LogError(ex, "Tran error");
			}

BeginTransaction() 定義如下:

ValueTask<MySqlTransaction> BeginTransactionAsync(IsolationLevel isolationLevel, 
                                                  CancellationToken cancellationToken = default)

DbTransaction 還可以設定儲存點。

			using var tran = await connection.BeginTransactionAsync();
			try
			{
				command.CommandText = "... ...";
				await command.ExecuteNonQueryAsync();

				// 儲存點
				await tran.SaveAsync("stepa");

				// 釋放儲存點、回滾到該儲存點
				if(...)
				{
					await tran.ReleaseAsync("stepa");
				}
			}

BeginTransaction() 的使用比較簡單,也不太容易出錯。

可以不手動復原

很多時候我們會在 catch{} 回滾事務,如下程式碼所示。

			try
			{
                ... ...
				await tran.CommitAsync();
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error");
				await tran.RollbackAsync();
			}

實際上是當一個事務在 IDbConnection 中或者在此 IDbCommand 中沒有主動提交時,當物件生命週期結束或主動斷開連線時、被回收到連線池時,事務會自動回滾。只要沒有主動提交,則之前的操作皆無效。

比如,我們執行下面的 SQL 時,posts 表會被插入一條新的資料,id 為 101。

-- 開啟事務
BEGIN; -- 或者使用 START TRANSACTION;
INSERT INTO demo.posts (id, author_id, title, description, content, date)
VALUES (101, 1, '測試', '測試', '測試', '2023-12-08');
COMMIT ;

而執行以下程式碼時,因為沒有呼叫 CommitAsync() 方法提交事務,因此程式結束後,插入資料庫的資料並不會起效。

			using var connection = dataSource.CreateConnection();
			await connection.OpenAsync();
			using var tran = await connection.BeginTransactionAsync();
			using var command = new MySqlCommand()
			{
				Connection = connection,
				Transaction = tran
			};

			try
			{
				command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (102, 1, '測試', '測試', '測試', '2023-12-08');
				""";
				await command.ExecuteNonQueryAsync();
                // await tran.CommitAsync();
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error");
			}

TransactionScope

如以下程式碼所示,雖然程式碼執行不會報錯,但是其不受事務所控制,也就是說,雖然沒有提交,但是資料庫實實在在的插入了一條新的資料。

這是因為事務完全沒有起效,因為只有在 TransactionScope 中開啟的資料庫連線,才會起效

			using var connection = dataSource.CreateConnection();
			await connection.OpenAsync();

			using (TransactionScope transactionScope = new TransactionScope())
			{
				var command = connection.CreateCommand();
				try
				{
					command.CommandText = 
                        """
                        INSERT INTO demo.posts (id, author_id, title, description, content, date) 
                        VALUES (103, 1, '測試', '測試', '測試', '2023-12-08');
                        """;
					await command.ExecuteNonQueryAsync();
					//transactionScope.Complete();
				}
				catch (Exception ex)
				{
					logger.LogError(ex, "Tran error");
				}
			}

修正之後:

			using (TransactionScope transactionScope = new TransactionScope())
			{
				using var connection = dataSource.CreateConnection();
				await connection.OpenAsync();

				var command = connection.CreateCommand();
				try
				{
					command.CommandText = 
                        """
                        INSERT INTO demo.posts (id, author_id, title, description, content, date) 
                        VALUES (104, 1, '測試', '測試', '測試', '2023-12-08');
                        """;
					await command.ExecuteNonQueryAsync();
					//transactionScope.Complete();
				}
				catch (Exception ex)
				{
					logger.LogError(ex, "Tran error");
				}
			}

但是,上面的程式碼還是會報錯。這是因為 TransactionScope 預設不支援非同步方法,而該程式碼使用了非同步,導致釋放時沒有使用相同的執行緒。

System.InvalidOperationException:「A TransactionScope must be disposed on the same thread that it was created.」

當然,TransactionScope 是支援非同步的,我們只需要啟用設定即可。

			using (TransactionScope transactionScope = 
			new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
			{
				using var connection = dataSource.CreateConnection();
				await connection.OpenAsync();

				var command = connection.CreateCommand();
				try
				{
					command.CommandText = 
                        """
                        INSERT INTO demo.posts (id, author_id, title, description, content, date) 
                        VALUES (104, 1, '測試', '測試', '測試', '2023-12-08');
                        """;
					await command.ExecuteNonQueryAsync();
					//transactionScope.Complete();
				}
				catch (Exception ex)
				{
					logger.LogError(ex, "Tran error");
				}
			}

如下程式碼所示,當執行程式碼之後,因為我們沒有主動提交事務,因此,資料庫中不會真的插入資料。

			using (TransactionScope transactionScope = 
                   // 使其支援非同步
                   new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
			{
				using var connection = dataSource.CreateConnection();
				await connection.OpenAsync();

				var command = connection.CreateCommand();

				command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (105, 1, '測試', '測試', '測試', '2023-12-08');
				""";
                    
				await command.ExecuteNonQueryAsync();
				//transactionScope.Complete();
			}

有了經驗之後,我們發現,如果我們不呼叫 Complete() 方法,那麼資料庫中不會真的插入資料。

可是問題來了,因為是在 TransactionScope 中建立 IDbConnection 並開啟連線,也就是說 TransactionScope 作用域範圍大於 IDbConnection ,那麼 IDbConnection 釋放之後,再提交 TransactionScope ,是否可以?

			using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
			{
				using (var connection = dataSource.CreateConnection())
				{
					await connection.OpenAsync();
					var command = connection.CreateCommand();
					command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (105, 1, '測試', '測試', '測試', '2023-12-08');
				""";
					await command.ExecuteNonQueryAsync();
				}

				transactionScope.Complete();
			}

答案是一切正常。

簡化程式碼如下所示:

			using (TransactionScope transactionScope = ...)
			{
				using (var connection = dataSource.CreateConnection())
				{
					await connection.OpenAsync();
					await command.ExecuteNonQueryAsync();
				}

				transactionScope.Complete();
			}

雖然, IDbConnection 在 using 中,transactionScope.Complete() 在 using 之外,但是事務依然可以起效。如果呼叫 .Complete(),則事務提交。如果不呼叫 .Complete() 則事務不會提交。

回到本小節第一個程式碼範例中,事務不起效的問題。我們已經知道了是因為 IDbConnection 沒有在 TransactionScope 內建立,所以導致事務不能作用。

但是,對於 ASP.NET Core 程式、Context 形式的 ORM、倉儲形式的 ORM 等,由於其封裝在上下文內,不太可能在開發者使用 TransactionScope 時,再手動開啟 IDbConnection.Open() 。不過這些 ORM 框架大多數都做了封裝,而本文末尾也介紹了幾種封裝方式。

總結

通過 BeginTransaction() 建立的事務,不會因為非同步等出現問題,因為其是明確在一個 IDbCommand 、IDbConnection 中起效。

			using var tran = await connection.BeginTransactionAsync();
			using var command = new MySqlCommand()
			{
				Connection = connection,
                // 注意這裡
				Transaction = tran
			};

所以說,通過 .BeginTransactionAsync() 使用事務,是最簡單、最不容易出錯的,而且其明確在哪個 IDbCommand 中使用事情,出現問題時,排除起來也相對簡單。

而對於 TransactionScope 來說,筆者花費了比較多的篇幅去實驗和解釋,TransactionScope 是使用事務作用域實現隱式事務的,使用起來有一定難度,也容易出錯。

DML 是否可以使用事務

開始的時候,筆者並沒有想到這個事情,在跟同事偶然吹水時,提到了這個事情。

Mysql 的事務對刪除表、建立表這些 DML 命令,其事務是無效的,起效的是表資料相關的操作,即 insert、update、delete 語句。

如下 SQL 所示,雖然回滾了事務,但是最後還是建立了檢視。

-- 開啟事務
use  demo;
BEGIN;
create view v_posts AS  SELECT * FROM posts;
ROLLBACK;
-- COMMIT ;

順序多操作

先從 TransactionScope 說起,情況如下程式碼所示:

TransactionScope 中包含、建立了兩個 IDbConnection ,並且兩個 IDbConnection 都插入了資料。

也就是說使用 TransactionScope 同時管理多個 IDbConnection 。

			using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
			{
				using (var connection = dataSource.CreateConnection())
				{
					await connection.OpenAsync();
					var command = connection.CreateCommand();
					command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (108, 1, '測試', '測試', '測試', '2023-12-08');
				""";
					await command.ExecuteNonQueryAsync();
				}

				using (var connection = dataSource.CreateConnection())
				{
					await connection.OpenAsync();
					var command = connection.CreateCommand();
					command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (109, 1, '測試', '測試', '測試', '2023-12-08');
				""";
					await command.ExecuteNonQueryAsync();
				}

				//transactionScope.Complete();
			}

這樣是可以的,TransactionScope 管理在期內的所有 IDbConnection,讓他們在當前的事務中保持一致。

但是 BeginTransaction() 是使用 IDbConnection.BeginTransaction() 建立的,不能跨 IDbConnection 使用。

比如,以下程式碼會報錯:

			using var connection1 = dataSource.CreateConnection();
			using var connection2 = dataSource.CreateConnection();
			await connection1.OpenAsync();
			await connection2.OpenAsync();

			try
			{
				var tran1 = connection1.BeginTransaction();

				var command1 = connection1.CreateCommand();
				command1.Transaction = tran1;
				var command2 = connection2.CreateCommand();
				command2.Transaction = tran1;

				command1.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (108, 1, '測試', '測試', '測試', '2023-12-08');
				""";
				await command1.ExecuteNonQueryAsync();
				command2.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (108, 1, '測試', '測試', '測試', '2023-12-08');
				""";
				await command2.ExecuteNonQueryAsync();
				tran1.Commit();
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error");
			}

所以,這裡又有一個區別。

巢狀事務

.BeginTransaction() 不支援巢狀事務,程式碼如下所示:

		static async Task Main(string[] args)
        {
            using var connection = dataSource.CreateConnection();
			await connection.OpenAsync();
			var tran = connection.BeginTransaction();

			try
			{
				var command = connection.CreateCommand();
				command.Transaction = tran;
				command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (110, 1, '測試', '測試', '測試', '2023-12-08');
				""";
				await command.ExecuteNonQueryAsync();

				// 巢狀事務
				try
				{
					await InsertAsync(connection);
				}
				catch (Exception ex)
				{
					logger.LogError(ex, "Tran error.");
					await tran.RollbackAsync();
					return;
				}

				await tran.RollbackAsync();
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error");
			}
		}

		// 巢狀的子事務
		private static async Task InsertAsync(MySqlConnection connection)
		{
			var tran = connection.BeginTransaction();
			var command = connection.CreateCommand();
			command.Transaction = tran;
			command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (112, 1, '測試', '測試', '測試', '2023-12-08');
				""";
			await command.ExecuteNonQueryAsync();
			await tran.CommitAsync();
		}

當一個 IDbConnection 呼叫兩次 .BeginTransaction() 時,程式碼會報錯。

 System.InvalidOperationException: Transactions may not be nested.

所以,我們只能寄望於 TransactionScope。

使用 TransactionScope 做巢狀事務,可以做到靈活的邏輯客製化,每個巢狀子事務都有自己的邏輯。

每個子事務只需要正常編寫自己的 TransactionScope 即可,即使子事務的 TransactionScope 已提交,如果最外層的 TransactionScope 事務沒有提交,則所有的事務都不會提交。

如下程式碼所示:

	static async Task Main(string[] args)
	{
		using var connection = dataSource.CreateConnection();
		using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
		{
			await connection.OpenAsync();
			var command = connection.CreateCommand();
			command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (110, 1, '測試', '測試', '測試', '2023-12-08');
				""";
			await command.ExecuteNonQueryAsync();

			// 巢狀事務
			try
			{
				await InsertAsync(connection);
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error.");
				return;
			}
			// transactionScope.Complete();
		}
	}

	// 巢狀的子事務
	private static async Task InsertAsync(MySqlConnection connection)
	{
		using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
		{
			var command = connection.CreateCommand();
			command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (112, 1, '測試', '測試', '測試', '2023-12-08');
				""";
			await command.ExecuteNonQueryAsync();
			transactionScope.Complete();
		}
	}

雖然 InsertAsync() 中的事務已經提交,但是由於其受到外層 TransactionScope 事務的影響,因此當外層事務不提交時,子事務也不會提交。

當然,即使不是同一個 IDbConnection 也是可以的。

	static async Task Main(string[] args)
	{
		using var connection = dataSource.CreateConnection();
		using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
		{
			await connection.OpenAsync();
			var command = connection.CreateCommand();
			command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (110, 1, '測試', '測試', '測試', '2023-12-08');
				""";
			await command.ExecuteNonQueryAsync();

			// 巢狀事務
			try
			{
				await InsertAsync();
			}
			catch (Exception ex)
			{
				logger.LogError(ex, "Tran error.");
				return;
			}
			// transactionScope.Complete();
		}
	}

	// 巢狀的子事務
	private static async Task InsertAsync()
	{
		using var connection = dataSource.CreateConnection();
		using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
		{
			await connection.OpenAsync();
			var command = connection.CreateCommand();
			command.CommandText = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (112, 1, '測試', '測試', '測試', '2023-12-08');
				""";
			await command.ExecuteNonQueryAsync();
			transactionScope.Complete();
		}
	}

所以,每個方法的程式碼,只需要關注自己的邏輯即可。對於模組分離、職責分離的程式碼很有用。

事務範圍

前面我們提到了 TransactionScope 的巢狀事務。

TransactionScope 對於巢狀事務的處理,有一個 TransactionScopeOption 列舉設定。

	public enum TransactionScopeOption
	{
        // 該範圍需要一個事務。 如果已經存在環境事務,則使用該環境事務。 否則,在進入範圍之前建立新的事務。 這是預設值。
		Required = 0,
        
        // 總是為該範圍建立新事務。
		RequiresNew = 1,
        
        // 如果使用 Suppress 範例化範圍,則無論是否存在環境事務,該範圍都不會參與事務。使用此值範例化的範圍始終將 null 作為其環境事務。
		Suppress = 2
	}

使用範例:

using(TransactionScope scope1 = new TransactionScope())
{
    // 預設支援巢狀
    using(TransactionScope scope2 = new TransactionScope(TransactionScopeOption.Required))
    {
        //...
    }
    
    // 不受 scope1 的影響
    using(TransactionScope scope3 = new TransactionScope(TransactionScopeOption.RequiresNew))
    {
        //...  
    }
  
    // 如果使用 Suppress 範例化範圍,則無論是否存在環境事務,該範圍都不會參與事務。
    using(TransactionScope scope4 = new TransactionScope(TransactionScopeOption.Suppress))
    {
        //...  
    }
}

對於巢狀事務作用域範圍,讀者可以從這篇文章中瞭解更多:https://learn.microsoft.com/en-us/previous-versions/ms172152(v=vs.90)?redirectedfrom=MSDN

封裝 DbContext

前面提到過,IDbConnection 需要在 TransactionScope 中開啟連線,TransactionScope 才能管控其連線的事務。

不過,有一些資料庫驅動已經支援了 TransactionScope ,即使不在其內開啟連結也可以。比如 EFCore 框架,EFCore 自動管理 IDbConnection 的生命週期,因此我們往往不會手動管理連線,因此事務事務時,我們不太可能這樣做:

MyContext _context;

using (TransactionScope transactionScope = ...)
{
    _context.Connection.Open()
}

在使用資料庫事務之前,往往連線早就已經開啟了。

MyContext _context;
_context.SelectAsync()....
_context.User.SectAsync()....
using (TransactionScope transactionScope = ...)
{
}

所以,我們需要封裝一個上下文型別,能夠在連線開啟後,自動使用上下文的事務。

TransactionScope

封裝一個資料庫上下文,執行命令時,如果發現其在事務範圍內,則主動使用上下文事務。

	public class DbContext
	{
		private readonly DbConnection _connection;

		public DbContext(DbConnection connection)
		{
			_connection = connection;
		}

		public async Task ExecuteAsync(string sql)
		{
			var command = _connection.CreateCommand();
            // 獲取當前事務
			var tran = Transaction.Current;
			if (tran != null)
			{
                // 注意這裡。
				_connection.EnlistTransaction(tran);
			}

			command.CommandText = sql;
            
			await command.ExecuteNonQueryAsync();
		}
	}

使用範例:

		using var connection = dataSource.CreateConnection();
// 在之外開啟
		await connection.OpenAsync();
		var context = new DbContext(connection);

		using (TransactionScope transactionScope = new TransactionScope(asyncFlowOption: TransactionScopeAsyncFlowOption.Enabled))
		{
			var sql = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (111, 1, '測試', '測試', '測試', '2023-12-08');
				""";

			await context.ExecuteAsync(sql);
		}

BeginTransaction()

使用上下文的形式封裝 BeginTransaction() 開啟的事務比較簡單,只需要手動維護 DbTransaction 即可。


	public class DbContext
	{
		private readonly DbConnection _connection;
		private DbTransaction? _tran;
		public DbContext(MySqlConnection connection)
		{
			_connection = connection;
		}

		public async Task OpenTran()
		{
			if (_tran != null) throw new Exception("請勿重複開啟事務");
			_tran = await _connection.BeginTransactionAsync();
		}

		public async Task ExecuteAsync(string sql)
		{
			var command = _connection.CreateCommand();
			command.CommandText = sql;

			if (_tran != null)
			{
				command.Transaction = _tran;
			}
			await command.ExecuteNonQueryAsync();
		}

		public async Task EndTran()
		{
			if (_tran == null) throw new Exception("未開啟事務");
			await _tran.CommitAsync();
			_tran.Dispose();
			_tran = null;
		}
	}

使用方法:

		using var connection = dataSource.CreateConnection();
		await connection.OpenAsync();
		DbContext context = new DbContext(connection);

		await context.OpenTran();
		var sql = """
				INSERT INTO demo.posts (id, author_id, title, description, content, date) 
				VALUES (111, 1, '測試', '測試', '測試', '2023-12-08');
				""";
		await context.ExecuteAsync(sql);

當然,由於不同的 ORM 封裝的資料庫事務方法不一樣,因此 ORM 的差異比較大。