SQL Server数据库调用的多线程C#应用程序

时间:2012-03-31 01:09:49

标签: c# sql sql-server multithreading architecture

我有一个SQL Server数据库,表main中有500,000条记录。还有另外三个名为child1child2child3的表。 child1child2child3main之间的多对多关系是通过三个关系表实现的:main_child1_relationshipmain_child2_relationship,和main_child3_relationship。我需要读取main中的记录,更新main,并在关系表中插入新行以及在子表中插入新记录。子表中的记录具有唯一性约束,因此实际计算的伪代码(CalculateDetails)将类似于:

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 
}

这可以作为单线程应用程序正常工作。但它太慢了。 C#中的处理非常繁重,耗时太长。我想把它变成一个多线程的应用程序。

最好的方法是什么?我们正在使用Linq to Sql。

到目前为止,我的方法是为DataContext的每批记录创建一个新的main对象,并使用ThreadPool.QueueUserWorkItem来处理它。然而,这些批次踩到彼此的脚趾,因为一个线程添加了一个记录,然后下一个线程尝试添加相同的一个...我得到了各种有趣的SQL Server死锁。

以下是代码:

    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();
            }
        }
    }

这是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);
        };
    }

并且

    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最好用顶部的伪代码来解释。

我认为必须有更好的方法来做到这一点。请帮忙。非常感谢!

6 个答案:

答案 0 :(得分:47)

以下是我对这个问题的看法:

  • 当使用多个线程在SQL Server或任何数据库中插入/更新/查询数据时,死锁是生活中的事实。你必须假设它们会发生并妥善处理它们。

  • 不是这样说我们不应该试图限制死锁的发生。但是,很容易阅读deadlocks的基本原因并采取措施来防止它们,但SQL Server总会让您大吃一惊: - )

死锁的一些原因:

  • 线程太多 - 尝试将线程数限制到最小,但当然我们需要更多线程以获得最佳性能。

  • 索引不够。如果选择和更新没有足够的选择性,那么SQL将获取比健康更大的范围锁。尝试指定适当的索引。

  • 索引太多。更新索引会导致死锁,因此请尝试将索引减少到所需的最小值。

  • 交易隔离级别太高。使用.NET时的默认isolation level是'Serializable',而使用SQL Server的默认值是'Read Committed'。降低隔离级别可以提供很多帮助(当然,如果适当的话)。

这就是我解决问题的方法:

  • 我不会推出自己的线程解决方案,我会使用TaskParallel库。我的主要方法看起来像这样:

    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));
    }
    
  • 执行CalculateDetails方法并重试死锁失败

    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));
        }
    }
    
  • 核心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();
            }
        }
    }
    
  • 当然我实现了死锁重试帮助

    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;
        }
    }
    
  • 另一种可能性是使用分区策略

如果您的表可以自然地划分为几个不同的数据集,那么您可以使用SQL Server partitioned tables and indexes,也可以将现有表manually split分成几组表。我建议使用SQL Server的分区,因为第二个选项会很混乱。此外,内置分区仅适用于SQL Enterprise Edition。

如果你可以进行分区,你可以选择一个破坏你数据的分区方案,比如8个不同的集合。现在您可以使用原始的单线程代码,但每个目标有一个单独的分区。现在不会有任何(或至少是最小数量的)死锁。

我希望这是有道理的。

答案 1 :(得分:5)

概述

问题的根源在于L2S DataContext与Entity Framework的ObjectContext一样,不是线程安全的。正如this MSDN forum exchange中所解释的那样,.NET ORM解决方案中的异步操作支持从.NET 4.0开始仍然未决;您必须推出自己的解决方案,正如您所发现的那样,当您的框架采用单线程时,您并不容易做到这一点。

我将借此机会注意到L2S是建立在ADO.NET之上的,它本身完全支持异步操作 - 就个人而言,我更愿意直接处理下层并自己编写SQL,只是为了确保我完全理解网络上发生的事情。

SQL Server解决方案?

话虽如此,我不得不问 - 这必须是C#解决方案吗?如果你可以从一组插入/更新语句中编写解决方案,你可以直接发送SQL,你的线程和性能问题就会消失。*在我看来,你的问题与实际的数据转换无关。制作,但围绕使他们从.NET的表现。如果从等式中删除.NET,则任务变得更简单。毕竟,最好的解决方案通常是你编写最少量代码的解决方案,对吧? ;)

即使你的更新/插入逻辑不能以严格的设置 - 关系方式表达,SQL Server确实有一个内置的机制来迭代记录和执行逻辑 - 虽然它们被公正地用于许多用途在某些情况下,游标实际上可能适合您的任务。

如果这是一项必须重复进行的任务,那么将其编码为存储过程可以从中受益匪浅。

*当然,长时间运行的SQL带来了自己的问题,例如你必须应对的锁升级和索引使用。

C#解决方案

当然,可能在SQL中执行此操作是不可能的 - 例如,您的代码决策可能依赖于来自其他地方的数据,或者您的项目可能存在严格的问题。没有-SQL允许&#39;惯例。你提到了一些典型的多线程错误,但是如果没有看到你的代码,我就无法真正对它们有所帮助。

从C#执行此操作显然是可行的,但您需要处理这样一个事实:每次调用都会存在固定数量的延迟。您可以通过使用池化连接,启用多个活动结果集以及使用异步Begin / End方法来执行查询来缓解网络延迟的影响。即使有了所有这些,您仍然必须接受将数据从SQL Server发送到您的应用程序的成本。

保持代码不被自己踩到的最好方法之一是尽可能避免在线程之间共享可变数据。这意味着不跨多个线程共享相同的DataContext。下一个最好的方法是锁定触摸共享数据的关键代码段 - lock阻止所有DataContext访问,从第一次读取到最终写入。这种方法可能完全消除了多线程的好处;你可能会使你的锁定更精细,但你要警告这是一条痛苦的道路。

更好的办法是让您的运营完全分开。如果你可以将你的逻辑划分为主要的&#39;记录,这是理想的 - 也就是说,只要在各种子表之间没有关系,并且只要一个记录在&#39; main&#39;不会对另一个产生影响,您可以将操作分成多个线程,如下所示:

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) });
    }
}

显然,这与您的问题中的伪代码一样是一个玩具示例,但希望它可以帮助您思考如何确定任务范围,使得它们之间没有(或最小)共享状态。我认为,这将是正确的C#解决方案的关键。

编辑响应更新和评论

如果您发现数据一致性问题,我建议强制执行事务语义 - 您可以使用System.Transactions.TransactionScope(添加对System.Transactions的引用)来执行此操作。或者,您可以通过访问内部连接并在其上调用BeginTransaction(或调用任何DataConnection方法)在ADO.NET级别执行此操作。

你还提到了死锁。您正在与SQL Server死锁作斗争表明实际的SQL查询正在踩到彼此的脚趾。在不知道实际通过网络发送什么的情况下,很难详细说明发生了什么以及如何解决它。可以说SQL死锁是由SQL查询引起的,而不一定是来自C#线程构造 - 你需要检查究竟是通过线路进行的。我的直觉告诉我,如果每个主要&#39;记录真正独立于其他记录,那么不应该需要行和表锁,并且Linq to SQL可能是这里的罪魁祸首。

通过将DataContext.Log属性设置为例如,您可以在代码中获取L2S发出的原始SQL的转储Console.Out。虽然我从来没有亲自使用它,但我知道LINQPad提供了L2S设施,你也可以在那里获得SQL。

SQL Server Management Studio将为您提供剩余的工作 - 使用活动监视器,您可以实时监视锁升级。使用查询分析器,您可以查看SQL Server将如何执行查询。有了这些,你应该能够很好地了解你的代码在服务器端做什么,以及如何修复它。

答案 2 :(得分:2)

我建议将所有XML处理也移到SQL服务器中。你的所有僵局不仅会消失,而且你会看到性能的提升,你永远不会想要回归。

最好用一个例子来解释。在这个例子中,我假设XML blob已经进入你的主表(我称之为壁橱)。我将假设以下架构:

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)
)

我希望您的数据(仅限主表)最初看起来像这样:

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>')

然后你的整个任务就像下面这样简单:

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

但鉴于您将进行大量类似的处理,您可以通过将主blob声明为XML类型来简化生活,并进一步简化:

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

可以进行额外的性能优化,例如在临时或永久表中预先计算重复调用的Xpath结果,或者将主表的初始填充转换为BULK INSERT,但我不认为您真的需要那些成功的人。

答案 3 :(得分:1)

sql server死锁是正常的&amp;在这种情况下可以预料到 - MS的建议是these should be handled on the application side而不是数据库方面。

但是,如果确实需要确保只调用一次存储过程,则可以使用sp_getapplock使用sql mutex锁。以下是如何实现此

的示例
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  

答案 4 :(得分:0)

这可能是显而易见的,但循环遍历每个元组并在servlet容器中完成工作会涉及大量的每个记录开销。

如果可能,通过将逻辑重写为一个或多个存储过程,将部分或全部处理移动到SQL Server。

答案 5 :(得分:-2)

如果

  • 你没有太多时间花在这个问题上,需要立即修复它
  • 您确定您的代码已完成,以便不同的线程不会修改相同的记录
  • 你不怕

然后......您可以在查询中添加“WITH NO LOCK”,以便MSSQL不会应用锁定。

谨慎使用:)

但无论如何,你没有告诉我们时间丢失的地方(在单线程版本中)。因为如果它在代码中,我建议你直接在DB中写入所有内容,以避免连续的数据交换。如果它在数据库中,我会建议检查索引(太多?),i / o,cpu等。