.NET 數據庫事務的各種玩法進化

事務是數據庫系統中的重要概念,本文講解作者從業 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:SqlHepler

原始 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

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