在 ASP.NET Core 使用 EF Core DbContext 的標準姿勢是在 Startup.ConfigureServices() 用 services.AddDbContextPool<MyDbContext>(options => { options.UseSqlite(...) 或是 options.UseSqlserver(...) }) 註冊成 Scoped 服務(參考:不可不知的 ASP.NET Core 依賴注入),需要存取資料庫的類別、服務則在建構式宣告 MyDbContext 參數取得 DbContext 物件。(參考:快速寫出 ASP.NET Core DI 需要的參數建構式)

由於 DbContext 的生命周期是 Scoped,在整個 HttpRequest 處理階段將共用同一顆 DbContext 物件。,例如:在 Razor Page 或 MVC Controller 同時用了 XXService 跟 YYService,XXService 與 YYService 都在建構式參數引用 MyDbContext,則 XXService 與 YYService 取得的 MyDbContext Instance 將會是同一個。這有助於維持 HttpRequest 過程資料狀態的一致性,但其中的快取特性可能會讓新手迷惑。我是在 Debug 過程遇過幾次(背景作業與網頁呼叫動作交雜,同時查看來自兩個 Scope 的 DbContext),一開始不知其然,檢查結果與資料庫老對不上,查了半天以為見鬼,摸索一陣子才搞懂怎麼一回事。

一般在 Razor Page 或 MVC Controller 我們拿到的 DbContext 都會是同一顆,為了實驗,借用昨天提到的 IServiceProvider.CreateScope 技巧,並改寫 ASP.NET Core 新增修改刪除(CRUD)介面傻瓜範例,新增一個測試用 Razor Page,在其中取得屬於不同 Scope 的兩個 DbContext 跑測試:先比對資料筆數,第一筆資料的 User 欄位;接著用第一個 DbContext 更新第一筆資料的 User 欄位,用第二個 DbContext 新增一筆記錄,做完後再查一次資料筆數及第一筆資料 User 欄位。程式如下:

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

namespace CRUDExample.Pages
{
    public class TestScopedDbContextModel : PageModel
    {
        private readonly IServiceProvider services;
        private readonly JournalDbContext dbCtx1;

        public TestScopedDbContextModel(IServiceProvider services, JournalDbContext dbCtx1)
        {
            this.services = services;
            this.dbCtx1 = dbCtx1;
        }

        public void OnGet()
        {
            var rec = dbCtx1.Records.First();
            using (var scope = services.CreateScope())
            {
                var dbCtx2 = scope.ServiceProvider.GetRequiredService<JournalDbContext>();
                
                Debug.WriteLine($"Recourds.Count in dbCtx1 = {dbCtx1.Records.Count()}");
                Debug.WriteLine($"1st Record.User in dbCtx1 = {dbCtx1.Records.Single(o => o.Id == 1).User}");
                Debug.WriteLine($"Recourds.Count in dbCtx2 = {dbCtx2.Records.Count()}");
                Debug.WriteLine($"1st Record.User in dbCtx2 = {dbCtx2.Records.Single(o => o.Id == 1).User}");

                //dbCtx2 新增一筆
                dbCtx2.Records.Add(new DailyRecord()
                {
                    Date = DateTime.Today,
                    Status = StatusFlags.Normal,
                    User = "Jeffrey",
                    EventSummary = "Just say hello"
                });
                dbCtx2.SaveChanges();

                //dbCtx1 查到的 Record User 改成 darkthread
                rec.User = "darkthread";
                dbCtx1.SaveChanges();

                Debug.WriteLine($"Recourds.Count in dbCtx1 = {dbCtx1.Records.Count()}");
                Debug.WriteLine($"1st Record.User in dbCtx1 = {dbCtx1.Records.Single(o => o.Id == 1).User}");
                Debug.WriteLine($"Recourds.Count in dbCtx2 = {dbCtx2.Records.Count()}");
                Debug.WriteLine($"1st Record.User in dbCtx2 = {dbCtx2.Records.Single(o => o.Id == 1).User}");

            }
        }
    }
}

猜猜結果是什麼?

在第二次檢測,dbCtx1 有察覺到筆數增加,dbCtx2 讀到的 User 欄仍是舊值。這是因為 dbCtx2 快取了 Record 資料內容,第二次檢測仍會讀取 DB,但會使用先前讀取內容[註]。如果第一次檢測時沒有讀取首筆 Record (下圖橘框處),則第二次檢測 dbCtx2 讀到的便是被 dbCtx1.SaveChanges() 更新過的內容(下圖箭頭所指處)。

註:2022-10-06 更新,依據微軟文件說明,即使資料 Entity 物件已存在於 DbContext,查詢時仍會對資料庫進行查詢,若 Entity 已存在並在追蹤中即直接傳回,否則新建物件並設定追蹤。更多細節可參考 How EF Core LINQ Queries Work。感謝讀者 Ho.Chun 補充。

那如果 dbCtx1 與 dbCtx2 更新同一筆 Record 會怎樣?先實驗 dbCtx1 更新 User 欄位、dbCtx2 更新 EventSummary 欄位的狀況:

如上圖所示,SaveChanges() 後,dbCtx1 認知的是只有 User 改掉的版本,dbCtx2 認知的是只有 EventSummary 異動的版本,而資料庫真實的資料狀況則是 User 與 EventSummary 都被改掉了。(註:如果 dbCtx1、dbCtx2 都更新 User 欄位,則看 SaveChanges 的時機,以後執行的為準。關於 EF Core 的更新衝突議題,下一篇再聊。)

實務上,Scoped DbContext 多半只活在一個網頁從接收請求到傳送回應的短短幾秒內,上述快取行為的影響通常不明顯,但中斷偵錯過程,即時運算或變數檢視會觀察到這些原本稍縱即逝的快取資料,請記住它與實際 DB 裡的資料可能有差距,不要像我一樣被迷惑,沒 Bug 傻傻抓半天。

Experiment of scoped EF Core DbContext caching behavior.


Comments

# by 鄉民

dbCtx 認知的是只有 EventSummary… 好像因該是 dbCtx2 認知的是只有 EventSummary…

# by Jeffrey

to 鄉民,素的,偶又漏字惹,謝謝指正。

# by Ho.Chun

Q1. 請問有哪些資料會被快取 ? .Count() 看起來不會 .Single() 看起來會 Q2. 有文件可以查詢到什麼 function 會被快取,而有哪些又不會 ?

# by Jeffrey

to Ho.Chun, 依我的理解,這個 Cache 效果來自 Change Tracking 機制,依據文件 https://learn.microsoft.com/en-us/ef/core/change-tracking/,會發生在以下情況: Entity instances become tracked when they are: * Returned from a query executed against the database * Explicitly attached to the DbContext by Add, Attach, Update, or similar methods * Detected as new entities connected to existing tracked entities

# by Ho.Chun

to Jeffrey, 感謝幫忙! 不過有個地方我覺得怪怪的 : "第二次檢測時沒從 DB 讀取,故仍顯示舊值"。我利用 SQL Server Profile 去觀察,第二次其實還是有從 DB 讀取資料,只是 EF Core 還是使用第一次的資料。

# by Ho.Chun

to Jeffrey, 找到一些文件給您參考 1. Queries are always executed against the database even if the entities returned in the result already exist in the context. => ref: https://learn.microsoft.com/en-us/ef/core/querying/ 2. For each item in the result set a. If the query is a tracking query, EF checks if the data represents an entity already in the change tracker for the context instance - If so, the existing entity is returned - If not, a new entity is created, change tracking is set up, and the new entity is returned b. If the query is a no-tracking query, then a new entity is always created and returned => ref: https://learn.microsoft.com/en-us/ef/core/querying/how-query-works#the-life-of-a-query

# by Jeffrey

to Ho.Chun, 謝謝你幫忙補上拼圖,已更新及補充於文章。

Post a comment