【问题标题】:How to deal with concurrent updates in databases?如何处理数据库中的并发更新?
【发布时间】:2009-07-28 18:27:02
【问题描述】:

在 SQL 数据库中处理并发更新的常用方法是什么?

考虑一个简单的 SQL 模式(未显示约束和默认值..)

create table credits (
  int id,
  int creds,
  int user_id
);

目的是为用户存储某种信用,例如类似于 stackoverflow 的声誉。

如何处理对该表的并发更新? 几个选项:

  • update credits set creds= 150 where userid = 1;

    在这种情况下,应用程序检索了当前值,计算了新值 (150) 并执行了更新。如果其他人同时做同样的事情,那就是灾难。 我猜想将当前值的检索和更新包装在事务中会解决这个问题,例如Begin; select creds from credits where userid=1; do application logic to calculate new value, update credits set credits = 160 where userid = 1; end; 在这种情况下,您可以检查新信用是否小于 0,如果负信用没有意义,则将其截断为 0。

  • update credits set creds = creds - 150 where userid=1;

    这种情况不需要担心并发更新,因为数据库会处理一致性问题,但存在 creds 很容易变成负数的缺陷,这对于某些应用程序可能没有意义。

那么简单,处理上述(非常简单)问题的公认方法是什么,如果 db 抛出错误怎么办?

【问题讨论】:

  • 如果您担心违反列约束,请在数据库中定义 CONSTRAINTS。

标签: sql concurrency


【解决方案1】:

使用交易:

BEGIN WORK;
SELECT creds FROM credits WHERE userid = 1;
-- do your work
UPDATE credits SET creds = 150 WHERE userid = 1;
COMMIT;

一些重要说明:

  • 并非所有数据库类型都支持事务。特别是,mysql 的旧默认数据库引擎(5.5.5 之前的默认数据库引擎)MyISAM 没有。如果您使用的是 mysql,请使用 InnoDB(新的默认设置)。
  • 由于您无法控制的原因,交易可能会中止。如果发生这种情况,您的应用程序必须准备好从 BEGIN WORK 重新开始。
  • 您需要将隔离级别设置为 SERIALIZABLE,否则第一个选择可以读取其他事务尚未提交的数据(事务不像编程语言中的互斥锁)。如果有并发正在进行的 SERIALIZABLE 事务,某些数据库会抛出错误,您必须重新启动事务。
  • 某些 DBMS 提供 SELECT .. FOR UPDATE ,这将锁定 select 检索到的行,直到事务结束。

将事务与SQL存储过程结合起来,可以让后面的部分更容易处理;应用程序只会在事务中调用单个存储过程,并在事务中止时重新调用它。

【讨论】:

  • 在这种情况下你不需要“选择更新”,还是至少需要一个 SERIALIZABLE 隔离级别?
  • @nos,这取决于数据库。具有真正事务支持的数据库应该仅通过事务提供一致的快照,尽管默认情况下可能不是。对于 innodb,只要您对任何 innodb 表进行选择,就会生成数据库状态的快照。
  • 您可能确实需要设置隔离级别:dev.mysql.com/doc/refman/5.0/en/set-transaction.html
  • @Ankush,大多数 SQL 服务器将识别死锁并回滚涉及解决死锁的 txns 之一。根据您的 SQL 服务器,您还可以使用“SELECT ... FOR UPDATE”或类似的方法从一开始就获取写锁。
  • @bdonlan 没错,SQL 会识别死锁并选择受害者,但这会损害您的产品整体性能,尤其是如果上述代码位于关键路径上。
【解决方案2】:

对于 MySQL InnoDB 表,这实际上取决于您设置的隔离级别。

如果您使用默认级别 3 (REPEATABLE READ),那么您需要锁定任何影响后续写入的行,即使您在事务中也是如此。在您的示例中,您需要:

SELECT FOR UPDATE creds FROM credits WHERE userid = 1;
-- calculate --
UPDATE credits SET creds = 150 WHERE userid = 1;

如果您使用的是级别 4 (SERIALIZABLE),那么一个简单的 SELECT 后跟更新就足够了。 InnoDB 中的第 4 级是通过对您读取的每一行进行读锁定来实现的。

SELECT creds FROM credits WHERE userid = 1;
-- calculate --
UPDATE credits SET creds = 150 WHERE userid = 1;

但是在这个具体的例子中,由于计算(添加学分)很简单,可以在 SQL 中完成,所以很简单:

UPDATE credits set creds = creds - 150 where userid=1;

将等同于 SELECT FOR UPDATE 后跟 UPDATE。

【讨论】:

  • 谢谢,更新数据是避免任何错误的最佳方式
【解决方案3】:

在某些情况下,无论您定义的隔离级别如何(例如,您已将代码部署到生产中的 2 个不同服务器中),将代码包装在事务中是不够的。

假设您有这些步骤和 2 个并发线程:

1) open a transaction
2) fetch the data (SELECT creds FROM credits WHERE userid = 1;)
3) do your work (credits + amount)
4) update the data (UPDATE credits SET creds = ? WHERE userid = 1;)
5) commit

还有这个时间线:

Time =  0; creds = 100
Time =  1; ThreadA executes (1) and creates Txn1
Time =  2; ThreadB executes (1) and creates Txn2
Time =  3; ThreadA executes (2) and fetches 100
Time =  4; ThreadB executes (2) and fetches 100
Time =  5; ThreadA executes (3) and adds 100 + 50
Time =  6; ThreadB executes (3) and adds 100 + 50
Time =  7; ThreadA executes (4) and updates creds to 150
Time =  8; ThreadB tries to executes (4) but in the best scenario the transaction
          (depending of isolation level) won't allow it and you get an error

事务阻止您用错误的值覆盖 creds 值,但这还不够,因为我不想让任何错误失败。

我更喜欢一个永远不会失败的较慢进程,并且在我获取数据(步骤 2)的那一刻,我用“数据库行锁定”解决了这个问题,它阻止其他线程读取同一行,直到我完成它。

在 SQL Server 中有几种方法,这是其中之一:

SELECT creds FROM credits WITH (UPDLOCK) WHERE userid = 1;

如果我用这个改进重新创建之前的时间线,你会得到这样的结果:

Time =  0; creds = 100
Time =  1; ThreadA executes (1) and creates Txn1
Time =  2; ThreadB executes (1) and creates Txn2
Time =  3; ThreadA executes (2) with lock and fetches 100
Time =  4; ThreadB tries executes (2) but the row is locked and 
                   it's has to wait...

Time =  5; ThreadA executes (3) and adds 100 + 50
Time =  6; ThreadA executes (4) and updates creds to 150
Time =  7; ThreadA executes (5) and commits the Txn1

Time =  8; ThreadB was waiting up to this point and now is able to execute (2) 
                   with lock and fetches 150
Time =  9; ThreadB executes (3) and adds 150 + 50
Time = 10; ThreadB executes (4) and updates creds to 200
Time = 11; ThreadB executes (5) and commits the Txn2

【讨论】:

    【解决方案4】:

    使用新的timestamp 列的乐观锁定可以解决这个并发问题。

    UPDATE credits SET creds = 150 WHERE userid = 1 and modified_data = old_modified_date
    

    【讨论】:

      【解决方案5】:

      对于第一种情况,您可以在 where 子句中添加另一个条件,以确保您不会覆盖并发用户所做的更改。例如。

      update credits set creds= 150 where userid = 1 AND creds = 0;
      

      【讨论】:

        【解决方案6】:

        您可以设置一个排队机制,在该机制中,排名类型值的添加或减去将排队等待某个作业的定期 LIFO 处理。如果需要有关等级“余额”的实时信息,这将不适合,因为在未完成的队列条目被协调之前不会计算余额,但如果它不需要立即协调它可能会起作用。

        这似乎反映了,至少从外观上看,像老装甲将军系列这样的游戏是如何处理个人动作的。轮到一名玩家,他们宣布他们的行动。每个动作依次按顺序处理,并且没有冲突,因为每个动作在队列中都有自己的位置。

        【讨论】:

        • 存储各个记录总是一个好主意。如果您的代码中存在错误并且您需要从源记录重新创建余额,这也很有用。它也很有用,因为您现在有一个返回汇总表的写入器,这减少了争用、阻塞和复杂性,并使用户体验更加流畅。
        【解决方案7】:

        表可以修改如下,引入新的字段版本来处理乐观锁定。与在数据库级别使用锁相比,这是实现更好性能的更具成本效益和效率的方法

        create table credits (
          int id,
          int creds,
          int user_id,
          int version
        );
        

        select creds, user_id, version from credits where user_id=1;

        假设这会返回 creds = 100 和 version=1

        更新信用集 creds = creds*10, version=version+1 where user_id=1 and version=1;

        始终确保拥有最新版本号的人只能更新此记录,并且不允许脏写

        使用 java/hibernate 支持开箱即用,在所需的表字段/列上使用 @version 注释

        【讨论】:

        • 如此简单,但非常有效!这正是我想要的
        【解决方案8】:

        当您将用户的当前信用字段减少请求的金额并且如果成功减少您执行其他操作并且问题在理论上存在可以有许多并行请求以减少操作,例如,当用户有 1 个积分余额并且有 5 个并行 1 个积分收费请求时,如果请求将在同一时间发送,他可以购买 5 件东西,你最终得到 -用户余额中的 4 个积分。

        为避免这种情况您应该根据请求的金额减少当前信用值(在我们的示例中为 1 信用)并且还要检查当前值减去请求的金额是否大于或等于零:

        UPDATE credits SET creds = creds-1 WHERE creds-1>=0 and userid = 1

        这将保证用户不会在很少的积分下购买很多东西,如果他愿意做你的系统。

        在此查询之后,您应该运行 ROW_COUNT() 来判断当前用户信用是否满足条件并且行已更新:

        UPDATE credits SET creds = creds-1 WHERE creds-1>=0 and userid = 1
        IF (ROW_COUNT()>0) THEN 
           --IF WE ARE HERE MEANS USER HAD SURELY ENOUGH CREDITS TO PURCHASE THINGS    
        END IF;
        

        PHP 中类似的事情可以这样完成:

        mysqli_query ("UPDATE credits SET creds = creds-$amount WHERE creds-$amount>=0 and userid = $user");
        if (mysqli_affected_rows())
        {
           \\do good things here
        }
        

        这里我们没有使用 SELECT ... FOR UPDATE 也没有 TRANSACTION 但是如果你把这段代码放在事务中,只需确保事务级别总是提供来自行的最新数据(包括其他已经提交的事务)。如果 ROW_COUNT()=0

        ,您也可以使用 ROLLBACK

        没有行锁定的 WHERE credit-$amount>=0 的缺点是:

        更新后,您肯定知道用户有足够的信用余额,即使他尝试通过许多请求来破解信用,但您不知道其他信息,例如收费前的信用(更新)以及收费后的信用(更新)。

        注意:

        不要在不提供最新行数据的事务级别内使用此策略。

        如果您想知道更新前后的价值,请不要使用此策略。

        请尝试相信信用已成功收取且不低于零的事实。

        【讨论】:

          【解决方案9】:

          如果您将上次更新时间戳与记录一起存储,则在读取值时也要读取时间戳。当您去更新记录时,请检查以确保时间戳匹配。如果有人在您身后并在您之前更新,则时间戳将不匹配。

          【讨论】:

          • 如果它们恰好在同一毫秒内(或您的数据库对时间戳的任何分辨率),您就会遇到麻烦。这当然永远不会发生,直到有一天它突然发生,让你可怜的用户失去信用(或金钱)。
          猜你喜欢
          • 2011-05-24
          • 2018-10-03
          • 2013-04-14
          • 2016-02-17
          • 1970-01-01
          • 2021-01-08
          • 1970-01-01
          • 2019-03-04
          • 2018-10-01
          相关资源
          最近更新 更多