C# 從代碼入門 Mysql 數據庫事務

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

生成數據庫數據

爲了演示各種事務操作,我們想要先創建 demo 數據,打開 filldb 官網,根據操作提示生成模擬數據。

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

FillDB 是一款免費工具,可快速生成大量 MySql 格式的自定義數據,用於測試軟件和使用隨機數據填充數據庫。

1702002573903

然後按照 authors、posts 的順序,點擊 Generate ,生成數據庫數據。

因爲 posts 有 authors 的外鍵,因此生成數據的順序是 authors、posts。

1702002601288

image-20231208103015730

最後點擊 Export database 導出 SQL 即可。

image-20231208103109514

1702002685527

然後在數據庫中導入數據。

image-20231208110139302

image-20231208103454494

爲了連接 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.”

image-20231208142521404

當然,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#Y1642

封裝 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 的差異比較大。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章