在使用 C# 进行业务开发时,经常会遇到需要同时修改多张数据库表的情况,比如新增订单的同时扣减商品库存。EF Core 作为流行的对象关系映射框架,提供了多种控制数据库事务的手段,理解这些手段可以避免数据不一致的问题。

EF Core 默认的事务行为
EF Core 的 DbContext 在调用 SaveChanges 或 SaveChangesAsync 时,如果当前没有由用户开启的事务,框架会自动创建一个数据库事务,将本次上下文中所有被跟踪实体的变更在一个事务里提交。这意味着单次 SaveChanges 内部的多次 INSERT、UPDATE、DELETE 是具有原子性的。
但很多开发者忽略的是,这种自动事务仅覆盖单次 SaveChanges 调用。如果你为了业务逻辑清晰,分两次调用 SaveChanges,或者在两次操作之间执行了原生 SQL,那么默认机制就无法保证它们之间的一致性。一旦第一次成功、第二次失败,系统就会出现部分写入的脏数据。
使用 BeginTransaction 手动控制事务
当我们需要在同一个事务中执行多个 SaveChanges,或者混合使用 EF 跟踪实体与原生 SQL 时,应当使用 Database.BeginTransaction 方法。该方法返回一个 IDbContextTransaction 对象,我们可以显式调用 Commit 或 Rollback。
下面的示例展示了在单个 DbContext 中,同时写入订单表和扣减库存表,并保证两者要么都成功要么都失败:
using (var transaction = context.Database.BeginTransaction())
{
try
{
// 新增订单
var order = new Order
{
OrderNo = "20240101001",
ProductId = 1,
Quantity = 2,
CreateTime = DateTime.Now
};
context.Orders.Add(order);
context.SaveChanges();
// 扣减库存,使用原生 SQL
context.Database.ExecuteSqlInterpolated($"UPDATE Products SET Stock = Stock - {order.Quantity} WHERE Id = {order.ProductId}");
// 提交事务
transaction.Commit();
}
catch (Exception ex)
{
// 发生异常回滚所有操作
transaction.Rollback();
throw;
}
}
上述代码中,即使库存更新语句因为并发冲突或字段约束报错,之前写入的订单记录也会随 Rollback 一起撤销。注意 BeginTransaction 默认使用数据库连接的隔离级别,一般无需额外配置。
如果你希望使用异步方式,可以改用 BeginTransactionAsync 并在内部使用 SaveChangesAsync 与 ExecuteSqlInterpolatedAsync,代码结构保持一致,只是在调用链上增加 await 关键字。
跨 DbContext 的分布式事务
在分层架构中,有时订单上下文和库存上下文是分开的 DbContext 类。如果它们指向同一个数据库,可以使用 Database.BeginTransaction 配合 TransactionScope 来涵盖多个上下文。但如果数据库不同,就需要引入分布式事务管理器,这在 EF Core 中支持有限且依赖操作系统配置。
以下代码演示同一数据库下两个上下文共享事务的方式:
var orderContext = new OrderContext();
var stockContext = new StockContext();
using (var transaction = orderContext.Database.BeginTransaction())
{
// 让库存上下文加入同一个事务
stockContext.Database.UseTransaction(transaction.GetDbTransaction());
orderContext.Orders.Add(new Order { OrderNo = "T002", ProductId = 3, Quantity = 1 });
orderContext.SaveChanges();
stockContext.Database.ExecuteSqlInterpolated($"UPDATE Stock SET Count = Count - 1 WHERE ProductId = 3");
stockContext.SaveChanges();
transaction.Commit();
}
这里的关键是 UseTransaction 方法,它接收一个底层的 DbTransaction 实例,使得第二个上下文复用第一个上下文开启的事务。这样做可以避免自己写复杂的协调逻辑。
需要提醒的是,如果两个上下文使用了不同的连接字符串,即便域名相同,数据库不同,上述代码也会抛出异常,因为底层 SQL 事务无法跨库提升为分布式事务而不借助 MSDTC 等服务。
依赖注入场景下的使用注意
在 ASP.NET Core 中,DbContext 通常注册为 Scoped 服务,由容器在每个请求中提供同一个实例。此时在 Controller 或 Service 里注入 DbContext,然后开启事务是安全的,因为整个请求共用一个上下文,事务生命周期不会错乱。
但如果你的服务中注入了多个不同种类的 DbContext,或者使用了 DbContextFactory 每次创建新实例,就必须确认这些实例是否共享数据库连接。若未共享,事务代码将不会如预期工作。推荐的做法是尽量在一个请求内只通过一个上下文操作同一数据库,或显式传递事务对象。
常见误区与最佳实践
一个典型误区是认为只要把多个 SaveChanges 写在一个方法里,EF Core 就会自动包成事务。事实上只有单次 SaveChanges 内部才是自动事务。另一个误区是在 using 块外调用了 Commit,却忘了在 catch 中 Rollback,这可能导致连接被占用或锁表。
最佳实践包括:尽量缩短事务持续时间,不要在事务中调用外部 HTTP 接口;对只涉及单表的操作不必手动开事务;使用拦截器或单元测试验证回滚逻辑是否生效。这样可以在保证数据一致的同时,维持系统的吞吐量与稳定性。
小结对比
下表列出了不同事务处理方式的特点:
| 方式 | 适用场景 | 原子性范围 |
|---|---|---|
| 默认自动事务 | 单次 SaveChanges 多实体变更 | 仅本次 SaveChanges |
| BeginTransaction | 多次 SaveChanges 或混合 SQL | 显式提交前所有操作 |
| TransactionScope | 跨上下文同库或分布式 | 代码块内全部资源 |
通过合理选择上述方式,C# 开发者可以让 EF Core 下的数据写入既灵活又可靠。
EF_Core数据库事务SaveChanges修改时间:2026-08-04 09:15:33