EF Core高效查詢

一个人走在路上發表於2024-04-03

高效查詢

本文內容
正確使用索引
只投影需要的屬性
限制結果集大小
高效分頁
在載入相關實體時避免笛卡爾爆炸
儘可能預先載入相關實體
緩衝和流式處理
跟蹤、非跟蹤和標識解析
使用 SQL 查詢
非同步程式設計
其他資源
顯示較少選項
高效查詢是一個龐大的主題,涵蓋的主題就像索引、相關實體載入策略以及許多其他主題一樣廣泛。 本部分詳細介紹了一些用於更快地進行查詢的常見主題以及使用者通常會遇到的隱患。

正確使用索引
查詢能否快速執行的主要決定因素是它是否在恰當的位置使用索引:資料庫通常用於儲存大量資料,而遍歷整個表的查詢往往是嚴重效能問題的根源。 索引問題不容易發現,因為給定的查詢是否會使用索引並不是顯而易見的。 例如:

C#

複製
// 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 能非常輕鬆地查詢出實體例項,然後將它們用於程式碼中。 但是,查詢實體例項可能會頻繁從資料庫中拉取回超出所需的資料。 考慮以下情況:

C#

複製
foreach (var blog in context.Blogs)
{
Console.WriteLine("Blog: " + blog.Url);
}
儘管此程式碼實際上只需要每個部落格的 Url 屬性,但它提取了整個部落格實體,並且從資料庫傳輸了不需要的列:

SQL

複製
SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
對於這一點的最佳化方法是,使用 Select 告訴 EF 要投影出的列:

C#

複製
foreach (var blogName in context.Blogs.Select(b => b.Url))
{
Console.WriteLine("Blog: " + blogName);
}
生成的 SQL 僅拉取回需要的列:

SQL

複製
SELECT [b].[Url]
FROM [Blogs] AS [b]
如果需要投影出多列,請使用需要的屬性投影到 C# 匿名型別。

請注意,這種方法對只讀查詢非常有用,但如果你需要更新提取的部落格,事情會變得更加複雜,因為 EF 的更改跟蹤僅適用於實體例項。 可以在不載入整個實體的情況下執行更新,方法是:附加一個修改後的部落格例項,並告訴 EF 已更改的屬性。不過,這種方法更復雜,不值得嘗試。

限制結果集大小
查詢預設返回與篩選器匹配的所有行:

C#

複製
var blogsAll = context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToList();
返回的行數取決於資料庫中的實際資料,因此不可能知道將從資料庫中載入的資料量、結果佔用的記憶體量以及處理這些結果時(例如,透過網路將它們傳送到使用者瀏覽器)將額外生成的負載量。 非常重要的一點是,測試資料庫往往包含少量資料,所以測試時一切正常,但當查詢開始基於實際資料執行並且返回了許多行時,會突然出現效能問題。

因此,通常有必要考慮限制結果的數量:

C#

複製
var blogs25 = context.Posts
.Where(p => p.Title.StartsWith("A"))
.Take(25)
.ToList();
至少你的 UI 可能會顯示一條訊息,指出資料庫中可能有更多行(並允許使用某種其他方式檢索這些行)。 全面的解決方案將實現分頁,其中 UI 一次僅顯示一定數量的行,並允許使用者根據需要前進到下一頁;請參閱下一部分,詳細瞭解如何高效實現此功能。

高效分頁
分頁是指在頁面中檢索結果,而不是一次性檢索結果;這通常是針對大型結果集完成的,會顯示允許使用者導航到結果的下一頁或上一頁的使用者介面。 使用資料庫實現分頁的一種常見方法是使用 Skip 和 Take 運算子(SQL 中的 OFFSET 和 LIMIT);雖然這是一個直觀的實現,但它也相當低效。 對於允許一次移動一頁(而不是跳轉到任意頁面)的分頁,請考慮改用鍵集分頁。

有關詳細資訊,請參閱分頁的文件頁。

在載入相關實體時避免笛卡爾爆炸
在關聯式資料庫中,所有相關實體透過在單個查詢中引入 JOIN 來載入。

SQL

複製
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 可以在一次往返中提取所有必需的資料。 經過篩選的包含功能讓你能限制要載入的相關實體,同時使載入過程保持為預先載入,從而可在一次往返中實現:

C#

複製
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 會在程式碼訪問相關實體時,從資料庫中自動載入這些實體。 這避免了載入不需要的相關實體(就像顯式載入一樣),而且似乎使程式設計師不必一起處理相關實體。 不過,延遲載入特別容易產生不必要的額外往返,從而降低應用程式的速度。

考慮以下情況:

C#

複製
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(並且我們應只載入需要的內容), 我們將改為使用投影:

C#

複製
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 行,記憶體要求都相同。另一方面,返回的行數越多,緩衝查詢需要的記憶體越多。 對於產生大型結果集的查詢,這可能是一個重要的效能因素。

查詢是執行緩衝還是流式處理取決於其計算方式:

C#

複製
// 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
如果查詢只返回幾個結果,可能無需擔心這一點。 但是,如果查詢可能返回的行數非常多,則有必要考慮使用流式處理而不是緩衝。

備註

如果要對結果使用另一個 LINQ 運算子,請避免使用 ToList 或 ToArray,因為這樣會不必要地將所有結果緩衝到記憶體中。 請改用 AsEnumerable。

EF 執行的內部緩衝
在某些情況下,無論如何計算查詢,EF 都會在內部自行緩衝結果集。 出現這種情況的兩個場景是:

重試執行策略已準備就緒時。 這樣做是為了確保在以後重試查詢時返回相同的結果。
使用拆分查詢時,會緩衝除最後一個查詢外的所有查詢的結果集,除非在 SQL Server 上啟用了 MARS(多重活動結果集)。 這是因為通常無法同時啟用多個查詢結果集。
請注意,除透過 LINQ 運算子引發的任何緩衝外,還會發生這種內部緩衝。 例如,如果你對查詢使用了 ToList 並且重試執行策略就緒,則結果集會被載入到記憶體中兩次:一次由 EF 在內部載入,一次由 ToList 載入。

跟蹤、非跟蹤和標識解析
建議在繼續本部分之前,先閱讀關於跟蹤和非跟蹤的專用頁面。

EF 預設跟蹤實體例項,因此在呼叫 SaveChanges 時,會檢測並儲存對實體例項所做的更改。 跟蹤查詢的另一個作用是:EF 檢測是否已為你的資料載入了例項,並將自動返回跟蹤的例項,而不是返回新例項。我們將這種做法稱為標識解析。 從效能的角度來看,更改跟蹤意味著:

EF 在內部維護跟蹤例項的字典。 載入新資料時,EF 會查閱字典,以瞭解是否已為該實體的鍵跟蹤了例項(標識解析)。 載入查詢結果時,字典維護和查詢會花費一些時間。
在將載入的例項交給應用程式之前,EF 擷取該例項的快照,並在內部儲存該快照。 呼叫 SaveChanges 時,會將應用程式的例項與快照做比較,以發現要儲存的更改。 快照佔用更多記憶體,擷取快照的過程本身需要時間;有時可以透過值比較器指定不同的、可能更高效的快照擷取行為,或使用更改跟蹤代理完全繞過快照擷取過程(雖然這種做法本身也有一些缺點)。
在不將更改儲存回資料庫的只讀場景中,可透過使用非跟蹤查詢來避免上述開銷。 但非跟蹤查詢不執行標識解析,所以由多個其他已載入的行引用的資料庫行將被具體化為不同的例項。

為了說明這一點,假設我們要從資料庫中載入大量文章以及每篇文章引用的部落格。 如果碰巧有 100 篇文章引用了同一個部落格,則跟蹤查詢透過標識解析來檢測這種情況,並且所有文章例項都將引用同一個刪除了重複資料的部落格例項。 而無跟蹤查詢會將相同的部落格重複 100 次,我們必須相應地編寫應用程式程式碼。

下面是比較載入 10 個部落格(各有 20 篇文章)的查詢的跟蹤行為與非跟蹤行為的基準檢驗的結果。 此處提供了原始碼,請根據需要將它用作自己的度量的基礎。

展開表
方法 NumBlogs NumPostsPerBlog 平均值 錯誤 標準偏差 中值 比率 RatioSD Gen 0 Gen 1 Gen 2 已分配
AsTracking 10 20 1,414.7 us 27.20 us 45.44 us 1,405.5 us 1.00 0.00 60.5469 13.6719 - 380.11 KB
AsNoTracking 10 20 993.3 us 24.04 us 65.40 us 966.2 us 0.71 0.05 37.1094 6.8359 - 232.89 KB
最後,可以在不產生更改跟蹤開銷的情況下執行更新,方法是:利用無跟蹤查詢,再將返回的例項附加到上下文中,同時指定要進行的更改。 這種做法將更改跟蹤的負擔從 EF 轉移到使用者,我們只應在更改跟蹤開銷已透過分析或基準測試顯示為不可接受時嘗試這麼做。

使用 SQL 查詢
在某些情況下,你的查詢存在更最佳化的 SQL,而 EF 不能生成這種 SQL。 如果 SQL 構造是特定於不受支援的資料庫的擴充套件,或者 EF 不轉換為該構造,可能會發生這種情況。 在這些情況下,手動編寫 SQL 可以顯著提高效能,而 EF 支援透過多種方法來實現此目的。

直接在查詢中使用 SQL 查詢,例如透過 FromSqlRaw。 EF 讓你甚至可以透過常規 LINQ 查詢基於 SQL 進行撰寫,從而能夠在 SQL 中僅表達查詢的一部分。 只需要將 SQL 用於程式碼庫中的一個查詢時,這種方法很不錯。
定義使用者定義的函式 (UDF),然後從查詢中呼叫它。 請注意,EF 允許 UDF 返回完整的結果集,這些 UDF 被稱為表值函式 (TVF),它還允許將 DbSet 對映到函式,使其看起來像另一個表。
在查詢中定義一個資料庫檢視並從中進行查詢。 請注意,與函式不同,檢視不能接受引數。
備註

在確定 EF 無法生成所需的 SQL 後,並且當效能問題大到給定查詢能判斷時,通常可以將原始 SQL 作為最後的方法。 使用原始 SQL 在維護方面的缺點相當大。

非同步程式設計
一般來說,為了使應用程式可縮放,務必始終使用非同步 API,而不是同步 API(例如使用 SaveChangesAsync,而不是 SaveChanges)。 同步 API 在資料庫輸入/輸出 (I/O) 期間阻止執行緒,增加了對執行緒的需要和必須發生的執行緒上下文切換的次數。

相關文章