前言
本來打算寫ASP.NET Core MVC基礎系列內容,看到有園友提出如何實現讀寫分離,這個問題提的好,大多數情況下,對於園友在評論中提出的問題,如果是值得深究或者大多數同行比較關注的問題我都會私下去看看,然后進行對應解答,若有敘述不當之處,還請海涵。我們稍微過一下事務,本文略長,請耐心閱讀。
事務
什么是事務呢?有關事務詳解可參看我寫的SQL Server基礎系列,我們可歸結為一句話:多個提交要么全部成功,要么全部失敗即同生共死,沒有臨陣脫逃者。那么問題來了,用了事務有什么作用或者說有什么優點呢?事務允許我們將相關操作組合打包,以確保應用程序數據的一致性。那么使用事務又有何缺點呢?使用事務雖然確保了數據一致性等等,但是會影響性能,可能會造成死鎖。那么問題又來了,既然有其優缺點,那么我們是否可以手寫邏輯實現數據一致性呢?當然可以,我們可以模擬事務回滾、提交的效果,但是這也無法百分百保證。
調用SaveChanges是否在一個事務內?
首先我們在控制台中進行如下數據添加,然后添加日志打印。
using (var context = new EFCoreDbContext()) { var blog = new Blog() { IsDeleted = false, CreatedTime = DateTime.Now, ModifiedTime = DateTime.Now, Name = "demo", Url = "http://www.cnblogs.com/createmyslef" }; context.Add(blog); context.SaveChanges(); }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var loggerFactory = new LoggerFactory(); loggerFactory.AddConsole(LogLevel.Debug); optionsBuilder.UseLoggerFactory(loggerFactory); optionsBuilder.UseSqlServer("data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=Demo1;integrated security=True;"); }
我們通過打印日志得知在調用SaveChanges方法時則包含在事務中進行提交,所以請那些可在項目中用到多表添加擔心出現問題就加上了如下開啟事務,這很顯然是多此一舉。
using (var context = new EFCoreDbContext()) { using (var transaction = context.Database.BeginTransaction()) { var blog = new Blog() { IsDeleted = false, CreatedTime = DateTime.Now, ModifiedTime = DateTime.Now, Name = "demo", Url = "http://www.cnblogs.com/createmyslef" }; context.Add(blog); context.SaveChanges(); try { transaction.Commit(); } catch (Exception) { //TODO } } }
看到如上日志信息還不是更加確定是不是,我們再來看看在上下文中的 context.Database.AutoTransactionsEnabled 方法,詳細解釋如下:
// 摘要: // Gets or sets a value indicating whether or not a transaction will be created // automatically by Microsoft.EntityFrameworkCore.DbContext.SaveChanges if none // of the 'BeginTransaction' or 'UseTransaction' methods have been called. // Setting this value to false will also disable the Microsoft.EntityFrameworkCore.Storage.IExecutionStrategy // for Microsoft.EntityFrameworkCore.DbContext.SaveChanges // The default value is true, meaning that SaveChanges will always use a transaction // when saving changes. // Setting this value to false should only be done with caution since the database // could be left in a corrupted state if SaveChanges fails.
通過AutoTransactionsEnabled方法解釋得知:其默認值為True,也就意味着當調用SaveChanges方法將使用事務性提交。當然我們可以在上下文構造函數中設置是否全局禁用事務,如下:
public class EFCoreDbContext : DbContext { public EFCoreDbContext() { Database.AutoTransactionsEnabled = false; } }
在EF Core中我們什么時候會用到事務呢?如果是單一上下文,單一數據庫,那么事務跟我們沒啥關系,壓根不用管事務。如果是在單一數據庫使用多個上下文(跨上下文)或者多個數據庫,這個時候事務就閃亮登場了。比如對於電商中的商品、購物車、訂單管理、支付、物流,我們完全可以實例化五個不同的上下文,此時將涉及到跨上下文操作使用事務保持數據一致性,當然這是針對在同一關系數據庫中。或者是實例化同一上下文多次來使用事務保持數據一致性。可以參看官網的介紹《https://docs.microsoft.com/en-us/ef/core/saving/transactions》,沒什么看頭,都是針對同一數據庫操作,無非還是我所說的跨上下文、使用上下文結合底層DbConnection來使用事務共享連接等等 ,稍微大一點的看點則是在EF Core 2.1中引入了System.Transactions,可指定隔離級別以及使用ambient transactions(查資料作用是存在多個事務,事務之間存在連接,如此一來將顯得整個作用域非常冗長,通過使用此事務則在特定范圍內,所有連接都將包含在該事務中),在此就不占用篇幅介紹了,和大家一樣我們最關心的是分布式事務,也就是使用不同上下文針對多個數據庫,但是遺憾的是直到EF Core 2.1還不支持分布式事務,因為.NET Core中相關APi也還不完善,繼續等待吧。
讀寫分離
隨着流量的進入,數據庫將承受不可抗拒的壓力,單一數據庫將不再適用,這都是隨着項目的演變所帶來架構的迭代改變,這個時候就涉及到分庫,對於查詢的數據單獨作為一個數據庫,作為數據的更改也單獨用一個數據庫,再結合那些什么負載均衡等等,數據庫壓力也就減弱了許多。只作查詢的數據庫我們稱之為從數據庫,對於數據庫更改的數據庫稱之為主數據庫,主-從數據庫(Master-Slave)數據的同步方式也有很多,雖然我也沒接觸過,我們就利用SQL Server中的復制進行發布-訂閱來模擬演示還是可以的。我們來看看.NET Core Web應用程序如何實現讀寫分離,額外加一句,項目中我也未用到,都是我私下的研究,方案行不行,合不合理可以一起探討。我們創建了兩個Demo數據庫,如下:
我們將Demo1作為主數據庫,Demo2作為從數據庫,接下來用一張動態圖演示創建復制發布-訂閱(每隔10秒發布一次)。
我們給出Demo1上下文,Demo2和其一樣,按照正常做法接下來我們應該在.NET Core Web應用程序中注入Demo1和Demo2上下文,如下:
public class Demo1DbContext : DbContext { public Demo1DbContext(DbContextOptions<Demo1DbContext> options) :base(options) { } public DbSet<Blog> Blogs { get; set; } }
public class Demo2DbContext : DbContext { public Demo2DbContext(DbContextOptions<Demo2DbContext> options) :base(options) { } public DbSet<Blog> Blogs { get; set; } }
services.AddDbContext<Demo1DbContext>(options => { options.UseSqlServer("data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=Demo1;integrated security=True;"); }).AddDbContext<Demo2DbContext>(options => { options.UseSqlServer("data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=Demo2;integrated security=True;"); });
然后我們創建Demo控制器,通過Demo1上下文添加數據,Demo2上下文讀取數據,如下:
[Route("[controller]")] public class DemoController : Controller { private readonly Demo1DbContext _demo1DbContext; private readonly Demo2DbContext _demo2DbContext; public DemoController(Demo1DbContext demo1DbContext, Demo2DbContext demo2DbContext) { _demo1DbContext = demo1DbContext; _demo2DbContext = demo2DbContext; } [HttpGet("index")] public IActionResult Index() { var blogs = _demo2DbContext.Blogs.ToList(); return View(blogs); } [HttpGet("create")] public IActionResult CreateDemo1Blog() { var blog = new Blog() { IsDeleted = false, CreatedTime = DateTime.Now, ModifiedTime = DateTime.Now, Name = "demoBlog1", Url = "http://www.cnblogs.com/createmyslef" }; _demo1DbContext.Blogs.Add(blog); _demo1DbContext.SaveChanges(); return RedirectToAction(nameof(Index)); } }
@{ ViewData["Title"] = "Index"; } @model IEnumerable<EFCore.Blog> <div class="panel panel-primary"> <div class="panel-heading panel-head">博客列表</div> <div class="panel-body"> <table class="table" style="margin: 4px"> <tr> <th> @Html.DisplayNameFor(model => model.Id) </th> <th> @Html.DisplayNameFor(model => model.Name) </th> <th> @Html.DisplayNameFor(model => model.Url) </th> </tr> @if (Model != null) { @foreach (var item in Model) { <tr> <td> @Html.DisplayFor(modelItem => item.Id) </td> <td> @Html.DisplayFor(modelItem => item.Name) </td> <td> @Html.DisplayFor(modelItem => item.Url) </td> </tr> } } </table> </div> </div>
我們看到通過Demo1上下文添加數據后重定向到Demo2上下文查詢到的列表頁面,到了10秒自動同步到Demo2數據庫,通過刷新可以看到數據顯示。雖然結果如我們所期望,但是實現的路徑卻令我們不是那么如意,因為所用實體都是一樣的,只是說所連接數據庫不一樣而已,但是我們需要創建兩個不同的上下文實例,很顯然這不是最佳實踐方式,那么我們如何做才是最佳實踐方式呢?接下來我們再來創建一個Demo3數據庫,表結構和Demo1、Demo2一致,如下:
接下來我們在.NET Core Web應用程序Demo1、Demo2上下文所在的類庫中創建如下擴展方法(方便有同行需要學習,給出Demo項目基本結構)。
public static class ChangeDatabase { public static void ChangeToDemo3Db(this DbContext context) { context.Database.GetDbConnection().ConnectionString = "data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=Demo3;integrated security=True;"; } }
我們暫且不去看為何這樣設置,我們只是添加上下文擴展方法,更改連接為Demo3的數據庫,然后接下來我們獲取博客列表時,調用上述擴展方法,請問:是否可以獲取到Demo3的數據或者說是否會拋出異常呢?我們依然通過動態圖來進行演示,如下:
一直以來我們認為利用 context.Database.GetDbConnection() 方法可以回到ADO.NET進行查詢,但是我們通過實際證明,我們可以設置其他數據庫連接從而達到讀寫分離最佳實踐方式,免去再實例化一個上下文。所以對於上述我們配置的Demo1和Demo2上下文,我們大可只需要Demo1上下文即主數據庫,對於從數據庫進行查詢,我們只需在Demo1上下文的基礎上更該連接字符串即可,如下:
public static class ChangeDatabase { public static void ChangeToDemo2Db(this DbContext context) { context.Database.GetDbConnection().ConnectionString = "data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=Demo2;integrated security=True;"; } } [HttpGet("index")] public IActionResult Index() { _demo1DbContext.ChangeToDemo2Db(); var blogs = _demo1DbContext.Blogs.ToList(); return View(blogs); }
接下來問題來了,那么為何更改Demo1上下文連接字符串就能轉移到其他數據庫查詢呢?就是為了解決讀寫分離免去實例化上下文即Demo2的情況,但是內部是如何實現的呢?因為EF Core內部添加了方法實現IRelationalConnection接口,使得我們可以在已存在的上下文實例上重新設置連接字符串即更換數據庫,但是其前提是必須保證當前上下文連接已關閉,也就是說比如我們在同一個事務中利用當前上下文進行更改操作,然后更改連接字符串進行更改操作,最后提交事務,因為在此事務內,當前上下文連接還未關閉,所以再更改連接字符串后進行數據庫更改操作,將必定會拋出異常。
總結
花了兩天時間研究研究,本文比較詳細講解了對於讀寫分離后,如何進行數據查詢和更改操作最佳實踐方式,不知道算不算最好的解決方案,若您有更好的方案,歡迎一起探討或者說還有其他理解和疑問,也歡迎在評論中提出。