Imbriquer des transactions en C#
Bonjour,
Cette librairie a été conçue pour être utilisée avec SQL Server.
Elle utilise les interfaces IDbConnection et IDbCommand afin d'être compatible aussi avec OleDbConnection et OdbcConnection, mais aussi pour vous premettre de la rendre compatible avec le SGBD de votre choix s'il supporte les transactions imbriquées ou les points de sauvegarde de transaction.
L'objet SqlTransaction ne supporte pas d'ouvrir deux transactions imbriquées.
Idem avec OleDbTransaction.
Pourtant, lorsqu'on a un long traitement, pouvoir imbriquer des transactions, c'est bien plus pratique que de gérer manuellement des Save() et ne plus trop savoir à quel savepoint on doit remonter lorsqu'on souhaite annuler une opération.
Limitation connues :
- SQL Server ne supporte pas les transactions imbriquées. A la place, il supporte les "savepoints". Il s'agit de commit intermédiaires au sein d'une unique transaction, qui permettent, lors d'un rollback, de choisir si on annule toute la transaction, ou seulement ce qui a été modifié depuis un savepoint donné. Donc une fois "à l'intérieur" d'une transaction imbriquées, vous devez la valider ou l'annuler avant de pouvoir modifier la transaction parente. Pour information, Oracle, qui est un des rares SGBD à supporter les transactions imbriquées ne recommande de toute façon pas de modifier une transaction parente lorsqu'une transaction imbriquée est active : en effet, on peut très aisément se retrouver avec un deadlock si la transaction mère tente de modifier des données modifiées par la transaction imbriquée. Pour que la notion d'imbrication soit lisible, utilisez des using() pour chaque objet MyTransaction.
- Le niveau de transaction en cours est stocké au niveau d'objet IDbConnexion. Donc si vous croyez faire une requête en dehors de la transaction en utilisant directement un cnx.CreateCommand, votre requête sera malgré tout exécutée au sein de la transaction en cours.
- MyTransaction.CreateNestedTransaction() retourne une nouvelle transaction [g]à la suite de la transaction en cours[/g], et n'a absolument rien à voir avec l'instance de MyTransaction qui a créé la nouvelle transaction. Il n'y a aucun moyen de faire autrement, ou alors demandez à Microsoft de supporter les transactions imbriquées ;)
- MyTransaction.CreateCommand() retourne un nouvel objet IDbCommand qui utilise [g]la transaction en cours[/g], et n'a absolument rien à voir avec l'instance de MyTransaction qui a créé la nouvelle transaction. Il n'y a aucun moyen de faire autrement, ou alors demandez de nouveau à Microsoft de supporter les transactions imbriquées ;)
- Le code devrait être threadsafe. Il n'a cependant pas été testé dans un tel contexte.
- Il faut impérativement faire un Begin() et Commit()/Rollback() pour chaque transaction crée.
Code qui ne marche pas : a la sortie du using(cnx), la transaction A n'est pas commitée. Un rollback se produit alors sur l'ensemble de la transaction ! A droite, le code SQL exécuté :
Code:
1 2 3 4 5 6 7 8 9 10 11
|
using (SqlConnexion cnx = new SqlConnexion(cnx_string))
{
MyTransaction tranA = new MyTransaction(cnx);
tranA.Begin(); BEGIN TRANSACTION
[...]
MyTransaction tranB = tranA.CreateNestedTransaction();
tranB.Begin(); SAVE TRANSACTION nested
[...]
tranA.Commit();
} |
Code qui marche :
Code:
1 2 3 4 5 6 7 8 9 10 11 12
|
using (SqlConnexion cnx = new SqlConnexion(cnx_string))
{
MyTransaction tranA = new MyTransaction(cnx);
tranA.Begin(); BEGIN TRANSACTION
[...]
MyTransaction tranB = tranA.CreateNestedTransaction();
tranB.Begin(); SAVE TRANSACTION nested
[...]
tranB.Commit();
tranA.Commit(); COMMIT TRANSACTION
} |
Voici le code, que j'ai essayé de documenter au maximum :
Code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
|
using System;
using System.Data;
namespace CustomTransaction
{
/// <summary>
/// Objet qui permet d'effectuer des transactions imbriquées.
/// On ne catch absolument aucune erreur volontairement.
/// Cette librairie a été écrite pour SQL Server, même si elle utilise l'interface IDbConnexion.
/// Pour créer une transaction imbriquée, on peut uiliser au choix l'un ou l'autre constructeur de MyTransaction, ou la méthode CreateNestedTransaction.
/// </summary>
public class MyTransaction : IDisposable
{
// Private attributes
IDbConnection Cnx;
bool IsActive = false;
string Name = string.Empty;
const int NAME_LENGTH = 4;
/// <summary>
/// Créée une transaction liée à la connexion.
/// </summary>
/// <param name="cnx">Connexion ouverte à la base de données.</param>
public MyTransaction(IDbConnection cnx)
{
// On stocke la connexion.
Cnx = cnx;
// On crée un nom aléatoire pour la transaction.
if (MyTransactionContext.Level > 0)
{
string lettres = "abcdefghijklmnopqrstuvwxyz";
char[] name = new char[NAME_LENGTH + 2];
name[0] = '[';
name[NAME_LENGTH + 1] = ']';
for (int i = 1; i <= NAME_LENGTH; i++)
{
name[i] = lettres[MyRandom.Next(0, 26)];
}
Name = string.Concat(name);
}
}
/// <summary>
/// Créée une transaction imbriquée.
/// </summary>
/// <param name="parent">Transaction mère.</param>
public MyTransaction(MyTransaction parent) : this(parent.Cnx)
{
}
/// <summary>
/// Débute une transaction
/// </summary>
public void Begin()
{
// Si un jour on souhaite que la classe soit threadsafe, c'est mieux de poser un lock
lock (this)
{
using (IDbCommand cmd = Cnx.CreateCommand())
{
if (MyTransactionContext.Level == 0)
{
// On est au premier niveau, on commence donc une transaction réelle
cmd.CommandText = "BEGIN TRANSACTION";
}
else
{
// On est dans une transaction imbriquée : en réalité, il s'agit d'un savepoint
cmd.CommandText = string.Format("SAVE TRANSACTION {0}", Name);
}
cmd.ExecuteNonQuery();
}
// La transaction est démarrée
IsActive = true;
MyTransactionContext.Level++;
}
}
/// <summary>
/// Valide la transaction
/// </summary>
public void Commit()
{
// Si un jour on souhaite que la classe soit threadsafe, c'est mieux de poser un lock
lock (this)
{
if (IsActive)
{
using (IDbCommand cmd = Cnx.CreateCommand())
{
if (--MyTransactionContext.Level == 0)
{
// On est au premier niveau, on commit donc la transaction
cmd.CommandText = "COMMIT";
cmd.ExecuteNonQuery();
}
// Si on n'est pas au niveau 0, on ne fait rien de plus, puisque nous sommes dans un savepoint
}
// La transaction est terminée
IsActive = false;
}
}
}
/// <summary>
/// Annule la transaction
/// </summary>
public void Rollback()
{
// Si un jour on souhaite que la classe soit threadsafe, c'est mieux de poser un lock
lock (this)
{
if (IsActive)
{
using (IDbCommand cmd = Cnx.CreateCommand())
{
if (--MyTransactionContext.Level == 0)
{
// On est au premier niveau, on annule dont toute la transaction
cmd.CommandText = "ROLLBACK";
}
else
{
// On est dans une transaction imbriquée, on annule jusqu'au savepoint qui a débuté la transaction
cmd.CommandText = string.Format("ROLLBACK TRANSACTION {0}", Name);
}
cmd.ExecuteNonQuery();
}
// La transaction est terminée
IsActive = false;
}
}
}
/// <summary>
/// Crée un IDbCommand à partir de la connexion. Cet objet Command sera utilisé dans la transaction.
/// </summary>
/// <returns>Nouvel objet Command</returns>
public IDbCommand CreateCommand()
{
return Cnx.CreateCommand();
}
/// <summary>
/// Crée une transaction imbriquée.
/// </summary>
/// <returns>Nouvelle transaction imbriquée.</returns>
public MyTransaction CreateNestedTransaction()
{
return new MyTransaction(this);
}
/// <summary>
/// Rollback implicite de la transaction si elle est encore active.
/// </summary>
public void Dispose()
{
// Le test pour savoir si la transaction est active est déjà pris en compte dans la méthode Rollback()
Rollback();
}
/// <summary>
/// Classe statique qui permet d'avoir un Random unique pour toutes les classes
/// et éviter ainsi de générer systématiquement les mêmes séries
/// </summary>
private static class MyRandom
{
private static Random rnd;
static MyRandom()
{
rnd = new Random();
}
public static int Next(int min, int max)
{
return rnd.Next(min, max);
}
}
/// <summary>
/// Niveau actuel d'imbrication de transaction
/// Attention, si vous avec des connexions dans des threads séparés, vous allez avoir des problèmes !
/// </summary>
private static class MyTransactionContext
{
[ThreadStatic]
public static int Level;
static MyTransactionContext()
{
Level = 0;
}
}
}
} |
Comment l'utiliser :
Création d'une transaction à partir d'une connexion (qui doit impérativement être ouverte) :
Code:
1 2
|
MyTransaction tranA = new MyTransaction(maSqlConnexion); |
Création d'une transaction imbriquée :
Code:
1 2 3 4 5 6
|
MyTransaction tranB = new MyTransaction(maSqlConnexion);
// ou
MyTransaction tranB = new MyTransaction(tranA);
// ou
MyTransaction tranB = tranA.CreateNestedTransaction(); |
Ces trois syntaxes sont absolument équivalentes.
La transaction tranB n'est pas dépendante de la transaction tranA. Elle est juste ajoutée à la suite des transactions en cours dans l'objet maSqlConnexion.
Création d'un object IDbCommand utilisant la transaction en cours :
Code:
1 2 3 4
|
maSqlConnexion.CreateCommand();
// ou
tranA.CreateCommand(); |
Les deux syntaxes sont absolument équivalentes, l'objet IDbCommand créé n'étant absolument pas lié à l'objet MyTransaction qui l'a créé.
Début de la transaction :
Validation de la transaction :
Annullation de la transaction :
Et un exemple complet d'utilisation :
Code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
|
using System;
using System.Data;
using System.Data.Sql;
using System.Data.SqlClient;
using CustomTransaction;
namespace TestNestedTransaction
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Utilisation de transactions imbriquée (B imbriquée dans A)");
Console.WriteLine("On utilise une librairie custom qui simule une transaction.");
using (SqlConnection cnx = new SqlConnection("Server=localhost\\SQLEXPRESS;Database=testlock;Trusted_Connection=True;"))
{
cnx.Open();
using (SqlCommand cmd = cnx.CreateCommand())
{
cmd.CommandText = "insert into test (name) values (@name);";
SqlParameter pName = cmd.Parameters.Add("name", SqlDbType.VarChar, 50);
Console.WriteLine();
Console.WriteLine("Cas 1 :");
Console.WriteLine("- Une simple transaction committée");
Console.WriteLine("On s'attend à ce que la ligne reste dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 1, transaction seule";
cmd.ExecuteNonQuery();
tranA.Commit();
}
Console.WriteLine();
Console.WriteLine("Cas 2 :");
Console.WriteLine("- Une simple transaction rollbackée");
Console.WriteLine("On s'attend à ce qu'aucune ligne ne reste dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 2, transaction seule";
cmd.ExecuteNonQuery();
tranA.Rollback();
}
Console.WriteLine();
Console.WriteLine("Cas 3 :");
Console.WriteLine("- La transaction parente est rollbackée");
Console.WriteLine("- La transaction fille est committée");
Console.WriteLine("On s'attend à ce qu'aucune ligne ne reste dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 3, transaction parente";
cmd.ExecuteNonQuery();
using (MyTransaction tranB = new MyTransaction(cnx))
{
tranB.Begin();
pName.Value = "Cas 3, transaction fille";
cmd.ExecuteNonQuery();
tranB.Commit();
}
tranA.Rollback();
}
Console.WriteLine();
Console.WriteLine("Cas 4 :");
Console.WriteLine("- La transaction parente est committée");
Console.WriteLine("- La transaction fille est rollbackée");
Console.WriteLine("On s'attend à ce que seule la ligne de la transaction parente soit dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 4, transaction parente";
cmd.ExecuteNonQuery();
using (MyTransaction tranB = new MyTransaction(cnx))
{
tranB.Begin();
pName.Value = "Cas 4, transaction fille";
cmd.ExecuteNonQuery();
tranB.Rollback();
}
tranA.Commit();
}
Console.WriteLine();
Console.WriteLine("Cas 5 :");
Console.WriteLine("- La transaction parente est committée");
Console.WriteLine("- La transaction fille est committée");
Console.WriteLine("On s'attend à ce que les deux lignes soient dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 5, transaction parente";
cmd.ExecuteNonQuery();
using (MyTransaction tranB = new MyTransaction(cnx))
{
tranB.Begin();
pName.Value = "Cas 5, transaction fille";
cmd.ExecuteNonQuery();
tranB.Commit();
}
tranA.Commit();
}
Console.WriteLine();
Console.WriteLine("Cas 6 :");
Console.WriteLine("- La transaction parente est rollbackée");
Console.WriteLine("- La transaction fille est rollbackée");
Console.WriteLine("On s'attend à ce qu'aucune ligne ne soit dans la base");
using (MyTransaction tranA = new MyTransaction(cnx))
{
tranA.Begin();
pName.Value = "Cas 6, transaction parente";
cmd.ExecuteNonQuery();
using (MyTransaction tranB = new MyTransaction(cnx))
{
tranB.Begin();
pName.Value = "Cas 6, transaction fille";
cmd.ExecuteNonQuery();
tranB.Rollback();
}
tranA.Rollback();
}
Console.WriteLine();
Console.WriteLine("Résultat :");
cmd.CommandText = "select id, name from test order by id;";
cmd.Parameters.Clear();
Console.WriteLine("ID\tNAME");
Console.WriteLine("------- --------------------------------------------------");
SqlDataReader da = cmd.ExecuteReader();
while (da.Read())
{
Console.WriteLine("{0}\t{1}", da.GetInt32(0), da.GetString(1));
}
}
cnx.Close();
}
Console.WriteLine();
Console.WriteLine("Fin");
Console.ReadKey(true);
}
}
} |
Ce qui donne :
Code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
Utilisation de transactions imbriquée (B imbriquée dans A)
On utilise une librairie custom qui simule une transaction imbriquée.
Cas 1 :
- Une simple transaction committée
On s'attend à ce que la ligne reste dans la base
Cas 2 :
- Une simple transaction rollbackée
On s'attend à ce qu'aucune ligne ne reste dans la base
Cas 3 :
- La transaction parente est rollbackée
- La transaction fille est committée
On s'attend à ce qu'aucune ligne ne reste dans la base
Cas 4 :
- La transaction parente est committée
- La transaction fille est rollbackée
On s'attend à ce que seule la ligne de la transaction parente soit dans la base
Cas 5 :
- La transaction parente est committée
- La transaction fille est committée
On s'attend à ce que les deux lignes soient dans la base
Cas 6 :
- La transaction parente est rollbackée
- La transaction fille est rollbackée
On s'attend à ce qu'aucune ligne ne soit dans la base
Résultat :
ID NAME
------- --------------------------------------------------
56 Cas 1, transaction seule
60 Cas 4, transaction parente
62 Cas 5, transaction parente
63 Cas 5, transaction fille
Fin |