如何使用Entity Framework选择正确的并发/锁定机制

时间:2017-11-21 20:42:53

标签: c# sql-server entity-framework concurrency

我尝试实施一个简单的服务(使用C#,SQL Server,实体框架)来处理来自客户的付款,事先进行了几次检查(例如,单个产品的购买次数不能超过10次一天等等。)

代码的简化版本如下:

public void ExecutePayment(int productId, PaymentInfo paymentInfo)
{
    using (var dbContext = new MyDbContext())
    {
        var stats = dbContext.PaymentStatistics.Single(s => s.ProductId== productId);
        var limits = dbContext.Limits.Single(l => l.ProductId == productId);
        int newPaymentCount = stats.DailyPaymentCount + 1;
        if (newPaymentCount > limits.MaxDailyPaymentCount)
        {
            throw new InvalidOperationException("Exceeded payment count limit");
        }

        // other limits here...

        var paymentResult = ProcessPayment(paymentInfo); <-- long operation, takes 2-3 seconds
        if (paymentResult.Success)
        {  
            stats.DailyPaymentCount = newPaymentCount;
        }

        dbContext.SaveChanges();
    }
}

我关注的是可能的并发问题。我需要确保没有2个线程/进程同时开始检查/更新stats.PaymentCount,否则统计信息将不同步。

我正在考虑将整个方法包装到分布式锁中(例如使用this implementation),如下所示:

string lockKey = $"processing-payment-for-product-{productId}";
var myLock = new SqlDistributedLock(lockKey);
using (myLock.Acquire())
{
    ExecutePayment(productId, paymentInfo);
}

但是这种方法的关注点是ProcessPayment非常慢(2-3秒),这意味着同一产品的任何并发支付请求都必须等待2-3秒才能开始限制检查。 / p>

有人能为这种情况建议一个好的锁定解决方案吗?

1 个答案:

答案 0 :(得分:0)

不是为每个事务使用锁(悲观并发) - 最好使用Optimistic Concurrency来检查DailyPaymentCount

使用原始SQL(因为EF中的原子增量很难) - 假定列名称为:

// Atomically increment dailyPaymentCount. Fail if we're over the limit.
private string incrementQuery = @"UPDATE PaymentStatistics p 
                SET dailyPaymentCount = dailyPaymentCount + 1 
                FROM PaymentStatistics p 
                JOIN Limits l on p.productId = l.productId 
                WHERE p.dailyPaymentCount < l.maxDailyPaymentCount 
                AND p.productId = @givenProductId";

// Atomically decrement dailyPaymentCount
private string decrementQuery = @"UPDATE PaymentStatistics p 
                SET dailyPaymentCount = dailyPaymentCount - 1 
                FROM PaymentStatistics p 
                WHERE p.productId = @givenProductId";

public void ExecutePayment(int productId, PaymentInfo paymentInfo)
{
    using (var dbContext = MyDbContext()) {

        using (var dbContext = new MyDbContext())
        {
            // Try to increment the payment statistics for the given product
            var rowsUpdated = dbContext.Database.ExecuteSqlCommand(incrementQuery, new SqlParameter("@givenProductId", productId));

            if (rowsUpdated == 0) // If no rows were updated - we're out of stock (or the product/limit doesn't exist)
                throw new InvalidOperationException("Out of stock!");

            // Note: there's a risk of our stats being out of sync if the program crashes after this point
            var paymentResult = ProcessPayment(paymentInfo); // long operation, takes 2-3 seconds

            if (!paymentResult.Success)
            {  
                dbContext.Database.ExecuteSqlCommand(decrementQuery, new SqlParameter("@givenProductId", productId));
            } 
        }
    }
}

这实际上包括&#34; 在飞行中&#34;在您的统计数据中为特定产品付款 - 并将其用作屏障。在处理付款之前 - 尝试(原子地)递增您的统计数据 - 如果productsSold + paymentsPending > stock则付款失败。如果付款失败,则递减paymentsPending - 这将允许后续付款请求成功。

正如评论中所提到的 - 如果付款失败,则统计信息与已处理的付款不同步,并且应用程序在dailyPaymentCount可以递减之前崩溃。如果这是一个问题(即您无法重建应用程序重新启动的统计信息) - 您可以使用RepeatableRead事务,该事务将在应用程序崩溃时回滚 - 但之后您又回到了能够同时处理每个productId的付款,因为产品的PaymentStatistic行将在其递增后被锁定 - 直到交易结束。这是不可避免的 - 在您知道自己有股票之前,您无法处理付款,并且在您处理/失败之前,您无法确定自己是否有股票机上付款。

this answer中对乐观/悲观并发的概述很好。