PAYATHON 2026

EF 6 保存更改后如何在支付失败时 rollback

支付小周

结论

SaveChanges() 之后仍可回滚,但需要满足两个条件:它运行在尚未提交的数据库事务中,而且事务创建、客户保存以及最终的提交或回滚都使用同一个 DbContext。

数据库事务无法覆盖第三方支付系统。即使在事务期间调用支付接口,也只能回滚本地数据库,无法撤销第三方已经完成的扣款。因此:

  • 从技术上看,可以由应用服务持有 EF 事务。支付成功后调用 Commit(),失败时调用 Rollback()。
  • 在生产环境中,更适合先把客户或订单保存为 PendingPayment,再根据支付结果改为 Paid 或 PaymentFailed。遇到边界异常时,通过退款等补偿操作处理。

为什么 repository 内部的 transaction 无法在外部回滚

如果 addCustomer() 在 repository 内创建事务,然后直接返回 userID,事务对象也就离开了它的作用域。如果使用了 using,方法返回时事务还会被释放。

事务的生命周期需要覆盖整个业务操作:

  1. 创建客户。
  2. 调用支付服务。
  3. 根据支付结果提交或回滚。

所以,事务边界不应放在单个 repository 方法中,而应由能够协调数据库与支付流程的 application service 或 service 层管理。

直接使用 EF 6 事务

下面的写法可以保证,当第三方支付明确失败或抛出异常时,本地新增的 customer 不会被提交。

public int CreateCustomerAndTakePayment(CustomerModel model)
{
    using (var context = new MCOEntities())
    using (var transaction = context.Database.BeginTransaction())
    {
        try
        {
            var customer = new Customer
            {
                Name = model.Name,
                Email = model.Email
            };

            context.Customers.Add(customer);
            context.SaveChanges();

            // SaveChanges 后数据库生成的 identity 已写回实体,
            // 此时可以取得 ID,但事务尚未提交。
            int userID = customer.UserID;

            bool success = makePaymentThroughPaymentProcessor(userID);

            if (!success)
            {
                transaction.Rollback();
                throw new InvalidOperationException("Payment was not completed.");
            }

            transaction.Commit();
            return userID;
        }
        catch
        {
            transaction.Rollback();
            throw;
        }
    }
}

这里需要注意,SaveChanges() 不代表事务已经提交。在显式事务中,它只是将 SQL 操作发送到数据库。只有执行 transaction.Commit() 后,其他连接才能正式看到这些更改。

repository 可以接收同一个 DbContext,但不应自行创建或提交事务:

public class CustomerRepository
{
    private readonly MCOEntities _context;

    public CustomerRepository(MCOEntities context)
    {
        _context = context;
    }

    public Customer AddCustomer(CustomerModel model)
    {
        var customer = new Customer
        {
            Name = model.Name,
            Email = model.Email
        };

        _context.Customers.Add(customer);
        _context.SaveChanges();

        return customer;
    }
}

事务由上层服务管理:

public int CreateCustomerAndTakePayment(CustomerModel model)
{
    using (var context = new MCOEntities())
    using (var transaction = context.Database.BeginTransaction())
    {
        try
        {
            var repository = new CustomerRepository(context);
            var customer = repository.AddCustomer(model);

            bool success =
                makePaymentThroughPaymentProcessor(customer.UserID);

            if (!success)
            {
                transaction.Rollback();
                throw new InvalidOperationException("Payment failed.");
            }

            transaction.Commit();
            return customer.UserID;
        }
        catch
        {
            transaction.Rollback();
            throw;
        }
    }
}

这种写法的局限

通常不适合把第三方网络调用放进数据库事务,因为支付接口可能需要数秒,甚至更久。在这段时间内,数据库连接和事务会始终保持,可能带来以下问题:

  • 锁的持有时间过长;
  • 并发性能下降;
  • 死锁概率上升;
  • 支付超时后无法确定实际支付状态;
  • 数据库提交失败时,第三方支付可能已经完成。

其中最难处理的是结果不确定。例如,支付平台已经扣款,但响应在网络传输过程中丢失。应用收到超时后回滚 customer,并不能证明支付没有发生。

TransactionScope 同样无法解决这个问题。除非第三方支付服务明确支持分布式事务协议,否则本地数据库和支付平台无法加入同一个原子事务,而常见的 HTTP 支付 API 通常不支持这类协议。

更稳妥的生产设计

更可靠的做法是将业务流程设计为状态机,而不是用一个数据库事务包住外部支付。

public enum PaymentStatus
{
    Pending,
    Paid,
    Failed,
    RefundPending,
    Refunded
}

第一步,使用短事务创建客户和支付记录:

public int CreatePendingCustomer(CustomerModel model)
{
    using (var context = new MCOEntities())
    {
        var customer = new Customer
        {
            Name = model.Name,
            Email = model.Email,
            PaymentStatus = PaymentStatus.Pending
        };

        context.Customers.Add(customer);
        context.SaveChanges();

        return customer.UserID;
    }
}

第二步,在事务之外调用支付接口:

var userID = CreatePendingCustomer(model);

try
{
    var paymentResult =
        makePaymentThroughPaymentProcessor(userID);

    if (paymentResult.Success)
    {
        MarkCustomerAsPaid(userID, paymentResult.TransactionId);
    }
    else
    {
        MarkPaymentAsFailed(userID);
    }
}
catch
{
    // 超时并不一定代表扣款失败。
    // 应记录待确认状态,并通过支付查询接口核对结果。
    MarkPaymentAsPendingConfirmation(userID);
    throw;
}

更新支付状态时,再开启一个短事务:

public void MarkCustomerAsPaid(
    int userID,
    string transactionId)
{
    using (var context = new MCOEntities())
    using (var transaction = context.Database.BeginTransaction())
    {
        try
        {
            var customer = context.Customers.Single(x => x.UserID == userID);

            customer.PaymentStatus = PaymentStatus.Paid;
            customer.PaymentTransactionId = transactionId;

            context.SaveChanges();
            transaction.Commit();
        }
        catch
        {
            transaction.Rollback();
            throw;
        }
    }
}

如果支付已经成功,但数据库更新失败,应保留支付平台返回的交易编号,并通过重试任务恢复数据库状态。如果业务最终无法完成,则使用该交易编号发起退款。这种结合重试与补偿的方式通常称为 Saga 或补偿事务。

需要特别注意的事项

使用幂等键

调用支付接口时,应提供稳定且唯一的业务编号作为幂等键,例如订单号或支付请求号。重试时继续使用同一个编号,以免网络超时造成重复扣款。

不要只依赖 userID,除非它能在支付系统中唯一标识一次支付。一个用户可能发起多次支付,因此更适合使用独立的 PaymentId 或 OrderId。

区分失败与未知状态

支付接口明确返回“失败”时,可以将本地状态更新为 Failed。如果发生超时、断网或进程终止,支付结果可能仍然未知,不能立即按失败处理。

此时应:

  1. 保存支付请求编号;
  2. 将状态标记为待确认;
  3. 调用支付平台的交易查询接口;
  4. 根据最终结果更新为成功、失败,或发起退款。

不要让 repository 独立拥有业务事务

repository 可以负责数据读写,但“创建客户并支付”属于一个完整的业务流程。事务何时提交,应由协调该流程的 service 决定,而不是由 addCustomer() 自行决定。

回滚后不要继续使用生成的 ID

即使事务已经回滚,数据库通常也不会重新使用生成过的 identity 值。因此,回滚后 ID 出现空缺很正常。不要依赖 ID 连续,也不要在回滚后继续把该 userID 当作已经存在的客户使用。

推荐选择

如果只是内部工具,支付调用速度较快,而且可以接受长事务,可以采用“同一个 DbContext 加显式事务”的方案。

正式支付业务更适合采用以下流程:

创建 Pending 记录
    → 提交短事务
    → 调用支付平台
    → 查询或确认支付结果
    → 更新为 Paid / Failed
    → 必要时退款或重试

这种流程不是数据库意义上的单一原子事务,但能正确处理支付成功后数据库更新失败、网络超时、重复请求以及服务进程中断等实际情况。

备注:内容仅供参考。