之前介紹過將 EF Core DbContext 動作包入 Transaction,做法有兩種:呼叫 DbContext.Database.BeginTransaction() 啟動交易、用 TransactionScope 包住 DbContext 動作。如果要將兩個以上 DbContext 包進同一個 Transaction 呢?

花了點時間,參考微軟文件 EF Core - Using Transactions 以及其他參考資料,又做了實驗,這才有了概念。

先說結論:

  • 一樣是用 Database.BeginTransaction() 或 TransactionScope(),前者需共用連線及 Transaction 物件。
  • 共享 Transaction 時要用 A DbContext 的連線物件建立 B DbContext
  • 不支援分散式交易 不支援分散式交易 不支援分散式交易 參考
    好消息是 EF Core 7 將會支援
  • 我測試了 SQL 及 SQLite,發現 EF Core SQLite 不支援 TransactionScope 參考
    會出現 An ambient transaction has been detected, but the current provider does not support ambient transactions 錯誤

底下是我的實驗專案,包含 AlphaDbContext 及 BetaDbContext 兩個 DbContext,並分別使用 SQL 或 SQLite 測試。專案包含多個 DbContext 及多種資料庫來源,需透過 命令列參數 控制使用 SQL 或 SQLite,並在 ``dotnet ef migrations add``` 時加上 --context 及命令列參數以產生指定 DbContext 的 Migration 指令。相關技巧可參考這篇微軟文件 - Migrations with Multiple Providers

AlphaDbContext,宣告一個只有 Id 跟 Name 的資料表,在 Name 加上 Unique Index,測試時可藉由插入重複資料引發錯誤,觀察 Rollback 是否成功。

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace ext_trans_dbctx
{
    public class Alpha
    {
        [Key]
        public int AlphaId { get; set; }
        [MaxLength(32)]
        public string Name { get; set; }
    }

    public class AlphaDbContext : DbContext
    {
        public DbSet<Alpha> Alphas { get; set; }
        public AlphaDbContext(DbContextOptions<AlphaDbContext> options) : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Alpha>().HasIndex(c => c.Name).IsUnique();
            base.OnModelCreating(modelBuilder);
        }
    }
}

BetaDbContext 的結構跟 AlphaDbContext 完全相同。

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

namespace ext_trans_dbctx
{
    public class Beta {
        [Key]
        public int BetaId {get; set;}
        [MaxLength(32)]
        public string Name { get; set;}
    }

    public class BetaDbContext : DbContext
    {
        public DbSet<Beta> Betas { get; set; }
        public BetaDbContext(DbContextOptions<BetaDbContext> options) : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Beta>().HasIndex(c => c.Name).IsUnique();
            base.OnModelCreating(modelBuilder);
        }
    } 
}

測試程式如下,分別使用 TransactionScope 及 BeginTransaction() 測試。測試前先清空 Alphas 及 Betas 資料表並各寫一筆 "Init",接著在 AlphaDbContext.Alphas 插入一筆 "To Rollback",在 BetaDbContext.Betas 插入兩筆 "Duplicated",故意引發錯誤。若 Transaction 機制有效,AlphaDbContext 的寫入動作應該要被 Rollback,故最後兩資料表應只有一筆 Init。

讓 AlphaDbContext 跟 BetaDbContext 參與同一個 Transaction 的關鍵在於必須用 AlphaDbContext 使用中的 DbConnection 物件(透過 alphaCtx.Database.GetDbConnection() 取得) 去建立 BetaDbContext,DbContextOptionsBuilder.UseSqlServer() 或 UseSqlite() 一般是傳連線字串,但也可傳入 DbConnection,要求沿用即有資料庫連線,另外由 IDbContextTransaction.GetDbTransaction() 取得當時執行中的 DbTransaction 物件,傳給 BetaDbContext.UseTransaction() 引用,就能將兩個 DbContext 加進同一個 Transaction。

using System.Text;
using System.Transactions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace ext_trans_dbctx
{
    public class TransTester
    {
        private readonly AlphaDbContext alphaCtx;
        private readonly BetaDbContext betaCtx;
        public TransTester(AlphaDbContext alphaCtx, BetaDbContext betaCtx)
        {
            this.alphaCtx = alphaCtx;
            this.betaCtx = betaCtx;
        }
        public void ResetData()
        {
            alphaCtx.Database.Migrate();
            alphaCtx.Alphas.RemoveRange(alphaCtx.Alphas.ToArray());
            alphaCtx.Alphas.Add(new Alpha { Name = "Init" });
            alphaCtx.SaveChanges();

            betaCtx.Database.Migrate();
            betaCtx.Betas.RemoveRange(betaCtx.Betas.ToArray());
            betaCtx.Betas.Add(new Beta { Name = "Init" });
            betaCtx.SaveChanges();
        }

        public string Check()
        {
            return "Alphas: " +
                string.Join(',', alphaCtx.Alphas.Select(o => o.Name).ToArray()) + "\n" +
                "Betas: " +
                string.Join(',', betaCtx.Betas.Select(o => o.Name).ToArray());
        }
        public string TestTranScope()
        {
            ResetData();
            var sb = new StringBuilder();
            sb.AppendLine("*** TestTranScope ***");
            using (var ts = new TransactionScope())
            {
                try
                {
                    alphaCtx.Alphas.Add(new Alpha { Name = "To Rollback" });
                    alphaCtx.SaveChanges();
                    betaCtx.Betas.Add(new Beta { Name = "Duplicated" });
                    betaCtx.Betas.Add(new Beta { Name = "Duplicated" });
                    betaCtx.SaveChanges();
                    ts.Complete();
                }
                catch (Exception ex)
                {
                    sb.AppendLine(ex.Message + "\n" + ex.InnerException?.Message);
                }
            }
            sb.AppendLine(Check());
            return sb.ToString();
        }
        public string TestSharedTrans()
        {
            ResetData();
            var sb = new StringBuilder();
            sb.AppendLine("*** TestSharedTrans ***");
            using (var cn = alphaCtx.Database.GetDbConnection())
            {
                var trn = alphaCtx.Database.BeginTransaction();
                try
                {
                    alphaCtx.Alphas.Add(new Alpha { Name = "To Rollback" });
                    alphaCtx.SaveChanges();
                    // 以現有 DbConnection 建立 BetaDbContext
                    var optBuilder = new DbContextOptionsBuilder<BetaDbContext>();
                    if (alphaCtx.Database.ProviderName.Contains("Sqlite"))
                        optBuilder.UseSqlite(cn);
                    else optBuilder.UseSqlServer(cn);
                    var b = new BetaDbContext(optBuilder.Options);
                    // 呼叫 Datadata.UseTransaction() 參與同一交易
                    b.Database.UseTransaction(trn.GetDbTransaction());
                    b.Betas.Add(new Beta { Name = "Duplicated" });
                    b.Betas.Add(new Beta { Name = "Duplicated" });
                    b.SaveChanges();
                    trn.Commit();
                }
                catch (Exception ex)
                {
                    sb.AppendLine(ex.Message + "\n" + ex.InnerException?.Message);
                    trn.Rollback();
                }
                sb.AppendLine(Check());
                return sb.ToString();
            }
        }
    }
}

Program.cs 如下:

using System.Text.Encodings.Web;
using System.Text.Unicode;
using ext_trans_dbctx;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

/*
https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli
dotnet ef migrations add InitSql --context AlphaDbContext --output-dir Migrations\AlphaSql -- --dbType sql
dotnet ef migrations add InitSql --context BetaDbContext --output-dir Migrations\BetaSql -- --dbType sql
dotnet ef migrations add InitSqlite --context AlphaDbContext --output-dir Migrations\AlphaSqlite -- --dbType sqlite
dotnet ef migrations add InitSqlite --context BetaDbContext --output-dir Migrations\BetaSqlite -- --dbType sqlite
*/

if (builder.Configuration.GetValue<string>("dbType", "sql") == "sqlite")
{
    var cs = "data source=" + Path.Combine(builder.Environment.ContentRootPath,
        "demo.sqlite");
    builder.Services.AddDbContext<AlphaDbContext>(options =>
    {
        options.UseSqlite(cs);
    });
    builder.Services.AddDbContext<BetaDbContext>(options =>
    {
        options.UseSqlite(cs);
    });
}
else
{
    var cs = @"Data Source=(localdb)\MSSQLLOcalDB;Initial Catalog=TransTest;Integrated Security=True;";
    builder.Services.AddDbContext<AlphaDbContext>(options =>
    {
        options.UseSqlServer(cs);
    });
    builder.Services.AddDbContext<BetaDbContext>(options =>
    {
        options.UseSqlServer(cs);
    });
}
builder.Services.AddScoped<TransTester>();

var app = builder.Build();
app.MapGet("/", () =>
    Results.Content(
        @"<a href=transcope target=res>TransactionScope</a> 
        <a href=sharetrans target=res>Shared Transaction</a> <br />
        <iframe name=res style='width: 640px; height: 150px'></iframe>",
        "text/html"));
app.MapGet("/transcope", (TransTester tester) => tester.TestTranScope());
app.MapGet("/sharetrans", (TransTester tester) => tester.TestSharedTrans());
app.Run();

先使用 SQL LocalDB 測試,用昨天介紹的方法建立 TransTest.mdf,使用以下指令建立 Migrations 並建好資料表:

dotnet ef migrations add InitSql --context AlphaDbContext --output-dir Migrations\AlphaSql -- --dbType sql
dotnet ef migrations add InitSql --context BetaDbContext --output-dir Migrations\BetaSql -- --dbType sql

實測 TransactionScope 跟共用 Transaction 都成功,Alpha 的寫入動作在 Beta 寫入失敗時 Rollback,二資料表僅有 Init 一筆資料。

接著測試 SQLite,將 SQL 的 Migragions 程式移除 (將專案資料夾的 Migrations 目錄刪除),執行以下指令為 SQLite 建立 Migrations。

dotnet ef migrations add InitSqlite --context AlphaDbContext --output-dir Migrations\AlphaSqlite -- --dbType sqlite
dotnet ef migrations add InitSqlite --context BetaDbContext --output-dir Migrations\BetaSqlite -- --dbType sqlite

TransactionScope 因 SQLite EF Core Proivder 不支援失敗,共享 Transaction 則成功。

實驗完畢。

因程式檔較多,我把範例丟到 Github 了,想試玩的同學請自取。

Tutorial of how to implementation transaction include multiple EF Core DbContext.


Comments

# by Ho.Chun

想請問下 "兩條連線到同一台 SQL Server 中的同一個 DB",這樣算分散式交易嗎 ? "兩條連線到同一台 SQL Server 中的不同個 DB",這樣算分散式交易嗎 ? "兩條連線到不同台 SQL Server",這樣算分散式交易嗎 ?

# by Jeffrey

to Ho.Chun,我的經驗,不同版本 Provider 行為可能不同(例如:ODP.NET 11.2 兩條連線字串相同的連線也會啟動分散式交易,但 12+ 跟 SQL 不會),以實測為準。參考:https://blog.darkthread.net/blog/oracle-msdtc-case/

Post a comment