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(); } }
这里说明一下:
new TransactionScope()
这个动作可能会改变Transaction.Current
的值,Transaction.Current
是线程相关的,总的来说,在TransactionScope
范围内的话,它就有值,否则是null
,这个特性会引起一个大坑,因为TransactionScope
可能会不经意地提前结束,后面会详细说。EnlistTransaction
这个方法的意思是将conn
加入到当前的TransactionScope
去,在TransactionScope
的范围内,conn
的各种对数据库的操作都能够有事务的保证。(BTW:很多DbConnection
对象本身有自动Enlist的功能,可以自行研究,这里为了演示清晰,使用手动Enlist方式)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(); }
关于事务隔离级别的更多信息,可以自行找资料看看。