异步、事务隔离级别及缓存优化

2019/02/14 15:57
阅读数 86

异步

既然是异步我们就得知道我们知道在什么情况下需要使用异步编程,当等待一个比较耗时的操作时,可以用异步来释放当前的托管线程而无需等待,从而在管理线程中不需要花费额外的时间,也就是不会阻塞当前线程的运行。

在客户端如: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));

        }

    }
复制代码

此时我们再来执行上述查询并多刷新几次,此时将执行一次查询,说明是在缓存中获取数据,所以二级缓存设置成功,如下:

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部