.NET 資料庫事務的各種玩法進化

nicye發表於2020-08-24

事務是資料庫系統中的重要概念,本文講解作者從業 CRUD 十餘載的事務多種使用方式總結。

  • 以下所有內容都是針對單機事務而言,不涉及分散式事務相關的東西!
  • 關於事務原理的講解不針對具體的某個資料庫實現,所以某些地方可能和你的實踐經驗不符。

認識事務

為什麼需要資料庫事務?

轉賬是生活中常見的操作,比如從A賬戶轉賬100元到B賬號。站在使用者角度而言,這是一個邏輯上的單一操作,然而在資料庫系統中,至少會分成兩個步驟來完成:

1.將A賬戶的金額減少100元

2.將B賬戶的金額增加100元。

在這個過程中可能會出現以下問題:

1.轉賬操作的第一步執行成功,A賬戶上的錢減少了100元,但是第二步執行失敗或者未執行便發生系統崩潰,導致B賬戶並沒有相應增加100元。

2.轉賬操作剛完成就發生系統崩潰,系統重啟恢復時丟失了崩潰前的轉賬記錄。

3.同時又另一個使用者轉賬給B賬戶,由於同時對B賬戶進行操作,導致B賬戶金額出現異常。

為了便於解決這些問題,需要引入資料庫事務的概念。

以上內容引用自:https://www.cnblogs.com/takumicx/p/9998844.html


認識 ADO.NET

ADO.NET是.NET框架中的重要元件,主要用於完成C#應用程式訪問資料庫。

ADO.NET的組成:

System.Data.Common → 各種資料訪問類的基類和介面
System.Data.SqlClient → 對Sql Server進行操作的資料訪問類

a) SqlConnection → 資料庫聯結器
b) SqlCommand → 資料庫命名物件
d) SqlDataReader → 資料讀取器
f) SqlParameter → 為儲存過程定義引數
g) SqlTransaction → 資料庫事物


事務1:ADO.NET

最原始的事務使用方式,缺點:

  • 程式碼又臭又長
  • 邏輯難控制,一不小心就忘了提交或回滾,隨即而來的是資料庫鎖得不到釋放、或者連線池不夠用
  • 跨方法傳遞 Tran 物件太麻煩

推薦:★☆☆☆☆

SqlConnection conn = new SqlConnection(connString);
SqlCommand cmd = new SqlCommand();
cmd.Connection = conn;
try
{
    conn.Open();
    cmd.Transaction = conn.BeginTransaction();//開啟事務
    int result = 0;
    foreach (string sql in sqlList)
    {
        cmd.CommandText = sql;
        result += cmd.ExecuteNonQuery();
    }
    cmd.Transaction.Commit();//提交事務
    return result;
}
catch (Exception ex)
{
    //寫入日誌...
    if (cmd.Transaction != null)
        cmd.Transaction.Rollback();//回滾事務
    throw new Exception("呼叫事務更新方法時出現異常:" + ex.Message);
}
finally
{
    if (cmd.Transaction != null)
        cmd.Transaction = null;//清除事務
    conn.Close();
}

事務2:SqlHelper

原始 ADO.NET 事務程式碼又臭又長,是時候封裝一個 SqlHelper 來操作 ADO.NET 了。比如:

SqlHelper.ExecuteNonQuery(...);
SqlHelper.ExecuteScaler(...);

這樣封裝之後對單次命令執行確實方法不了少,用著用著又發現,事務怎麼處理?重截一個 IDbTransaction 引數傳入嗎?比如:

SqlHelper.ExecuteNonQuery(tran, ...);
SqlHelper.ExecuteScaler(tran, ...);

推薦:★☆☆☆☆

好像也還行,勉強能接受。

隨著在專案不斷的實踐,總有一天不能再忍受這種 tran 傳遞的方式,因為它太容易漏傳,特別是跨方法傳來傳去的時候,真的太難了。


事務3:利用執行緒id

在早期 .NET 還沒有非同步方法的時候,對事務2的缺陷進行了簡單封裝,避免事務 tran 物件傳來傳去的問題。

其原理是利用執行緒id,在事務開啟之時儲存到 staic Dictionary<int, IDbTransaction> 之中,在 SqlHelper.ExecuteXxx 方法執行之前獲取當前執行緒的事務物件,執行命令。

這樣免去了事務傳遞的惡夢,最終呈現的事務程式碼如下:

SqlHelper.Transaction(() =>
{
    SqlHelper.ExecuteNonQuery(...); //不再需要顯式傳遞 tran
    SqlHelper.ExecuteScaler(...);
});

這種事務使用起來非常簡單,不需要考慮事務提交/釋放問題,被預設應用在了 FreeSql 中,缺點:不支援非同步。

推薦:★★★☆☆

同執行緒事務使用簡單,同時又產生了設計限制:

  • 預設是提交,遇異常則回滾;
  • 事務物件線上程掛載,每個執行緒只可開啟一個事務連線,巢狀使用的是同一個事務;
  • 事務體內程式碼不可以切換執行緒,因此不可使用任何非同步方法,包括FreeSql提供的資料庫非同步方法(可以使用任何 Curd 同步方法);

事務4:工作單元

顯式將 ITransaction 物件傳來傳去,說直接點像少女沒穿衣服街上亂跑一樣,不安全。而且到時候想給少女帶點貨(狀態),一絲不掛沒穿衣服咋帶貨(沒口袋)。

這個時候對 ITransaction 做一層包裝就顯得有必要了,在IUnitOfWork 中可以定義更多的狀態屬性。

推薦:★★★★☆

定義 IUnitOfWork 介面如下:

public interface IUnitOfWork : IDisposable
{
    IDbTransaction GetOrBeginTransaction(); //建立或獲取對應的 IDbTransaction
    IsolationLevel? IsolationLevel { get; set; }
    void Commit();
    void Rollback();
}

事務5:AOP 事務

技術不斷在發展,先來一堆理論:

以下內容引用自:https://www.cnblogs.com/zhugenqiang/archive/2008/07/27/1252761.html

AOP(Aspect-Oriented Programming,面向方面程式設計),可以說是OOP(Object-Oriented Programing,物件導向程式設計)的補充和完善。OOP引入封裝、繼承和多型性等概念來建立一種物件層次結構,用以模擬公共行為的一個集合。當我們需要為分散的物件引入公共行為的時候,OOP則顯得無能為力。也就是說,OOP允許你定義從上到下的關係,但並不適合定義從左到右的關係。例如日誌功能。日誌程式碼往往水平地散佈在所有物件層次中,而與它所散佈到的物件的核心功能毫無關係。對於其他型別的程式碼,如安全性、異常處理和透明的持續性也是如此。這種散佈在各處的無關的程式碼被稱為橫切(cross-cutting)程式碼,在OOP設計中,它導致了大量程式碼的重複,而不利於各個模組的重用。

而AOP技術則恰恰相反,它利用一種稱為“橫切”的技術,剖解開封裝的物件內部,並將那些影響了多個類的公共行為封裝到一個可重用模組,並將其名為“Aspect”,即方面。所謂“方面”,簡單地說,就是將那些與業務無關,卻為業務模組所共同呼叫的邏輯或責任封裝起來,便於減少系統的重複程式碼,降低模組間的耦合度,並有利於未來的可操作性和可維護性。AOP代表的是一個橫向的關係,如果說“物件”是一個空心的圓柱體,其中封裝的是物件的屬性和行為;那麼面向方面程式設計的方法,就彷彿一把利刃,將這些空心圓柱體剖開,以獲得其內部的訊息。而剖開的切面,也就是所謂的“方面”了。然後它又以巧奪天功的妙手將這些剖開的切面復原,不留痕跡。

使用“橫切”技術,AOP把軟體系統分為兩個部分:核心關注點和橫切關注點。業務處理的主要流程是核心關注點,與之關係不大的部分是橫切關注點。橫切關注點的一個特點是,他們經常發生在核心關注點的多處,而各處都基本相似。比如許可權認證、日誌、事務處理。Aop 的作用在於分離系統中的各種關注點,將核心關注點和橫切關注點分離開來。正如Avanade公司的高階方案構架師Adam Magee所說,AOP的核心思想就是“將應用程式中的商業邏輯同對其提供支援的通用服務進行分離。”

實現AOP的技術,主要分為兩大類:一是採用動態代理技術,利用擷取訊息的方式,對該訊息進行裝飾,以取代原有物件行為的執行;二是採用靜態織入的方式,引入特定的語法建立“方面”,從而使得編譯器可以在編譯期間織入有關“方面”的程式碼。

最終呈現的使用程式碼如下:

[Transactional]
public void SaveOrder()
{
    SqlHelper.ExecuteNonQuery(...);
    SqlHelper.ExecuteScaler(...);
}

推薦:★★★★☆

利用 [Transactional] 特性標記 SaveOrder 開啟事務,他其實是執行類似這樣的操作:

public void SaveOrder()
{
    var (var tran = SqlHelper.BeginTransaction())
    {
        try
        {
            SqlHelper.ExecuteNonQuery(tran, ...);
            SqlHelper.ExecuteScaler(tran, ...);
            tran.Commit();
        }
        catch
        {
            tran.Roolback();
            throw;
        }
    }
}

解決了即不用顯著傳遞 tran 物件,也解決了非同步邏輯難控制的問題。

目前該事務方式在 Asp.NETCore 中應用比較廣泛,實現起來相當簡單,利用動態代理技術,替換 Ioc 中注入的內容,動態攔截 [Transactional] 特性標記的方法。

使用 Ioc 後就不能再使用 SqlHelper 技術了,此時應該使用 Repository。

組合技術:Ioc + Repository + UnitOfWork

瞭解原理比較重要,本節講得比較抽象,如果想深入瞭解原理,請參考 FreeSql 的使用實現程式碼如下:

自定義倉儲基類

public class UnitOfWorkRepository<TEntity, TKey> : BaseRepository<TEntity, TKey>
{
    public UnitOfWorkRepository(IFreeSql fsql, IUnitOfWork uow) : base(fsql, null, null)
    {
        this.UnitOfWork = uow;
    }
}
public class UnitOfWorkRepository<TEntity> : BaseRepository<TEntity, int>
{
    public UnitOfWorkRepository(IFreeSql fsql, IUnitOfWork uow) : base(fsql, null, null)
    {
        this.UnitOfWork = uow;
    }
}

注入倉儲、單例 IFreeSql、AddScoped(IUnitOfWork)

public static IServiceCollection AddFreeRepository(this IServiceCollection services, params Assembly[] assemblies)
{
    services.AddScoped(typeof(IReadOnlyRepository<>), typeof(UnitOfWorkRepository<>));
    services.AddScoped(typeof(IBasicRepository<>), typeof(UnitOfWorkRepository<>));
    services.AddScoped(typeof(BaseRepository<>), typeof(UnitOfWorkRepository<>));

    services.AddScoped(typeof(IReadOnlyRepository<,>), typeof(UnitOfWorkRepository<,>));
    services.AddScoped(typeof(IBasicRepository<,>), typeof(UnitOfWorkRepository<,>));
    services.AddScoped(typeof(BaseRepository<,>), typeof(UnitOfWorkRepository<,>));

    if (assemblies?.Any() == true)
        foreach (var asse in assemblies)
            foreach (var repo in asse.GetTypes().Where(a => a.IsAbstract == false && typeof(UnitOfWorkRepository).IsAssignableFrom(a)))
                services.AddScoped(repo);

    return services;
}

事務6:UnitOfWorkManager

推薦:★★★★★

(事務5)宣告式事務管理在底層是建立在 AOP 的基礎之上的。其本質是對方法前後進行攔截,然後在目標方法開始之前建立或者加入一個事務,在執行完目標方法之後根據執行情況提交或者回滾事務。

宣告式事務最大的優點就是不需要通過程式設計的方式管理事務,這樣就不需要在業務邏輯程式碼中摻雜事務管理的程式碼,只需在配置檔案中做相關的事務規則宣告(或通過等價的基於標註的方式),便可以將事務規則應用到業務邏輯中。因為事務管理本身就是一個典型的橫切邏輯,正是 AOP 的用武之地。

通常情況下,筆者強烈建議在開發中使用宣告式事務,不僅因為其簡單,更主要是因為這樣使得純業務程式碼不被汙染,極大方便後期的程式碼維護。

和程式設計式事務相比,宣告式事務唯一不足地方是,後者的最細粒度只能作用到方法級別,無法做到像程式設計式事務那樣可以作用到程式碼塊級別。但是即便有這樣的需求,也存在很多變通的方法,比如,可以將需要進行事務管理的程式碼塊獨立為方法等等。

事務6 UnitOfWorkManager 參考隔壁強大的 java spring 事務管理機制,而事務5只能定義單一事務行為(比如不能巢狀),事務6 UnitOfWorkManager 實現的行為機制如下:

六種傳播方式(propagation),意味著跨方法的事務非常方便,並且支援同步非同步:

  • Requierd:如果當前沒有事務,就新建一個事務,如果已存在一個事務中,加入到這個事務中,預設的選擇。
  • Supports:支援當前事務,如果沒有當前事務,就以非事務方法執行。
  • Mandatory:使用當前事務,如果沒有當前事務,就丟擲異常。
  • NotSupported:以非事務方式執行操作,如果當前存在事務,就把當前事務掛起。
  • Never:以非事務方式執行操作,如果當前事務存在則丟擲異常。
  • Nested:以巢狀事務方式執行。

參考 FreeSql 的使用方式如下:

第一步:配置 Startup.cs 注入

//Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IFreeSql>(fsql);
    services.AddScoped<UnitOfWorkManager>();
    services.AddFreeRepository(null, typeof(Startup).Assembly);
}
UnitOfWorkManager 成員 說明
IUnitOfWork Current 返回當前的工作單元
void Binding(repository) 將倉儲的事務交給它管理
IUnitOfWork Begin(propagation, isolationLevel) 建立工作單元

第二步:定義事務特性

[AttributeUsage(AttributeTargets.Method)]
public class TransactionalAttribute : Attribute
{
    /// <summary>
    /// 事務傳播方式
    /// </summary>
    public Propagation Propagation { get; set; } = Propagation.Requierd;
    /// <summary>
    /// 事務隔離級別
    /// </summary>
    public IsolationLevel? IsolationLevel { get; set; }
}

第三步:引入動態代理庫

在 Before 從容器中獲取 UnitOfWorkManager,呼叫它的 var uow = Begin(attr.Propagation, attr.IsolationLevel) 方法

在 After 呼叫 Before 中的 uow.Commit 或者 Rollback 方法,最後呼叫 uow.Dispose

第四步:在 Controller 或者 Service 或者 Repository 中使用事務特性

public class SongService
{
    BaseRepository<Song> _repoSong;
    BaseRepository<Detail> _repoDetail;
    SongRepository _repoSong2;

    public SongService(BaseRepository<Song> repoSong, BaseRepository<Detail> repoDetail, SongRepository repoSong2)
    {
        _repoSong = repoSong;
        _repoDetail = repoDetail;
        _repoSong2 = repoSong2;
    }

    [Transactional]
    public virtual void Test1()
    {
        //這裡 _repoSong、_repoDetail、_repoSong2 所有操作都是一個工作單元
        this.Test2();
    }

    [Transactional(Propagation = Propagation.Nested)]
    public virtual void Test2() //巢狀事務,新的(不使用 Test1 的事務)
    {
        //這裡 _repoSong、_repoDetail、_repoSong2 所有操作都是一個工作單元
    }
}

問題:是不是進方法就開事務呢?

不一定是真實事務,有可能是虛的,就是一個假的 unitofwork(不帶事務)。

也有可能是延用上一次的事務。

也有可能是新開事務,具體要看傳播模式。


結束語

技術不斷的演變進步,從 1.0 -> 10.0 需要慢長的過程。

同時呼籲大家不要盲目使用微服務,演變的過程週期漫長對專案的風險太高。

早上五點半醒來,寫下本文對事務理解的一點總結。謝謝!!

以上各種事務機制在 FreeSql 中都有實現,FreeSql 是功能強大的物件關係對映技術(O/RM),支援 .NETCore 2.1+ 或 .NETFramework 4.0+ 或 Xamarin。支援 MySql/SqlServer/PostgreSQL/Oracle/Sqlite/達夢/人大金倉/神舟通用/Access;單元測試數量 5000+,以 MIT 開源協議託管於 github:https://github.com/dotnetcore/FreeSql

相關文章