DbContext 共用

DbContext 通常是輕量物件:建立和處置它並不需要資料庫作業,且大部分應用程式都可以執行此動作而不影響效能。 不過,每個內容執行個體都會設定各種必需內部服務和物件來執行其職責,而持續執行職責的額外負荷在高效能情境可能很高。 在這些情況下,EF Core 可以 收集 您的內容執行個體:在您處置內容時,EF Core 會重設其狀態,並儲存在內部集區中;下次您要求新的執行個體時,系統就會傳回該集區執行個體,而不是設定新的執行個體。 內容共用可讓您只需在程式啟動時支付一次內容設定成本,不必持續支付。

請注意,內容共用與資料庫連結共用彼此呈正交,此關係可在資料庫驅動程式的較低層級管理。

在 ASP.NET Core 應用程式使用 EF Core 的典型模式需要透過 DbContext 將自訂 類型註冊到 AddDbContext 容器。 然後,該類型的執行個體會透過控制器的建構函式參數或 Razor Page 取得。

若要啟用內容共用,只需將 AddDbContext 取代為 AddDbContextPool

builder.Services.AddDbContextPool<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));
              poolSizeAddDbContextPool 參數會為集區保留的執行個體設定數量上限 (預設值為 1024)。 如果超過 poolSize,系統就不會快取新的內容執行個體,且 EF 會回復為依需求建立執行個體的非共用行為。

若要在沒有相依性插入的情況下使用內容共用,請初始化 PooledDbContextFactory 並從中要求內容執行個體:

var options = new DbContextOptionsBuilder<PooledBloggingContext>()
    .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Blogging;Trusted_Connection=True;ConnectRetryCount=0")
    .Options;
var factory = new PooledDbContextFactory<PooledBloggingContext>(options);
using (var context = factory.CreateDbContext())
    var allPosts = await context.Posts.ToListAsync();
              poolSize 建構函式的 PooledDbContextFactory 參數會為集區保留的執行個體設定數量上限 (預設值為 1024)。 如果超過 poolSize,系統就不會快取新的內容執行個體,且 EF 會回復為依需求建立執行個體的非共用行為。

以下是從在相同機器上本機執行的 SQL Server 資料庫擷取單一列的基準結果,包含與不包含內容共用皆有。 一如往常,結果會隨著列的數量、資料庫伺服器的延遲和其他因素而變更。 重要的是,此結果為單一執行緒的共用效能建立了基準,然而真實世界的爭用情境可能有不同結果;請先對您的平台進行效能評定,再做任何決策。 原始程式碼可在此取得,請儘管用於自我衡量的基礎。

NumBlogs StdDev 第 0 代 第 1 代 第 2 代

管理集區內容的狀態

內容共用的運作方式是針對各種要求重複使用相同內容執行個體;也就是說它會註冊為很有效率的 Singleton,且多個要求 (或 DI 範圍) 會重複使用相同執行個體。 這表示如果內容涉及任何可能會在不同要求之間發生變更的狀態時,就必須特別謹慎使用。 關鍵是,內容的 OnConfiguring 只會在第一次建立執行個體內容時叫用一次,因此無法用來設定需要變化的狀態 (例如租用戶識別碼)。

涉及內容狀態的典型案例是多租用戶 ASP.NET Core 應用程式,它的內容執行個體的租用戶識別碼會在查詢中納入考量 (如需詳細資訊,請參閱全域查詢篩選條件)。 由於租用戶識別碼需要隨每個 Web 要求而變更,我們必須採取額外步驟,才能讓它完全與內容共用搭配運作。

假設您的應用程式註冊了設定範圍的 ITenant 服務,它會包裝租用戶識別碼和任何其他租用戶相關資訊:

// Below is a minimal tenant resolution strategy, which registers a scoped ITenant service in DI.
// In this sample, we simply accept the tenant ID as a request query, which means that a client can impersonate any
// tenant. In a real application, the tenant ID would be set based on secure authentication data.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenant>(sp =>
    var tenantIdString = sp.GetRequiredService<IHttpContextAccessor>().HttpContext.Request.Query["TenantId"];
    return tenantIdString != StringValues.Empty && int.TryParse(tenantIdString, out var tenantId)
        ? new Tenant(tenantId)
        : null;

如上所述,請特別注意您取得租用戶識別碼的來源,這對於應用程式的安全性很重要。

只要我們取得已設定範圍的 ITenant 服務,請一如往常,將共用內容處理站註冊為 Singleton 服務:

builder.Services.AddPooledDbContextFactory<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

接下來,撰寫自訂內容處理站,讓它從我們註冊的 Singleton 處理站取得集區內容,並將租用戶識別碼插入它傳出的內容執行個體:

public class WeatherForecastScopedFactory : IDbContextFactory<WeatherForecastContext>
    private const int DefaultTenantId = -1;
    private readonly IDbContextFactory<WeatherForecastContext> _pooledFactory;
    private readonly int _tenantId;
    public WeatherForecastScopedFactory(
        IDbContextFactory<WeatherForecastContext> pooledFactory,
        ITenant tenant)
        _pooledFactory = pooledFactory;
        _tenantId = tenant?.TenantId ?? DefaultTenantId;
    public WeatherForecastContext CreateDbContext()
        var context = _pooledFactory.CreateDbContext();
        context.TenantId = _tenantId;
        return context;

在我們具備自訂內容處理站之後,請將它註冊為有範圍的服務:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

最後,準備好要從有範圍處理站插入的內容:

builder.Services.AddScoped(
    sp => sp.GetRequiredService<WeatherForecastScopedFactory>().CreateDbContext());

在這個階段,您的控制器會自動插入具有正確租用戶識別碼的內容執行個體,這個動作不必取得任何相關資訊。

此範例的完整原始程式碼可在此處取得。

雖然 EF Core 會負責重設 DbContext 的內部狀態及其相關服務,但它通常不會重設在 EF 外部的基礎資料庫驅動程式狀態。 舉例來說,如果您手動開啟並使用 DbConnection 或操作 ADO.NET 狀態,您必需自行先透過關閉連結等方法還原該狀態,再將內容執行個體傳回集區。 若沒有成功,可能會導致狀態洩漏到不相關的要求。

連線池考量

在大部分的資料庫中,執行資料庫作業需要長時間存留的連線,因此開啟和關閉這類連線的成本可能很高。 EF 不會實作連線共用本身,而是依賴基礎資料庫驅動程式(例如 ADO.NET 驅動程式)來管理資料庫連線。 線上共用是一種客戶端機制,可重複使用現有的資料庫連線,以減少重複開啟和關閉連線的額外負荷。 此機制通常會在 EF 所支援的資料庫之間保持一致,例如 Azure SQL Database、PostgreSQL 和其他資料庫。雖然資料庫或環境的特定因素,例如資源限制或服務組態,可能會影響共用效率。 聯機共用通常是默認啟用,而且任何共用設定都必須在該驅動程序記載的低層級驅動程式層級執行;例如,使用 ADO.NET 時,通常會透過連接字串來設定集區大小下限或上限等參數。

連線集區與 EF 的 DbContext 集區完全獨立:雖然低階資料庫驅動程式集區數據庫連線(以避免開啟/關閉連線的額外負擔),EF 可以集區上下文實例(以避免上下文記憶體分配和初始化的額外負擔)。 不論內容實例是否集區化,EF 通常會在每個作業之前開啟連接(例如查詢),並在之後立即關閉,導致它返回集區:這樣做是為了避免將連線保持在集區之外的時間比必要時間長。

編譯的查詢

如果 EF 收到要執行的 LINQ 查詢樹狀結構,它必須先「編譯」該樹狀結構,例如從中產生 SQL。 由於這項工作是繁重程序,EF 必須依據樹狀結構的型態快取查詢,以便讓相同結構的查詢重複使用內部快取的編譯輸出。 這項快取可確保相同 LINQ 查詢可快速執行多次,即使參數值不同也可行。

不過,EF 仍必須執行特定工作,才能使用內部查詢快取。 舉例來說,查詢的運算式樹狀結構必須與快取查詢的運算是樹狀結構遞迴比較,才能找到正確的快取查詢。 這項初始處理的負荷在大部分 EF 應用程式中是微不足道的,與查詢執行 (網路 I/O、實際查詢處理,以及資料庫的磁碟 I/O...) 產生的其他成本相比尤其如此。不過,在某些高效能情境,您可能需要消除此負荷。

EF 支援編譯查詢,允許在 .NET 委派中明確編譯 LINQ 查詢。 取得此委派後,即可直接叫用此委派來執行查詢,不需提供 LINQ 運算式樹狀結構。 這項技術會略過快取查閱,並提供最佳化最徹底的方法在 EF Core 執行查詢。 以下是幾項比較編譯和非編譯查詢效能的評定結果;在做任何決策之前,請先對您的平台進行效能評定。 原始程式碼可在此取得,請儘管用於自我衡量的基礎。

NumBlogs StdDev 第 0 代

若要使用已編譯的查詢,請先使用 EF.CompileAsyncQuery 編譯查詢,如下所示 (同步查詢則使用 EF.CompileQuery):

private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> _compiledQuery
    = EF.CompileAsyncQuery(
        (BloggingContext context, int length) => context.Blogs.Where(b => b.Url.StartsWith("http://") && b.Url.Length == length));

在此程式碼範例中,我們會向 EF 提供 Lambda 來接受 DbContext 執行個體,以及要傳遞至查詢的任意參數。 您現在只要想執行查詢,就可叫用該委派:

await foreach (var blog in _compiledQuery(context, 8))
    // Do something with the results

請注意,委派是安全執行緒,且可對不同內容執行個體同時叫用。

  • 編譯查詢只能對單一 EF Core 模型使用。 相同類型的不同內容執行個體有時可以設定為使用不同模型;在此情境下執行編譯查詢並不受支援。
  • 在編譯查詢使用參數時,請使用簡單的純量參數。 更複雜的參數運算式,例如對執行個體進行成員/方法存取,並不受支援。
  • 查詢快取和參數化

    如果 EF 收到要執行的 LINQ 查詢樹狀結構,它必須先「編譯」該樹狀結構,例如從中產生 SQL。 由於這項工作是繁重程序,EF 必須依據樹狀結構的型態快取查詢,以便讓相同結構的查詢重複使用內部快取的編譯輸出。 這項快取可確保相同 LINQ 查詢可快速執行多次,即使參數值不同也可行。

    以下列兩個查詢為例:

    var post1 = await context.Posts.FirstOrDefaultAsync(p => p.Title == "post1");
    var post2 = await context.Posts.FirstOrDefaultAsync(p => p.Title == "post2");
    

    由於運算式樹狀結構包含不同常數,運算式樹狀結構就不同,且這些查詢每個都會由 EF Core 個別編譯。 此外,每個查詢都會產生稍微不同的 SQL 命令:

    SELECT TOP(1) [b].[Id], [b].[Name]
    FROM [Posts] AS [b]
    WHERE [b].[Name] = N'post1'
    SELECT TOP(1) [b].[Id], [b].[Name]
    FROM [Posts] AS [b]
    WHERE [b].[Name] = N'post2'
    

    由於 SQL 不同,資料庫伺服器可能也需要產生這兩個查詢的查詢計畫,而不是重複使用相同的計畫。

    對查詢進行小幅修改可能會產生大幅變更:

    var postTitle = "post1";
    var post1 = await context.Posts.FirstOrDefaultAsync(p => p.Title == postTitle);
    postTitle = "post2";
    var post2 = await context.Posts.FirstOrDefaultAsync(p => p.Title == postTitle);
    

    由於部落格名稱現在已參數化,因此這兩個查詢都有相同的樹狀結構型態,且 EF 只需要編譯一次。 產生的 SQL 也會參數化,讓資料庫重複使用相同的查詢計畫:

    SELECT TOP(1) [b].[Id], [b].[Name]
    FROM [Posts] AS [b]
    WHERE [b].[Name] = @__postTitle_0
    

    請注意,您不需要參數化每個查詢:部分查詢具有常數完全不會造成問題,且事實上,資料庫 (和 EF) 有時會對常數執行特定最佳化,而這種行為在查詢參數化後反而不可行。 請參閱動態建構的查詢一節,參考適當的參數化帶來關鍵作用的範例。

    EF Core 的指標會告知查詢快取命中率。 在一般應用程式中,這項指標會在大部分查詢都至少執行一次後,於程式啟動後不久達到 100%。 如果這項指標穩定維持在 100%,表示您的應用程式可能正在執行會導致查詢快取失敗的動作,建議您加以調查。

    資料庫管理快取查詢計畫的方式與資料庫相關。 舉例來說,SQL Server 會隱含維持 LRU 查詢計畫的快取,而 PostgreSQL 則不會 (但若準備好陳述式,可能會產生非常類似的最終效果)。 請參閱資料庫文件以取得詳細資訊。

    動態建構的查詢

    某些情況下,您必須動態建構 LINQ 查詢,而不是直接在原始程式碼中指定。 舉例來說,如果網站會從用戶端接收任意查詢的詳細資訊,其中包含開放式查詢運算子 (排序、篩選、分頁...),就可能需要這麼做。原則上,如果正確操作,動態建構的查詢可以和一般查詢一樣有效率 (雖然無法對動態查詢使用編譯查詢的最佳化)。 不過,實際上,它們往往是效能問題的來源,因為每次產生的運算式樹狀結構都很容易意外有不同型態。

    下列範例使用三種技術來建構查詢的 Where Lambda 運算式:

    具有常數的運算式 API:使用常數節點,透過表達式 API 動態建置表達式。 這是動態建置運算式樹狀結構時經常發生的錯誤,且會造成每次以不同的常數值叫用查詢時,查詢都會重新編譯 (通常也會在資料庫伺服器造成計畫快取受損)。 具有參數的運算式 API:以參數取代常數的較佳版本。 它可確保查詢只會編譯一次,無論提供的值為何,且產生的 (參數化) SQL 皆相同。 單純使用參數:不使用運算式 API 的版本,會建立與上述方法相同的樹狀結構,但較簡單。 許多情況下,您可以動態建置運算式樹狀結構而不使用運算式 API,但這很容易出錯。

    只有在指定的參數不是 Null 時,我們才會將 Where 運算子新增至查詢。 請注意,這對於動態建構查詢並非良好使用案例,但我們是為了簡單起見而使用:

    public async Task<int> ExpressionApiWithConstant() var url = "blog" + Interlocked.Increment(ref _blogNumber); using var context = new BloggingContext(); IQueryable<Blog> query = context.Blogs; if (_addWhereClause) var blogParam = Expression.Parameter(typeof(Blog), "b"); var whereLambda = Expression.Lambda<Func<Blog, bool>>( Expression.Equal( Expression.MakeMemberAccess( blogParam, typeof(Blog).GetMember(nameof(Blog.Url)).Single()), Expression.Constant(url)), blogParam); query = query.Where(whereLambda); return await query.CountAsync();
    [Benchmark]
    public async Task<int> ExpressionApiWithParameter()
        var url = "blog" + Interlocked.Increment(ref _blogNumber);
        using var context = new BloggingContext();
        IQueryable<Blog> query = context.Blogs;
        if (_addWhereClause)
            var blogParam = Expression.Parameter(typeof(Blog), "b");
            // This creates a lambda expression whose body is identical to the url captured closure variable in the non-dynamic query:
            // blogs.Where(b => b.Url == url)
            // This dynamically creates an expression node which EF can properly recognize and parameterize in the database query.
            // We then extract that body and use it in our dynamically-constructed query.
            Expression<Func<string>> urlParameterLambda = () => url;
            var urlParamExpression = urlParameterLambda.Body;
            var whereLambda = Expression.Lambda<Func<Blog, bool>>(
                Expression.Equal(
                    Expression.MakeMemberAccess(
                        blogParam,
                        typeof(Blog).GetMember(nameof(Blog.Url)).Single()),
                    urlParamExpression),
                blogParam);
            query = query.Where(whereLambda);
        return await query.CountAsync();
    
    [Benchmark]
    public async Task<int> SimpleWithParameter()
        var url = "blog" + Interlocked.Increment(ref _blogNumber);
        using var context = new BloggingContext();
        IQueryable<Blog> query = context.Blogs;
        if (_addWhereClause)
            Expression<Func<Blog, bool>> whereLambda = b => b.Url == url;
            query = query.Where(whereLambda);
        return await query.CountAsync();
    

    對這兩種技術進行效能評定可得到下列結果:

    StdDev

    編譯模型可以為具有大型模型的應用程式改善 EF Core 啟動時間。 大型模型通常表示它包含數百到數千個實體類型和關聯性。 本文的啟動時間是指在應用程式中第一次使用該 DbContext 類型時,對 DbContext 執行第一次作業的時間。 請注意,單純建立 DbContext 執行個體並不會使 EF 模型初始化。 相反地,會導致模型初始化的典型初次作業包括呼叫 DbContext.Add 或執行第一次查詢。

    編譯模型是使用 dotnet ef 命令行工具所建立。 請確定您已安裝最新版的工具,再繼續操作。

    新的 dbcontext optimize 命令可用來產生編譯模型。 例如:

    dotnet ef dbcontext optimize
                  --output-dir--namespace 選項可在要產生的編譯模型中指定目錄和命名空間。 例如:

    PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
    Build started...
    Build succeeded.
    Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
    PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>
    
  • 如需詳細資訊,請參閱:dotnet ef dbcontext optimize
  • 如果您更傾向在 Visual Studio 內部作業,您也可以使用 Optimize-DbContext
  • 執行此命令的輸出包含一段程式代碼,可複製並貼到您的 DbContext 設定,讓 EF Core 使用編譯模型。 例如:

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseModel(MyCompiledModels.BlogsContextModel.Instance)
            .UseSqlite(@"Data Source=test.db");
    

    編譯模型啟動載入

    通常您並不需要查看產生的啟動載入程式碼。 不過,有時候自訂模型或其載入可能很實用。 啟動載入程式碼外觀如下:

    [DbContext(typeof(BlogsContext))]
    partial class BlogsContextModel : RuntimeModel
        private static BlogsContextModel _instance;
        public static IModel Instance
                if (_instance == null)
                    _instance = new BlogsContextModel();
                    _instance.Initialize();
                    _instance.Customize();
                return _instance;
        partial void Initialize();
        partial void Customize();
    

    這是具有部分方法的部分類別,可視需要實作,藉此自訂模型。

    此外,您可以針對 DbContext 可能會根據某些執行階段組態而使用不同的模型的類型產生多個編譯模型。 這些模型應該放在不同資料夾和命名空間,如上所示。 您接著可以檢查執行階段資訊,例如連接字串,並依需要傳回正確的模型。 例如:

    public static class RuntimeModelCache
        private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
            = new();
        public static IModel GetOrCreateModel(string connectionString)
            => _runtimeModels.GetOrAdd(
                connectionString, cs =>
                    if (cs.Contains("X"))
                        return BlogsContextModel1.Instance;
                    if (cs.Contains("Y"))
                        return BlogsContextModel2.Instance;
                    throw new InvalidOperationException("No appropriate compiled model found.");
    

    編譯模型存在一些限制:

    不支援全域查詢篩選條件不支援延遲載入和變更追蹤 Proxy
  • 不支援參考私人方法的值轉換器。 改為將參考方法設為公用或內部。
  • 每次模型定義或組態變更時,都必須重新產生模型,藉此手動同步處理模型
  • 不支援自訂 IModelCacheKeyFactory 實作。 不過,您可以編譯多個模型,並視需要載入適當模型。
  • 由於這些限制,您只有在 EF Core 啟動時間太慢時,才應使用編譯模型。 編譯小型模型通常並不值得。

    如果支援上述任何功能對於您的成功很關鍵,請在上方連結投票選出正確問題。

    處理因類型參照不明確而導致的編譯錯誤

    當編譯具有相同名稱但存在於不同命名空間中的類型的模型時,產生的程式碼可能會因為類型引用不明確而產生編譯錯誤。 若要解決此問題,您可以透過覆寫 CSharpHelper.ShouldUseFullName 並使其傳回 true,來自訂程式碼以使用完整限定類型名稱。 如何覆寫如ICSharpHelper等設計階段服務的資訊,請參閱設計階段服務

    減少執行階段額外負荷

    和任何一個階層一樣,相較於直接針對較低層級的資料庫 API 撰寫程式碼,EF Core 會增加一點執行階段的額外負荷。 此執行階段額外負荷不太可能對多數實際的應用程式造成重大影響;本效能指南中的查詢效率、索引使用和縮減資料往返等其他主題遠比它更重要。 此外,即使是高度最佳化的應用程式,網路延遲和資料庫 I/O 通常也支配著 EF Core 本身所花費的任何時間。 不過,對於高效能、低延遲的應用程式而言,每一分效能都很重要,因此可採用下列建議來將 EF Core 額外負荷降到最低:

  • 開啟 DbContext 共用;我們的效能評定顯示這項功能可能對高效能、低延遲的應用程式產生決定性影響。
  • 請確認 maxPoolSize 可對應您的用量案例;如果用量過低,DbContext 執行個體會持續建立和處置執行個體,降低效能。 設定太高則可能會無端耗用記憶體,因為未使用的 DbContext 執行個體仍會保留在集區中。
  • 如需額外小幅提升效能,請考慮使用 PooledDbContextFactory,而不是直接插入 DI 內容執行個體。 DbContext 共用的 DI 管理會產生輕微的額外負荷。
  • 對熱門查詢使用預先編譯的查詢。
  • LINQ 查詢越複雜,包含的運算子越多,產生的運算式樹狀結構也越大,而使用已編譯的查詢可預期取得更多益處。
  • 請考慮將內容組態的 EnableThreadSafetyChecks 設為 false,藉此停用執行緒安全性檢查。
  • 系統不支援同時從不同執行緒使用相同的 DbContext 執行個體。 EF Core 有一項安全性功能,在許多情況下會偵測此程式設計錯誤 (但並非全部情況),並立即擲回包含資訊的例外狀況。 不過,這項安全性功能會增加一些執行階段額外負荷。
  • 警告:請務必在徹底測試過應用程式不包含這類並行錯誤後,再停用執行緒安全性檢查。