如何处理数据库中的并发更新?

时间:2009-07-28 18:27:02

标签: sql concurrency

在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;

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

那么简单,处理上面提到的(非常简单的)问题的接受方法是什么,如果db抛出错误怎么办?

9 个答案:

答案 0 :(得分:26)

使用交易:

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存储过程相结合可以使后一部分更容易处理;应用程序只会在事务中调用单个存储过程,并在事务中止时重新调用它。

答案 1 :(得分:16)

对于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。

答案 2 :(得分:11)

在事务中包装代码在某些情况下是不够的,无论您定义的隔离级别如何(例如,您已将代码部署到生产中的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

答案 3 :(得分:2)

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

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

答案 4 :(得分:2)

使用新的timestamp列进行乐观锁定可以解决此并发问题。

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

答案 5 :(得分:1)

您可以设置一种排队机制,其中排名类型值的添加或减少将排队等待某些作业定期进行LIFO处理。如果需要关于等级“余额”的实时信息,则这不适合,因为在核对未完成的队列条目之前,余额不会计算,但如果它不需要立即协调,则可以提供。

这似乎反映了,至少在外面看,像旧的Panzer General系列游戏如何处理个人动作。一名球员出现了,他们宣布了他们的举动。依次处理每个移动,并且没有冲突,因为每个移动都在队列中占有一席之地。

答案 6 :(得分:1)

表可以修改如下,引入新的字段版本来处理乐观锁定。这是实现更好性能的更具成本效益和更有效的方式,而不是在数据库级别使用锁 创建表信用(   int id,   int creds,   int user_id,   int版本 );

从credit_id = 1;

中选择信用卡,user_id,版本

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

更新信用设置creds = creds * 10,version = version + 1其中user_id = 1且version = 1;

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

答案 7 :(得分:1)

当您将用户的当前信用额减少一个请求数量时,您的情况有一个关键点,如果它成功减少,您执行其他操作,理论上问题 可以是许多并行请求减少操作当例如用户有1个余额的余额和5个并行的1个信用费用请求时他可以购买5个东西,如果请求将在同一时间完全发送并且你最终得到 - 用户余额4个学分。

为了避免这种你应该减少当前信用额度和要求金额(在我们的示例中为1个信用额度),还要检查当前值减去请求金额是否大于或等于零< /强>:

UPDATE credit SET creds = creds-1 WHERE creds-1&gt; = 0 和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&gt; = 0的下行是:

更新后你肯定知道一个用户有足够的信用余额,即使他尝试与许多请求你的黑客信用,但你不知道其他的东西,如充电前的信用(更新)收费后的信用额度(更新)。

注意:

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

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

只是试着依靠信用成功收费而不低于零的事实。

答案 8 :(得分:0)

如果存储记录的最后更新时间戳,则在读取值时,也请读取时间戳。当您更新记录时,请检查以确保时间戳匹配。如果有人进来并在你面前更新,那么时间戳将不匹配。