事务中的死锁重试

3

我有一个 C# 窗口服务,它与 MS SQL 服务器上的多个数据库通信。它是多线程的,并且有许多函数,每个函数都有长列表的数据库操作,每个这些函数都在自己的事务下运行。因此,典型的函数如下:

    public void DoSomeDBWork()
    {
        using (TransactionScope ts = new TransactionScope(TransactionScopeOption.RequiresNew))
        {
            DatabaseUpdate1();
            DatabaseUpdate2();
            DatabaseUpdate3();
            DatabaseUpdate4();
            DatabaseUpdate5();

            DatabaseUpdate6();

        }
    }

在高负载下我们遇到了死锁问题。我的问题是,如果我编写一些C#代码,在死锁发生时自动重新提交DatabaseUpdate操作,是否会阻塞未提交的操作?例如,如果在DatabaseUpdate6()中发生死锁异常并重试3次,每次等待3秒钟,在此期间所有未提交的操作“DatabaseUpdate 1到5”会占用它们的资源,这可能会进一步增加更多死锁的机会吗?在发生死锁情况时重试是否是一个好的做法?


4
我会尝试找出你遇到死锁的原因,并解决它,而不是仅仅重试调用。否则,情况只会变得更糟。你能提供一些关于你对数据库做出的调用的更多细节吗? - richard
1
什么导致了死锁?如果两个资源都没有经常被修改,你可以在有问题的批处理中加入 WITH (NOLOCK)。 - FlyingStreudel
2
@FlyingStreudel:为什么不把它作为答案放下,这样我就可以适当地给它投反对票了呢?NOLCOK永远都不是答案。 - Remus Rusanu
当雷穆斯,你首先发表了一个愚蠢的评论,以证明你真的不知道自己在说什么。然后你甚至都没有检查你的拼写。如果你正在对不变的数据执行查询,那么没有理由使用NOLOCK。我觉得你甚至不知道它是什么意思。 - FlyingStreudel
1
如果 OP 没有执行会改变数据的事务,他们就不会遇到这些死锁。用于读取的 S 锁不会发生冲突。我建议你使用搜索功能并查看 Remus 在死锁方面的以前的一些答案。我相信你很快就会意识到你最后一个句子中的假设是多么错误! - Martin Smith
显示剩余2条评论
4个回答

13

你是走错了方向。

死锁意味着整个事务范围都被撤销。根据您的应用程序,您可能能够从using块重新启动,即一个新的TransactionScope,但这几乎不可能是正确的。你看到死锁的原因是有人改变了你也在修改的数据。由于大多数这些更新是将更新应用于以前从数据库读取的值,所以死锁清楚地表明你读取的任何东西已经被更改了。因此,在不再阅读的情况下应用您的更新将覆盖其他事务更改的任何内容,从而导致丢失的更新。这就是为什么死锁几乎永远不会自动“重试”,如果涉及用户操作(例如表单编辑),则必须通知用户并重新验证更改,然后才能尝试更新。只有某些类型的自动处理操作可以进行重试,但它们永远不会像“尝试再次写入”一样重试,而总是以“读取-更新-写入”的循环方式操作,并且死锁将导致该循环尝试再次执行,因为它们始终从“读取”开始。它们会自动自我纠正。

话虽如此,你的代码很可能因为滥用串行化隔离级别而死锁,当不需要时:使用新的TransactionScope()被认为是有害的。您必须覆盖事务选项以使用ReadCommitted隔离级别,序列化几乎永远不需要,并且是导致死锁的保证方法。

第二个问题是为什么会出现序列化死锁?这是由于表扫描引起的,这表明您的读取和更新没有适当的索引。

最后一个问题是,你使用了RequiresNew,在99%的情况下是错误的。除非你对正在发生的事情有真正深入的理解,并且有一个强有力的理由需要独立的事务,否则应该始终使用Required并加入调用者所在的大事务。


1
这篇文章的链接很棒。我之前没有意识到我们的 TransactionScopes 是以 Serializable 隔离级别运行的。 - Eric

6

这并不能回答你问题中的所有内容,但是关于重试的话题。无论是数据库还是其他地方,重试事务的想法都是危险的,如果“幂等性”这个词对你毫无意义,那么请不要继续阅读(坦白地说,我自己也不太了解,但我的管理层最终决定,让我为死锁编写重试代码。我和几个在这个领域里最聪明的人交流过,他们都告诉我这是“非常糟糕”的做法,所以我对提交这个源代码没有好感。除了以上免责声明,我必须这样做,所以就让它变得有趣一些吧...下面是我最近编写的一个代码,用于在抛出异常之前重试指定次数的 MySql 死锁情况。

使用匿名方法,您只需要拥有一个接收器,可以动态处理方法签名和通用返回类型。您还需要一个类似的 void 返回方法,只需要使用 Action() 即可。对于 MSSQL,它看起来几乎相同,只是没有 “my” 前缀。

  1. The handler that does the retry:

    //

    private T AttemptActionReturnObject<T>(Func<T> action)
            {
                var attemptCount = 0;
    
                do
                {
                    attemptCount++;
                    try
                    {
                        return action();
                    }
                    catch (MySqlException ex)
                    {
                        if (attemptCount <= DB_DEADLOCK_RETRY_COUNT)
                        {
                            switch (ex.Number)
                            {
                                case 1205: //(ER_LOCK_WAIT_TIMEOUT) Lock wait timeout exceeded
                                case 1213: //(ER_LOCK_DEADLOCK) Deadlock found when trying to get lock
                                    Thread.Sleep(attemptCount*1000);
                                    break;
                                default:
                                    throw;
                            }
                        }
                        else
                        {
                            throw;
                        }
                    }
                } while (true);
            }
    
  2. Wrap your method call with delegate or lambda

        public int ExecuteNonQuery(MySqlConnection connection, string commandText, params MySqlParameter[] commandParameters)
    {
        try
        {
            return AttemptActionReturnObject( () => MySqlHelper.ExecuteNonQuery(connection, commandText, commandParameters) );
        }
        catch (Exception ex)
        {
            throw new Exception(ex.ToString() + " For SQL Statement:" + commandText);
        }
    }
    

它也可能看起来像这样:

return AttemptActionReturnObject(delegate { return MySqlHelper.ExecuteNonQuery(connection, commandText, commandParameters); });

请注意,我认为此代码中的错误编号不正确,应该从主数据库的sys.messages中获取--1205是死锁错误,1213是“生成锁监视器线程时出错:%ls”(摘自SQL Server 2008 R2 SP1)。 - redcalx

1
我认为不同的数据库服务器可能对死锁有不同的响应方式,但是在SQL Server中,如果两个事务发生死锁,服务器会选定一个事务作为死锁受害者(错误代码1205),并将该事务回滚。这当然意味着另一个事务可以继续进行。
如果你是死锁受害者,你将不得不重新执行所有的数据库更新,而不仅仅是update6。
针对使用NOLOCK等提示来避免死锁的评论,我强烈不推荐这样做。
死锁只是生活中的一部分。想象一下,两个用户同时向会计系统提交手工记账凭证。第一个凭证对银行账户进行贷方记账和应收账款进行借方记账。第二个凭证对应收账款进行贷方记账和银行账户进行借方记账。
现在想象一下,这两个事务同时进行(在测试中很少发生)。事务1锁定了银行账户,事务2锁定了应收账款。 事务1试图锁定应收账款,并在事务2上等待阻塞。 事务2试图锁定银行账户,自动且立即检测到死锁。 其中一个事务被选定为死锁受害者并被回滚。另一个事务继续进行,就像什么都没有发生过一样。

死锁是现实,应对它们的方法非常直接。"请挂断电话并重试。"

有关使用 SQL Server 处理死锁的更多信息,请参见 MSDN


1
在像会计系统这样的系统中,你绝不能让死锁发生然后“重试”。那是一种非常容易导致腐败或看似有缺陷的会计系统,而且用户也不会欣赏。首先,贷方和借方必须是插入操作,因此不会发生锁定。其次,你应该让你的事务以相同的顺序访问系统,以避免死锁。第三,如果你的会计系统存在潜在死锁风险,你应该通过将这些交易放入队列中等等手段来防止它。应当避免出现死锁的会计系统。 - richard

1

当SQL检测到死锁时,它会杀死一个线程并报告错误。如果您的线程被杀死,它会自动回滚任何未提交的事务 - 在您的情况下,是在最近的事务中已经运行的所有DatabaseUpdate*()

处理这个问题的方法完全取决于您的环境。如果您有像控制表或字符串表这样的东西,它们不会被更新,但经常被读取。您可以使用NOLOCK...虽然可能会引起一些抗议...但实际上它非常有用,当您不担心时间或事务敏感信息时。但是,当您处理易变或有状态的信息时,不能使用NOLOCK,因为它会导致意外的行为。

我使用两种处理死锁的方法。一种是在检测到失败时直接从头重新启动事务。另一种是在使用变量之前先读取它们,然后再执行。第二种方法会消耗大量资源,并且性能显著降低,因此不应用于高容量功能。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接