如何解决 C# web service 与 timer job 的 race condition
结论
timer job 不应同步所有“没有 GUID”的记录,只应同步支付状态已确定为成功的记录。
当前的 payment = false 同时表示“尚未支付”和“支付失败”,这正是 race condition 的根源。支付过程至少应分为三种状态:
Pending:等待支付结果Paid:支付成功,可以同步Failed或Expired:支付失败或超时
web service 负责以原子操作将订单从 Pending 改为 Paid,timer job 只领取状态为 Paid 且尚未同步的记录。两者应通过数据库状态协调,不能依赖进程内的 lock、固定延时或执行顺序。
为什么单独加锁解决不了问题
timer job 可能比支付回调早几分钟运行。即使 web service 和 timer job 使用了互斥锁,timer job 获取锁后仍会看到 payment = false,并把这个尚未确定的状态发送出去。
锁只能避免两个操作同时修改数据,却无法告诉 timer job:“支付结果还可能变化,现在不能发送。”
而且,Web 服务和定时任务通常会运行在不同进程、服务器或容器中。C# 的 lock、Monitor 和静态变量只对单个进程有效,不适合用来协调这类任务。
正确的数据状态设计
可以用枚举字段分别记录支付状态和同步状态:
public enum PaymentStatus
{
Pending = 0,
Paid = 1,
Failed = 2,
Expired = 3
}
public enum SyncStatus
{
Pending = 0,
Processing = 1,
Completed = 2,
Failed = 3
}
public sealed class Order
{
public long Id { get; set; }
public PaymentStatus PaymentStatus { get; set; }
public SyncStatus SyncStatus { get; set; }
public string? ThirdPartyGuid { get; set; }
public string? PaymentTransactionId { get; set; }
public DateTime CreatedAtUtc { get; set; }
public DateTime? PaidAtUtc { get; set; }
}
创建订单时,将两种状态都设为 Pending:
var order = new Order
{
PaymentStatus = PaymentStatus.Pending,
SyncStatus = SyncStatus.Pending,
CreatedAtUtc = DateTime.UtcNow
};
如果暂时无法修改字段类型,也可以先用可空布尔值过渡:
NULL:等待结果true:支付成功false:支付失败
从长期维护来看,枚举比布尔值表达得更清楚,后续也更容易加入退款、取消和超时等状态。
支付回调应原子更新
支付网关可能重复发送回调,因此处理逻辑必须具备幂等性。只有订单当前处于 Pending 状态时,才能将其更新为 Paid:
var affectedRows = await dbContext.Orders
.Where(x => x.Id == orderId &&
x.PaymentStatus == PaymentStatus.Pending)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.PaymentStatus, PaymentStatus.Paid)
.SetProperty(x => x.PaymentTransactionId, transactionId)
.SetProperty(x => x.PaidAtUtc, DateTime.UtcNow));
if (affectedRows == 0)
{
// 订单不存在、已经支付,或者已经进入其他最终状态。
// 重复的成功回调通常可以按幂等成功处理。
}
还应为 PaymentTransactionId 建立唯一约束,防止同一笔网关交易被错误绑定到多个订单。
timer job 只处理已支付记录
timer job 的筛选条件至少要包括:
var orders = await dbContext.Orders
.Where(x =>
x.PaymentStatus == PaymentStatus.Paid &&
x.SyncStatus == SyncStatus.Pending &&
x.ThirdPartyGuid == null)
.OrderBy(x => x.Id)
.Take(batchSize)
.ToListAsync();
其中最重要的是:
x.PaymentStatus == PaymentStatus.Paid
状态为 Pending 的记录不会被发送。支付回调到达并将记录改为 Paid 后,下一次 timer job 才会处理它。
防止多个 timer job 重复领取
如果多个 job 实例可能同时运行,先查询再更新并不安全,因为两个实例可能在同一时间查到同一条记录。应在数据库事务中,以原子操作把待处理记录改为 Processing。
以 SQL Server 为例,可以使用下面这样的领取语句:
;WITH Batch AS
(
SELECT TOP (@BatchSize) *
FROM Orders WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE PaymentStatus = 1
AND SyncStatus = 0
AND ThirdPartyGuid IS NULL
ORDER BY Id
)
UPDATE Batch
SET SyncStatus = 1
OUTPUT
inserted.Id,
inserted.PaymentTransactionId,
inserted.PaidAtUtc;
这里:
PaymentStatus = 1表示PaidSyncStatus = 0表示等待同步- 更新后的
SyncStatus = 1表示记录已被某个 worker 领取 UPDLOCK和READPAST可以避免多个 worker 同时领取同一行
成功调用 third party system 后,再保存对方返回的 GUID:
var thirdPartyGuid = await thirdPartyClient.CreateAsync(order);
await dbContext.Orders
.Where(x => x.Id == order.Id &&
x.SyncStatus == SyncStatus.Processing)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.ThirdPartyGuid, thirdPartyGuid)
.SetProperty(x => x.SyncStatus, SyncStatus.Completed));
如果调用失败,可以把状态改为 Failed,同时记录错误信息和重试次数,交给后续任务重试。
支付失败需要单独定义规则
按照描述,支付网关只会在支付成功时调用 web service。因此,没有收到回调并不代表支付失败,只能说明结果仍然未知。
如果业务还要求向 third party system 写入失败记录,就必须通过明确的方式确定支付已经失败,例如:
- 支付有效期结束后,将订单标记为
Expired - 主动调用支付网关的查询接口核对状态
- 根据网关提供的对账文件确认最终结果
- 人工处理长期停留在
Pending状态的异常订单
只要状态尚未最终确定,就不能把 payment = false 发送给无法更新记录的 third party system。
还要处理外部调用的一致性
数据库事务无法覆盖 third party API,因此可能发生以下情况:
- third party 已成功创建记录;
- 应用在保存 GUID 前崩溃;
- timer job 重试,再次创建了记录。
最好向 third party 发送稳定的业务唯一键,例如订单号,并使用对方提供的幂等创建能力。如果对方既不允许更新,也无法按业务键查询,还不支持幂等创建,那么本地代码无法保证严格的“恰好一次”同步。这类不确定状态只能通过对账或人工补偿处理。
原则很明确:先在数据库中得到不会再变化的支付结论,再允许 timer job 创建无法修改的外部记录。
备注:内容仅供参考。
