上一篇玩了用效能監視器實地觀察 SQL Connection Pooling 運作,做實驗做上癮,就再來觀察另一個我好奇的議題 - EF Core 更新資料時會怎麼包 Transaction?

我們都知道,對 DbContext.DbSet<T> 進行 Add()、Remove() 等操作,或是取得資料物件修改屬性值,都會延遲到 SaveChanges() 才實際對資料庫執行。至於 SaveChanges() 包成交易的方式,官方文件交易 - EF Core | Microsoft Docs 寫得蠻清楚,整理重點如下:

  • 每次呼叫 SaveChanges() 時,多筆資料異動指令會自動包成一個 Transaction,任一個動作失敗均會觸發 Rollback。
  • 透過 DbContext.DataBase.BeginTransaction() 可取得 IDbContextTransaction,可將多次 SaveChanges() 包成一個 Transaction,由程式決定何時 Commit 或 Rollback。
  • 多個 DbContext 可以共用 DbConnection 及 IDbContextTransaction (前題是兩個 DbContext 背後的資料庫是同一個),共用方法是 DbContext 建構式接受外部傳入 DbConnection,以及呼叫 DbContext.DataBase.UseTransaction()。
  • 另一種更無腦的寫法是用 TransactionScope 把要包成交易的更新動作包起來。前題是資料庫的 .NET Core 版程式庫要支援 System.Transactions,同時有一點要特別注意:.NET Core 2.1 起,因跨平台限制 System.Transactions 不支援分散式交易! 好消息是 .NET 5.0 System.Transactions 應會加入分散式交易支援(已排入 .NET 5.0 Milestone)。

第一個實驗是讓 SaveChanges() 包含兩個資料庫更新動作,故意讓第二個動作失敗,觀察第一個更新是否有 Rollback?

照慣例借用 ASP.NET Core CRUD 傻瓜範例的 MSSQL LocalDB 當白老鼠。開啟 Microsoft SQL Server Management Studio (SSMS) / SQL Server Profiler 進行觀察:

由於要觀察 Transaction,要加選幾個 Event:

接著要讓更新動作故意出錯,User 欄位為 VARCHAR(8),塞入長一點的字串一定出錯,是最淺顯易懂的爆炸點:

我新增了一個 Razor Page - TestSaveChanges.cshtml,在 OnGet 裡新增兩筆 Record,第二筆故意讓 Record.User 長度超過八個字元:

執行程式,如願產生 String or binary data would be truncated. 錯誤。檢查資料庫,第一筆資料並未寫入,證明有 Transaction 且 Rollback 了。由 SQL Profiler 也成功蒐集到證據,兩次 sp_executesql 前有 BEGIN TRANSACTION,之後有 ROLLBACK TRANSACTION:

實驗二,建立一個 IDbContextTransaction 把兩個 SaveChanges() 包含一個 Transaction:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CRUDExample.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CRUDExample.Pages.Records
{
    public class TestSaveChangesModel : PageModel
    {
        private readonly JournalDbContext dbCtx;

        public TestSaveChangesModel(JournalDbContext dbCtx)
        {
            this.dbCtx = dbCtx;
        }
        public void OnGet()
        {
            using (var tran = dbCtx.Database.BeginTransaction())
            {
                DbJob1();
                DbJob2();
                tran.Commit();
            }
        }
        void DbJob1()
        {
            var rec1 = new DailyRecord()
            {
                Date = DateTime.Today,
                Status = StatusFlags.Normal,
                EventSummary = "Hello",
                Remark = "Record 1",
                User = "Jeffrey"
            };
            dbCtx.Records.Add(rec1);
            var rec2 = new DailyRecord()
            {
                Date = DateTime.Today.AddDays(1),
                Status = StatusFlags.Normal,
                EventSummary = "",
                Remark = "Record 2",
                User = "Jeffrey"
            };
            dbCtx.Records.Add(rec2);
            dbCtx.SaveChanges();
        }
        void DbJob2()
        {
            var rec3 = new DailyRecord()
            {
                Date = DateTime.Today.AddDays(2),
                Status = StatusFlags.Normal,
                EventSummary = "",
                Remark = "Record 2",
                User = "欄位型別為VarChar(8),故意寫入過長字串"
            };
            dbCtx.Records.Add(rec3);
            dbCtx.SaveChanges();
        }
    }
}

實測資料一樣不會寫入資料庫,using (var tran = dbCtx.DataBase.BeginTransaction()) 範圍呼叫了兩次 SaveChanges(),SQL Profiler 觀察到一組 BEGIN TRANSACTION 跟 ROLLBACK TRANSACTION 包住三個 sp_executesql 動作:

進一步設定偵錯中斷點,可觀察到 BEGIN TRANSACTION 發生在 dbCtx.DataBase.BeginTransaction() 時:

實驗三來試一下 TransactionScope:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Transactions;
using CRUDExample.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CRUDExample.Pages.Records
{
    public class TestSaveChangesModel : PageModel
    {
        private readonly JournalDbContext dbCtx;

        public TestSaveChangesModel(JournalDbContext dbCtx)
        {
            this.dbCtx = dbCtx;
        }
        public void OnGet()
        {
            using (var scope = new TransactionScope(
                    TransactionScopeOption.Required,
                    new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }))
            {
                DbJob1();
                DbJob2();
                DbJob3();
                scope.Complete();
            }
        }
        void DbJob1()
        {
            var rec1 = new DailyRecord()
            {
                Date = DateTime.Today,
                Status = StatusFlags.Normal,
                EventSummary = "Hello",
                Remark = "Record 1",
                User = "Jeffrey"
            };
            dbCtx.Records.Add(rec1);
            dbCtx.SaveChanges();
        }
        void DbJob2()
        {
            var rec2 = new DailyRecord()
            {
                Date = DateTime.Today.AddDays(1),
                Status = StatusFlags.Normal,
                EventSummary = "",
                Remark = "Record 2",
                User = "Jeffrey"
            };
            dbCtx.Records.Add(rec2);
            dbCtx.SaveChanges();
        }
        void DbJob3()
        {
            var rec3 = new DailyRecord()
            {
                Date = DateTime.Today.AddDays(2),
                Status = StatusFlags.Normal,
                EventSummary = "",
                Remark = "Record 2",
                User = "欄位型別為VarChar(8),故意寫入過長字串"
            };
            dbCtx.Records.Add(rec3);
            dbCtx.SaveChanges();
        }
    }
}

觀察結果如下。一樣是 BEGIN TRANSACTION / ROLLBACK TRANSACTION 包了三組 sp_executesql,差別在於會觸發額外的 Logout、sp_resset_connection、Login,用 TransactionScope 雖然無腦,但不如 BeginTransaction 輕巧。

以上就是 EF Core Transaction 的簡單實驗,記憶強化程序完成。

Simple experiments to observe transaction behavior in EF Core.


Comments

Be the first to post a comment

Post a comment


15 - 0 =