锁定特定的表行以插入新行。

3

我有一个名为Operations的表格,其中包括sourceId, destinationId, amount, status等列。每当用户进行转账时,API会先计算信用操作金额之和减去借记操作金额之和,然后将一行新数据插入该表格,前提是余额大于或等于转账金额。

问题在于并发性,因为同时执行多个转账操作的用户可能会导致负余额。

有多种处理PostgreSQL并发性问题的方法:

  1. 可串行事务隔离级别
  2. 表锁定
  3. 行版本控制
  4. 行锁定
    等等。

我们期望的行为是,在设置事务隔离级别为SERIALIZABLE之外,数据库应该等待前一个事务结束,并获取最新插入的版本,而不是在(sourceId, version)上失败重复提交。

但是,我并不确定最佳的方法。这是我的尝试:

1. 可串行事务隔离级别

这是最简单的方法,但问题在于锁升级,因为如果数据库引擎负载过重,1个事务可能会锁定整个表格,这是记录下来的行为。

伪代码:

newId = INSERT INTO "Operations" ("SourceId", "DestinationId", "Amount", "Status", "OccuredAt") values (null, 2, 3, 100, 'PENDING', null);
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM "Operations" WHERE ("SourceId" = 2 or "DestinationId"=2) and "Status" = 'SUCCESSFUL';
'''API: check if balance > transfer amount'''
UPDATE "Operations" SET "Status" = 'SUCCESSFUL' where id = newId
COMMIT;

2. 表锁定

不使用序列化事务级别就可以避免这种情况。

3. 行版本控制

从性能角度考虑,这是目前最好的方法。我们添加了一个version int列和一个(sourceId, version)唯一索引,当事务被插入时,它会带有下一个版本号。如果两个事务并发,数据库将抛出错误:

重复键值违反了“IX_Transactions_SourceWalletId_Version”唯一约束条件

伪代码:

newId = INSERT INTO "Operations" ("SourceId", "DestinationId", "Amount", "Status", "OccuredAt") values (null, 2, 3, 100, 'PENDING', null);
BEGIN;
lastVersion = SELECT o."Version"
     FROM "Operations"
     WHERE ("SourceId" = 2) AND ("Version" IS NOT NULL)
     ORDER BY o."Version" DESC
     LIMIT 1
SELECT * FROM "Operations" WHERE ("SourceId" = 2 or "DestinationId"=2) 
   and "Status" = 'SUCCESSFUL';
'''API: check if balance > transfer amount'''
UPDATE "Operations" SET "Status" = 'SUCCESSFUL', "Version" = lastVersion + 1 where id = newId;
COMMIT;

4. 行锁定

在计算用户余额之前,使用 sourceWalletId = x(其中x是进行转账的用户)锁定所有交易行。但是我无法在PostgreSQL中找到此操作的方法,使用for update可以完成此操作,但在并发事务等待第一个事务后,结果不会返回新插入的行,这是PostgreSQL记录的行为。

3个回答

2
使用for update可以解决问题,但是在并发事务等待第一个事务后,结果不会返回新插入的行,这是PostgreSQL的文档行为。有点正确,但也不是致命问题。是的,在默认的READ COMMITTED事务隔离级别下,每个语句只能看到在查询开始之前提交的行,注意是查询而不是事务。请参阅:Can concurrent value modification impact single select in PostgreSQL 9.1?。在获取锁之后,在同一事务中开始下一个查询。假设一个表恰好保存每个(相关)用户的一行(像您应该拥有的那样)。我将其称为"your_wallet_table",基于引用的"sourceWalletId"
BEGIN;
SELECT FROM "your_wallet_table" WHERE "sourceWalletId" = x FOR UPDATE;
-- x is the user making the transfer

-- check the user's balance (separate query!)

INSERT INTO "Operations" ...  -- 'SUCCESSFUL' or 'PENDING'

COMMIT;

只有在没有其他事务处理同一个用户时,锁才会被获取,并且仅在事务结束时释放。

下一个事务将在其下一个语句中看到所有已提交的行。 如果所有事务都遵循这种操作方式,一切都不错。 当然,不允许事务更改影响其他用户余额的行。

相关:


抱歉回复晚了,你在伪代码中提到的“your_wallet_table”表不应该使用。在交易中,我们通过重放每个操作来计算用户金额(余额)。BEGIN; SELECT FROM “Operations” WHERE (“SourceId” = x or “DestinationId” = x) and “Status” = ‘successful’ FOR UPDATE; -- 这里的x指代进行转账的用户-- 检查用户余额(单独的查询!)INSERT INTO “Operations” … -- 'SUCCESSFUL' 或 'PENDING'COMMIT; - Reda

1
你的示例中排名第一的可序列化变体是唯一一个可以保证正确行为,而不需要代码重试事务,如果更新计数为零或事务已回滚。它也是最简单的。顺便说一下,REPEATABLE READ 对于已存在的行也足够好。
在你的示例中排名第三的东西,看起来有点像乐观锁定,但这取决于负载类型。在你的情况下,更新索引(唯一索引)也可能会影响性能。通常,您对索引上的锁的控制较少,这使得情况不太确定或更难以理解。此外,在REPEATABLE READ以下的任何事务隔离级别中仍然可能遭受不同的读取值。
另一件事是,您的实现在以下场景中会出现错误:
  • 进程1开始并读取版本1
  • 进程2开始并读取版本1
  • 进程2成功并写入版本2
  • 进程3开始并读取版本2
  • 进程3成功并写入版本3
  • 进程1成功并写入版本2 // 没有违规!
有效的是乐观锁定,看起来有点像你的第三个示例。伪代码/ SQL:
BEGIN 
  SELECT "version", "amount" FROM "operations" WHERE "id" = identifier
  // set variable oldVersion to version
  // set variable newVersion to version + 1
  // set variable newAmount to amount + amountOfOperation
  IF newAmount < 0
    ROLLBACK
  ELSE
    UPDATE "operations" SET "version" = newVersion, "amount" = newAmount WHERE "id" = identifier AND "version" = oldVersion
   COMMIT
 ENDIF

这并不需要包含版本的唯一索引。通常情况下,在 UPDATEWHERE 条件中的查询和更新本身即使使用 READ COMMITTED 事务隔离级别也是正确的。关于PostGreSQL我不能确定:请在文档中验证!

通常情况下,我会从可串行化编号为1的示例开始,直到实际使用情况的测量表明它是一个问题。然后尝试乐观锁定并查看其是否改善,同时考虑实际使用情况。

请记住,如果 UPDATE 报告零个更新的行或事务失败,则代码必须始终能够从 BEGIN 到 END 回放事务。

祝你好运并玩得开心!


能否请您解释一下为什么在您的例子中不会出现违规,因为进程1不应该能够写入版本1,因为进程2已经完成了写入版本2?进程1成功并写入版本2 // 没有违规! 请问您指的是什么? - Reda
1
抱歉回复晚了。我给出的示例要么失败(没有更新,也没有更改数据,可以重试),要么成功(在这种情况下,通过更新中的条件确保正确的更新)。这与违规(发生更新并创建不正确的状态)不同。真正理解乐观锁定的主题以及可用的解决方案/示例确实很有帮助。希望这可以帮到你! - Emmef

0

你将不得不承受性能损失。SERIALIZABLE隔离级别是最简单的方法。你可以增加max_predicate_locks_per_relationmax_predicate_locks_per_xactmax_predicate_locks_per_page(取决于你遇到的限制)以后升级锁。

或者,你可以有一个表来存储每个用户的余额,并由延迟触发器更新。然后你可以在那个表上设置检查约束。这将仅对每个用户序列化操作。


老实说,我宁愿继续使用行版本控制方法,而不是使用可序列化的隔离级别。我们曾经遇到了很多问题(死锁),使用可序列化时。 - Reda
2
“SERIALIZABLE”并不会导致任何死锁,这与其他隔离级别没有区别。也许你真正的问题在别处... - Laurenz Albe

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