web-dev-qa-db-fra.com

Application multi-threading C # avec appels de base de données SQL Server

J'ai une base de données SQL Server avec 500 000 enregistrements dans la table main. Il existe également trois autres tables appelées child1, child2 et child3. Les relations nombreuses à multiples entre child1, child2, child3 et main sont implémentées via les trois tables de relations: main_child1_relationship, main_child2_relationship et main_child3_relationship. Je dois lire les enregistrements dans main, mettre à jour main et insérer également dans les tables de relations de nouvelles lignes, ainsi que d'insérer de nouveaux enregistrements dans les tables enfants. Les enregistrements dans les tables enfant ont des contraintes d'unicité, donc le pseudo-code pour le calcul réel (CalculateDetails) devrait ressembler à ceci:

for each record in main
{
   find its child1 like qualities
   for each one of its child1 qualities
   {
      find the record in child1 that matches that quality
      if found
      {
          add a record to main_child1_relationship to connect the two records
      }
      else
      {
          create a new record in child1 for the quality mentioned
          add a record to main_child1_relationship to connect the two records
      }
   }
   ...repeat the above for child2
   ...repeat the above for child3 
}

Cela fonctionne très bien comme une application à thread unique. Mais c'est trop lent. Le traitement en C # est assez lourd et prend trop de temps. Je veux transformer cela en une application multi-thread.

Quelle est la meilleure façon de procéder? Nous utilisons Linq to Sql.

Jusqu'ici, mon approche a consisté à créer un nouvel objet DataContext pour chaque lot d'enregistrements à partir de main et à utiliser ThreadPool.QueueUserWorkItem pour le traiter. Cependant, ces lots se font mutuellement concurrence, car un thread ajoute un enregistrement, puis le suivant essaie d'ajouter le même et ... Je reçois toutes sortes de verrous morts intéressants sur SQL Server.

Voici le code:

    int skip = 0;
    List<int> thisBatch;
    Queue<List<int>> allBatches = new Queue<List<int>>();
    do
    {
        thisBatch = allIds
                .Skip(skip)
                .Take(numberOfRecordsToPullFromDBAtATime).ToList();
        allBatches.Enqueue(thisBatch);
        skip += numberOfRecordsToPullFromDBAtATime;

    } while (thisBatch.Count() > 0);

    while (allBatches.Count() > 0)
    {
        RRDataContext rrdc = new RRDataContext();

        var currentBatch = allBatches.Dequeue();
        lock (locker)  
        {
            runningTasks++;
        }
        System.Threading.ThreadPool.QueueUserWorkItem(x =>
                    ProcessBatch(currentBatch, rrdc));

        lock (locker) 
        {
            while (runningTasks > MAX_NUMBER_OF_THREADS)
            {
                 Monitor.Wait(locker);
                 UpdateGUI();
            }
        }
    }

Et voici ProcessBatch:

    private static void ProcessBatch( 
        List<int> currentBatch, RRDataContext rrdc)
    {
        var topRecords = GetTopRecords(rrdc, currentBatch);
        CalculateDetails(rrdc, topRecords);
        rrdc.Dispose();

        lock (locker)
        {
            runningTasks--;
            Monitor.Pulse(locker);
        };
    }

Et 

    private static List<Record> GetTopRecords(RecipeRelationshipsDataContext rrdc, 
                                              List<int> thisBatch)
    {
        List<Record> topRecords;

        topRecords = rrdc.Records
                    .Where(x => thisBatch.Contains(x.Id))
                    .OrderBy(x => x.OrderByMe).ToList();
        return topRecords;
    }

CalculateDetails s'explique mieux par le pseudo-code en haut. 

Je pense qu'il doit y avoir un meilleur moyen de faire cela. S'il vous plaît aider. Merci beaucoup!

22
Barka

Voici mon point de vue sur le problème:

  • Lorsque vous utilisez plusieurs threads pour insérer/mettre à jour/interroger des données dans SQL Server, ou dans n'importe quelle base de données, les blocages sont une réalité. Vous devez supposer qu'ils se produiront et les manipuler de manière appropriée.

  • Cela ne veut pas dire que nous ne devrions pas tenter de limiter le nombre de blocages. Cependant, il est facile de lire les causes de base de deadlocks et de prendre des mesures pour les prévenir, mais SQL Server vous surprendra toujours :-)

Quelques raisons pour des impasses:

  • Trop de threads - essayez de limiter le nombre de threads au minimum, mais bien sûr, nous voulons plus de threads pour des performances optimales.

  • Pas assez d'index. Si les sélections et les mises à jour ne sont pas suffisamment sélectives, SQL supprime les verrous de plage plus volumineux que normal. Essayez de spécifier les index appropriés.

  • Trop d'index. La mise à jour des index entraîne des blocages. Essayez donc de réduire les index au minimum requis.

  • Niveau d'isolement de la transaction trop élevé. Le niveau isolation par défaut lors de l'utilisation de .NET est 'Serializable', alors que celui par défaut pour SQL Server est 'Read Committed'. Réduire le niveau d’isolement peut être très utile (le cas échéant, bien sûr).

Voici comment je pourrais aborder votre problème:

  • Je ne lancerais pas ma propre solution de threading, j'utiliserais la bibliothèque TaskParallel. Ma méthode principale ressemblerait à ceci:

    using (var dc = new TestDataContext())
    {
        // Get all the ids of interest.
        // I assume you mark successfully updated rows in some way
        // in the update transaction.
        List<int> ids = dc.TestItems.Where(...).Select(item => item.Id).ToList();
    
        var problematicIds = new List<ErrorType>();
    
        // Either allow the TaskParallel library to select what it considers
        // as the optimum degree of parallelism by omitting the 
        // ParallelOptions parameter, or specify what you want.
        Parallel.ForEach(ids, new ParallelOptions {MaxDegreeOfParallelism = 8},
                            id => CalculateDetails(id, problematicIds));
    }
    
  • Exécutez la méthode CalculateDetails avec des tentatives pour les échecs d'interblocage

    private static void CalculateDetails(int id, List<ErrorType> problematicIds)
    {
        try
        {
            // Handle deadlocks
            DeadlockRetryHelper.Execute(() => CalculateDetails(id));
        }
        catch (Exception e)
        {
            // Too many deadlock retries (or other exception). 
            // Record so we can diagnose problem or retry later
            problematicIds.Add(new ErrorType(id, e));
        }
    }
    
  • La méthode principale CalculateDetails

    private static void CalculateDetails(int id)
    {
        // Creating a new DeviceContext is not expensive.
        // No need to create outside of this method.
        using (var dc = new TestDataContext())
        {
            // TODO: adjust IsolationLevel to minimize deadlocks
            // If you don't need to change the isolation level 
            // then you can remove the TransactionScope altogether
            using (var scope = new TransactionScope(
                TransactionScopeOption.Required,
                new TransactionOptions {IsolationLevel = IsolationLevel.Serializable}))
            {
                TestItem item = dc.TestItems.Single(i => i.Id == id);
    
                // work done here
    
                dc.SubmitChanges();
                scope.Complete();
            }
        }
    }
    
  • Et bien sûr, ma mise en œuvre d’un assistant de relance dans l’impasse

    public static class DeadlockRetryHelper
    {
        private const int MaxRetries = 4;
        private const int SqlDeadlock = 1205;
    
        public static void Execute(Action action, int maxRetries = MaxRetries)
        {
            if (HasAmbientTransaction())
            {
                // Deadlock blows out containing transaction
                // so no point retrying if already in tx.
                action();
            }
    
            int retries = 0;
    
            while (retries < maxRetries)
            {
                try
                {
                    action();
                    return;
                }
                catch (Exception e)
                {
                    if (IsSqlDeadlock(e))
                    {
                        retries++;
                        // Delay subsequent retries - not sure if this helps or not
                        Thread.Sleep(100 * retries);
                    }
                    else
                    {
                        throw;
                    }
                }
            }
    
            action();
        }
    
        private static bool HasAmbientTransaction()
        {
            return Transaction.Current != null;
        }
    
        private static bool IsSqlDeadlock(Exception exception)
        {
            if (exception == null)
            {
                return false;
            }
    
            var sqlException = exception as SqlException;
    
            if (sqlException != null && sqlException.Number == SqlDeadlock)
            {
                return true;
            }
    
            if (exception.InnerException != null)
            {
                return IsSqlDeadlock(exception.InnerException);
            }
    
            return false;
        }
    }
    
  • Une autre possibilité consiste à utiliser une stratégie de partitionnement

Si vos tables peuvent naturellement être partitionnées en plusieurs ensembles de données distincts, vous pouvez utiliser des tables et des index partitionnés SQL Server ou vous pouvez scinder manuellement vos tables existantes en plusieurs ensembles de tables. Je recommanderais d'utiliser le partitionnement de SQL Server, car la deuxième option serait compliquée. Le partitionnement intégré est également disponible uniquement sur SQL Enterprise Edition.

Si le partitionnement est possible pour vous, vous pouvez choisir un schéma de partition qui vous a divisé les données en 8 ensembles distincts. Vous pouvez maintenant utiliser votre code unique à thread unique, mais avoir 8 threads, chacun visant une partition distincte. Maintenant, il n'y aura plus aucune impasse (ou du moins un minimum).

J'espère que cela à du sens. 

46
Phil

Vue d'ensemble

La racine de votre problème est que le DataContext L2S, comme le ObjectContext de Entity Framework, n'est pas thread-safe. Comme expliqué dans cet échange de forum MSDN , la prise en charge des opérations asynchrones dans les solutions .NET ORM est toujours en attente à partir de .NET 4.0; vous devrez rouler votre propre solution, ce qui, comme vous l'avez découvert, n'est pas toujours facile à faire lorsque votre infrastructure repose sur un seul thread.

Je profite de l’occasion pour noter que L2S est basé sur ADO.NET, qui prend lui-même en charge le fonctionnement asynchrone. Personnellement, je préférerais de loin traiter directement avec cette couche inférieure et écrire le code SQL moi-même, juste pour être sûr J'ai parfaitement compris ce qui se passait sur le réseau.

Solution SQL Server?

Cela étant dit, je dois demander: faut-il que ce soit une solution C #? Si vous pouvez composer votre solution à partir d'un ensemble d'instructions insert/update, vous pouvez simplement envoyer le code SQL directement et vos problèmes de threading et de performance disparaissent. * Il me semble que vos problèmes ne sont pas liés aux transformations de données réelles. fait, mais centrez-les autour de les rendre performants à partir de .NET. Si .NET est supprimé de l'équation, votre tâche devient plus simple. Après tout, la meilleure solution est souvent celle avec laquelle vous écrivez le moins de code possible, non? ;)

Même si votre logique de mise à jour/insertion ne peut pas être exprimée de manière strictement relationnelle, SQL Server possède un mécanisme intégré permettant de parcourir les enregistrements et d’exécuter la logique - bien qu’ils soient correctement décochés pour de nombreux cas d’utilisation, les curseurs peuvent fait convenir à votre tâche.

S'il s'agit d'une tâche qui doit être répétée à plusieurs reprises, vous pourriez grandement bénéficier de son codage en tant que procédure stockée.

* Bien sûr, SQL de longue durée apporte ses propres problèmes, comme l’escalade de verrous et l’utilisation des index, avec lesquels vous devrez composer.

Solution C #

Bien entendu, il est peut-être hors de question de procéder de la sorte dans SQL. Par exemple, les décisions de votre code dépendent de données provenant d’autres sources, ou bien votre projet a une convention stricte "non autorisé par SQL". Vous mentionnez quelques bogues multithreading typiques, mais sans voir votre code, je ne peux pas leur être utile, plus précisément.

Faire cela à partir de C # est évidemment viable, mais vous devez tenir compte du fait qu’un montant fixe de latence existera pour chaque appel que vous passez. Vous pouvez atténuer les effets de la latence du réseau en utilisant des connexions en pool, en activant plusieurs ensembles de résultats actifs et en utilisant les méthodes asynchrone Begin/End pour l'exécution de vos requêtes. Même avec tous ceux-ci, vous devrez toujours accepter le fait que l'envoi de données de SQL Server à votre application entraîne un coût.

L'un des meilleurs moyens d'empêcher votre code de se répandre sur lui-même consiste à éviter autant que possible de partager des données mutables entre les threads. Cela signifierait ne pas partager le même DataContext sur plusieurs threads. La meilleure solution consiste ensuite à verrouiller les sections critiques du code qui touchent les données partagées - des blocs lock entourant tout accès à DataContext, de la première lecture à la dernière écriture. Cette approche risquerait d’éviter les avantages du multithreading; vous pouvez probablement rendre votre verrouillage plus fin, mais soyez averti qu'il s'agit d'un sentier douloureux.

Le mieux est de garder vos opérations complètement séparées les unes des autres. Si vous pouvez partitionner votre logique entre des enregistrements "principaux", c'est l'idéal, c'est-à-dire tant qu'il n'y a pas de relations entre les différentes tables enfants et qu'un enregistrement dans "principal" n'a pas d'incidence sur une autre, vous pouvez diviser vos opérations sur plusieurs threads comme ceci:

private IList<int> GetMainIds()
{
    using (var context = new MyDataContext())
        return context.Main.Select(m => m.Id).ToList();
}

private void FixUpSingleRecord(int mainRecordId)
{
    using (var localContext = new MyDataContext())
    {
        var main = localContext.Main.FirstOrDefault(m => m.Id == mainRecordId);

        if (main == null)
            return;

        foreach (var childOneQuality in main.ChildOneQualities)
        {
            // If child one is not found, create it
            // Create the relationship if needed
        }

        // Repeat for ChildTwo and ChildThree

        localContext.SaveChanges();
    }
}

public void FixUpMain()
{
    var ids = GetMainIds();
    foreach (var id in ids)
    {
        var localId = id; // Avoid closing over an iteration member
        ThreadPool.QueueUserWorkItem(delegate { FixUpSingleRecord(id) });
    }
}

De toute évidence, il s’agit là d’un exemple de jouet tout comme le pseudocode de votre question, mais nous espérons que cela vous incitera à réfléchir à la manière de définir vos tâches de manière à ce qu’il n’y ait aucun état partagé (ou minimal) entre elles. Je pense que cela sera la clé d'une solution C # correcte.

EDIT Répondre aux mises à jour et aux commentaires

Si vous rencontrez des problèmes de cohérence des données, je vous conseillerais d'appliquer la sémantique des transactions. Pour ce faire, utilisez un System.Transactions.TransactionScope (ajoutez une référence à System.Transactions). Vous pouvez également effectuer cette opération à un niveau ADO.NET en accédant à la connexion interne et en appelant BeginTransaction sur celle-ci (ou à l’appel de la méthode DataConnection).

Vous mentionnez également les impasses. Le fait que vous combattiez les blocages de SQL Server indique que les requêtes SQL se font mutuellement concurrence. Sans savoir ce qui est réellement envoyé sur le réseau, il est difficile de dire en détail ce qui se passe et comment y remédier. Il suffit de dire que les impasses SQL résultent de requêtes SQL, et pas nécessairement de constructions de threading C # - vous devez examiner ce qui se passe exactement sur le fil. Mon instinct me dit que si chaque enregistrement «principal» est vraiment indépendant des autres, il ne devrait pas être nécessaire de verrouiller les lignes et les tables, et que Linq to SQL est probablement le coupable ici.Vous pouvez obtenir un vidage du SQL brut émis par L2S dans votre code en définissant la propriété DataContext.Log sur quelque chose, par exemple. Console.Out. Bien que je ne l'ait jamais utilisé personnellement, je comprends que LINQPad offre des fonctionnalités de L2S et vous pourrez également accéder à la base de données SQL.

SQL Server Management Studio vous permet de faire le reste du chemin. Avec le moniteur d'activité, vous pouvez surveiller l'escalade de verrous en temps réel. À l'aide de l'analyseur de requêtes, vous pouvez avoir une vue précise de la manière dont SQL Server exécutera vos requêtes. Avec ceux-ci, vous devriez pouvoir avoir une bonne idée de ce que fait votre code côté serveur, et comment y remédier.

SQL Server Management Studio will get you the rest of the way there - using the Activity Monitor, you can watch for lock escalation in real time. Using the Query Analyzer, you can get a view of exactly how SQL Server will execute your queries. With those, you should be able to get a good notion of what your code is doing server-side, and in turn how to go about fixing it.

5
Ben

Je recommanderais également de transférer tout le traitement XML sur le serveur SQL. Non seulement toutes vos impasses disparaîtront, mais vous constaterez une telle amélioration des performances que vous ne voudrez plus jamais revenir en arrière.

Cela sera mieux expliqué par un exemple. Dans cet exemple, je suppose que le blob XML entre déjà dans votre table principale (je l'appelle placard). Je vais assumer le schéma suivant:

CREATE TABLE closet (id int PRIMARY KEY, xmldoc ntext) 
CREATE TABLE shoe(id int PRIMARY KEY IDENTITY, color nvarchar(20))
CREATE TABLE closet_shoe_relationship (
    closet_id int REFERENCES closet(id),
    shoe_id int REFERENCES shoe(id)
)

Et j'attends que vos données (table principale uniquement) ressemblent initialement à ceci:

INSERT INTO closet(id, xmldoc) VALUES (1, '<ROOT><shoe><color>blue</color></shoe></ROOT>')
INSERT INTO closet(id, xmldoc) VALUES (2, '<ROOT><shoe><color>red</color></shoe></ROOT>')

Ensuite, toute votre tâche est aussi simple que celle-ci:

INSERT INTO shoe(color) SELECT DISTINCT CAST(CAST(xmldoc AS xml).query('//shoe/color/text()') AS nvarchar) AS color from closet
INSERT INTO closet_shoe_relationship(closet_id, shoe_id) SELECT closet.id, shoe.id FROM shoe JOIN closet ON CAST(CAST(closet.xmldoc AS xml).query('//shoe/color/text()') AS nvarchar) = shoe.color

Mais étant donné que vous allez faire beaucoup de traitements similaires, vous pouvez vous simplifier la vie en déclarant votre blob principal comme type XML, et en simplifiant davantage ceci:

INSERT INTO shoe(color)
    SELECT DISTINCT CAST(xmldoc.query('//shoe/color/text()') AS nvarchar)
    FROM closet
INSERT INTO closet_shoe_relationship(closet_id, shoe_id)
    SELECT closet.id, shoe.id
    FROM shoe JOIN closet
        ON CAST(xmldoc.query('//shoe/color/text()') AS nvarchar) = shoe.color

Des optimisations de performances supplémentaires sont possibles, telles que le pré-calcul répété de manière répétée des résultats Xpath dans une table temporaire ou permanente, ou la conversion de la population initiale de la table principale en un INSERT EN VRAC, mais je ne m'attends pas à ce que vous ayez vraiment besoin de ceux-ci pour réussir. .

2
Jirka Hanika

les blocages de serveur SQL sont normaux et à prévoir dans ce type de scénario - la recommandation de MS est que ils doivent être gérés du côté de l'application plutôt que du côté de la base de données.

Cependant, si vous devez vous assurer qu'une procédure stockée n'est appelée qu'une fois, vous pouvez utiliser un verrou mutl SQL à l'aide de sp_getapplock. Voici un exemple de la façon de mettre en œuvre cette

BEGIN TRAN
DECLARE @mutex_result int;
EXEC @mutex_result = sp_getapplock @Resource = 'CheckSetFileTransferLock',
 @LockMode = 'Exclusive';

IF ( @mutex_result < 0)
BEGIN
    ROLLBACK TRAN

END

-- do some stuff

EXEC @mutex_result = sp_releaseapplock @Resource = 'CheckSetFileTransferLock'
COMMIT TRAN  
1
Johnv2020

Ce problème peut être résolu à l'aide d'un LimitedConcurrencyLevelTaskScheduler

public class InOutMessagesController
{
    private static LimitedConcurrencyLevelTaskScheduler scheduler = new LimitedConcurrencyLevelTaskScheduler(1);
    private TaskFactory taskFactory = new TaskFactory(scheduler);
    private TaskFactory<MyTask<Object[]>> taskFactoryWithResult = new TaskFactory<MyTask<Object[]>>(scheduler);
    private ConcurrentBag<Task> tasks = new ConcurrentBag<Task>();
    private ConcurrentBag<MyTask<Object[]>> tasksWithResult = new ConcurrentBag<MyTask<Object[]>>();
    private ConcurrentBag<int> endedTaskIds = new ConcurrentBag<int>();
    private ConcurrentBag<int> endedTaskWithResultIds = new ConcurrentBag<int>();
    private Task TaskForgetEndedTasks;
    private static object taskForgetLocker = new object();


    #region Conveyor
    private async void AddTaskVoidToQueue(Task task)
    {
        try
        {
            tasks.Add(task);

            await taskFactory.StartNew(() => task.Start());

            if (TaskForgetEndedTasks == null)
            {
                ForgetTasks();
            }
        }
        catch (Exception ex)
        {
            NLogger.Error(ex);
        }
    }

    private async Task<Object[]> AddTaskWithResultToQueue(MyTask<Object[]> task)
    {
        ForgetTasks();

        tasksWithResult.Add(task);

        return await taskFactoryWithResult.StartNew(() => { task.Start(); return task; }).Result;
    }

    private Object[] GetEqualTaskWithResult(string methodName)
    {
        var equalTask = tasksWithResult.FirstOrDefault(x => x.MethodName == methodName);

        if (equalTask == null)
        {
            return null;
        }
        else
        {
            return equalTask.Result;
        }
    }

    private void ForgetTasks()
    {
        Task.WaitAll(tasks.Where(x => x.Status == TaskStatus.Running || x.Status == TaskStatus.Created || x.Status == TaskStatus.WaitingToRun).ToArray());

        lock (taskForgetLocker)
        {
            if (TaskForgetEndedTasks == null)
            {
                TaskForgetEndedTasks = new Task(ForgetEndedTasks);

                TaskForgetEndedTasks.Start();
            }

            TaskForgetEndedTasks.Wait();

            TaskForgetEndedTasks = null;
        }
    }

    private void ForgetEndedTasks()
    {
        try
        {
            var completedTasks = tasks.Where(x => x.IsCompleted || x.IsFaulted || x.IsCanceled);
            var completedTasksWithResult = tasksWithResult.Where(x => x.IsCompleted || x.IsFaulted || x.IsCanceled);

            if (completedTasks.Count() > 0)
            {
                foreach (var ts in completedTasks)
                {
                    if (ts.Exception != null)
                    {
                        NLogger.Error(ts.Exception);

                        if (ts.Exception.InnerException != null)
                        {
                            NLogger.Error(ts.Exception.InnerException);
                        }
                    }

                    endedTaskIds.Add(ts.Id);
                }

                if (endedTaskIds.Count != 0)
                {
                    foreach (var t in endedTaskIds)
                    {
                        Task ct = completedTasks.FirstOrDefault(x => x.Id == t);

                        tasks.TryTake(out ct);
                    }
                }

                endedTaskIds = new ConcurrentBag<int>();
            }

            if (completedTasksWithResult.Count() > 0)
            {
                foreach (var ts in completedTasksWithResult)
                {
                    if (ts.Exception != null)
                    {
                        NLogger.Error(ts.Exception);

                        if (ts.Exception.InnerException != null)
                        {
                            NLogger.Error(ts.Exception.InnerException);
                        }
                    }

                    endedTaskWithResultIds.Add(ts.Id);
                }

                foreach (var t in endedTaskWithResultIds)
                {
                    var ct = tasksWithResult.FirstOrDefault(x => x.Id == t);

                    tasksWithResult.TryTake(out ct);
                }

                endedTaskWithResultIds = new ConcurrentBag<int>();
            }
        }
        catch(Exception ex)
        {
            NLogger.Error(ex);
        }
    }
    #endregion Conveyor

    internal void UpdateProduct(List<ProductData> products)
    {
            var updateProductDataTask = new Task(() => ADOWorker.UpdateProductData(products));

            AddTaskVoidToQueue(updateProductDataTask);
    }

    internal async Task<IEnumerable<ProductData>> GetProduct()
    {
        string methodName = "GetProductData";

        Product_Data[] result = GetEqualTaskWithResult(methodName) as Product_Data[];

        if (result == null)
        {
            var task = new MyTask<Object[]>(ADOWorker.GetProductData, methodName);

            result = await AddTaskWithResultToQueue(task) as Product_Data[];
        }

        return result;
    }
}

public class ADOWorker
{
    public Object[] GetProductData()
    {
        entities = new DataContext();

        return entities.Product_Data.ToArray();
    }

    public void UpdateProductData(List<Product_Data> products)
    {
            entities = new DataContext();

            foreach (Product_Data pr_data in products)
            {
                entities.sp_Product_Data_Upd(pr_data);
            }            
    }
}
1
Maxim

Cela peut sembler évident, mais parcourir en boucle chaque tuple et effectuer votre travail dans votre conteneur de servlets implique beaucoup de temps système par enregistrement.

Si possible, déplacez tout ou partie de ce traitement sur le serveur SQL en réécrivant votre logique sous la forme d'une ou de plusieurs procédures stockées.

0
Scott Smith