EntityFramework 優化建議

風靈使發表於2018-06-09

Entity Framework目前最新版本是6.1.3,當然Entity Framework 7 目前還是預覽版,並不能投入正式生產環境,估計正式版16年第一季度會出來,瞭解過EF7的部分新特性後,還是狠狠期待一下滴。

EF效能問題一直為開發者所詬病,最讓人糾結的也是這塊,所以此次我也來談談EF的效能優及建議。既然是把優化點列舉出來,可能有些地方關於底層的知識就不會介紹的太深刻,權當拋磚引玉吧。

先說說EF效能優化工具MiniProfiler,(不過也可以直接用Sqlserver profilerMiniProfilerStackOverFlow團隊設計的一款對.net的效能分析小程式。

在這裡我們可以使用MiniProfiler嵌入頁面檢視頁面處理的週期和Sql語句執行的週期及Sql語句。可以通過Nuget下載MiniProfilerMiniProfiler.EF然後進行安裝與配置(具體操作暫不細說)。

因為作為宇宙級的開發工具VS2015已經提供了一個更為直接明瞭的方式,那就是“診斷工具”,具體開啟的位置

這裡寫圖片描述

此工具能更為直觀的將EF運算元據庫的SQL語句所列舉出來。如我要查詢角色表資料

EntityDB db = new EntityDB();

db.Role.Where(a => a.Id > 1).Select(a => a.Id).ToList();

檢視工具顯示
這裡寫圖片描述

檢視“執行Reader”可以看到SQL語句
這裡寫圖片描述

方便你根據查詢語句修改你的查詢表示式及顯示model.

下面開始一一介紹

1.使用最新版的EF

使用最新版的EF正式版本代替老的版本(除舊迎新哈哈),畢竟EF是微軟所重視的主流資料操作庫,每次升級版本優化效果都挺明顯的。

2. 禁用延遲載入

若使用延遲載入遍歷單個Model下的某一集合屬性,如下面的例子:

var user = db.Person.Single(a => a.Id == 1);

foreach (var role in user.Roles)
{
    Console.WriteLine(role.Name);
}

每次我們需要訪問屬性Role.Name的時候都會訪問資料,這樣累加起來的開銷是很大的。

EF預設使用延遲載入獲取導航屬性關聯的資料。

作為預設配置的延遲載入,需要滿足以下幾個條件:

  1. context.Configuration.ProxyCreationEnabled = true;
  2. context.Configuration.LazyLoadingEnabled = true;
  3. 導航屬性被標記為virtual

這三個條見缺一不可。因此可以選擇性禁用全域性延遲載入或者是某一屬性的延遲載入.

3.使用貪婪載入(又叫預載入就是資料庫的多表查詢)

這點其實也跟上面的一樣響應了一個原則:儘量的減少資料庫的訪問次數,

var user = db.Person.Include(a=>a.Roles);

一次查詢將UserProfile與其Role表資料查詢出來

4.瞭解 IQueryableIEnumerable的區別

IQueryable返回的是查詢表示式,也就是說生成了SQL查詢語句但是卻還沒有與資料庫進行互動。

IEnumerable則是已經執行查詢資料庫的操作且資料儲存在了記憶體中

所以在進行條件拼接的時候一定要在IQueryable型別後面追加Where條件語句,而不是等到ToList之後再開始寫條件

錯誤的寫法:

db.Person.ToList().Where(a => a.IsDeleted == false);

正確的寫法:

db.Person.Where(a => a.IsDeleted == false).ToList();

這些寫法的意思就是把資料條件拼湊好,再訪問資料庫。否則從資料庫獲取全部資料後再過濾,假如資料很龐大幾十萬,那後果可想而知!

5.優化操作AsNoTracking()Attach

對於只讀操作,強烈建議使用AsNoTracking進行資料獲取,這樣省去了訪問EF Context的時間,會大大降低資料獲取所需的時間。

同時由於沒有受到上下文的跟蹤快取,因此取得的資料也是及時最新的,更利於某些對資料及時性要求高的資料查詢。

db.Person.Where(a => a.IsDeleted == false).AsNoTracking().ToList();

下面是本人編寫關於更改AsNoTracking資料Update的兩種方式測試與總結:

EntityDB db = new EntityDB();
var users = db.User.AsNoTracking().ToList();
foreach (var user in users)
{
    db.Set<User>().Attach(user);
}
foreach (var user in users)
{
    user.IsDeleted = true;
    //db.Entry(user).State=EntityState.Modified;
}
db.SaveChanges();

以上程式碼我將未跟蹤的資料做Attach後賦值SaveChanges生成的SQL語句如下:

這裡寫圖片描述

而採用直接賦值後Entry修改State狀態為Modified

 EntityDB db = new EntityDB();
 var users = db.User.AsNoTracking().ToList();
/* foreach (var user in users)
 {
     db.Set<User>().Attach(user);
 }*/
 foreach (var user in users)
 {
     user.IsDeleted = false;
     db.Entry(user).State=EntityState.Modified;
 }
 db.SaveChanges();

生成的SQL語句如下:

這裡寫圖片描述

對比我們得出結論第一種採用Attach後賦值的方法是執行的按需更新,也就是說更新哪個欄位就update它,而第二種則是不管更新了哪個欄位,生成的SQL語句都是更新全部。

為什麼第一種方法中我Attach後僅僅只是給物件賦值且沒有修改StateModified,但EF卻能幫我修改資料值,那是因為

SaveChanges時,將會自動呼叫DetectChanges方法,此方法將掃描上下文中所有實體,

並比較當前屬性值和儲存在快照中的原始屬性值。如果被找到的屬性值發生了改變,

此時EF將會與資料庫進行互動,進行資料更新,所以不用設定StateModified

對於刪除操作則需要在Attach後設定 db.Entry(user).State = EntityState.Deleted;

借鑑於此,我又封裝了一個獨立的AttachList方法,此方法僅僅只是將由AsNoTracking 取得的資料附加到上下文中,因為不用關注之後的操作是Update或者Delete所以只用了Attach

以下截圖程式碼是直接從我的專案中摘取出來展示:

這裡寫圖片描述

其中最關鍵的是效能上的提高(就是上述文字標記的地方),當查詢大量資料時,使用此方法比不使用而將其附加到上下文容器中,效能提升不是一點點。

6.EF使用SqlQuery

對於某些特殊業務,我們也可以使用sql語句查詢實體,以下只是一個簡單的事例操作

SqlParameter[] parameter = { };
var user = db.Database.SqlQuery<User>("select * from user", parameter).ToList();

此方法獲得的實體查詢是在資料庫(Database)上,實體不會被上下文跟蹤。

SqlParameter[] parameter = { };
var user = db.Set<User>().SqlQuery("select * from user", parameter).ToList();

此方法獲得的實體查詢是被上下文跟蹤,所以能直接賦值後SaveChanges()

var user = db.Set<User>().SqlQuery("select * from user").ToList();
user.Last().Name = "makmong";
db.SaveChanges();

當然同樣支援帶引數的查詢與儲存過程操作,我就不一一列出了此處只做點出即可。

7.關於AsNonUnicode

我們執行如下語句

var query = db.User.Where(a=>a.Name=="makmong").ToList();

生成的SQL語句

這裡寫圖片描述

再試一個語句

var query = db.User.Where(a=>a.Name== DbFunctions.AsNonUnicode("makmong")).ToList();

生成的SQL語句

這裡寫圖片描述

其中生成的SQL語句區別了,一個加了N,一個未加N,N是將字串作為Unicode格式進行儲存。

因為.Net字串是Unicode格式,在上述SQL的Where子句中當一側有N型而另一側沒有N型時,此時會進行資料轉換,也就是說如果你在表中建立了索引此時會失效代替的是造成全表掃描。

DbFunctions.AsNonUnicode 方法來告訴.Net將其作為一個非Unicode來對待,此時生成的SQL語句兩側都沒有N型,就不會進行更多的資料轉換,也就是說不會造成更多的全表掃描。

所以當有大量資料時如果不進行轉換會造成意想不到的結果。

因此在進行字串查詢或者比較時建議用AsNonUnicode()方法來提高查詢效能。

8.建議使用ViewModel代替實體Model

大家可能都會碰到這種情況就是Model實體擁有多個欄位,但是查詢資料到頁面展示的時候可能只需要顯示那麼幾個欄位,這個時候建議使用ViewModel查詢,

也就是說需要哪些欄位就查詢哪些,而不是 “select *”將全部欄位載入出來。此操作即出於安全考慮 (不應該將實體Model直接傳遞到View上面),同時查詢的欄位減少 (可能就幾個) 對查詢效能也有所提升。

例:

var query = db.User.ToList();

對應的查詢語句為:

這裡寫圖片描述

接著新建ViewModel

public class UserViewModel
{
  public int Id { get; set; }
  public string Name { get; set; }
}

開始查詢:

var query = db.User.Select(a=>new UserViewModel()
{
    Id = a.Id,
    Name = a.Name
}).ToList();

對應的查詢語句為:

這裡寫圖片描述

9.建議Model實體中列舉使用byte型別

我們先來了解下Sqlserver中tinyint, smallint, int, bigint的區別

  • bigint:從-263(-9223372036854775808)到263-1(9223372036854775807)的整型資料,儲存大小為8 個位元組。一個位元組就是8位,那麼bigint就有64位
  • int:從-231(-2,147,483,648)到231-1(2,147,483,647)的整型資料,儲存大小為 4個位元組。int型別,最大可以儲存32位的資料
  • smallint:從-215(-32,768)到215-1(32,767)的整數資料,儲存大小為 2 個位元組。smallint就是有16位
  • tinyint:從0到255的整數資料,儲存大小為 1 位元組。tinyint就有8位。

所以對於有些範圍比較短的數值長度,例如列舉型別值,完全可以使用byte型別替換int型別,對應生成資料庫tinyint型別以節省資料儲存。

如:

public CouponType CouponType { get; set; }
public enum CouponType : byte
{
    RedBag = 0,
    Experience = 1,
    Cash = 2,
    JiaXiQuan = 3
}

對應的資料庫型別:

這裡寫圖片描述

此時的CouponType欄位對應資料庫就是一個tinyint型別

10.Model實體使用DateTime2替換DateTime控制內容值精度

我們先看下 SQL ServerDateTimeDateTime2的區別

  • DateTime欄位型別對應的時間格式是 yyyy-MM-dd HH:mm:ss.fff3個f,精確到1毫秒(ms),示例 2014-12-03 17:06:15.433
  • DateTime2欄位型別對應的時間格式是 yyyy-MM-dd HH:mm:ss.fffffff
    ,7個f,精確到0.1微秒(μs),示例 2014-12-03 17:23:19.2880929

我們知道EF ModelDateTime對應的SQL型別是DateTime

例:

public DateTime CreateDateTime { get; set; }

對應的資料庫實體型別:

這裡寫圖片描述

這裡寫圖片描述

但是在業務操作中很多時間值我們僅僅只需要精確到秒就夠了(特殊業務除外),

那多餘的毫秒數既無用又佔資料庫儲存(逼死處女座),既然是優化操作那麼我們是否可以去除毫秒數而只儲存到秒呢?例:2014-12-03 17:06:15

So我們可以使用特性Attribute及抽象類PrimitivePropertyAttributeConfigurationConvention來達到這一目的。

不多說直接上程式碼:

[AttributeUsage(AttributeTargets.Property)]
public sealed class DateTime2PrecisionAttribute : Attribute
{
    public DateTime2PrecisionAttribute(byte precision = 0)
    {
        Precision = precision;
    }
    public byte Precision { get; set; }
}

public class DateTime2PrecisionAttributeConvention: PrimitivePropertyAttributeConfigurationConvention<DateTime2PrecisionAttribute>      
{
    public override void Apply(ConventionPrimitivePropertyConfiguration configuration,
        DateTime2PrecisionAttribute attribute)
    {
        if (attribute.Precision > 7)
        {
            throw new InvalidOperationException("Precision must be between 0 and 7.");
        }
        configuration.HasPrecision(attribute.Precision);
        configuration.HasColumnType("datetime2");
    }
}

理解一下程式碼,第一句中的AttributeTargets.Property表示可以對屬性(Property)應用特性(Attribute)

而建構函式DateTime2PrecisionAttribute則指定了要應用的datetime的精度值。

而最後兩句

configuration.HasPrecision(attribute.Precision);
configuration.HasColumnType("datetime2");

則是將我們所定義的型別精度與對應宣告資料型別附加給要標記的實體型別。

最後還需要將DateTime2PrecisionAttributeConvention方法註冊到我們的DbContext

public virtual DbSet<User> User { get; set; }
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Conventions.Add(new DateTime2PrecisionAttributeConvention());
}

現在我們再使用此特性在上面的屬性CreateDateTime中看下效果吧

結果圖:

這裡寫圖片描述

這裡寫圖片描述

是不是感覺不錯。當然基於此擴充,我們也可以擴充套件我們想要的Model資料型別,如:控制decimal的精度(2位或4位小數),改邊nvarchar(max)為我們想要的長度型別(具體情況看業務再優化吧)。

11.合理使用EF擴充套件庫

1.EF實現指定欄位的更新

在以往的資料更新操作中我們使用EF的修改都是先查詢一次資料附加到上下文中,然後給需要修改的屬性賦值,雖說EF能夠自動跟蹤實體做到按需更新,但更新前查詢不僅沒有必要,而且增加了額外的開銷。EF刪除和修改資料只能先從資料庫取出,然後再進行刪除.

當進行如下操作時:

delete from user where Id>5;

update user set Name=”10”;

我們需要這樣操作

var t1 = db.User.Where(t => t.Id > 5).ToList();
foreach (var t in t1)
{
    db.User.Remove(t);
}
db.SaveChanges();
var t2 = db.User.ToList();
foreach (var t in t1)
{
    t.Name = "ceshi";
}
db.SaveChanges();

有沒辦法做到一條語句操作的更改呢?如update user set name=’張三’where id=1

此時就需要使用EF的擴充套件庫EntityFramework.Extended了。

在github中提供了一個EF擴充套件庫https://github.com/loresoft/EntityFramework.Extended

在VS可以直接通過NuGet安裝

enter description here

安裝完成後試驗下:

當然需要先引用:

using EntityFramework.Extensions;

編寫程式碼測試及檢視結果:

EntityDB db = new EntityDB();
db.User.Where(a => true).Update(a => new User() {Name = "ceshi"});

這裡寫圖片描述

EntityDB db = new EntityDB();
db.User.Where(a => true).Delete();

這裡寫圖片描述

嗯,至於具體選擇怎麼用,看業務分析哈。

2.批量查詢功能

例如:在分頁查詢的時候,需要查詢結果數,和結果集

EF做法:查詢兩次

var q = db.User.Where(u => u.Name.StartsWith("a"));
var count = q.Count();
var data = q.Skip(10).Take(10).ToList();

EF擴充套件庫的做法:一次查詢

var q = db.User.Where(t => t.Name.StartsWith("a"));
var q1 = q.FutureCount();
var q2 = q.Skip(10).Take(10).Future();
var data = q2.ToList();
var count = q1.Value;

3.查詢快取功能

我們現在的後臺專案許可權管理模組,所有的選單項都是寫進資料庫裡,不同的角色使用者所獲取展示的選單項各不相同。

專案導航選單就是頻繁的訪問資料庫導致效能低下(一開始得到1級選單,然後通過1級獲取2級選單,2級獲取3級)

解決方法就是第一次查詢後把資料給快取起來設定快取時間,然後一段時間繼續查詢此資料(譬如整個頁面重新整理)則直接在快取中獲取,從而減少與資料庫的互動。

程式碼如下:

var users = db.User.Where(u => u.Id > 5).FromCache(CachePolicy.WithDurationExpiration(TimeSpan.FromSeconds(30)));

如果在30秒內重複查詢,則會從快取中讀取,不會查詢資料庫

我們再提出二個問題那就是,

  1. 第一次查詢快取資料修改後(如:儲存到資料庫)緊接著繼續查詢一次,由於快取時間沒有失效,此時在快取中查詢的資料是剛剛修改的最新的嗎?
    2.在不同的上下文中快取獲取結果是一樣的嗎?

寫程式碼測試看下:

enter description here

上圖中我在第一個上下文中獲得資料快取,然後給Name賦值”sss”,當然此處為了測試快取是否更新所以我沒有做SaveChanges()的操作,然後接著從快取中獲取資料,由結果可知此快取值也相應的更改了。

因此在一段時間內即使操作修改了資料值也只需要在更改的時候操作一次資料庫,減少了與資料庫的互動。

另外需要注意的是更改的時候可以根據操作結果選擇是否繼續快取,例如資料更改失敗但是快取卻改動了,下次取值資料就會不一致,所以當我們在更新資料庫失敗時就可以選擇移除快取呼叫RemoveCache()方法。

12.EF使用SQL分庫操作

當資料庫的表及資料達到一定規模後我們想到的優化就有分庫,分表之類的優化操作。

對於之前的ADO.NET來說分庫是一件很普通的操作。

比如下面的非跨資料庫查詢語句:

SELECT Name FROM dbo.User WHERE ID=1

跨資料庫查詢語句:

SELECT Name FROM MaiMangAdb.dbo.blog_PostBody WHERE ID=1

我們知道EF的DbContext中已經指定了連線字串

public EntityDB() : base("DefaultConnection")
<connectionStrings>
  <add name="DefaultConnection" connectionString="Data Source=.;Initial Catalog=EFStudy;Integrated Security=True;" providerName="System.Data.SqlClient" />
</connectionStrings>

也就是說所有的上下文操作都是基於這個資料庫來操作的,那我們就不能用ADO.NET那套,多個查詢配多個連結去運算元據庫。

當然大神們也給出了一套方法,而且也是簡單明瞭。那我也就直接將其移植過來記錄一下吧。

方法就是給資料庫新增SYNONYM 同義詞,我在此演示下

建立2張ModelUserRole

public class User
{
    [Key]
    public int Id { get; set; }
    public string Name { get; set; }
    public bool IsDeleted { get; set; }
    [DateTime2Precision]
    public DateTime CreateDateTime { get; set; }
}
public class Role
{
    [Key]
    public int Id { get; set; }
    public string Name { get; set; }
}

並新增一條語句:

EntityDB db = new EntityDB();
db.User.Add(new User { Id = 1, Name = "ddd" ,CreateDateTime = DateTime.Now});
db.Role.Add(new Role() {Id = 1, Name = "admin"});
db.SaveChanges();

執行檢視資料庫:

這裡寫圖片描述

現在資料庫表及內容都有了。然後我要把User表及內容移植到另一個資料庫中,且不影響當前的EF操作。

建立新的資料庫EFSYNONYM並新增User表,表結構和EFStudy中的User一致。

image

然後在EFStudy中刪除表User且建立同義詞

CREATE SYNONYM [dbo].[Users] FOR  [EFSYNONYM].[dbo].[Users]

效果如圖
這裡寫圖片描述

此時的UserRole已經分別存在於不同的資料庫裡面,我們來插入查詢資料操作下

這裡寫圖片描述

至此分庫成功。當然此方法也有個缺點就是分庫表和主表間由同義詞關聯而無法建立主外來鍵關係(其實當資料量達到一定級別後聯合join查詢反而不如分開多次查詢來得快,且由於在同一個上下文中,不用太過於關心由資料多次連線開關而產生影響,凡事有利弊總得有個最優是吧),因此我們可以把一些獨立的容易過期的資料表給移植到單獨的資料庫,利於管理同時也利於優化查詢。

相關文章