異步、事務隔離級別及緩存優化
異步
既然是異步我們就得知道我們知道在什麽情況下需要使用異步編程,當等待一個比較耗時的操作時,可以用異步來釋放當前的托管線程而無需等待,從而在管理線程中不需要花費額外的時間,也就是不會阻塞當前線程的運行。
在客戶端如:Windows Form以及WPF應用程序中,當執行異步操作時,則當前線程能夠保持用戶界面持續響應。在服務器端如:ASP.NET應用程序中,執行異步操作可以用來處理多個請求,可以提高服務器的吞吐量等等。
在大部分應用程序中,對於比較耗時的操作用異步來實現可能會有一些改善,但是若你不多加考慮,動不動就用異步反而會得到相反的效果以及對應用程序也是致命的。
鑒於上述描述,我們接下來通過EF實現異步來加深理解。(想想還是把所用類及映射給出來,以免沒看過前面的文章的同仁不知所雲。)
Student(學生)類:
public class Student { public int Id { get; set; } public string Name { get; set; } public int FlowerId { get; set; } public virtual Flower Flower { get; set; } }
Flower(小紅花)類
public class Flower { public int Id { get; set; } public string Remark { get; set; } public virtual ICollection<Student> Students { get; set; } }
相關映射:
public class StudentMap : EntityTypeConfiguration<Student> { public StudentMap() { ToTable("Student"); HasKey(key => key.Id); HasRequired(p => p.Flower).WithMany(p => p.Students).HasForeignKey(p => p.FlowerId); } } public class FlowerMap:EntityTypeConfiguration<Flower> { public FlowerMap() { ToTable("Flower"); HasKey(p => p.Id); } }
接下來我們添加相關數據並實現異步:
static async Task AsycOperation() { using (var ctx = new EntityDbContext()) { ctx.Set<Student>().FirstOrDefault(d => d.Name == "xpy0928"); Console.WriteLine("準備添加數據,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); (3) Thread.Sleep(3000); ctx.Set<Student>().Add(new Student() { Flower = new Flower() { Remark = "so bad" }, Name = "xpy0928" }); await ctx.SaveChangesAsync(); Console.WriteLine("數據保存完成,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); (4) } }
接下來就是在控制臺進行調用以及輸出:
Console.WriteLine("執行異步操作之前,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); (1)
AsycOperation();
Console.WriteLine("執行異步操作後,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); (2)
Console.ReadKey();
這段代碼不難理解,基於我們對於異步的理解,輸出順序應該是(1)(3)(2)(4),結果如我們預期一樣,如下:
我們知道await關鍵字的作用是:在線程池中新起一個將被執行的工作線程Task,當要執行IO操作時則會將工作線程歸還給線程池,因此await所在的方法不會被阻塞。當此任務完成後將會執行該關鍵字之後代碼
所以當執行到await關鍵字時,會在狀態機(async/await通過狀態機實現原理)中執行異步方法並等待執行結果,當異步執行完成後,此時再在線程池中新開一個Id為11的工作線程,繼續await之後的代碼執行。此時要執行添加數據,所以此時將線程歸還給主線程,不阻塞主線程的運行所以就出現先執行(2)而不是先執行(4)。
接下來看一個稍微在上述基礎上經過改造的方法。如下:
static async Task AsycOperation() { using (var ctx = new EntityDbContext()) { ctx.Set<Student>().FirstOrDefault(d => d.Name == "xpy0928"); Console.WriteLine("準備添加數據,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); Thread.Sleep(3000); ctx.Set<Student>().Add(new Student() { Flower = new Flower() { Remark = "so bad" }, Name = "xpy09284" }); await ctx.SaveChangesAsync(); Console.WriteLine("數據保存完成,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine("開始執行查詢,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); var students = await (from stu in ctx.Set<Student>() select stu).ToListAsync(); Console.WriteLine("遍歷獲得所有學生的姓名,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); foreach (var stu in students) { Console.WriteLine("學生姓名為:{0}", stu.Name); } } }
接下來在控制臺中進行如下調用:
Console.WriteLine("執行異步操作之前,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); var task = AsycOperation(); ; task.Wait(); Console.WriteLine("執行異步操作後,當前線程Id為{0}", Thread.CurrentThread.ManagedThreadId); Console.ReadKey();
接下我們進行打印如下:
上述至於為什麽不是執行【執行異步操作後,當前線程Id為10】然後執行【遍歷獲得所有學生的姓名,當前線程Id為12】,想必大家能清楚的明白,是執行上述 task.Wait() 的緣故,必須進行等待當前任務執行完再執行主線程後面的輸出。
對於處理EF中的異步沒有太多去探索的東西,基本就是調用EF中對應的異步方法即可,重點是EF中的事務,請繼續往下看:
*事務
默認情況下
- 可能我們未曾註意到,其實在EF的所有版本中,當我們調用SaveChanges方法來執行增、刪、改時其操作內部都用一個transaction包裹著。不信,如下圖,當添加數據時:
- 對於上下文中的 ExecuteSqlCommand() 方法默認情況下也是用transaction包裹著命令(Command),其有重載我們可以顯示指定執行事務還是不確定執行事務。
- 在此上兩種情況下,事務的隔離級別是數據庫提供者認為的默認設置的任何隔離級別,例如在SQL Server上默認是READ COMMITED(讀提交)。
- EF對於任何查詢都不會用transaction來進行包裹。
在EF 6.0版本以上,EF一直保持數據庫連接打開,因為要啟動一個transaction必須是在數據庫連接打開的前提下,同時這也就意味著我們執行多個操作在一個transaction的唯一方式是要麽使用 TransactionScope 要麽使用 ObjectContext.Connection 屬性並且啟動調用Open()方法以及BeginTransaction()方法直接返回EntityConnection對象。如果你在底層數據庫連接上啟動了transaction,再調用API連接數據庫可能會失敗。
概念
在開始學習事務之前我們先了解兩個概念:
- Database.BeginTransaction():它是在一個已存在的DbContext上下文中對於我們去啟動和完成transactions的一種簡單方式,它允許多個操作組合存在在相同的transaction中,所以要麽提交要麽全部作為一體回滾,同時它也允許我們更加容易的去顯示指定transaction的隔離級別。
- Dtabase.UseTransaction():它允許DbContext上下文使用一個在EF實體框架之外啟動的transaction。
在相同上下文中組合幾個操作到一個transaction
Database.BeginTransaction有兩種重載——一種是顯示指定隔離級別,一種是無參數使用來自於底層數據庫提供的默認隔離級別,兩種都是返回一個DbContextTransaction對象,該對象提供了事務提交(Commint)以及回滾(RollBack)方法直接表現在底層數據庫上的事務提交以及事務回滾上。
DbContextTransaction一旦被提交或者回滾就會被Disposed,所以我們使用它的簡單的方式就是使用using(){}語法,當using構造塊完成時會自動調用Dispose()方法。
根據上述我們現在通過兩個步驟來對學生進行操作,並在同一transaction上提交。如下:
using (var ctx = new EntityDbContext()) { using (var ctxTransaction = ctx.Database.BeginTransaction()) { try { ctx.Database.Log = Console.WriteLine; ctx.Database.ExecuteSqlCommand("update student set name=‘xpy0929‘"); var list = ctx.Set<Student>().Where(p => p.Name == "xpy0929").ToList(); list.ForEach(d => { d.Name = "xpy0928"; }); ctx.SaveChanges(); ctxTransaction.Commit(); } catch (Exception) { ctxTransaction.Rollback(); } } }
我們通過控制臺輸出SQL日誌查看提交事務成功如下:
【註意】 要開始一個事務必須保持底層數據庫連接是打開的,如果數據庫不總是打開的我們可以通過 BeginTransaction() 方法將打開數據庫連接,如果 DbContextTransaction 打開了數據庫,當調用Disposed()方法時將會關閉數據庫連接。
註意事項
當用EF上下文中的 Database.ExecuteSqlCommand 方法來對數據庫進行如下操作時
using (var ctx = new EntityDbContext()) { var sqlCommand = String.Format("ALTER DATABASE {0} SET SINGLE_USER WITH ROLLBACK IMMEDIATE", "DBConnectionString"); ctx.Database.ExecuteSqlCommand(sqlCommand); }
此時將會報錯如下:
上述已經講過此方法會被Transaction包裹著,所以導致出錯,但是此方法有重載,我們進行如下設置即可
ctx.Database.ExecuteSqlCommand(TransactionalBehavior.DoNotEnsureTransaction,sqlCommand);
將一個已存在的事務添加到上下文中
有時候我們可能需要事務的作用域更加廣一點,當然是在同一數據庫上但是是在EF之外完全進行操作。基於此,此時我們必須手動打開底層的數據庫連接來啟動事務,同時通知EF使用我們手動打開的連接來使現有的事務連接在此連接上,這樣就達到了在EF之外使用事務的目的。
為了實現上述在EF之外使用事務我們必須在DbContext上下文中的派生類的構造器中關閉自身的連接而使用我們傳入的連接。
第一步
上下文中關閉EF連接使用底層連接。
代碼如下:
public EntityDbContext(DbConnection con) : base(con, contextOwnsConnection: false) { }
第二步
啟動Transcation(如果我們想避免默認設置我們可以手動設置隔離級別),通知EF一個已存在的Transaction已經在我們手動的設置的底層連接上啟動。
using (var con = new SqlConnection("ConnectionString")) { using (var SqlTransaction = con.BeginTransaction()) { using (var ctx = new EntityDbContext(con)) {
} } }
第三步
因為此時是在EF實體框架外部執行事務,此時則需要用到上述所講的 Database.UseTransaction 將我們的事務對象傳遞進去。
ctx.Database.UseTransaction(SqlTransaction);
此時我們將能通過SqlConnection實例來自由執行數據庫操作或者說是在上下文中,執行的所有操作都是在一個Transaction上,而我們只負責提交和回滾事務並調用Dispose方法以及關閉數據庫連接即可。
至此給出完整代碼如下:
using (var con = new SqlConnection("ConnectionString")) {
con.Open(); using (var SqlTransaction = con.BeginTransaction()) { try { var sqlCommand = new SqlCommand(); sqlCommand.Connection = con; sqlCommand.Transaction = SqlTransaction; sqlCommand.CommandText = @"update student set name = ‘xpy0929‘"; sqlCommand.ExecuteNonQuery(); using (var ctx = new EntityDbContext(con)) { ctx.Database.UseTransaction(SqlTransaction); var list = ctx.Set<Student>().Where(d => d.Name == "xpy0929").ToList(); list.ForEach(d => { d.Name = "xpy0928"; }); ctx.SaveChanges(); } SqlTransaction.Commit(); } catch (Exception) { SqlTransaction.Rollback(); } } }
【註意】你可以設置 ctx.Database.UseTransaction(null); 為空來清除當前EF中的事務,如果你這樣做了,那麽此時EF既不會提交事務也不會回滾現有的事務,除非你清楚這是你想做的 ,否則請謹慎使用。
TransactionScope Transactions
在msdn上對 TransactionScope 類定義為是:類中的代碼稱為事務性代碼。
我們將上述代碼包含在如下代碼中,則此作用域裏的代碼為事務性代碼
using ( var scope = new TransactionScope(TransactionScopeOption.Required)) { }
【註意】此時SqlConnection和EF實體框架都使用 TransactionScope ,因此此時將被會一起提交。
在.NET 4.5.1中 TransactionScope 能夠和異步方法一起使用通過TransactionScopeAsyncFlowOption的枚舉來啟動。
通過如下實現:
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) {}
接著就是將數據庫連接的打開方法(Open)、查詢方法(ExecuteNonQuery)、以及上下文中保存的方法(SaveChanges)都換為對應的異步方法(OpenAsync)、(ExecuteNonQueryAsync)以及(SaveChangesAsync)即可
使用TransactionScope異步有幾點限制,例如上述的必須是在.NET 4.5.1中才有異步方法等等。
在EF應用程序中避免死鎖建議
事務隔離級別
我們知道在查詢上是沒有transaction的,EF只有在SaveChanges上的本地transaction(除非外界系統的transaction即System.Transaction被檢測到,在此種情況下才會被用到)。
在SQL Server上默認的隔離級別是READ COMMITTED,並且READ COMMITED默認情況下是共享鎖的,盡管當每條語句完成時鎖會釋放但是這種情況下還是極易導致鎖爭用。 那是可能的我們配置數據庫通過設置 READ_COMMITTED_SNAPSHOT 的選項為ON來避免完全讀取甚至是READ COMMITTED隔離級別上。SQL Servert采取了Row Version以及Snapshot(Snapshot和Row Version以及Set Transaction Isolation Level)而不是共享鎖的方式來提供了同樣的保障為READ COMMITED隔離。
Snapshot Isolation Level(從字面意思將其理解為快照式隔離級別)
由於本人對隔離級別中最熟悉的是 READ_UNCOMMITED 、 READ_COMMITED 、 REPEATABLE_READ 以及 SERIALIZABLE ,而對此Snapshot隔離級別不太熟悉,就詳細敘述下,以備忘。
-
在SQL Server 2005版本中引入此隔離級別,此隔離級別依賴於增強行版本(Row Version)旨在通過避免讀寫阻塞來提高性能,通過非阻塞行為來顯著降低復雜事務死鎖的可能性。
-
啟動該隔離級別將激活臨時數據庫上的臨時表存儲Row Version(行版本)的機制,此時臨時表將更新每個行版本,用事務序列號來標識每個事務,同時每個行版本的序列號也將被記錄下來,此隔離級別的事務適用於在此事務序列號之前有一個序列號的最新行版本,在事務已經開始後創建的新的行版本會被事務所忽略。
-
該隔離級別使用樂觀並發模式,如果一個Snapshot事務試圖提交已經發生了修改的數據,因為此時事務已經啟動,所以事務將會回滾並拋出一個錯誤。
-
在事務開始時,在事務中指定要讀取的數據與已存在的數據是事務一致性版本,該事務只知道在該事務啟動之前被提交的修改的數據而通過其他事務執行當前事務語句對數據做出的更改在當前事務啟動之後是不可見的。這個作用就是好像事務中的語句獲得了已經提交數據的快照,因為它存在於事務的開始。
-
當一個數據庫正在恢復時,當Snapshot事務讀取數據時不會要求鎖定。Snapshot事務不會阻塞其他事務對其執行寫的操作,同時事務也不會阻塞Snapshot對其指定讀的操作。
-
在啟動一個事務為Snapshot隔離級別時之前必須將ALLOW_SNAPSHOT_ISOLATION設置為ON,當使用Snapshot隔離級別在不同數據庫間訪問數據必須保證每個數據庫上的ALLOW_SNAPSHOT_ISOLATION為ON。
考慮到SQL Server的默認值以及EF的相關行為,大部分情況下每個EF執行查詢是在它自己被自動調用以及SaveChanges運行在用READ COMMITED隔離的本地事務裏。也就是說EF被設計的能很好和System.Transactions.Transaction一起工作。對於 System.Transactions.Transaction 的默認隔離級別是 SERIALIZABLE ,我們知道此隔離級別是最嚴格的隔離級別能同時解決臟讀、不可重復讀以及幻影讀取的問題,當然這也就意味著默認情況下使用 TransactionScope 或者 CommitableTransaction 的話,我們應該選擇最為嚴格的隔離級別,同時裏面也要添加許多鎖。
但是幸運的是,這種默認的情況我們能輕而易舉的進行覆蓋, 例如,為了配置Snapshot,我們可以通過使用TransactionSope來實現。
using (var scope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.Snapshot })) { //Do Something scope.Complete(); }
上述建議通過封裝此構造的方法來簡化使用。
建議
鑒於上述對快照式隔離級別(Snapshot Isolation Level)以及EF相關描述,我們可以將避免EF應用程序死鎖歸結於以下:
-
使用快照式事務隔離級別(Snapshot Transaction Isolation Level)或者快照式 Read Committed(Snapshot Read Committed)同時也推薦利用TransactionScope來使用事務。通過使用如下代碼:
using (var scope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.Snapshot })) { //You need to do something scope.Complete(); }
-
當然EF版本更高更好。
-
當在Transaction裏查詢相同的表時,盡量使用相同的順序。
性能優化
貌似寫EF系列以來我們從未談論過一個東西,而這個東西卻一直是我們關註的,那就是緩存,難道在EF中沒有緩存嗎,答案是否定的,至少個人覺得在此篇文章談論緩存還是比較合適宜,因為與事務有關,再加上這本來就是一個需要深入去學習的地方,所以不能妄自菲薄,若有不妥之處,請指正。
我們談論的是二級緩存,通過二級緩存來提高查詢性能,所以一語道破天機二級緩存就是一個查詢緩存,通過SQL命令將查詢的結果存儲在緩存中,以至於當我們下次執行相同的命令時會去緩存中去拿數據而不是一遍又一遍的執行底層的查詢,這將對我們的應用程序有一個性能上的提升同時也減少了對數據庫的負擔,當然這也就造成了對內存的占用。
EF 6.1二級緩存
接下來我們進入實戰,我們依然借用【異步】中的兩個類來進行查詢。通過如下代碼我們來進行查詢:
using (var ctx = new EntityDbContext()) { ctx.Set<Student>().FirstOrDefault(d => d.Name == "xpy0928"); }
我們同時刷新一次,此時我們通過Sql Profiler進行監控,毫無疑問此時會執行兩次查詢對於相同的查詢
接下來我們通過EF來實現二級緩存試試看,首先我們添加的在EF 6.1中沒有二級緩存,此時我們需要通過NuGet手動安裝最新版本的二級緩存如下:
EF對於相關的配置是在 DbConfiguraion 中,所以肯定是在此配置中的構造函數中進行。通過以下步驟進行:
第一步
首先要獲得二級緩存實例,如下:
var transactionHandler = new CacheTransactionHandler(new InMemoryCache());
第二步
因為是對於查詢結果的緩存所以我們將其註冊到監聽,如下:
AddInterceptor(transactionHandler);
第三步
因為其緩存服務肯定是在在EF初始化過程中進行加載,也就是將緩存服務添加到DbConfiguration中的Loaded事件中即可。如下:
Loaded += (sender, args) => args.ReplaceService<DbProviderServices>( (s, _) => new CachingProviderServices(s, transactionHandler, cachingPolicy));
以上是我們整個實現二級緩存的大概思路,完整代碼如下【參考官方Second Level Cace for EntityFramework】
public class EFConfiguration : DbConfiguration { public EFConfiguration() { var transactionHandler = new CacheTransactionHandler(new InMemoryCache()); AddInterceptor(transactionHandler); var cachingPolicy = new CachingPolicy(); Loaded += (sender, args) => args.ReplaceService<DbProviderServices>( (s, _) => new CachingProviderServices(s, transactionHandler, cachingPolicy)); } }
此時我們再來執行上述查詢並多刷新幾次,此時將執行一次查詢,說明是在緩存中獲取數據,所以二級緩存設置成功,如下:
異步、事務隔離級別及緩存優化