EF 6 保存更改后如何在支付失败时 rollback
结论
SaveChanges() 之后仍可回滚,但需要满足两个条件:它运行在尚未提交的数据库事务中,而且事务创建、客户保存以及最终的提交或回滚都使用同一个 DbContext。
数据库事务无法覆盖第三方支付系统。即使在事务期间调用支付接口,也只能回滚本地数据库,无法撤销第三方已经完成的扣款。因此:
- 从技术上看,可以由应用服务持有 EF 事务。支付成功后调用
Commit(),失败时调用Rollback()。 - 在生产环境中,更适合先把客户或订单保存为
PendingPayment,再根据支付结果改为Paid或PaymentFailed。遇到边界异常时,通过退款等补偿操作处理。
为什么 repository 内部的 transaction 无法在外部回滚
如果 addCustomer() 在 repository 内创建事务,然后直接返回 userID,事务对象也就离开了它的作用域。如果使用了 using,方法返回时事务还会被释放。
事务的生命周期需要覆盖整个业务操作:
- 创建客户。
- 调用支付服务。
- 根据支付结果提交或回滚。
所以,事务边界不应放在单个 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。如果发生超时、断网或进程终止,支付结果可能仍然未知,不能立即按失败处理。
此时应:
- 保存支付请求编号;
- 将状态标记为待确认;
- 调用支付平台的交易查询接口;
- 根据最终结果更新为成功、失败,或发起退款。
不要让 repository 独立拥有业务事务
repository 可以负责数据读写,但“创建客户并支付”属于一个完整的业务流程。事务何时提交,应由协调该流程的 service 决定,而不是由 addCustomer() 自行决定。
回滚后不要继续使用生成的 ID
即使事务已经回滚,数据库通常也不会重新使用生成过的 identity 值。因此,回滚后 ID 出现空缺很正常。不要依赖 ID 连续,也不要在回滚后继续把该 userID 当作已经存在的客户使用。
推荐选择
如果只是内部工具,支付调用速度较快,而且可以接受长事务,可以采用“同一个 DbContext 加显式事务”的方案。
正式支付业务更适合采用以下流程:
创建 Pending 记录
→ 提交短事务
→ 调用支付平台
→ 查询或确认支付结果
→ 更新为 Paid / Failed
→ 必要时退款或重试
这种流程不是数据库意义上的单一原子事务,但能正确处理支付成功后数据库更新失败、网络超时、重复请求以及服务进程中断等实际情况。
备注:内容仅供参考。
