PAYATHON 2026

如何解决 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 表示 Paid
  • SyncStatus = 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,因此可能发生以下情况:

  1. third party 已成功创建记录;
  2. 应用在保存 GUID 前崩溃;
  3. timer job 重试,再次创建了记录。

最好向 third party 发送稳定的业务唯一键,例如订单号,并使用对方提供的幂等创建能力。如果对方既不允许更新,也无法按业务键查询,还不支持幂等创建,那么本地代码无法保证严格的“恰好一次”同步。这类不确定状态只能通过对账或人工补偿处理。

原则很明确:先在数据库中得到不会再变化的支付结论,再允许 timer job 创建无法修改的外部记录。

备注:内容仅供参考。