TransactionScope的使用及遇到的坑

事务的一般使用

我们使用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();
}

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

 

posted @ 2023-02-05 16:48  guogangj  阅读(1813)  评论(2编辑  收藏  举报