有效率的查詢

有效率地查詢是一個龐大的主題,涵蓋範圍廣泛的主題,如索引、相關的實體載入策略,以及其他許多主題。 本節詳述一些常見的主題,讓您的查詢更快,而且使用者通常會遇到的陷阱。

正確使用索引

查詢是否快速執行的主要決定因素是,它會適當地利用索引:資料庫通常用來保存大量資料,而周遊整個資料表的查詢通常是嚴重效能問題的來源。 索引編制問題不容易發現,因為指定的查詢是否會使用索引並不明顯。 例如:

// Matches on start, so uses an index (on SQL Server)
var posts1 = context.Posts.Where(p => p.Title.StartsWith("A")).ToList();
// Matches on end, so does not use the index
var posts2 = context.Posts.Where(p => p.Title.EndsWith("A")).ToList();

找出索引問題的好方法是先找出緩慢的查詢,然後透過您資料庫慣用的工具檢查其查詢計劃; 如需如何執行此動作的詳細資訊,請參閱效能診斷 頁面。 查詢計劃會顯示查詢是周遊整個資料表,還是使用索引。

一般情況下,使用索引或診斷與其相關的效能問題沒有任何特殊的 EF 知識;與索引相關的一般資料庫知識與 EF 應用程式一樣,與不使用 EF 的應用程式一樣。 下列列出使用索引時要記住的一些一般指導方針:

  • 雖然索引可加速查詢,但它們也會讓更新變慢,因為它們必須保持最新狀態。 請避免定義不需要的索引,並考慮使用 索引篩選 將索引限制為數據列子集,進而降低此額外負荷。
  • 複合索引可以加速對多個資料行進行篩選的查詢,但也可以根據排序來加速不會篩選所有索引資料行的查詢。 例如,A 和 B 資料行上的索引可加速 A 和 B 的查詢篩選,以及只依 A 篩選查詢,但不會加速查詢只篩選 B。
  • 如果查詢依運算式篩選資料行 (例如 price / 2 ),便無法使用簡單的索引。 不過,您可以定義 運算式的預存資料 行,並針對該資料行建立索引。 某些資料庫也支援運算式索引,這可以直接用來加速任何運算式的查詢篩選。
  • 不同的資料庫允許以各種方式設定索引,而且在許多情況下,EF Core 提供者會透過 Fluent API 公開這些索引。 例如,SQL Server 提供者可讓您設定索引 是叢集 的,還是設定其 填滿因數 。 如需詳細資訊,請參閱提供者的檔。

您只需要專案屬性

EF Core 可讓您輕鬆地查詢實體實例,然後在程式碼中使用這些實例。 不過,查詢實體實例經常會從資料庫提取超過必要資料。 請考量下列各項:

foreach (var blog in context.Blogs)
{
    Console.WriteLine("Blog: " + blog.Url);
}

雖然此程式碼實際上只需要每個 Blog 的 Url 屬性,但會擷取整個 Blog 實體,而不需要的資料行會從資料庫傳輸:

SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]

這可以使用 來告訴 EF 要投射哪些資料行來優化 Select

foreach (var blogName in context.Blogs.Select(b => b.Url))
{
    Console.WriteLine("Blog: " + blogName);
}

產生的 SQL 只會提取所需的資料行:

SELECT [b].[Url]
FROM [Blogs] AS [b]

如果您需要將多個資料行投影出來,請使用您想要的屬性,將專案投射到 C# 匿名型別。

請注意,這項技術對於唯讀查詢非常有用,但如果您需要 更新 所擷取的部落格,會變得更複雜,因為 EF 的變更追蹤僅適用于實體實例。 藉由附加已修改的 Blog 實例並告訴 EF 哪些屬性已變更,但這是一種較進階的技術,可能不值得執行更新,而不需要載入整個實體。

限制結果集大小

根據預設,查詢會傳回符合其篩選準則的所有資料列:

var blogsAll = context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .ToList();

由於傳回的資料列數目取決於資料庫中的實際資料,因此無法知道會從資料庫載入多少資料、結果會佔用多少記憶體,以及處理這些結果時會產生多少額外負載(例如,透過網路將它們傳送至使用者瀏覽器)。 關鍵是,測試資料庫經常包含少量資料,讓所有專案在測試時運作良好,但當查詢開始在真實世界資料上執行且傳回許多資料列時,效能問題就會突然出現。

因此,通常值得考慮限制結果數目:

var blogs25 = context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .Take(25)
    .ToList();

您的 UI 至少會顯示一則訊息,指出資料庫中可能有更多資料列(並允許以其他方式擷取它們)。 完整的解決方案會實 作分頁 ,您的 UI 一次只會顯示特定數目的資料列,並允許使用者視需要前進到下一頁;如需如何有效率地實作此作業的詳細資訊,請參閱下一節。

有效率的分頁

分頁是指在頁面中擷取結果,而不是一次擷取結果;這通常是針對大型結果集完成的,其中會顯示使用者介面,讓使用者流覽至結果的下一頁或上一頁。 使用資料庫實作分頁的常見方式是使用 SkipTake 運算子( OFFSET 以及 LIMIT 在 SQL 中):雖然這是直覺式實作,但它也相當沒有效率。 對於允許一次移動一頁的分頁(而不是跳至任意頁面),請考慮改用 索引鍵集分頁

如需詳細資訊, 請參閱分頁 的檔頁面。

在關係資料庫中,透過在單一查詢中引進 JOIN 來載入所有相關實體。

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url], [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Post] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId], [p].[PostId]

如果一般部落格有多個相關文章,這些文章的資料列將會複製部落格的資訊。 這種重複會導致所謂的「笛卡兒爆炸」問題。 載入多個一對多關聯性時,重複的資料量可能會成長,並對您的應用程式的效能造成負面影響。

EF 允許透過使用「分割查詢」來避免這種影響,這會透過個別查詢載入相關實體。 如需詳細資訊,請參閱 分割和單一查詢 的檔。

注意

分割查詢 的目前實作 會針對每個查詢執行往返。 我們計畫在未來加以改善,並在單次往返中執行所有查詢。

建議您先閱讀 相關實體 的專用頁面,再繼續進行本節。

處理相關實體時,我們通常事先知道需要載入的內容:典型的範例會載入一組特定的部落格,以及其所有文章。 在這些案例中,最好使用 積極式載入 ,讓 EF 可以在一次往返中擷取所有必要的資料。 篩選 的 Include 功能也可讓您限制您想要載入哪些相關實體,同時讓載入程式保持急切,因此可在單一往返中執行:

using (var context = new BloggingContext())
{
    var filteredBlogs = context.Blogs
        .Include(
            blog => blog.Posts
                .Where(post => post.BlogId == 1)
                .OrderByDescending(post => post.Title)
                .Take(5))
        .ToList();
}

在其他案例中,在取得主體實體之前,我們可能不知道我們需要哪些相關實體。 例如,載入某些部落格時,我們可能需要諮詢一些其他資料來源,可能是 Web 服務,以瞭解我們是否對部落格的文章感興趣。 在這些情況下, 明確 延遲 載入可用來個別擷取相關實體,並填入部落格的文章導覽。 請注意,由於這些方法並不急切,因此需要對資料庫進行額外的往返,這是速度變慢的來源;視您的特定案例而定,一律載入所有貼文可能會更有效率,而不是執行額外的往返,並選擇性地只取得您需要的貼文。

注意延遲載入

延遲載入 通常似乎是撰寫資料庫邏輯的一種非常有用的方式,因為 EF Core 會在程式碼存取相關實體時自動從資料庫載入相關實體。 這可避免載入不需要的相關實體(例如 明確載入 ),而且似乎讓程式設計人員不必完全處理相關實體。 不過,延遲載入特別容易產生不必要的額外往返,這可能會讓應用程式變慢。

請考量下列各項:

foreach (var blog in context.Blogs.ToList())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

這看似無辜的程式碼片段逐一查看所有部落格及其文章,並將它們列印出來。開啟 EF Core 的 語句記錄 會顯示下列內容:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [b].[BlogId], [b].[Rating], [b].[Url]
      FROM [Blogs] AS [b]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[@__p_0='1'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='2'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
      FROM [Post] AS [p]
      WHERE [p].[BlogId] = @__p_0

... and so on

這其中發生了什麼狀況? 為何會針對上述簡單迴圈傳送所有這些查詢? 隨著延遲載入,部落格的貼文只有在存取其 Posts 屬性時才會載入(懶惰):因此,內部 foreach 中的每個反復專案都會在自己的往返中觸發額外的資料庫查詢。 因此,在初始查詢載入所有部落格之後,我們接著會有另一個每個部落格的查詢 ,載入其所有文章;這有時稱為 N+1 問題,而且可能會導致非常顯著的效能 問題。

假設我們需要所有部落格的文章,請改用這裡的積極式載入。 我們可以使用 Include 運算子來執行載入,但因為我們只需要部落格的 URL(而且我們只 應該載入所需的 內容)。 因此,我們將改用投影:

foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).ToList())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

這會讓 EF Core 在單一查詢中擷取所有部落格及其文章。 在某些情況下,使用分割查詢 來避免笛卡兒爆炸效果 也很有用。

警告

因為延遲載入讓不小心觸發 N+1 問題變得非常簡單,因此建議您避免此問題。 當資料庫往返發生時,積極式或明確載入會在原始程式碼中非常清楚。

緩衝處理和串流

緩衝處理是指將所有查詢結果載入記憶體中,而串流表示 EF 每次都會將應用程式交出單一結果,永遠不會在記憶體中包含整個結果集。 原則上,串流查詢的記憶體需求是固定的,無論查詢傳回 1 個數據列或 1000,都是相同的:另一方面,緩衝查詢需要更多記憶體,因此會傳回更多資料列。 對於產生大型結果集的查詢,這可以是重要的效能因素。

查詢緩衝區或資料流程取決於其評估方式:

// ToList and ToArray cause the entire resultset to be buffered:
var blogsList = context.Posts.Where(p => p.Title.StartsWith("A")).ToList();
var blogsArray = context.Posts.Where(p => p.Title.StartsWith("A")).ToArray();

// Foreach streams, processing one row at a time:
foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")))
{
    // ...
}

// AsEnumerable also streams, allowing you to execute LINQ operators on the client-side:
var doubleFilteredBlogs = context.Posts
    .Where(p => p.Title.StartsWith("A")) // Translated to SQL and executed in the database
    .AsEnumerable()
    .Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results

如果您的查詢只傳回一些結果,您可能不必擔心這個問題。 不過,如果您的查詢可能會傳回大量資料列,值得考慮串流而不是緩衝處理。

注意

請避免在 ToListToArray 結果上使用另一個 LINQ 運算子 ,這不需要將所有結果緩衝處理到記憶體中。 請改用 AsEnumerable

EF 的內部緩衝

在某些情況下,不論您評估查詢的方式為何,EF 都會在內部緩衝結果集。 發生此情況的兩個案例如下:

  • 當重試執行策略就緒時。 這麼做可確保稍後重試查詢時,會傳回相同的結果。
  • 使用分割查詢 ,除非在 SQL Server 上啟用 MARS(多個作用中結果集),否則最後一個查詢除了最後一個查詢的結果集,還會緩衝處理所有結果集。 這是因為通常不可能同時有多個查詢結果集作用中。

請注意,除了透過 LINQ 運算子所造成的任何緩衝之外,也會發生此內部緩衝處理。 例如,如果您在 ToList 查詢上使用 ,且重試執行策略已就緒,則結果集會載入記憶體 兩次 :一次由 EF 在內部,一次由 ToList

追蹤、無追蹤和身分識別解析

建議您先閱讀 追蹤和無追蹤 的專用頁面,再繼續進行本節。

EF 預設會追蹤實體實例,以便在呼叫 時偵測並保存它們上的 SaveChanges 變更。 追蹤查詢的另一個效果是 EF 會偵測是否已為您的資料載入實例,而且會自動傳回該追蹤的實例,而不是傳回新的實例;這稱為 身分識別解析 。 從效能的觀點來看,變更追蹤表示下列各項:

  • EF 會在內部維護追蹤實例的字典。 載入新資料時,EF 會檢查字典,以查看是否已追蹤該實體金鑰的實例(身分識別解析)。 載入查詢結果時,字典維護和查閱需要一些時間。
  • 將載入的實例交給應用程式之前,EF 快照 集會先將該實例保留在內部。 呼叫 時 SaveChanges ,應用程式實例會與快照集進行比較,以探索要保存的變更。 快照集佔用更多記憶體,而快照集進程本身需要時間;有時候,您可以透過 值比較子 指定不同的、可能更有效率的快照集行為,或使用變更追蹤 Proxy 來完全略過快照集程式(雖然這具有自己的一組缺點)。

在未將變更儲存回資料庫的唯讀案例中,可以使用無追蹤查詢 來避免 上述額外負荷。 不過,由於無追蹤查詢不會執行身分識別解析,因此多個其他載入資料列所參考的資料庫資料列將會具體化為不同的實例。

為了說明,假設我們正在從資料庫載入大量文章,以及每個文章所參考的部落格。 如果 100 個文章碰巧參考相同的部落格,追蹤查詢會透過身分識別解析偵測到此問題,而且所有 Post 實例都會參考相同的重複部落格實例。 相反地,無追蹤查詢會重複相同的部落格 100 次,而且必須據此撰寫應用程式程式碼。

以下是比較追蹤與無追蹤行為的基準測試結果,其中每個查詢載入 10 個部落格與 20 個文章。 原始程式碼可在這裡 取得,您可以隨意使用它作為您自己的測量基礎。

方法 NumBlogs NumPostsPerBlog 平均數 錯誤 StdDev Median 比例 RatioSD Gen 0 第 1 代 第 2 代 已配置
AsTracking 10 20 1,414.7 我們 27.20 我們 45.44 我們 1,405.5 我們 1.00 0.00 60.5469 13.6719 - 380.11 KB
AsNoTracking 10 20 993.3 我們 24.04 我們 65.40 我們 966.2 我們 0.71 0.05 37.1094 6.8359 - 232.89 KB

最後,您可以利用無追蹤查詢,然後將傳回的實例附加至內容,以指定要進行的變更,以執行沒有變更追蹤的額外負荷的更新。 這會將變更追蹤的負擔從 EF 傳送給使用者,而且只有在變更追蹤額外負荷透過分析或效能評定顯示為無法接受時,才應該嘗試。

使用 SQL 查詢

在某些情況下,您的查詢有更優化的 SQL,EF 不會產生此查詢。 當 SQL 建構是不受支援之資料庫特定的擴充功能,或只是因為 EF 尚未轉譯為資料庫時,就會發生這種情況。 在這些情況下,手動撰寫 SQL 可以提供大量的效能提升,而 EF 支援數種方式來執行這項操作。

  • 直接在查詢 中使用 SQL 查詢 ,例如透過 FromSqlRaw 。 EF 甚至可讓您使用一般 LINQ 查詢撰寫 SQL,讓您只表達 SQL 中查詢的一部分。 當您的程式碼基底中只需要在單一查詢中使用 SQL 時,這是一個很好的技術。
  • 定義使用者定義函 式 (UDF),然後從您的查詢呼叫該函式。 請注意,EF 允許 UDF 傳回完整的結果集-這些稱為資料表值函式(TVF)-也允許對應 DbSet 至函式,使其看起來就像另一個資料表一樣。
  • 在您的查詢中定義資料庫檢視和查詢。 請注意,與函式不同,檢視無法接受參數。

注意

在確定 EF 無法產生您想要的 SQL 之後,原始 SQL 通常應該用來做為最後手段,而且當效能足夠重要時,指定的查詢才能證明其合理性。 使用原始 SQL 會產生相當大的維護缺點。

非同步程式設計

一般規則是,為了讓應用程式可調整,請務必一律使用非同步 API,而不是同步 API(例如 SaveChangesAsyncSaveChanges 而不是 )。 同步 API 會在資料庫 I/O 期間封鎖執行緒,增加執行緒的需求,以及必須發生的執行緒內容切換數目。

如需詳細資訊,請參閱非同步程式設計 頁面

警告

避免在相同的應用程式中混合同步和非同步程式碼 - 很容易不小心觸發微妙的執行緒集區饑餓問題。

警告

不幸的是,Microsoft.Data.SqlClient 的非同步實作 有一些已知問題(例如 #593 #601 和其他問題)。 如果您看到非預期的效能問題,請嘗試改用同步命令執行,尤其是在處理大型文字或二進位值時。

其他資源