Change isolation level in individual ADO.NET trans

2020-07-22 10:34发布

问题:

What is the best way to implement different isolation levels for individual transactions when using a client framework, ORM or similar to build queries, which does not support query hints like WITH(NOLOCK)?

Imagine an application which uses ReadUncommitted level for a number of complex and long running queries (well aware of the related risks), and it is supposed to run with NHibernate and it's query criteria (or QueryOver/LINQ, just no string concatenation!).

NHibernate doesn't support the with(nolock) hint (except when using native SQL, which is currently used in many cases).

So, in order to replace the native SQL strings and their tedious building code, I want to use transactions with IsolationLevel.ReadUncommitted to replace 'with(nolock)'.

But the connection remains in the changed isolation level even after Commit/Rollback, running everything in the new level. Even after connection.Close(), it is returned to the connection pool and reused with the changed isolation level.

I originally noticed this, because I tested opening a connection with Snapshot isolation level and sending a simple query, to disable Read Uncommitted if Snapshot mode on database is enabled (no easy switch to snapshot in general possible). The test database had snapshot mode disabled, so I got an exception and set my UseReadUncommitted variable to 'true' in the catch block, but later queries from a "new"/reused connection still got the same exception.

I wrote a simple class to wrap transaction handling in a using block, automatically resetting the IsolationLevel in .Dispose(). But this seems to cause two extra roundtrips to the DB, and I am not sure if the altered isolation level might "survive" the disposal in certain situations and affect other queries. The code worked in a first try, it's for plain ADO.NET connections/transactions (I will do another for NHibernate sessions, if good!).

Any suggestions?

public class TransactionContainerTempIsolationLevel : IDisposable
{
    public IsolationLevel OldIsolationLevel { get; private set; }

    public IsolationLevel TempIsolationLevel { get; private set; }

    public IDbTransaction Transaction { get; private set; }

    private readonly IDbConnection _conn;

    public TransactionContainerTempIsolationLevel(IDbConnection connection, IsolationLevel tempIsolationLevel)
    {
        _conn = connection;
        LocalIsolationLevel = localIsolationLevel;

        var checkTran = _conn.BeginTransaction();
        if (checkTran.IsolationLevel == tempIsolationLevel)
        {
            Transaction = checkTran;
        }
        else
        {
            OldIsolationLevel = checkTran.IsolationLevel;
            checkTran.Dispose();
            Transaction = _conn.BeginTransaction(tempIsolationLevel);
        }
    }

    public void Dispose()
    {
        Transaction.Dispose();
        if (OldIsolationLevel != TempIsolationLevel)
        {
            using (var restoreTran = _conn.BeginTransaction(OldIsolationLevel))
            {
                restoreTran.Commit();
            }
        }
    }
}

回答1:

The fact that many ORMs do not support (dynamic) query hints is a shame. Setting the isolation level or writing wrapper views and TVF's are common workarounds.

But the connection remains in the changed isolation level even after Commit/Rollback, running everything in the new level. Even after connection.Close(), it is returned to the connection pool and reused with the changed isolation level.

Yes, this is a design flaw in SQL Server that was fixed in 2014.

The test database had snapshot mode disabled, so I got an exception

This is exactly how I found out about this as well. A disturbing find.

The code that you posted should work in general. It does require additional round trips to the database as you say. In fact restoring the old isolation level causes two round trips. In total I count 2 or 6 round trips in your code depending on whether the level was changed or not.

The only sane way to deal with the isolation level leak in <2014 that I found was to always use an explicit transaction for every access to the database. This is, in my mind, a good idea in most cases anyway. You often need to chose an isolation level and provide atomicity anyway. And in case you can make the transition to SNAPSHOT (which I recommend) you probably want to run many queries in one snapshot transaction so that all queries see the same data.

In >=2014 the default level for a connection freshly opened is READ COMMITTED.

I don't see why you are restoring the old isolation level at all. It seems that your code must deal with the fact that the isolation level is arbitrary when opening a connection. That means that restoring to the old level only in some code path (not in all) will not remove the need for guarding against an arbitrary level. If you do restore the old level in all paths then you might as well use a single transaction everywhere without any restore logic.

So you could simply use a single transaction and let the level leak. If you really want to restore I would suggest this T-SQL:

SELECT isolation_level FROM sys.sessions WHERE session_id = @@SPID
SET TRANSACTION ISOLATION LEVEL X
BEGIN TRAN

Hopefully, this performs well. This is a single round trip. You need one more round trip to restore the old level.

If you are really keen on performance you can keep your own simple connection pool with connections in a known state.

Or, use one connection string per isolation level. Make them unique using AppName.

If you are only reading under RUC or RC you don't even need a transaction this way. You might end up with one round trip.

I recommend that you use the simplest possible solution of these that works for you.