進階效能主題

DbContext 共用

DbContext通常是淺色物件:建立和處置一個不會牽涉到資料庫作業,而且大部分的應用程式都可以這麼做,而不會影響效能。 不過,每個內容實例都會設定執行其職責所需的各種內部服務和物件,而持續執行此作業的額外負荷在高效能案例中可能相當重要。 在這些情況下,EF Core 可以 集區 您的內容實例:當您處置內容時,EF Core 會重設其狀態,並將其儲存在內部集區中;下次要求新的實例時,會傳回該集區實例,而不是設定新的實例。 內容共用可讓您在程式啟動時只支付內容設定成本一次,而不是持續支付。

請注意,內容共用與資料庫連接共用是正交的,這是在資料庫驅動程式中較低層級管理。

使用 EF Core ASP.NET Core 應用程式中的典型模式,包括透過 將自訂 DbContext 類型註冊至 相依性插入 AddDbContext 容器。 然後,該類型的實例會透過控制器或 Razor Pages 中的建構函式參數取得。

若要啟用內容共用,只需將 取代 AddDbContextAddDbContextPool

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

poolSize 參數 AddDbContextPool 會設定集區保留的最大實例數目(預設值為 1024)。 超過之後 poolSize ,不會快取新的內容實例,而且 EF 會回復為依需求建立實例的非共用行為。

效能評定

以下是從在相同電腦上本機執行的 SQL Server 資料庫擷取單一資料列的基準測試結果,且不需要內容共用。 一如往常,結果會隨著資料列數目、資料庫伺服器的延遲和其他因素而變更。 重要的是,此效能會評定單一執行緒共用效能,而真實世界的競爭案例可能會有不同的結果:在做出任何決策之前,請先在您的平臺上進行基準測試。 原始程式碼可在這裡 取得,您可以隨意使用它作為您自己的測量基礎。

方法 NumBlogs 平均數 錯誤 StdDev Gen 0 第 1 代 第 2 代 已配置
WithoutCoNtextPooling 1 701.6 我們 26.62 我們 78.48 我們 11.7188 - - 50.38 KB
WithCoNtextPooling 1 350.1 我們 6.80 我們 14.64 我們 0.9766 - - 4.63 KB

在集區內容中管理狀態

內容共用的運作方式是跨要求重複使用相同的內容實例;這表示它會有效地註冊為 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 Factory 取得集區內容,並將租使用者識別碼插入它所傳出的內容實例:

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 收到 LINQ 查詢樹狀結構以供執行時,它必須先「編譯」該樹狀結構,例如從該樹狀結構產生 SQL。 由於這項工作是繁重的程式,因此 EF 會快取查詢樹狀結構圖形的查詢,讓具有相同結構的查詢重複使用內部快取編譯輸出。 此快取可確保執行相同的 LINQ 查詢多次非常快,即使參數值不同也一樣。

不過,EF 仍必須執行某些工作,才能使用內部查詢快取。 例如,相較于快取查詢的運算式樹狀結構,查詢的運算式樹狀結構必須遞迴,才能尋找正確的快取查詢。 此初始處理的額外負荷在大部分 EF 應用程式中是微不足道的,特別是相較于與查詢執行相關的其他成本(網路 I/O、實際查詢處理和資料庫上的磁片 I/O...)。不過,在某些高效能案例中,可能需要將其消除。

EF 支援 編譯的查詢 ,允許將 LINQ 查詢明確編譯至 .NET 委派。 取得此委派之後,即可直接叫用此委派來執行查詢,而不需提供 LINQ 運算式樹狀結構。 這項技術會略過快取查閱,並提供在 EF Core 中執行查詢的最優化方式。 以下是比較已編譯和非編譯查詢效能的一些基準測試結果;在做出任何決策之前,請先在您的平臺上進行基準測試。 原始程式碼可在這裡 取得,您可以隨意使用它作為您自己的測量基礎。

方法 NumBlogs 平均數 錯誤 StdDev Gen 0 已配置
WithCompiledQuery 1 564.2 我們 6.75 我們 5.99 我們 1.9531 9 KB
WithoutCompiledQuery 1 671.6 我們 12.72 我們 16.54 我們 2.9297 13 KB
WithCompiledQuery 10 645.3 我們 10.00 我們 9.35 我們 2.9297 13 KB
WithoutCompiledQuery 10 709.8 我們 25.20 我們 73.10 我們 3.9063 18 KB

若要使用已編譯的查詢,請先編譯 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,其中包含接受 DbContext 實例的 Lambda,以及要傳遞至查詢的任意參數。 您現在可以在想要執行查詢時叫用該委派:

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

請注意,委派是安全線程,而且可以在不同的內容實例上同時叫用。

限制

  • 編譯的查詢只能用於單一 EF Core 模型。 相同類型的不同內容實例有時可以設定為使用不同的模型;不支援在此案例中執行已編譯的查詢。
  • 在編譯的查詢中使用參數時,請使用簡單純量參數。 不支援更複雜的參數運算式,例如實例上的成員/方法存取。

查詢快取和參數化

當 EF 收到 LINQ 查詢樹狀結構以供執行時,它必須先「編譯」該樹狀結構,例如從該樹狀結構產生 SQL。 由於這項工作是繁重的程式,因此 EF 會快取查詢樹狀結構圖形的查詢,讓具有相同結構的查詢重複使用內部快取編譯輸出。 此快取可確保執行相同的 LINQ 查詢多次非常快,即使參數值不同也一樣。

請考慮下列兩個查詢:

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

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

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog1'

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog2'

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

對查詢的小型修改可能會大幅變更:

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

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

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

請注意,不需要參數化每個和每個查詢:擁有一些具有常數的查詢完全沒問題,事實上,資料庫 (和 EF) 有時會在參數化查詢時,對常數執行某些優化。 如需適當參數化非常重要的範例,請參閱動態建構查詢 一節

注意

EF Core 的事件計數器 會報告查詢快取命中率。 在一般應用程式中,此計數器會在程式啟動後不久達到 100%,一旦大部分的查詢至少執行一次。 如果此計數器維持在 100% 以下的穩定性,表示您的應用程式可能正在執行會失敗查詢快取的動作,建議您調查此狀況。

注意

資料庫如何管理快取查詢計劃與資料庫相關。 例如,SQL Server 隱含地維護 LRU 查詢計劃快取,而 PostgreSQL 則不會(但備妥的語句可能會產生非常類似的結束效果)。 如需詳細資訊,請參閱您的資料庫檔案。

動態建構的查詢

在某些情況下,必須動態建構 LINQ 查詢,而不是在原始程式碼中直接指定它們。 例如,在從用戶端接收任意查詢詳細資料的網站中,可能會發生此情況,並具有開放式查詢運算子(排序、篩選、分頁...)。原則上,如果正確完成,動態建構的查詢可以和一般查詢一樣有效率(雖然無法搭配動態查詢使用編譯的查詢優化)。 不過,實際上,它們通常是效能問題的來源,因為很容易不小心產生具有每次不同圖形的運算式樹狀結構。

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

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

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

[Benchmark]
public 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 query.Count();
}

這兩種技術的效能評定可提供下列結果:

方法 平均數 錯誤 StdDev Gen0 Gen1 已配置
ExpressionApiWithConstant 1,665.8 我們 56.99 我們 163.5 我們 15.6250 - 109.92 KB
ExpressionApiWithParameter 757.1 我們 35.14 我們 103.6 我們 12.6953 0.9766 54.95 KB
SimpleWithParameter 760.3 我們 37.99 我們 112.0 我們 12.6953 - 55.03 KB

即使子毫秒的差異似乎很小,請記住,常數版本會持續污染快取,並導致重新編譯其他查詢,並降低速度,並對您的整體效能產生一般負面影響。 強烈建議您避免常數查詢重新編譯。

注意

除非您真的需要,否則請避免使用運算式樹狀架構 API 建構查詢。 除了 API 的複雜性之外,使用 API 時很容易不小心造成顯著的效能問題。

已編譯的模型

已編譯的模型可以改善具有大型模型之應用程式的 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>

執行此命令的輸出包含一段程式碼,可複製並貼到您的 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
    {
        get
        {
            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 啟動時間太慢時,才應該使用已編譯的模型。 編譯小型模型通常不值得。

如果支援上述任何功能對於您的成功至關重要,請投票處理上述連結的適當問題。

減少執行時間額外負荷

如同任何一層,EF Core 相較于直接針對較低層級的資料庫 API 撰寫程式碼,會增加一點執行時間額外負荷。 此執行時間額外負荷不太可能以重要方式影響大多數真實世界應用程式:本效能指南中的其他主題,例如查詢效率、索引使用量和最小化往返,更為重要。 此外,即使是高度優化的應用程式,網路延遲和資料庫 I/O 通常也會主宰 EF Core 本身所花費的任何時間。 不過,對於效能高、低延遲的應用程式而言,其中每一位效能都很重要,下列建議可用來將 EF Core 額外負荷降到最低:

  • 開啟 DbCoNtext 共用 ;我們的基準測試顯示這項功能可能會對高效能、低延遲的應用程式產生決定性影響。
    • 請確定 對應 maxPoolSize 至您的使用案例;如果太低, DbContext 就會持續建立和處置實例,降低效能。 設定太高可能會不必要地耗用記憶體,因為未使用的 DbContext 實例會保留在集區中。
    • 如需額外的微小效能提升,請考慮使用 ,而不是直接插入 PooledDbContextFactory DI 內容實例。 共用的 DbContext DI 管理會產生輕微的額外負荷。
  • 針對經常性查詢使用先行編譯查詢。
    • LINQ 查詢越複雜,其包含的運算子越多,產生的運算式樹狀結構愈大,使用已編譯的查詢可預期會取得更多收益。
  • 請考慮在內容組態中將 設定 EnableThreadSafetyChecks 為 false,以停用執行緒安全性檢查。
    • DbContext不支援從不同的執行緒並行使用相同的實例。 EF Core 具有一項安全性功能,可在許多情況下偵測此程式設計 Bug(但並非全部),並立即擲回信息例外狀況。 不過,這項安全性功能會增加一些執行時間額外負荷。
    • 警告: 只有在徹底測試應用程式未包含這類並行錯誤之後,才停用執行緒安全性檢查。