efcore技巧貼-也許有你不知道的使用技巧

前言

.net 環境近些年也算是穩步發展。在開發的過程中,與資料庫打交道是必不可少的。早期的開發者都是DbHelper一擼到底,到現在的各種各樣的ORM框架大行其道。孰優孰劣誰也說不清楚,文無第一武無第二說的就是這個理。沒有什麼最好的,只有最適合你的。

本人也是從DbHelper開始,期間用過SugarSql,再到EFCODE。本著學習分享的初衷分享本人工作中總結的一些小技巧,希望能幫助更多開發者,期望能達到共同進步。文中若有錯誤地方,歡迎大家不吝賜教。

1. DbContext配置

在asp.net中,通常情況下,通過在Startup類的ConfigureServices方法中,將ef服務注入。
示例程式碼如下:

services.AddDbContext<DemoDbContext>(opt=>opt.UseMySql("server=.;Database=demo;Uid=root;Pwd=123;Port=3306;"));

以上程式碼表示使用MySql資料庫。如果使用SqlServer資料庫,可以把UseMySql改為UseSqlServer,其他資料庫的使用方式也是通過調用不同的方法進行選擇。但需要安裝對應的擴展方法的程式包,如 Microsoft.EntityFrameworkCore.SqlServer 或 Microsoft.EntityFrameworkCore.Sqlite。

另外,UseMySql方法還包含了一個可空的Action類型的參數,可以通過此參數進行一些個性化的配置,比如配置重試機制。如下所示:

services.AddDbContext<DemoDbContext>(opt => opt.UseMySql("server=.;Database=demo;Uid=root;Pwd=123456;Port=3306;",
                provideropt => provideropt.EnableRetryOnFailure(3,TimeSpan.FromSeconds(10),new List<int>(){0} )));

這個重試機制在某些場景下還是比較有用的。比如,由於網路波動或訪問量導致的一瞬間的連接超時。如果不設置重試機制,則會直接觸發異常,設置了超時後,則會根據設置的時間間隔以及重試次數進行重試。EnableRetryOnFailure方法的最後一個參數是用來設置錯誤程式碼的,只有設置了錯誤程式碼的錯誤,才會觸發重試。獲取錯誤程式碼的方法有很多種,個人比較推薦的是,通過異常資訊進行獲取,比如,使用MySql數據時,觸發的異常類型是MySqlException,此類的Number屬性的值EnableRetryOnFailure方法所需要的Number

2. DbContext執行緒問題

efcore不支援在同一個DbContext實例上運行多個並行操作,這包括非同步查詢的並行執行以及從多個執行緒進行的任何顯式並發使用。 因此,始終 await 非同步調用,或對並行執行的操作使用單獨的 DbContext 實例。
當 EF Core 檢測到並行操作或多個執行緒同時嘗試使用 DbContext 實例時,你將看到一條 InvalidOperationException,其中包含類似於下面的消息:

A second operation started on this context before a previous operation completed. Any instance members are not guaranteed to be thread safe.

意思是,在上一個操作沒有執行完畢之前,又啟動了一個新的操作,所以不能保證執行緒是安全的。

下面是一段錯誤的,可以觸發這個異常的示例程式碼:

所以,請始終await非同步調用。如果在多個多個執行緒中使用DbContext,需保證每個執行緒的DbContext的實例是唯一的。

3. 資料庫使用連接池

使用 services.AddDbContextPool比使用 services.AddDbContext吞吐量提升在10~20的百分點(非官方說法,對性能提高數據是本人測試後得到的結果)。
需要注意的是,連接池大小並不是越大越好。

4. 日誌記錄

在使用ef時,基本上絕大多數和資料庫的交互都是通過linq實現的,然後ef將linq翻譯成對應的sql語句,在排查問題的時候,在開發或者排查問題時,往往需要關注最終執行的sql腳本,所以就需要通過日誌的方式查看。
efcore2.x的版本默認是注入日誌服務,所以不需要額外的操作,就可以查看對應的sql腳本。但efcore3.x的版本默認移除了日誌服務,具體原因參照://docs.microsoft.com/zh-cn/ef/core/what-is-new/ef-core-3.0/breaking-changes#adddbc。
可通過自定義DbContext的方式注入日誌任務,示例程式碼如下:

public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder => { builder.AddConsole(); });
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    base.OnConfiguring(optionsBuilder);
    optionsBuilder.UseLoggerFactory(MyLoggerFactory);
}

當執行ef程式碼時,可在控制台中查看相關的sql腳本,如下圖所示:
TIM截圖20200618204603

5. 增

插入數據到資料庫常用的場景有:普通單表單行插入,多表級聯插入,批量插入。
普通單表單行插入比較簡單,實例程式碼如下:

var student = new Student {CreateTime = DateTime.Now, Name = "zjjjjjj"};
await _context.Students.AddAsync(student);
await _context.SaveChangesAsync();

多表級聯插入,需要在實體映射中配置屬性導航。
比如Blog表和Post是的關係是1對多的關係。則在Blog的實體中,定義一個類型為List的屬性。示例程式碼如下:

[Table("blog")]
public class Blog 
{
    [Column("id")]
    public long Id { get; set; }
    [Column("title")]
    public string Title { get; set; }
    public List<Post> Posts { get; set; }
    [Column("create_date")]
    public DateTime CreateDate { get; set; }
}

對應的插入語句如下所示:

var blog = new Blog
{
    Title = "測試標題",
    Posts = new List<Post>
    {
        new Post{Content = "評論1"},
        new Post{Content = "評論2"},
        new Post{Content = "評論3"},
    }
};
await _context.Blog.AddAsync(blog);
await _context.SaveChangesAsync();

執行此程式碼,會生成如下的日誌:
111
從日誌中可以看出,通過這種方式實現了級聯插入的效果。

批量插入實現方式有兩種,一種是EF默認實現,適用於數據源較少的情況。另一種,我們基於EF開發一個大數據量批量插入的服務,適合於數據源大於1000的場景。在萬級及以上的數據量上,較EF默認的批量插入性能上有非常明顯的提升。具體參考://www.cnblogs.com/fulu/p/13370335.html

EF默認實現:

var list = new List<Student>();
for (int i = 0; i < num; i++)
{
    list.Add(new Student { CreateTime = DateTime.Now, Name = "zjjjjjj" });
}

await _context.Students.AddRangeAsync(list);
await _context.SaveChangesAsync();

ISqlBulk實現:

var list = new List<Student>();
for (int i = 0; i < 100000; i++)
{
    list.Add(new Student { CreateTime = DateTime.Now, Name = "zjjjjjj" });
}
await _bulk.InsertAsync(list);

自增 OR GUID

int自增的優點:

1、需要很小的數據存儲空間,僅僅需要4 byte 。

2、insert和update操作時使用INT的性能比GUID好,所以使用int將會提高應用程式的性能。

3、index和Join 操作,int的性能最好。

4、容易記憶。

int自增的缺點:

1、使用INT數據範圍有限制。如果存在大量的數據,可能會超出INT的取值範圍。

2、很難處理分散式存儲的數據表。

GUID做主鍵的優點:

1、唯一性。

2、適合大量數據中的插入和更新操作。

3、跨伺服器數據合併非常方便。

GUID做主鍵的缺點:

1、存儲空間大(16 byte),因此它將會佔用更多的磁碟大小。

2、很難記憶。join操作性能比int要低。

3、沒有內置的函數獲取最新產生的guid主鍵。

4、EF默認生成的GUID是無序的,會影響數據插入性能。

結論:

在數據量比較少的場景下,建議使用int自增,比如分類。對於大數據量,建議使用有序GUID。因為默認.net生成GUID是無序的,而資料庫中主鍵默認是聚集索引,而聚集索引在物理上的存儲是有序的,當插入數據時,如果插入的是無序的GUID,可能就會涉及到移動數據的情況,進而影響插入的性能,特別是百萬級數據量的時候,性能影響則較為明顯。參考資料://www.cnblogs.com/CameronWu/p/guids-as-fast-primary-keys-under-multiple-database.html

其他可選方案:

經過個人多番了解,目前市面上常用的分散式id生成演算法和Twitter發布的雪花演算法大同小異,個人也在項目中使用過雪花演算法,有興趣的朋友可以在部落格園找下相關的內容。不過目前用.net封裝的雪花演算法普遍較基礎,很難在docker或者k8s環境下簡單的使用,所以在此預告下,本人根據雪花演算法編寫的可用於k8s環境的即將開源,敬請期待。

6. 查

EF使用Linq查詢資料庫中的數據,使用Linq可編寫強類型的查詢。當命令執行時,EF先將Linq表達式轉換成sql腳本,然後再提交給資料庫執行。可在日誌中查看生成的sql腳本。

根據條件查詢:
await _context.Blog.Where(x=>x.Id>0).ToListAsync();

上述程式碼執行時生成的sql腳本如下所示:

SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 0
獲取單個實體

可實現獲取單個實體的方式有First,FirstOrDefault,Single,SingleOrDefault
其中First,FirstOrDefault執行時生成的sql腳本如下:

 SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 10
      LIMIT 1

Single,SingleOrDefault執行時生成的sql腳本如下:

 SELECT `x`.`id`, `x`.`create_date`, `x`.`title`
      FROM `blog` AS `x`
      WHERE `x`.`id` > 10
      LIMIT 2

細心的你應該已經發現了兩者的區別,Single需要查詢2條數據,當返回的數據多餘一條時,Single,SingleOrDefault方法就會報Source sequence contains more than one element.異常。所以Single方法僅適用於查詢條件對應的數據只有一條的場景,比如查詢主鍵的值。如下所示:

await _context.Blog.SingleOrDefaultAsync(x => x.Id==100);

後綴帶OrDefault和不帶後綴的區別是,當sql腳本執行查詢不到數據時,帶後綴的會返回空值,而不帶後綴的則會直接報異常。

判斷資料庫是否存在

可通過Any()和Count()方法實現是否存在數據。示例程式碼如下:

await _context.Blog.AnyAsync(x => x.Id > 100);

await _context.Blog.CountAsync(x => x.Id > 100)>0;

生成的sql腳本對應如下:

SELECT CASE
          WHEN EXISTS (
              SELECT 1
              FROM `blog` AS `x`
              WHERE `x`.`id` > 100)
          THEN TRUE ELSE FALSE
      END
SELECT COUNT(*)
      FROM `blog` AS `x`
      WHERE `x`.`id` > 100

乍一看,Any方法生成的腳本貌似更複雜些,但實際上,Any方法的性能在大數據量下比Count方法高了很多。所以在判斷是否存在時,請使用Any方法。

連接查詢

連接查詢是關係資料庫中最主要的查詢,主要包括內連接、外連接(左連接、外連接)和交叉連接等。通過連接運算符可以實現多個表查詢。本文主要講解下常用的內連接和左連接。
內連接的示例程式碼如下:

var query = from post in _context.Post
            join blog in _context.Blog on post.BlogId equals blog.Id
    where blog.Id > 0
    select new {blog, post};

左連接的示例程式碼如下:

var query = from post in _context.Post
                        join blog in _context.Blog on post.BlogId equals blog.Id
                        into pbs
                        from pb in pbs.DefaultIfEmpty()
                where pb.Id>0 && post.Content.Contains("1")
                        select new {post,pb.Title};
級聯查詢

在很多場景中,可能會涉及到查詢與父表關聯的子表數據,在這樣的場景中,會有一部分人先查出主表數據,然後根據主表的主鍵再去查詢子表的數據,筆者在使用ef初期也是這種處理方式的。但藉助Include的方法可以讓我們更方便的解決父子表級聯查詢的問題。示例程式碼如下:

var result = await _context.Blog.Include(b => b.Posts) .SingleOrDefaultAsync(x=>x.Id==157);

如果有更多的層級,可以藉助ThenInclude進行查詢。

有的時候,還有這樣的場景:我們不是簡單的查詢子表的數據,而是需要查詢滿足指定條件的數據,那就要求咱們在調用Include的方法時傳入參數,示例程式碼如下:

 var filteredBlogs = await _context.Blogs
        .Include(blog => blog.Posts
            .Where(post => post.BlogId == 1)
            .OrderByDescending(post => post.Title)
            .Take(5))
        .ToListAsync();

註:以上方法僅在.net5中支援。所以,efcore也是在一個發展的過程中,隨著時間與版本的更新,功能也會漸漸趨於完善。相關內容請參考://docs.microsoft.com/zh-cn/ef/core/querying/related-data

7. 改

使用過EF的應該都了解查詢的跟蹤與非跟蹤的概念吧(納尼?你沒聽說過,老衲給您指條明路吧://docs.microsoft.com/zh-cn/ef/core/querying/tracking)。

通常來講,更新的流程大概是這樣:查詢出數據,修改某些欄位的值,調用Update方法,然後調用SaveChange方法。看上去毫無破綻,但如果你仔細觀察過生成的sql腳本的話,或許你就應該有更好的方法,咱們先來看看示例程式碼:

var school = await _context.Schools.FirstAsync(x => x.Id > 0);
school.Name = "6666";
_context.Schools.Update(school);
await _context.SaveChangesAsync();

如下圖所示的是執行以上程式碼生成的update的sql語句,我們發現明明程式碼中只對Name重新賦了值,但生成的腳本卻將此記錄的所有欄位進行了更新,顯然這不是我們想要的結果。

20200828011210

其實,如果實體是通過跟蹤查詢得到的,則可直接調用SaveChage方法,而不用多餘調用Update方法,此時,EF內部會自動判斷哪些欄位進行了更新,從而只生成值改變了的sql語句。

結論:當要更新的實體開啟了跟蹤,則更新時,無需調用Update方法, 直接調用SaveChange方法,此時之後更新值發生改變的欄位。 如果先調用Update則SaveChange,則不管實體的欄位有沒有更新,生成的sql腳本依舊會更新所有的欄位,犧牲了性能。假如你的實體不是通過資料庫的跟蹤查詢獲取的,則在調用時才需要調用Update方法。


福祿ICH.架構出品

作者:福爾斯

2020年8月