.Net EF Core千萬級資料實踐
.Net 開發中操作資料庫EF一直是我的首選,工作和學習也一直在使用。EF一定程度的提高了開發速度,開發人員專注業務,不用編寫sql。方便的同時也產生了一直被人詬病的一個問題效能低下。
EF Core + MySql的組合越來越流行,所以本文資料庫使用MySql完成相關示例。
說明
由於工作中也一直使用Sql Server,所以記錄這篇文章時也學習了很多MySql的內容。
MySql安裝,開啟官網(https://dev.mysql.com/downloads/installer/)下載安裝。
示例專案說明:
.Net 5.0 + 最基本的 EF Code First 模型。兩個Entity,分別為Order和OrderItem。
資料庫:
Order資料量500W
Order實體除了基本欄位定義還定義了一個OrderItems
OrderItems資料量800W
OrderItem定義了一個Order virtual 屬性
並在實體和表對映是定義了外來鍵關聯
正常系統中單表最大可能就千萬級資料,資料再多便會考慮分表,所以最初設想是單個表準備1000W+的資料,但是沒有考慮到我這個老年筆記本,所以實際操作時資料做了適當減少。
MySql記錄
準備好測試資料後寫了一些簡單的SQL查詢來做測試,一些稍微複雜點的查詢耗時就十秒、二十秒。此時應該從資料庫的優化入手,優化EF查詢不能解決我們的問題。優化MySql查詢和排序最簡單有效的辦法就是建立索引,根據業務需求合理的建立索引,保證索引的命中(最左原則),還要設定一個足夠大的innodb-buffer-pool-size。
參考文章
必須掌握的 MySQL 優化原理:https://mp.weixin.qq.com/s/wuGbnvo3bCThO2ERqHpPAQ
MySQL 效能優化的21條實用技巧:https://mp.weixin.qq.com/s/pyAddBuxjodmT7gkOBamTw
深入理解MySQL索引之B+Tree:https://blog.csdn.net/b_x_p/article/details/86434387
MySql最左匹配原則解析:https://www.cnblogs.com/wanggang0211/p/12599372.html
日誌記錄和診斷
專案中添加了兩種方式檢視EF生成的SQL和執行耗時。做一下簡單說明實際開發中可自行選擇。
Microsoft.Extensions.Logging
確保專案安裝了Microsoft.Extensions.Logging包。
新增一個ILoggerFactory型別靜態屬性
public static readonly ILoggerFactory MyLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); });
EF Core 註冊此例項
options.EnableSensitiveDataLogging() .UseLoggerFactory(MyLoggerFactory) .EnableDetailedErrors()
MiniProfile
安裝MiniProfiler.AspNetCore.Mvc包
Startup的ConfigureServices方法增加程式碼
services.AddMiniProfiler(options => { // All of this is optional. You can simply call .AddMiniProfiler() for all defaults // (Optional) Path to use for profiler URLs, default is /mini-profiler-resources options.RouteBasePath = "/profiler"; // (Optional) Control which SQL formatter to use, InlineFormatter is the default options.SqlFormatter = new StackExchange.Profiling.SqlFormatters.InlineFormatter(); // (Optional) You can disable "Connection Open()", "Connection Close()" (and async variant) tracking. // (defaults to true, and connection opening/closing is tracked) options.TrackConnectionOpenClose = true; // (Optional) Use something other than the "light" color scheme. // (defaults to "light") options.ColorScheme = StackExchange.Profiling.ColorScheme.Auto; // The below are newer options, available in .NET Core 3.0 and above: // (Optional) You can disable MVC filter profiling // (defaults to true, and filters are profiled) options.EnableMvcFilterProfiling = true; // ...or only save filters that take over a certain millisecond duration (including their children) // (defaults to null, and all filters are profiled) // options.MvcFilterMinimumSaveMs = 1.0m; // (Optional) You can disable MVC view profiling // (defaults to true, and views are profiled) options.EnableMvcViewProfiling = true; // ...or only save views that take over a certain millisecond duration (including their children) // (defaults to null, and all views are profiled) // options.MvcViewMinimumSaveMs = 1.0m; }).AddEntityFramework();
Startup的Configure
方法增加
如下程式碼
app.UseMiniProfiler();
_ViewImports.cshtml檔案中新增引用和對應taghelper
@using StackExchange.Profiling @addTagHelper *, MiniProfiler.AspNetCore.Mvc
在檢視檔案中新增MiniProfiler
<mini-profiler />
.Net Core 5 提供了IQueryable的ToQueryString()方法可以直接獲取Linq查詢對應的SQL語句。
查詢資料
先說明兩個例項中沒有出現的基本查詢優化方案
1.大表避免整表返回(sql中的select *),簡化查詢實體僅返回業務需要的欄位,返回多個欄位時可以將Select查詢對映到匿名類。
2.如果只是單純的獲取列表不需要更新從資料庫中檢索到的實體,應使用AsNoTracking方法設定非跟蹤查詢,無需設定更改跟蹤資訊(EF 在內部維護跟蹤例項的字典),更快速地執行查詢。
Find
示例中實現兩個方法GetByIdAsync和GetByIdFromSql,實現如下
啟動專案看到如下輸出:
EF的Find方法生成了一個簡單的sql語句執行耗時19ms,反而通過FromSqlInterpolated呼叫自己寫的SQL卻生成一個看著怪異的sql語句,執行耗時3ms。MiniProfiler中檢視耗時差不多
兩個SQL耗時不應該有這麼大的差距,把兩個SQL複製到資料庫中執行時發現兩個SQL執行時間基本相同,說明呼叫EF方法EF到SQL的轉換耗時也計算在內,因為EF的快取機制再次呼叫時發現兩個方法的耗時基本持平。
兩種方式返回的Order中OrderItems數量為零,解決這個問題就涉及到EF載入相關資料的知識。
這裡演示預先載入和延遲載入兩種方式
預先載入
修改GetByIdAsync程式碼:
var order = await _dataDBContext.Orders.Include(a => a.OrderItems).FirstOrDefaultAsync(a => a.Id == id);
此時EF生成的程式碼就會關聯查詢OrderItem,EF生成SQL如下
檢視列印的log會發現一個問題,我們修改EF程式碼為預先載入,SQL查詢生成的SQL相同卻會關聯查詢出OrderItems的資料。
再次修改程式碼
並修改GetByIdFromSql方法引數為1362(之前和GetByIdAsync引數一樣為1360),執行
同樣是Find查詢,1362對應的OrderItems為空,1360對應的OrderItems的Count卻為3,對應Sql查詢的1362的OrderItems也為空。應該是EF的快取機制造成的這種情況,有興趣和精力的可以檢視一下EF Core的原始碼。
延時載入
AddDbContext時增加UseLazyLoadingProxies方法呼叫
此時不管是EF的Find還是原始SQL都能查詢出OrderItems的值。
查詢結果集及外來鍵關聯資料
定義如下方法查詢結果為某個使用者訂單及關聯資料
執行程式碼,會遇到使用EF時經常遇到的一個錯誤
因為獲取orders時已經建立一個連線,當我們迴圈orders獲取OrderItems時(當前設定為延時載入)需要在建立連線從而引發這個異常。修改程式碼通過ToList來避免這個異常
此時可以正常獲取OrderItems的資料,通過MiniProfiler檢視生成的sql
這個EF方法生成了21(1條查詢orders+20條延時查詢orderitems)條sql。再次修改程式碼,改為預先載入的方式,查詢Order的同時返回OrderItems資料。
EF生成的sql從21條變為1條。
拆分查詢
EF Core 5.0 中引入拆分查詢功能以避免“笛卡爾爆炸”問題,可以將指定 LINQ 查詢拆分為多個 SQL 查詢,僅在使用Include
時可用。
單個EF查詢呼叫AsSplitQuery方法啟用拆分查詢。也可以全域性啟用拆分查詢,在設定應用程式資料庫連線上下文時呼叫UseQuerySplittingBehavior開啟全域性拆分。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseSqlServer( @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0", o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); }
設定拆分查詢為預設的查詢方式後,可以再呼叫AsSingleQuery方法指定具體的EF查詢為單個查詢模式。
為了測試這個功能我又添加了一張Customer表,修改程式碼如下:
public async Task GetOrdersAsync(int customerId, int pageIndex, int pageSize) { var order = await _dataDBContext.Orders.Where(a => a.CustomerId == customerId) .OrderBy(a => a.CreatedTime) .Skip((pageIndex - 1) * pageSize).Take(pageSize) .Include(a => a.OrderItems).Include(a => a.Customer) .TagWith("--Get Orders").AsSplitQuery().FirstOrDefaultAsync(); var orders = await _dataDBContext.Orders.Where(a => a.CustomerId == customerId) .OrderBy(a => a.CreatedTime) .Skip((pageIndex - 1) * pageSize).Take(pageSize) .Include(a => a.OrderItems).Include(a => a.Customer) .TagWith("--Get Orders").AsSplitQuery().ToListAsync(); var count1 = 0; foreach (var _order in orders) { count1 += order.OrderItems.Count; } Console.WriteLine($"count1:{count1}"); }
上面並不是一個能正常執行的程式碼,丟擲異常MySql.Data.MySqlClient.MySqlException (0x80004005): There is already an open DataReader associated with this Connection which must be closed first。
Github的issues提到這個問題,拆分查詢需要開啟Sql Server的MARS(MultipleActiveResultSets=true)。但是MySql不持支MARS,目前我不知道如何在MySql下正常執行AsSplitQuery的程式碼。
拆分查詢當前實現執行為每個查詢的往返(類似延時載入), 這個將來會修改為單次往返中執行所有查詢。
更新資料
EF Core 預設情況下,僅在單個批處理中執行最多42條語句,可以調整這些閾值實現可能更高的效能,但在修改之前應進行基準測試確保有更高的效能。
摘自官網的一個段示例說明
很遺憾EF目前還不支援批量更新和刪除操作,官網也給出了優化方案,用原始SQL來執行:
context.Database.ExecuteSqlRaw("UPDATE [Employees] SET [Salary] = [Salary] + 1000");
B站活躍使用者楊中科老師的一篇文章也有介紹:https://www.bilibili.com/read/cv8545714
但是複雜的更新業務寫SQL同樣是讓人頭疼的一件事,不想寫一行SQL語句,又想實現批量更新和刪除操作可以藉助第三方庫Zack.EFCore.Batch或Z.EntityFramework.Extensions.EFCore(https://entityframework-extensions.net)。
效能提升
DbContext 池
AddDbContextPool 啟用可重用上下文例項的池,上下文池可以重複使用上下文例項,而不用每個請求建立新例項,從而提高大規模方案(如 web 伺服器)的吞吐量。在請求上下文例項時,EF 首先檢查池中是否有可用的例項。 請求處理完成後,例項的任何狀態都將被重置,並且例項本身會返回池中。
services.AddDbContextPool<BloggingContext>(options => options.UseSqlServer(connectionString));
poolSize
引數AddDbContextPool設定池保留的最大例項數 中128。 一旦poolSize
超出,就不會快取新的上下文例項,EF 會回退到按需建立例項的非池行為。
上下文池的工作方式是跨請求重複使用同一上下文例項。上下文池適用於上下文配置(包括解析的服務)在請求之間固定的場景。 對於需要作用域服務或需要更改配置的情況,請勿使用池。 池的效能提升通常很小,僅在高度優化的方案中採用。
預編譯查詢
執行普通Linq查詢的時會執行一次Compile,雖然EF對查詢的Linq有快取機制,但是編譯的查詢比自動快取的 LINQ 查詢效率更高。對於多次執行結構類似的查詢可以通過預編譯,僅編譯查詢一次並在每次執行時使用不同引數的方法來提高效能。
示例程式碼:
Func<DataDBContext, decimal, SingleQueryingEnumerable<int>> compiledProductReports = EF.CompileQuery<DataDBContext, decimal, SingleQueryingEnumerable<int>>( (ctx, total) => ctx.OrderItems.AsNoTracking().IgnoreAutoIncludes() .GroupBy(a => a.ProductId).Select(a => new { ProductId = a.Key, Quantity = a.Sum(b => b.Quantity), Price = a.Sum(b => b.Price), }).Where(a => a.Price > total).Select(a => a.ProductId) as SingleQueryingEnumerable<int> ); [Benchmark] public async Task ProductReports() { //var productIds = await _dataDBContext.OrderItems.IgnoreAutoIncludes().AsNoTracking() // .GroupBy(a=>a.ProductId).Select(a => new { // ProductId = a.Key, // Quantity = a.Sum(b => b.Quantity), // Price = a.Sum(b => b.Price), // }).Where(a=>a.Price>100000).Select(a=>a.ProductId) // .ToListAsync(); var productIds = compiledProductReports(_dataDBContext, 100000).ToList(); }
很遺憾這又不是一個好的程式碼,由於EF Core 5.0 增加了單個查詢和拆分查詢的概念,返回的型別為SingleQueryingEnumerable,遇到了Expression of type 'Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1[System.Int32]' cannot be used for return type 'System.Linq.IQueryable`1[System.Int32]' 這個錯誤,所以只能強轉為SingleQueryingEnumerable。但是會產生如下警告
Any
Count和Any
當有個需求判斷滿足條件的資料是否存在通常會有如下寫法
檢視三種方式生成的SQL和執行耗時,Any是效率最高的一種。
補充
提升EF的效能還有很多辦法,分為三大類:資料庫效能(純資料庫優化)、網路傳輸(減少資料傳輸和連線次數)和EF執行時開銷(跟蹤和生成SQL語句)。還有很多優化技巧沒能提及到如AsEnumerable方法改為
流式處理處理每次只獲取一條資料,但是會增加資料連線。想進一步提醒程式的效能最簡單的辦法就是在加入快取機制(Redis快取等),快取模式介紹(https://mp.weixin.qq.com/s/iUDA8L30-z_36XvYP8Dq1w),EF的攔截器也為我們提供了更多的解決方案(https://docs.microsoft.com/zh-cn/ef/core/logging-events-diagnostics/interceptors#example-advanced-command-interception-for-caching)。
Github地址:https://github.com/MayueCif/EFCore