首页 > 其他分享 >TransactionScope的使用及遇到的坑

TransactionScope的使用及遇到的坑

时间:2023-02-05 16:55:17浏览次数:44  
标签:事务 遇到 数据库 TransactionScope 使用 new 方法 conn

事务的一般使用

我们使用dotnet访问数据库,通常是这么写的(以PostgreSQL为例,其它数据库类似的):

//其它数据库无非就是NpgsqlConnection换成别的,如SQL Server的SqlConnection
using(NpgsqlConnection conn = new NpgsqlConnection(connString)){
    // conn.Execute("sql...");
}

如果使用事务,就变成:

using(NpgsqlConnection conn = new NpgsqlConnection(connString)){
    using(TransactionScope ts = new TransactionScope(TransactionScopeOption.Required))
        conn.EnlistTransaction(Transaction.Current);
        // conn.Execute("sql1...");
        // conn.Execute("sql2...");
        ts.Complete();
    }
}

这里说明一下:

  1. new TransactionScope()这个动作可能会改变Transaction.Current的值,Transaction.Current是线程相关的,总的来说,在TransactionScope范围内的话,它就有值,否则是null,这个特性会引起一个大坑,因为TransactionScope可能会不经意地提前结束,后面会详细说。
  2. EnlistTransaction这个方法的意思是将conn加入到当前的TransactionScope去,在TransactionScope的范围内,conn的各种对数据库的操作都能够有事务的保证。(BTW:很多DbConnection对象本身有自动Enlist的功能,可以自行研究,这里为了演示清晰,使用手动Enlist方式)
  3. TransactionScope使用Required模式获得时,表示如果当前已经在事务范围内,就不会创建新的事务,也就是说Transaction.Current不会被改变(我们99%都是想要这种情况),只有外围事务不存在时,才会创建新的事务。TransactionScope还有另外两种获得模式:一是RequiredNew,表示新建一个事务,跟外围事务无关;另一是Suppress,表示不使用外围的事务,这样的话对数据库的操作是直接生效的,没在事务的管理范围内,这种情况下得重新打开一个连接来做,否则也会遇到麻烦,后面会提到。
本文代码偏向于写成伪码形式,重理解

嵌套调用

到此问题不大,但考虑下方法嵌套调用的场景。A方法打开数据库操作,且有一个事务,B方法打开数据库操作,也有一个事务,且这个事务中调用了A方法:

 

让嵌套调用使用同一个连接

先解决一下这个问题:如果A和B都是各自new一个DbConnection出来,那两者使用的并不是同一个数据库连接对象,这可能会带来一些问题。我们99%的情况,都是希望A和B使用同一个数据库连接,上面这种情况,应该是当A发现当前上下文已经有数据库连接的时候,就直接使用数据库连接,而不重新new一个,也就是说,我们做一个线程相关的DbConnection,要做到这点并不难,利用dotnet的ThreadStatic注解即可办到。下面是参考代码:

public static class DbConnManager {
    [ThreadStatic]
    private static DbConnection _contextDbConnection;

    /// <summary>
    /// 尝试从当前线程上下文中获取数据库连接
    /// 如果当前线程上下文中不存在可用的数据库连接,那么创建之并打开
    /// 注意:此方法只能在Service层使用,如果在UI层使用,MVC的异步模式可能会带来意外的结果
    /// </summary>
    /// <param name="conn">要获取的数据库连接(输出值,实际使用时,应当使用这个值)</param>
    /// <param name="connString">数据库连接字符串,默认为DefaultConnString</param>
    /// <returns>如果是第一次创建的数据库连接,会返回数据库连接,否则返回null</returns>
    public static DbConnection GetContextConnectionAndOpen(out DbConnection conn, string connString = null) {
        DbConnection forReturn = null;
        if (_contextDbConnection == null || _contextDbConnection.State != System.Data.ConnectionState.Open) {
            _contextDbConnection = new NpgsqlConnection(connString ?? DefaultConnString);
            _contextDbConnection.Open();
            forReturn = _contextDbConnection;
        }
        conn = _contextDbConnection;
        return forReturn;
    }
}

使用方法如此:

using (DbConnManager.GetContextConnectionAndOpen(out DbConnection conn)) {
    using (TransactionScope ts = new TransactionScope(TransactionScopeOption.Required)) {
        connInner.EnlistTransaction(Transaction.Current);
        //conn.Execute("sql1...")
        //conn.Execute("sql2...")
        ts.Complete();
    }
}

我们全部的程序都使用这样的调用方式,就能确保层级调用使用的都是同一个数据库连接,如上面的例子,B方法会新建一个数据库连接,而到了A方法这边,发现当前上下文已经有数据库连接了,就直接取得当前连接,而不是新建一个,方法返回null,将其包在using中相当于是不起任何作用,所以A方法返回的时候,也不会关闭数据库连接,这是一个挺巧妙的设计。

这是一个大坑,甚至是一个天坑,只是很长一段时间里,我都没意识到。看下面这种情况:

调用A方法时,加了个try-catch,其目的是记录日志。想想看如果A方法真的抛出了异常,会产生什么样的结果?(思考1分钟)

其结果非常出乎预料,sql1和sql2未能成功执行,但sql3成功了。这就打破了我们对事务的预期——原子性,一个TransactionScope中,要不都成功,要不都失败,现在是有的失败有的却成功了,这是为什么?

其原因是A方法的TransactionScope没有执行到Complete,在A方法抛出异常,离开这个using作用域的时候,此TransactionScope被认为是Abort了,当执行回到B方法后,我们观察到Transaction.Current竟然变成了null,(我没去看TransactionScope的源代码,但应该就是TransactionScope的Dispose方法直接或间接干的)也就是说在执行sql3的时候,并没有处于事务当中(尽管代码上看是将它包围起来了),所以它能单独执行成功,而事务没了,B方法的ts.Complete()调用是不起任何作用的,它也不报错。这就给我们造成了事务范围内原子性丧失的假象,简单地说,事务提前结束了,我们没意识到而已。如果没有try-catch,则没有问题,因为sql3不会被执行到。

填坑

如何解决这个问题?我的思路跟前面的“让嵌套调用使用同一个连接”很类似,那就是:我不光要确保嵌套调用使用的是同一个数据库连接,我还要确保自始至终只有一个TransationScope对象,且当外围事务存在时,using所获得的是个null对象,这样A方法抛出异常,脱离using的作用范围时,相当于什么都没干,也就不存在回到了B方法,Transaction.Current变为null的问题了。

我们先要对TransactionScope做一个包装:

    /// <summary>
    /// TSR - Transaction Scope Wrapper
    /// </summary>
    public sealed class Tsr : IDisposable {
        private TransactionScope _scope;
        public Tsr(TransactionScope scope) {
            _scope = scope;
        }
        public void Complete() {
            _scope?.Complete();
        }
        public void Dispose() {
            _scope?.Dispose();
        }
    }

在DbConnManager中再加个方法:

public static class DbConnManager {
    /// <summary>
    /// 使用事务的帮助方法(required方式,就是我们99%的方式,如果不是要用这种方式使用事务,那此方法不适合)
    /// </summary>
    /// <param name="conn">数据库链接</param>
    /// <returns>如果当前上下文已经包含在事务当中,就不再产生新的事务</returns>
    static public Tsr RequireTransactionScope(this DbConnection conn, out Tsr tsr) {
        if (Transaction.Current != null) {
            tsr = new Tsr(null);
            return null;
        }
        TransactionScope scope = new TransactionScope(TransactionScopeOption.Required);
        conn.EnlistTransaction(Transaction.Current);
        tsr = new Tsr(scope);
        return tsr;
    }
}

再使用统一的方法来获得这个Wrapper,如果存在外围事务,这个Wrapper的TransactionScope就是空的,其Complete方法和Dispose方法什么都不会干。使用的套路:

using (DbConnManager.GetContextConnectionAndOpen(out DbConnection conn)) {
    using (conn.RequireTransactionScope(out Tsr ts)) {
        // conn.Execute("sql1...")
        // conn.Execute("sql2...")
        ts.Complete();
    }
}

好,根据这种使用方法,我们分析一下前面B方法调用A方法,A方法抛异常的问题。如果A方法抛的不是数据库的异常,比如这样:

那执行成功的是sql1,sql2_1和sql3,方法A中的ts相当于什么都没干,sql2_2也没被执行,而sql1,sql2_1和sql3组成一个事务,由B方法的ts.Complete()提交,至于这个事务是不是业务上正确的,那就得具体分析了,通常我们要避免捕捉那些我们不能够处理的Exception,这是程序开发的一个原则。

那如果方法A抛出的是数据库的异常(比如违反唯一键约束),这又是怎样的情况呢?数据库的异常,这表明事务已经失败,根据事务的原子性,要么都成功,要么都失败,只要产生了一个数据库异常,那事务肯定是失败的:

sql2_2的执行出错了,这是个数据库错误,由于其调用者试用了try-catch,所以sql3会被执行到,但由于事务已经在sql2_2执行中失败,所以到了sql3的执行这里,肯定失败,sql3的执行会报错,大概这样的错误:current transaction is aborted, commands ignored until end of transaction block,意思是当前事务已经中止,命令将被忽略,直至事务块结束。

这就是我们预期的效果了。顺便说一下我推荐的处理方式:A方法的ArgumentException最好放在最前面,就是各个SQL没被执行之前先统一做参数检验,B方法捕捉有限的A方法返回的异常,而不是捕捉最大的Exception异常类型,根据实际的业务进行处理,如果实在要捕捉数据库的异常,以满足记录特定日志等需求,则加上这样的语句更好一点,因为再往下执行还是会报错的,不如早点结束:

    try{
        A方法();
    }
    catch(Exception ex){
        logger.LogError(ex);
        if(ex is PostgresqlException){ //再次声明:异常处理必须考虑实际业务
            throw;
        }
    }

将异常信息记录到数据库表去

B方法中捕获了A方法的所有异常,并将错误信息记录到数据库中去,有问题吗?(花10秒钟想想)

当然有问题,如果A方法已经出现数据库异常了,那事务就中止了啊,如何再在这个事务中访问数据库?这种记录所有异常的动作在实际的业务场景中还是挺常见的,解决的方法有:

  •  把这个日志记录的动作放到事务范围外
  •  记录到日志文件就行了,不要记到关系型数据库中了
  •  记录到MongoDB这样的文档数据库去,它对日志的处理更加合适
  •  打开一个新的数据库连接并使用新的事务

现在来讲讲最后一种方法,我直接贴代码了:

B方法(){
    using(conn){
        using(ts){
            conn.Execute(sql1);
            try{
                A方法();
            }
            catch(Exception ex){
                //往数据库里记录错误信息
                NpgsqlConnection subConn = new NpgsqlConnection(connStr); 
                using (subConn) {
                    subConn.Open(); //需要手工Open
                    //RequiresNew表示开启一个新的事务,跟外围的事务无关
                    using (TransactionScope tsNew = new TransactionScope(TransactionScopeOption.RequiresNew)) {
                        subConn.EnlistTransaction(Transaction.Current);
                        subConn.Execute("insert into log_table ...");
                        tsNew.Complete(); //得Commit
                    }
                }
                throw; //打断后续执行
            }
            conn.Execute(sql3);
            ts.Complete()
        }
    }
}

这么一来,写日志的这部分代码就是新的连接+新的事务,执行成功没问题的。另一种做法是把TransactionScopeOption.RequiresNew改为TransactionScopeOption.Suppress,这样相当于是屏蔽掉外围的事务,这样也不需要调用Complete,因为没事务。

其它一些说明

可以用TransactionScope指定执行超时时间。比如指定永不超时:

using (TransactionScope ts = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { Timeout = TimeSpan.Zero })) {
    conn.EnlistTransaction(Transaction.Current);
    conn.Execute(sql);
    ts.Complete();
}

还能用TransactionScope指定事务隔离级别。比如指定事务隔离级别为“可重复读”(默认是“可序列化”):

using (TransactionScope ts = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.RepeatableRead })) {
    conn.EnlistTransaction(Transaction.Current);
    conn.Execute(sql);
    ts.Complete();
}

关于事务隔离级别的更多信息,可以自行找资料看看。

 

标签:事务,遇到,数据库,TransactionScope,使用,new,方法,conn
From: https://www.cnblogs.com/guogangj/p/usage-of-transactionscope-and-possible-problems.html

相关文章

  • java——spring boot集成MongoDB——数据库安装和登录、简单使用
    参考文档,菜鸟教程:https://www.runoob.com/mongodb/mongodb-tutorial.html  参考文档、黑马教程:https://www.bilibili.com/video/BV1bJ411x7mq?p=1&vd_source=79bbd5b7......
  • 多线程之countDownlLatch项目使用
    packagecom.company;importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.*;publicclassMain{publicstaticvoidmain(Strin......
  • Docker原理与使用
    Docker原理与使用学习笔记转载请声明,违者必究!!!一、概述Docker是一个开源的应用容器引擎,基于Go语言并遵从Apache2.0协议开源。当人们说“Docker”时,他们通常是指Docke......
  • 使用策略模式-手写本地负载均衡器轮训算法
    分析有轮训随机权重等本地负载均衡器算法多个策略的共同行为从集群里取一个出来本文采用策略模式去手写 Maven依赖Maven依赖信息<parent><groupId>org.sp......
  • MyBatis的使用八(动态SQL)
    本主要讲述mybatis处理动态sql语句一.问题引入前端展示的数据表格中,查询条件可能不止一个,如何将用户输入的多个查询条件,拼接到sql语句中呢?DynamicMapper接口声......
  • 11.进程管理命令,用户管理和使用
    d.service结尾的进程是守护进程,守护系统后台服务  守护进程和系统服务是一一对应的关系 只查看当前用户使用的进程以及跟当前控制台相关联的进程。 “linux的终......
  • Nginx unexpected end of file 配置证书遇到问题,如何解决?
    原文链接https://bysocket.com/nginx-unexpected-end-of-file-expecting-in-key-file/一、Nginxunexpectedendoffile问题通过letsencrypt申请证书后,默认服务器安......
  • 整理我遇到的 Python 的疑难问题
    1如果字典里一个键指向一个实例,深拷贝会拷贝出一个新的实例吗?不会:classfoo:def__init__(self):print('doinitfoo')a={'cls':foo()}a#......
  • mac-docker安装使用mysql
    参考:https://blog.csdn.net/m0_67402588/article/details/1260751861、下载镜像,注意这里要下载适配了arm架构的镜像源dockerpullmysql/mysql-server2、创建容器dock......
  • 4-使用IDEA开发
    IDEA官方下载地址注释publicclassHelloWorld{publicstaticvoidmain(String[]args){System.out.println("Hello,World!");//单行注释//单......