我尝试实施一个简单的服务(使用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>
有人能为这种情况建议一个好的锁定解决方案吗?
答案 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中对乐观/悲观并发的概述很好。