将 DbContextTransaction.BeginTransaction 防止这种竞争情况

Will DbContextTransaction.BeginTransaction prevent this race condition

我有一种方法需要“索取”付款号码以确保它在以后可用。当准备好提交到数据库时,我不能只获得一个新的付款号码,因为该号码已添加到签名令牌中,然后在提交到数据库时从签名令牌中获取付款号码以允许令牌之后链接到付款。

付款编号是连续的,现有代码中使用的当前方法是:

在我的服务中,我试图防止以下竞争条件:

  1. 我的服务读取付款号码(例如 100)
  2. 另一项服务使用并更新了付款号码(现在是 101)
  3. 我的服务在本地增加数字(到 101)并更新数据库(仍然是 101)

这将产生两笔付款,付款编号为 100。

到目前为止,这是我的实现,在我的 Transaction class:

private DbSet<PaymentIdentifier> paymentIdentifier;

//...

private int ClaimNextPaymentNumber()

{
    int nextPaymentNumber = -1;

    using(var dbTransaction = db.Database.BeginTransaction())
    {
        int lastPaymentNumber = paymentIdentifier.ElementAt(0).Identifier;
        nextPaymentNumber = lastPaymentNumber + 1;

        paymentIdentifier.ElementAt(0).Identifier = nextPaymentNumber;
        db.SaveChanges();

        dbTransaction.Commit();
    }
    
    return nextPaymentNumber;
}

PaymentIdentifier table 具有单行和单列“标识符”(因此是 .ElementAt(0))。我无法更改数据库结构,因为有很多依赖它的遗留代码非常脆弱。

将代码包装在事务中(正如我所做的那样)是否会防止竞争条件,或者是否有一些 Entity Framework / PostgreSQL 特性我需要处理以保护标识符不被读取执行交易?

谢谢!

(附带一点,我相信连接到数据库的其他软件中的许多遗留代码只是忽略了竞争条件并依赖于它“非常快”)

仅当所有代码(包括遗留代码)都使用此方法时,它才能帮助您解决竞争条件。如果仍然有代码在没有事务的情况下继续使用客户端递增,您将遇到同样的问题。只需在您的描述中交换 'My service' 和 'Another service'。

 1. Another service reads the payment number (eg. 100) **without** transaction
 2. My service uses and updates the payment number (now 101) **with** transaction
 3. Another  service increments the number locally (to 101) and updates the database (still 101) **without** transaction

请注意,您可以通过在没有显式事务的情况下执行此查询,用更简单的代码替换您的代码。

update PaymentIdentifier set Identifier = Identifier + 1 returning Identifier;

但同样,在您替换所有标识符递增的地方之前,它不会解决您的并发问题。如果您可以更改它,您最好使用 SEQUENCEGenerators 来安全地为您提供增量 ID。

交易不会自动锁定您的 table。 Transaction 只是确保对数据库的多个更改完全完成或根本不执行(请参阅 ACID 中的 A(原子))。但你想要的是只有一个会话可以读取、添加一个、更新值。完成后,允许下一个会话做同样的事情。

所以你现在有不同的可能性:

  1. 使用 Sequence 你可以获得下一个值,例如 SELECT nextval('mysequencename')。如果两个会话同时尝试获取一个值,它们将获得两个不同的值。
  2. 如果您有更复杂的需求,并希望将每个“令牌”与附加数据一起存储在 table 中。所以每个标记都是 table 中的一行,您可以使用其他列 table locking。有了这个,您可以限制对 table 的访问。因此一次只允许一个会话访问 table。但是请确保您使用锁的时间尽可能短,因为这将成为您的性能瓶颈。

在这种情况下,数据库通过抛出并发冲突错误来防止竞争条件。因此,我查看了遗留代码中的处理方式(遵循@sergey-l 的建议),它使用了一种简单的重试机制。所以,我做了同样的事情:

private int ClaimNextPaymentNumber()
{
    DbContextTransaction dbTransaction;
    bool failed;
    int paymentNumber = -1;

    do
    {
        failed = false;

        using(dbTransaction = db.Database.BeginTransaction())
        {
            try
            {
                paymentNumber = TryToClaimNextPaymentNumber();
            }
            catch(DbUpdateConcurrencyException ex)
            {
                failed = true;
                ResetForClaimPaymentNumberRetry(ex);                        
            }

            dbTransaction.Commit();
            concurrencyExceptionRetryCount = 0;
        }
    }
    while(failed);

    return paymentNumber;
}