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

福祿網路技術團隊發表於2020-08-28

前言

.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的版本預設移除了日誌服務,具體原因參照:https://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預設的批量插入效能上有非常明顯的提升。具體參考:https://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,可能就會涉及到移動資料的情況,進而影響插入的效能,特別是百萬級資料量的時候,效能影響則較為明顯。參考資料:https://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也是在一個發展的過程中,隨著時間與版本的更新,功能也會漸漸趨於完善。相關內容請參考:https://docs.microsoft.com/zh-cn/ef/core/querying/related-data

7. 改

使用過EF的應該都瞭解查詢的跟蹤與非跟蹤的概念吧(納尼?你沒聽說過,老衲給您指條明路吧:https://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月

相關文章