martes, 8 de marzo de 2011

TransactionScope, ¡ha venido para quedarse!

Todas las aplicaciones que desarrollo en mi empresa están orientadas a datos. De este modo, una de mis grandes preocupaciones es como gestionar de forma eficiente las transacciones en el acceso a datos. Hasta ahora, siempre había utilizado el objeto SqlTransaction para llevar a cabo esta tarea, pero lo cierto es que no estoy del todo satisfecho con su uso. Para intentar argumentar porque no quiero SqlTransaction, primero veamos un ejemplo de cómo utilizar este objeto.

    Sub Main()

        Using cnn As New SqlConnection("Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;Integrated Security=True")

            cnn.Open()

            Dim tran As SqlTransaction = Nothing

            Try

                tran = cnn.BeginTransaction

                InsertarPrimerCliente(cnn, tran)

                InsertarSegundoCliente(cnn, tran)

                tran.Commit()

            Catch ex As Exception

                If Not tran Is Nothing Then

                    tran.Rollback()

                End If

                Console.WriteLine(ex.Message)

            Finally

                Console.ReadLine()

            End Try

        End Using

    End Sub

 

    Sub InsertarPrimerCliente(ByVal cnn As SqlConnection, ByVal tran As SqlTransaction)

        Dim cmdText As String = "INSERT INTO Clientes VALUES (1, 'Cliente 1')"

        Using cmd As New SqlCommand(cmdText, cnn, tran)

            cmd.ExecuteNonQuery()

        End Using

    End Sub

 

    Sub InsertarSegundoCliente(ByVal cnn As SqlConnection, ByVal tran As SqlTransaction)

        Dim cmdText As String = "INSERT INTO Clientes VALUES (2, 'Cliente 2')"

        Using cmd As New SqlCommand(cmdText, cnn, tran)

            cmd.ExecuteNonQuery()

        End Using

    End Sub

 

Realmente este código no me gusta porque los métodos “InsertarPrimerCliente” e “InsertarSegundoCliente” tienen que recibir como parámetros tanto el objeto SqlConnection como el objeto SqlTransaction. Esto es así porque si quiero que los comandos ejecutados desde estos métodos y contra la base de datos participen todos en la misma transacción, tiene que compartir tanto la misma conexión como la misma transacción.

Creo que es fácil entender que la dependencia de este modelo transaccional en ADO.NET implica mantener siempre viva la referencia tanto al objeto SqlConnection como al objeto SqlTransaction. Ahora imagina, no 2 métodos, sino X métodos en distintas clases, distintos ensamblados, etc. No sé, en mi opinión, en el momento que tu código transaccional ya no es sólo una sencilla llamada a un único método con un principio y fin concreto, este modelo en vez de ayudarme, me la hace la vida más difícil.

De hecho, si por ejemplo sólo pasamos a los métodos el objeto SqlConnection, pero no el objeto SqlTransaction, obtendremos este bonito error que para mí ya es todo un clásico:
ExecuteNonQuery requiere que el comando tenga una transacción cuando la conexión asignada al mismo está en una transacción local pendiente. No se ha inicializado la propiedad Transaction del comando..
Por otro lado, si directamente no pasamos ni la conexión ni la transacción, nuestros comandos en métodos como los expuestos serán simple y llanamente una nueva conexión sin intervenir en el contexto de ninguna transacción.

Para solucionar esto, hemos ideado en nuestro empresa un contexto ficticio de conexión y transacción que mejor o peor funciona y evita tener que pasar constantemente estos parámetros (SqlConnection y SqlTransaction), pero aún así es una carga extra de diseño en cualquier programa que hay que soportar y por supuesto no está exenta de errores ni de posibles refactorizaciones.

En este punto creí que todo estaba perdido hasta que descubrí el objeto TransactionScope.

Ahora, el anterior código pasaría a ser este otro:

Sub Main()

        Using tran As New Transactions.TransactionScope

                Try

                    InsertarPrimerCliente()

                    InsertarSegundoCliente()

                    tran.Complete()

                Catch ex As Exception

                    Console.WriteLine(ex.Message)

                End Try

        End Using

        Console.ReadLine()

    End Sub

 

    Sub InsertarPrimerCliente()

        Using cnn As New SqlConnection("Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;Integrated Security=True")

            cnn.Open()

            Dim cmdText As String = "INSERT INTO Clientes VALUES (1, 'Cliente 1')"

            Using cmd As New SqlCommand(cmdText, cnn)

                cmd.ExecuteNonQuery()

            End Using

        End Using

    End Sub

 

    Sub InsertarSegundoCliente()

        Using cnn As New SqlConnection("Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;Integrated Security=True")

            cnn.Open()

            Dim cmdText As String = "INSERT INTO Clientes VALUES (2, 'Cliente 2')"

            Using cmd As New SqlCommand(cmdText, cnn)

                cmd.ExecuteNonQuery()

            End Using

        End Using

    End Sub

 

Si nos fijamos, ahora simplemente “iniciamos un contexto transaccional” y a partir de aquí cualquier comando ejecutado contra esa misma conexión o contra cualquier otra nueva conexión será ejecutado de forma automática en el contexto de la misma “transacción de ambiente” (más adelante veremos que se puede configurar esto, pero en principio este es el comportamiento predeterminado). Por otro lado, sólo hay que controlar el éxito de la operación para llamar al método Complete, mientras que para anular, revertir, deshacer o rechazar la transacción (hoy estoy que lo tiro con los sinónimos), simplemente se hará automáticamente si al salir de la instrucción Using donde se declaró el objeto TransactionScope no se llamó de forma explícita al método Complete.

Para la utilización de TransactionScope es necesario agregar una referencia a System.Transactions.
Esta referencia sólo es necesario en los proyectos que hacen referencia explícita al objeto TransactionScope. Si por ejemplo la función “InsertarPrimerCliente” estuviera en una biblioteca de clases, participaría de la transacción pero no sería necesario agregar la referencia a System.Transactions porque no la utiliza de forma explícita.

Realmente, TransactionScope utiliza MSDTC (Microsoft Data Transaction Coordinator, coordinador de transaciones distribuidas) para llevar a cabo su tarea. De hecho y donde radica principalmente la potencia de TransactionScope (más allá de su facilidad de uso frente a SqlTransaction) es que es capaz de incluir en misma transacción, operaciones contra distintos orígenes de base de datos o recursos transaccionales (por ejemplo las colas también son transaccionables). Eso significa que en nuestro ejemplo anterior, “InsertarPrimerCliente” podría haber insertado en una base de datos A, mientras que “InsertarSegundoCliente” podría haberlo hecho en una base de datos B, todo ello en una misma transacción que se completaría o rechazaría como una operación ACID.

Algo importante es que si estamos utilizando una sola base de datos en nuestra transacción, sólo tenemos una conexión abierta a la vez y además no tenemos transacciones anidadas (todos estos requisitos se cumplen en el ejemplo anterior), TransactionScope utilizará una “transacción ligera” gestionada por LTM (Lightweight Transaction Coordinator), pero en el momento en que utilicemos 2 o más base de datos, las cadenas de conexión sean distintas (aunque finalmente apunten a la misma base de datos y servidor) o anidemos transacciones, la transacción ligera será promocionada a MSDTC.  Además, todo lo anterior se cumple en SQL Server 2008, pero en versiones anteriores (2005) incluso abrir 2 conexiones simultaneas a la misma base de datos y con la misma cadena de conexión, también promocionará la transacción, ver más en Avoid unwanted Escalation to Distributed Transactions

Cuando decía que hay que tener cuidado con las cadenas de conexión me refería a que si accedemos a nuestra única base de datos (en la que suponemos será gestionada por LTM) pero con distintas cadenas de conexión, entonces TransactionScope pensará que son distintas base de datos y pasará a ser una transacción gestionada por MSDTC.

Para ver esto lo mejor será un ejemplo. Simplemente cambiaremos la cadena de conexión de “InsertarPrimerCliente” a otra cadena de conexión válida para la misma base de datos y observaremos como ahora pasamos de LTM a MSDTC.

InsertarPrimerCliente

Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;User Id=sa;Password=XX

InsertarSegundoCliente

Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;Integrated Security=True

 

clip_image002[4]

Sobra decir que está claro que la transacción ha sido promovida a MSDTC, ¡si es un perro nos muerde!, pero además vemos como hemos recibido un error que nos informa de que MSDTC no está disponible en nuestro equipo. Aunque podría ser más complicado, inicialmente para configurar MSDTC tienes que abrir “Servicios de componentes” y en las propiedades de DTC local, activar el checkbox “Permitir clientes remotos”.

Una vez ya tenemos configurado MSDTC, las transacciones promovidas podemos visualizarlas también en “Servicios de componentes”. Vamos a poner un Console.ReadLine() antes de llamar a Complete() para poder ver esto.

clip_image004[4]

clip_image006[4]

Lo que hemos hecho hasta ahora ha sido romper el hielo con el tema de TransactionScope, pero ahora debemos ver unas cuantas opciones más en lo relativo a su uso que cerrarán el círculo y nos permitirán tener mayor control sobre cómo y cuándo suceden las cosas.

Todos los ejemplos vistos han utilizando la instrucción Using, diciendo que si al salir de ella aún no se ha llamado al método Complete() los cambios serán rechazados. Esto es totalmente cierto, pero también es totalmente cierto que no es necesario utilizar Using (aunque si recomendable) puesto que Using lo único que asegura es que se llama siempre al método Dispose(). Pues bien, es en ese método donde se haya toda la lógica de rechazo de la transacción, así que si tienes la necesidad de usar TransactionScope sin Using, simplemente llama a Dispose() y obtendrás la misma funcionalidad que con Using.

La clase TransactionOptions permite especificar el comportamiento de la transacción. Por ejemplo, la propiedad Timeout especifica el tiempo de espera máximo de la transacción antes de que sea abortada automáticamente, mientras que la propiedad IsolationLevel especifica el nivel aislamiento de la transacción.

Y ahora viene en mi opinión la parte más importante y que es precio entender para manejar correctamente TransactionScope y es la relación entre la transacción de ambiente con siguientes nuevas transacciones declaradas en el código. Veamos esto con un ejemplo:

        Using cnn As New SqlConnection("Data Source=SERGIO-VAIO;Initial Catalog=Pedidos;Integrated Security=True")

            cnn.Open()

            Using tran1 As New TransactionScope

                InsertarPrimerCliente()

                Using tran2 As New TransactionScope

                    InsertarSegundoCliente()

                    tran2.Complete()

                End Using

                tran1.Complete()

            End Using

        End Using

 

En este ejemplo podemos ver como hay 2 TransactionScope, una de ellas anidada dentro de otra (haz esto extensible a llamadas entre métodos, etc.). ¿Cómo se comporta entonces la segunda transacción?

Primero decir que la siguientes instrucciones son equivalentes:

Using tran As New TransactionScope()

 

Using tran As New TransactionScope(TransactionScopeOption.Required)

 

Cuando declaramos un nuevo objeto TransactionScope podemos definir su comportamiento respecto a una posible transacción de ambiente existente. El valor predeterminado es Required, pero veamos que valores puede tomar esta opción:

Required

El ámbito requiere una transacción.

Utiliza una transacción de ambiente si ya existe una.

De lo contrario, crea una nueva transacción antes de introducir el ámbito. Éste es el valor predeterminado.

RequiresNew

Siempre se crea una nueva transacción para el ámbito.

Supress

Se suprime el contexto de transacción de ambiente al crear el ámbito.

Todas las operaciones dentro del ámbito se realizan sin un contexto de transacción de ambiente.

 

Esto traducido al español es:

Required

Si hay una transacción de ambiente la utilizaré sino crearé una nueva.

RequiresNew

Me da igual si hay o no hay una transacción de ambiente, yo a mi bola en una nueva transacción.

Supress

Paso de transacciones, soy un machote y no quiero ejecutarme en el contexto de ninguna transacción.

 

Cabe mencionar que todos estos ejemplos lo estoy realizando con SQL Server 2008 y al menos esta versión soporta perfectamente transacciones anidadas, por lo que por ejemplo si la transacción externa es Required y la interna RequiresNew se crearán 2 transacciones (esto es 2 BEGIN TRANSACTION en SQL Server).

Por último, un par de apuntes sobre el método Complete y sobre como averiguar si nuestro código está en el contexto de una transacción y en qué transacción.

  • Si se llama a Complete para la misma instancia de TransactionScope 2 o más veces, excepto la primera, las siguientes fallarán con System.InvalidOperationException.
  • Si se llama a Complete desde una transacción anidada especificada como Required (ámbito predeterminado), realmente no se está confirmando la transacción y no tiene por ahora ningún efecto en la misma, sólo la llamada a Complete de la transacción de ambiente (la más externa) será la que confirma o rechace los cambios.
  • La propiedad Transaction.Current nos devuelve (también sirve para establecer) la transacción de ambiente. Además también nos devuelve información sobre la transacción ¿Recuerdas ese maravillo GUID que salía en “Servicios de componentes” como identificador de la transacción activa?.

Bueno, la verdad es que este post podría continuar y continuar, pero por hoy ya es suficiente.

Lo dicho, TransactionScope ha venido para quedarse.

Un saludo!

26 comentarios:

  1. Hola Sergio, felicitaciones, muy bueno el artículo que has publicado.
    Una consulta, tengo que grabar una factura para esto tengo dos funciones GrabarCabecera y GrabarDetalle, estas dos funciones tiene conexiones independientes osea cada una tiene su propio cnn.open, si bien es cierto el TransacctioneScope los administra muy bien, pero imagina que mi Detalle tiene 15 registros, se va a ejecutar 15 veces cnn.open?, dime el TransactionScope es inteligente como para detectar que está intentanto abrir una conexión similar a la anterior?, osea mi preocupación es que se haga 15 viajes al servidor ocasionando que se consuman recursos.

    ResponderEliminar
  2. Hola anónimo...

    Dependerá de cómo tengas echas las funciones que comentas, pero lo ideal es que las dos compartan la conexión y la operación (Grabar cabecera y sus líneas) se realice de forma conjunta, bajo el contexto de una transacción.

    Por lo que escribes, parece que si, se ejecutarán tantos cnn.open como líneas de detalle tengas.

    Pon algo de código y te comentamos.
    Y a ver si Sergio nos ilumina!

    Un saludo!

    ResponderEliminar
  3. Hola Antonio, gracias por tu respuesta, lo que quiero es que cada grabación sea independiente, la otra alternativa es pasar la conexión como parámetro, el pasar la conexión como parámetro es una buena práctica?, esto haría que no haga cnn.open por cada linea. Por otro lado si junto las dos acciones en un solo método o función, si más adelante necesito grabar solo lineas tendría que crear un método que grabe solo lineas y eso sería redundar código.

    Saludos
    Ysrael

    ResponderEliminar
  4. Hola:
    Yo creo que, efectivamente, la conexión se abrirá y cerrará tantas veces como llamadas tenga el método GrabarLinea. En cualquier caso, yo no me preocuparía en exceso por esto, porque casi todos los gestores de bases de datos modernos tienen activo un "pool" de conexiones, con lo que abrir y cerrar una nueva conexión es una operación casi "gratis". De todas formas, si sabes que esta operación de abrir una nueva conexión es costosa, bien porque tu bd no tiene pool de conexiones o bien por la latencia de red o cualquier otro motivo, quizás podrías dejar los métodos como están (es decir, GrabarCabecera y GrabarLínea cada uno con su cnn.open), pero además sobrecargar GrabarLínea para que aceptará un objeto connection. De este modo, cuando grabes cabecera y 15 líneas (por ejemplo), vas pasando la conexión, pero si en algún momento quieres llamar a GrabarLinea por sí sólo también puedes sin necesidad de pasar el objeto connection (logicamente tienes que controlar en GrabarLinea si el objeto connection es Nothing para, bien abrir con cnn.open o utilizar el objeto que te han pasado). A mí esta solución me gusta porque "optimizas" cuando puedes (aunque insisto que el pool hace maravillas) y cuando no puedes, y como tú bien dices, no tienes ninguna dependencia y puedes llamar a GrabarLinea si pasar antes por GrabarCabecera.
    Gracias a los dos por dar vidilla al post! ;-)
    Un saludo.

    ResponderEliminar
  5. Hola Sergio, me parece adecuada tu recomendación, muchas gracias por compartir tus conocimientos que siempre son de mucha ayuda.

    Saludos
    Ysrael.

    ResponderEliminar
  6. Gran aporte, lo usare para trabajar junto con SqlBulkCopy.

    El Pirras (@Pirras, Pirras Torres)

    ResponderEliminar
  7. Excelente artículo, me sirvió de mucho. Felicitaciones.

    ResponderEliminar
  8. Gracias Carlos, apunto tu blog que está muy bien para cuando tenga un rato y ponga en el mio enlaces a otros blogs!
    Un saludo.

    ResponderEliminar
  9. Antes que todo felicitaciones muy bien detallado todo, pero tengo una duda hice aplique esto a un proceso y mi problemas es que actualiza la base de datos al dar error es decir insertar hasta el error sin darle sin haber pasado por .complete.

    Gracias

    ResponderEliminar
  10. Hola:
    Pues es eso es muuuy raro xD
    Si tienes algo de código, pégalo como un comentario y además ¿Qué base de datos estás utilizando?
    Sin más info no puedo ayudarte, sorry ;-(

    ResponderEliminar
  11. Muy buen post!! Gracias Sergio!!

    ResponderEliminar
  12. chevere con el aporte

    ResponderEliminar
  13. Me sirvió mucho, muchas gracias por tomarte el tiempo :)...

    ResponderEliminar
  14. Muy buen articulo, excelente, sirvio mucho para aclarar las cosas. saludos!

    ResponderEliminar
  15. Gracias por comentar Luis y me alegro que te haya servido el post!

    ResponderEliminar
  16. Hola Sergio, no me he parado a mirar con detenimiento el código, todo el código que has puesto, si que he visto el inicio (es el que tengo yo) y el destino, el "transaction scope", decir que me parece interesante, y puede que le eche un vistazo. Comentar que he llegado hasta aquí por ese pedazo de mensaje que te sale y que evidentemente me ha salido a mi ahora ... lo curioso del tema, es que me ha salido realizando las pruebas de una aplicacion yendo contra sql-server, curiosamente, dicha aplicación no me ha dado ningun problema ni me ha salido ese mensaje yendo contra mysql o contra oracle, y si contra sql-server ¿? ¿alguna explicación? no lo sé, pero siempre el tema microsoft da problemas, cuando no es el explorer es sql-server ... Ahora deberé "perder", invertir tiempo, en hallar una solución a dicha incidencia ... teniendo en cuenta que es una aplicación que llevaba tiempo sin tocar ... lo dicho, si no encuentro una solución propia miraré la tuya ;-)

    ResponderEliminar
  17. Hola algoran, gracias por comentar :) Si te puedo echar un cable en algo lo vemos aquí en los comentarios, si das con la solución te agradecería que la pusieras aquí también para compartirla con todos.
    Un saludo!

    ResponderEliminar
  18. Hola, me podrias ayudar, estoy utilizando :
    try
    Using scope As TransactionScope = New TransactionScope()
    TablaDatos = GuardarUno
    TablaDatos2 = GuardarDos

    scope.Complete()
    Catch ex As Exception
    scope.Dispose()
    Return -1
    Throw ex
    End Try
    End Using



    En GuardarUno y GuardarDos : Dim Db As New SqlDatabase(ConfigurationManager.ConnectionStrings("ConnectionStringPrestar").ConnectionString)
    ' RETORNAMOS EL RESULTADO DE LA CONSULTA
    Return DataReadearToDataTable(Db.ExecuteReader(SQL))


    El proceso pasa por scope.Complete()
    sale del using
    Y genera error : TRANSACCION ABORTADA.

    El cordinador de transacciones esta inicializado.

    Pero no se porque se da ese ERROR!!!.

    Agradeceria cualquier tu ayuda al respecto.

    ResponderEliminar
  19. Sergio:

    Muchísimas gracias por tomarte el tiempo de explicar (y redactar) tan claramente los beneficios de los TransactionScope desinteresadamente.
    Realmente me ayudó no te imaginás cuánto tu artículo, fijáte que en este momento son las 2a.m., estoy terminando un TP para mañana entregarlo y esto me ha ayudado enormemente a solucionar los últimos detalles.

    Un fuerte abrazo, nuevamente gracias, y felices fiestas.

    Alan.

    ResponderEliminar
  20. Simplemente Excelente....

    ResponderEliminar
  21. Excelente documento, una pregunta, cual seria el orden del transaccion y los try en un ejemplo sencillo,

    ResponderEliminar
  22. Gracias Sergio por tu Post, me ha ayudado muchisimo, solo tengo una duda en un entorno Web, cual seria la forma correcta de manejar las transacciones?, todas con el mismo conection string del web.config ?

    Saludos
    Jorge

    ResponderEliminar
    Respuestas
    1. Sí, siempre, no deberías nunca ver escrita una connection string en tu código, siempre leerla desde la configuracion, desde el web.config :)
      Un saludo y gracias por comentar

      Eliminar
  23. Hola Sergio, tengo un error que me da en la transaccion "Contexto de transacción en uso por otra sesión", este error se produce en un procedimiento almacenado en el cual tengo un consulta vinculando a una 2da base de datos, siempre me da error en este procedimiento, pero tengo otros procedimientos en donde tengo ejecutando varios commandos (Insert, Update, Delete) y select's osea todo en un procedimiento que que corresponden a una sola base de datos, en este ultimo caso no da ningun error en la transaccionScope en la aplicacion. Por favor si me podrias ayudar a resolver este problema.

    gracias
    saludos

    ResponderEliminar
  24. Consulta,
    respecto al Timeout.

    Este Timeout aplica a todas las conexion generadas?
    Te pongo un ejemplo:

    . Ejecuto un procedimiento almacenado A que se ejecuta en un servidor X,
    este procedimiento almacenado A ejecuta un procedimiento almacenado B en un servidor Y.

    El Timeout que genero en la transacción es valido para todas las conexiones?
    Si se me acaba el tiempo, se realizara el rollback de todas las conexiones?
    Sabiendo que existe un timeout para transacciones distribuidas entre servidor por defecto de 10 minutos.

    Saludos.

    ResponderEliminar
  25. Sergio cordial saludo, felicitaciones por tu aporte, tengo una consulta, estoy usando el procedimiento tal cual lo comentas, guardo en varias tablas a la vez pero si ocurre un error en la mitad del procedimiento guarda los datos en las tablas anteriores, aun cuando no se ejecuta el tran.complete.
    Te agradecería mucho si me colaboras con este problema, la verdad estoy bloqueado.
    Gracias.

    ResponderEliminar