EntityFramework Core进行读写分离最佳实践方式,了解一下?
2019-07-24

前言

本来打算写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接口,使得我们可以在已存在的上下文实例上重新设置连接字符串即更换数据库,但是其前提是必须保证当前上下文连接已关闭,也就是说比如我们在同一个事务中利用当前上下文进行更改操作,然后更改连接字符串进行更改操作,最后提交事务,因为在此事务内,当前上下文连接还未关闭,所以再更改连接字符串后进行数据库更改操作,将必定会抛出异常。

总结 

花了两天时间研究研究,本文比较详细讲解了对于读写分离后,如何进行数据查询和更改操作最佳实践方式,不知道算不算最好的解决方案,若您有更好的方案,欢迎一起探讨或者说还有其他理解和疑问,也欢迎在评论中提出。