EF Core DbContext 快取特性實驗
7 |
在 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, 謝謝你幫忙補上拼圖,已更新及補充於文章。