有效率地查詢是一個龐大的主題,涵蓋範圍廣泛的主題,如索引、相關的實體載入策略,以及其他許多主題。 本節詳述一些常見的策略,來加快您的查詢,以及使用者通常會遇到的陷阱。
正確使用索引
查詢是否快速執行的主要決定因素是,它會適當地利用索引:資料庫通常用來保存大量數據,而周游整個數據表的查詢通常是嚴重效能問題的來源。 索引編製問題不容易發現,因為指定的查詢是否會使用索引並不明顯。 例如:
// Matches on start, so uses an index (on SQL Server)
var posts1 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
// Matches on end, so does not use the index
var posts2 = await context.Posts.Where(p => p.Title.EndsWith("A")).ToListAsync();
找出索引問題的好方法是先找出緩慢的查詢,然後透過您資料庫慣用的工具檢查其查詢計劃;如需如何執行此動作的詳細資訊,請參閱 效能診斷 頁面。 查詢計劃會顯示查詢是周游整個數據表,還是使用索引。
一般來說,使用索引或診斷與索引相關的效能問題不需要任何特殊的 EF 知識。與索引相關的資料庫一般知識對 EF 應用程式和不使用 EF 的應用程式同樣重要。 下列列出使用索引時要記住的一些一般指導方針:
- 雖然索引會加速查詢,但它們也會降低更新速度,因為它們需要保留 up-to日期。 請避免定義不需要的索引,並考慮使用 索引篩選將索引 限制為數據列子集,進而降低此額外負荷。
- 複合索引可以加速對多個數據行進行篩選的查詢,但也可以根據排序來加速不會篩選所有索引數據行的查詢。 例如,在資料行 A 和 B 上的索引可以加速依 A 和 B 篩選的查詢,以及只依 A 篩選的查詢,但不會加速只依 B 篩選的查詢。
- 如果查詢依表達式篩選資料行 (例如
price / 2
),便無法使用簡單的索引。 不過,您可以定義表示式 的預存數據 行,並針對該數據行建立索引。 某些資料庫也支持表達式索引,這可以直接用來加速任何表達式的查詢篩選。 - 不同的資料庫允許以各種方式設定索引,而且在許多情況下,EF Core 提供者會透過 Fluent API 公開這些索引。 例如,SQL Server 提供者可讓您設定索引是 叢集的,還是設定其 填滿因數。 若要取得更多資訊,請參閱提供者的文件。
您只需投影出所需的屬性
EF Core 可讓您輕鬆地查詢實體實例,然後在程式代碼中使用這些實例。 不過,查詢實體實例經常會從資料庫提取超過必要數據。 請考慮下列事項:
await foreach (var blog in context.Blogs.AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blog.Url);
}
雖然此程式代碼實際上只需要每個 Blog 的 Url
屬性,但會擷取整個 Blog 實體,而不需要的數據行會從資料庫傳輸:
SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
這可以透過使用 Select
來告訴 EF 要投射哪些欄位以進行優化。
await foreach (var blogName in context.Blogs.Select(b => b.Url).AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blogName);
}
產生的 SQL 只會提取所需的資料列:
SELECT [b].[Url]
FROM [Blogs] AS [b]
如果您需要將多個數據行投影出來,請使用您想要的屬性,將專案投射到 C# 匿名類型。
請注意,這項技術對於唯讀查詢非常有用,但如果您需要 更新 所擷取的部落格,會變得更複雜,因為 EF 的變更追蹤僅適用於實體實例。 有可能在不載入整個實體的情況下執行更新,方法是附加已修改的 Blog 實例並告訴 EF 哪些屬性已變更,但這是一種更進階的技術,可能不值得。
限制結果集大小
根據預設,查詢會傳回符合其篩選條件的所有數據列:
var blogsAll = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToListAsync();
由於傳回的數據列數目取決於資料庫中的實際數據,因此無法知道會從資料庫載入多少數據、結果會佔用多少記憶體,以及處理這些結果時會產生多少額外負載(例如,透過網路將它們傳送至使用者瀏覽器)。 關鍵是,測試資料庫經常包含少量數據,讓所有專案在測試時運作良好,但當查詢開始在真實世界數據上執行且傳回許多數據列時,效能問題就會突然出現。
因此,通常值得考慮限制結果數目:
var blogs25 = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.Take(25)
.ToListAsync();
您的UI至少會顯示一則訊息,指出資料庫中可能有更多數據列(並允許以其他方式擷取它們)。 完整的解決方案會實作 分頁,您的UI一次只會顯示特定數目的數據列,並允許用戶視需要前進到下一頁:如需如何有效率地實作的詳細資訊,請參閱下一節。
有效率的分頁
分頁是指在頁面中擷取結果,而不是一次擷取結果;這通常是針對大型結果集完成的,其中會顯示使用者介面,讓用戶流覽至結果的下一頁或上一頁。 使用資料庫實作分頁的常見方式是使用 Skip
和 Take
運算符(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 = await context.Blogs
.Include(
blog => blog.Posts
.Where(post => post.BlogId == 1)
.OrderByDescending(post => post.Title)
.Take(5))
.ToListAsync();
}
在其他案例中,在取得主體實體之前,我們可能不知道我們需要哪些相關實體。 例如,載入某些部落格時,我們可能需要諮詢一些其他數據源,可能是 Web 服務,以了解我們是否對部落格的文章感興趣。 在這些情況下,顯性載入 或 延遲載入 可以用來分別提取相關實體,並填充部落格的文章導覽屬性。 請注意,由於這些方法不是以急切方式執行,因此需要對資料庫進行額外的往返,這可能導致速度變慢。根據您的具體情況,可能更有效率的方法是直接載入所有貼文,而不是執行額外的往返來選擇性取得您所需的貼文。
注意延遲載入
延遲載入 通常似乎是撰寫資料庫邏輯的一種非常有用的方式,因為EF Core 會在程式碼存取相關實體時自動從資料庫載入相關實體。 這可避免載入不需要的相關實體(例如 明確載入),而且似乎讓程式設計人員不必完全處理相關實體。 不過,延遲載入特別容易產生不必要的額外往返,這可能會讓應用程式變慢。
請考慮下列事項:
foreach (var blog in await context.Blogs.ToListAsync())
{
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(而且我們只應該 載入所需的內容)。 因此,我們將改用投影:
await foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).AsAsyncEnumerable())
{
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 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
var blogsArray = await context.Posts.Where(p => p.Title.StartsWith("A")).ToArrayAsync();
// Foreach streams, processing one row at a time:
await foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")).AsAsyncEnumerable())
{
// ...
}
// AsAsyncEnumerable 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
.AsAsyncEnumerable()
.Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results
如果您的查詢只傳回一些結果,您可能不必擔心這個問題。 不過,如果您的查詢可能會傳回大量數據列,值得考慮串流而不是緩衝處理。
備註
請避免使用ToList或ToArray,如果您打算在結果上使用另一個 LINQ 運算子,這將不必要地將所有結果緩衝到記憶體中。 請改用 AsEnumerable。
EF 的內部緩衝
在某些情況下,不論您評估查詢的方式為何,EF 都會將結果集緩衝在內部。 發生此情況的兩個案例如下:
- 當重試執行策略就緒時。 這麼做可確保稍後重試查詢時,會傳回相同的結果。
- 使用 分割查詢 時,所有查詢的結果集除了最後一個查詢結果集會被緩衝處理,除非在 SQL Server 上啟用 MARS(多個作用中結果集)。 這是因為通常不可能同時有多個查詢結果集作用中。
請注意,除了您使用 LINQ 運算子所造成的任何緩衝處理之外,還會發生額外的內部緩衝。 例如,如果您在查詢中使用 ToList 並且重試執行策略已就緒,則結果集會載入記憶體 兩次:一次是由EF在內部載入,一次是由 ToList。
追蹤、無追蹤和身份識別解決方案
建議您先閱讀 追蹤和無追蹤的專用頁面 ,再繼續進行本節。
EF 預設會追蹤實體實例,以便在呼叫 時偵測並保存它們上的 SaveChanges 變更。 追蹤查詢的另一個效果是 EF 會偵測是否已為您的數據載入實例,而且會自動傳回該追蹤的實例,而不是傳回新的實例;這稱為 身分識別解析。 從效能的觀點來看,變更追蹤表示下列各項:
- EF 會在內部維護追蹤實例的字典。 載入新數據時,EF 會檢查字典,以查看是否已追蹤該實體密鑰的實例(身分識別解析)。 載入查詢結果時,字典維護和查閱需要一些時間。
- 在將載入的實例交給應用程式之前,EF 會先對該實例進行快照並在內部保留該快照。 呼叫 時 SaveChanges ,應用程式實例會與快照集進行比較,以探索要保存的變更。 快照集佔用更多記憶體,而快照集進程本身需要時間;有時候,您可以透過 值比較子指定不同的、可能更有效率的快照集行為,或使用變更追蹤 Proxy 來完全略過快照集程式(雖然這具有自己的一組缺點)。
在未將變更儲存回資料庫的唯讀案例中,可以使用 無追蹤查詢來避免上述額外負荷。 不過,由於無追蹤查詢不會執行身分識別解析,因此多個其他載入數據列所參考的資料庫數據列將會具體化為不同的實例。
為了說明,假設我們正在從資料庫載入大量文章,以及每個文章所參考的部落格。 如果 100 篇文章碰巧參考相同的部落格,追蹤查詢會透過身份解析偵測到此情況,所有文章實例都會參考相同的去重部落格實例。 相反地,無追蹤查詢會重複相同的部落格 100 次,而且必須據此撰寫應用程式程序代碼。
以下是比較追蹤與無追蹤行為的基準檢驗結果,其中每個查詢載入 10 個部落格與 20 個文章。 原始程式碼可在此取得,請儘管用於自我衡量的基礎。
方法 | NumBlogs | 每個博客的貼文數量 (NumPostsPerBlog) | 平均數 | 錯誤 | StdDev | 中位數 | 比率 | 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 查詢可以直接在您的查詢中使用,例如透過 。 EF 甚至可讓您使用一般 LINQ 查詢撰寫 SQL,讓您只表達 SQL 中查詢的一部分。 當您的程式代碼基底中只需要在單一查詢中使用 SQL 時,這是一個很好的技術。
- 定義 使用者定義函 式 (UDF),然後從您的查詢呼叫該函式。 請注意,EF 允許 UDF 傳回完整的結果集-這些稱為數據表值函式(TVF)-也允許對應
DbSet
至函式,使其看起來就像另一個數據表一樣。 - 請定義一個資料庫檢視,然後在您的查詢中從中查詢。 請注意,與函式不同,檢視無法接受參數。
備註
在確定 EF 無法產生您想要的 SQL 之後,原始 SQL 通常應該用來做為最後手段,而且當效能足夠重要時,指定的查詢才能證明其合理性。 使用原始 SQL 會產生相當大的維護缺點。
非同步程式設計
一般來說,為了使您的應用程式具有擴充性,請務必一律使用異步 API,而不是同步 API(例如 SaveChangesAsync 而不是 SaveChanges)。 同步 API 會在資料庫 I/O 期間封鎖線程,增加線程的需求,以及必須發生的線程內容切換數目。
如需詳細資訊,請參閱 異步程序設計頁面。
警告
避免在相同的應用程式中混合同步和非同步代碼,很容易不小心觸發微妙的執行緒池枯竭問題。
警告
Microsoft.Data.SqlClient 的異步實作不幸有一些已知問題(例如 #593、#601等)。 如果您看到非預期的效能問題,請嘗試改用同步命令執行,尤其是在處理大型文字或二進位值時。