一、你遇到过的“诡异”数据库问题,大概率和事务隔离有关
你有没有过这种情况:自己写的代码在测试环境跑着好好的,一上生产就出乱子?比如给用户发优惠券时,明明判断了库存够,结果最后库存扣成负数;或者同时处理两个订单时,程序突然卡死,过几十秒才报“死锁”错误,重启后又莫名其妙好了?
别慌,这些问题大概率不是你代码逻辑写错了,而是没管好数据库的“事务隔离”——说白了就是数据库里多个操作同时跑时,互相干扰的程度。很多人用EF Core时,都是让框架自动处理事务,根本没主动控制过隔离级别,等出了问题才抓瞎。这篇内容就教你怎么用EF Core主动管隔离级别,解决长事务、死锁这些头疼的问题。
二、先搞懂:事务隔离到底是啥?(用大白话讲)
你可以把数据库想象成一个大仓库,每个对仓库的操作(比如查库存、扣库存、发优惠券)就是一个工人在干活。如果两个工人同时干同一件事,比如都去拿最后一件商品,就可能出问题:工人A先查了库存是1,然后去拿,这时候工人B也查了库存是1,也去拿,最后两个人都拿到了,但库存实际只剩1件。
事务隔离级别就是给这些工人定的“干活规矩”,规矩越严,互相干扰越少,但干活速度可能越慢;规矩越松,速度越快,但越容易出乱子。数据库里有四个标准隔离级别,从松到严分别是:读未提交、读已提交、可重复读、串行化。
我们用一个简单的例子帮你理解,所有示例统一用.NET 6 + EF Core 6 + SQL Server,先把这个说清楚,后面所有代码都用这个技术栈。
2.1 四个隔离级别的实际效果(配可运行代码)
我们先准备一个简单的测试类,用来模拟两个工人同时操作:
// 技术栈:.NET 6 + EF Core 6 + SQL Server
// 测试用的数据库上下文,包含一个商品表
public class ShopDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// 替换成你自己的SQL Server连接字符串
optionsBuilder.UseSqlServer("Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().HasKey(p => p.Id);
}
}
// 商品实体类
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int Stock { get; set; }
}
我们先在数据库里插一条测试数据:
// 初始化测试数据
using var db = new ShopDbContext();
db.Products.Add(new Product { Id = 1, Name = "测试商品", Stock = 1 });
db.SaveChanges();
现在模拟两个工人同时操作,第一个工人用读未提交隔离级别:
// 工人A:用读未提交隔离级别
async Task WorkerA()
{
using var db = new ShopDbContext();
// 开始事务,指定隔离级别为读未提交
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.ReadUncommitted);
try
{
// 第一步:查库存
var product = await db.Products.FirstAsync(p => p.Id == 1);
Console.WriteLine($"工人A查库存:{product.Stock}");
// 模拟干活耗时,故意停5秒,让工人B有机会操作
await Task.Delay(5000);
// 第二步:扣库存
product.Stock -= 1;
await db.SaveChangesAsync();
Console.WriteLine($"工人A扣完库存:{product.Stock}");
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"工人A出错:{ex.Message}");
}
}
工人B用默认的读已提交隔离级别:
// 工人B:用默认的读已提交隔离级别
async Task WorkerB()
{
using var db = new ShopDbContext();
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
// 第一步:查库存
var product = await db.Products.FirstAsync(p => p.Id == 1);
Console.WriteLine($"工人B查库存:{product.Stock}");
// 扣库存
product.Stock -= 1;
await db.SaveChangesAsync();
Console.WriteLine($"工人B扣完库存:{product.Stock}");
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"工人B出错:{ex.Message}");
}
}
然后同时跑两个工人:
// 同时启动两个工人
var taskA = WorkerA();
var taskB = WorkerB();
await Task.WhenAll(taskA, taskB);
你会发现,工人A和工人B都查到库存是1,最后库存变成了-1——这就是读未提交的问题:工人A还没提交扣库存的操作,工人B就看到了工人A修改后的库存,等于两个工人都拿了最后一件商品。
如果把工人A的隔离级别改成读已提交,你会发现工人B会被工人A“挡住”,等工人A的操作提交完,工人B才会继续,最后库存变成0,不会出负数。
三、EF Core里怎么主动控制隔离级别?
很多人用EF Core时,都是直接写Add、Update然后SaveChanges,根本没碰过事务。EF Core默认的事务隔离级别是读已提交,这个级别在大部分场景下够用,但遇到长事务、高并发的情况,就必须主动调整。
3.1 基础用法:手动开启事务并指定隔离级别
EF Core里主动控制隔离级别,核心就是用BeginTransactionAsync方法,这个方法可以传入IsolationLevel参数,指定你想要的隔离级别。
比如你要做一个“批量给用户发优惠券”的操作,这个操作要跑很久(可能十几秒),属于长事务,这时候就需要指定隔离级别为可重复读,避免中途库存被别人改了:
// 技术栈:.NET 6 + EF Core 6 + SQL Server
// 批量发优惠券的方法,指定隔离级别为可重复读
async Task SendCoupons(List<int> userIds, int productId)
{
using var db = new ShopDbContext();
// 开始事务,指定隔离级别为可重复读
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// 第一步:查商品库存,可重复读级别下,这次查的结果在整个事务里不会变
var product = await db.Products.FirstAsync(p => p.Id == productId);
if (product.Stock < userIds.Count)
{
throw new Exception("库存不足");
}
// 模拟批量操作耗时:给每个用户发优惠券、扣库存
foreach (var userId in userIds)
{
// 这里可以加发优惠券的逻辑,比如插入优惠券表
Console.WriteLine($"给用户{userId}发优惠券");
// 每发一个扣1库存
product.Stock -= 1;
await db.SaveChangesAsync();
// 模拟发优惠券的耗时操作,比如调用第三方短信接口
await Task.Delay(1000);
}
// 所有操作完成,提交事务
await transaction.CommitAsync();
Console.WriteLine("所有优惠券发送完成");
}
catch (Exception ex)
{
// 出错就回滚,所有操作都撤销
await transaction.RollbackAsync();
Console.WriteLine($"发优惠券出错:{ex.Message}");
}
}
3.2 注意事项:隔离级别不是越高越好
很多人觉得隔离级别越高越安全,就不管什么场景都用串行化,结果把系统跑的巨慢,甚至比不用事务还卡。这是因为隔离级别越高,数据库加的锁就越严,别人要操作同一个数据,就得等你操作完,并发性能就会掉下来。
比如你用串行化级别,同时跑上面的两个工人,工人B会等工人A操作完才开始,速度比读已提交慢好几倍。所以选隔离级别要根据场景来:
- 普通的查询、更新操作:用读已提交,速度快,大部分场景不会出问题
- 长事务、批量操作:用可重复读,避免中途数据被修改
- 极重要的操作(比如转账):可以考虑用串行化,但要控制操作范围,别让锁占太久
四、实战:解决长事务导致的死锁问题
死锁是很多人最头疼的问题,简单说就是两个操作互相等对方的锁:工人A拿了锁1,想要锁2;工人B拿了锁2,想要锁1,结果两个都等,谁也动不了,最后数据库只能强行终止一个,报死锁错误。
4.1 死锁的典型场景
我们来模拟一个死锁场景:有两个操作,一个是“扣商品库存”,一个是“扣用户余额”,两个操作都要先锁商品表,再锁用户表;或者反过来,一个先锁商品再锁用户,一个先锁用户再锁商品。
比如下面两个方法:
// 操作1:先扣商品库存,再扣用户余额
async Task PayOrder1(int productId, int userId)
{
using var db = new ShopDbContext();
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// 第一步:锁商品表,扣库存
var product = await db.Products.FirstAsync(p => p.Id == productId);
product.Stock -= 1;
await db.SaveChangesAsync();
Console.WriteLine("操作1:扣完商品库存");
// 模拟耗时,让操作2有机会锁用户表
await Task.Delay(5000);
// 第二步:锁用户表,扣余额
var user = await db.Users.FirstAsync(u => u.Id == userId);
user.Balance -= 100;
await db.SaveChangesAsync();
Console.WriteLine("操作1:扣完用户余额");
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"操作1出错:{ex.Message}");
}
}
// 操作2:先扣用户余额,再扣商品库存
async Task PayOrder2(int productId, int userId)
{
using var db = new ShopDbContext();
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// 第一步:锁用户表,扣余额
var user = await db.Users.FirstAsync(u => u.Id == userId);
user.Balance -= 100;
await db.SaveChangesAsync();
Console.WriteLine("操作2:扣完用户余额");
// 模拟耗时,让操作1有机会锁商品表
await Task.Delay(5000);
// 第二步:锁商品表,扣库存
var product = await db.Products.FirstAsync(p => p.Id == productId);
product.Stock -= 1;
await db.SaveChangesAsync();
Console.WriteLine("操作2:扣完商品库存");
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"操作2出错:{ex.Message}");
}
}
同时跑这两个操作,大概率会出现死锁:操作1先锁了商品表,想要锁用户表;操作2先锁了用户表,想要锁商品表,两个都等,最后数据库会报死锁错误。
4.2 解决死锁的三个方法
死锁的核心原因是两个操作拿锁的顺序不一样,所以解决方法也围绕这个来:
方法一:统一拿锁顺序
把两个操作拿锁的顺序改成一样的,比如都先锁商品表,再锁用户表,这样就不会出现互相等的情况。比如把操作2改成先扣库存再扣余额:
// 修改后的操作2,拿锁顺序和操作1一致
async Task PayOrder2(int productId, int userId)
{
using var db = new ShopDbContext();
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// 第一步:先锁商品表,和操作1顺序一致
var product = await db.Products.FirstAsync(p => p.Id == productId);
product.Stock -= 1;
await db.SaveChangesAsync();
Console.WriteLine("操作2:扣完商品库存");
await Task.Delay(5000);
// 第二步:再锁用户表
var user = await db.Users.FirstAsync(u => u.Id == userId);
user.Balance -= 100;
await db.SaveChangesAsync();
Console.WriteLine("操作2:扣完用户余额");
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"操作2出错:{ex.Message}");
}
}
方法二:缩短事务时间
死锁的另一个原因是事务时间太长,锁占的时间越久,出现死锁的概率越高。比如上面的操作里,故意停了5秒,就是为了模拟长事务。实际开发中,要尽量把事务里的耗时操作拿出来,比如发优惠券时,先把库存扣了,再去调用第三方短信接口,不要把耗时的第三方调用放在事务里。
比如修改发优惠券的方法:
// 优化后的发优惠券方法,把耗时操作拿出事务
async Task SendCoupons(List<int> userIds, int productId)
{
using var db = new ShopDbContext();
using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
try
{
// 事务里只做库存检查、扣库存、发优惠券的核心操作,耗时很短
var product = await db.Products.FirstAsync(p => p.Id == productId);
if (product.Stock < userIds.Count)
{
throw new Exception("库存不足");
}
foreach (var userId in userIds)
{
// 插入优惠券表,核心操作
db.Coupons.Add(new Coupon { UserId = userId, ProductId = productId });
product.Stock -= 1;
}
await db.SaveChangesAsync();
await transaction.CommitAsync();
Console.WriteLine("核心操作完成,开始发通知");
// 把耗时的第三方调用(比如短信、推送)拿出事务,不会占锁
foreach (var userId in userIds)
{
await SendSmsToUser(userId);
}
}
catch (Exception ex)
{
await transaction.RollbackAsync();
Console.WriteLine($"发优惠券出错:{ex.Message}");
}
}
// 模拟发短信的方法
async Task SendSmsToUser(int userId)
{
await Task.Delay(1000);
Console.WriteLine($"给用户{userId}发通知完成");
}
方法三:调整隔离级别
如果是读已提交级别下出现死锁,可以考虑改成可重复读,因为可重复读级别下,数据库加的锁会更合理,有时候能避免死锁。但要注意,可重复读级别下,锁占的时间可能更长,所以要配合缩短事务时间一起用。
五、场景总结:什么时候该主动控制隔离级别?
不是所有场景都需要主动控制隔离级别,大部分普通的操作(比如查商品列表、更新用户信息),用EF Core默认的读已提交就够了。需要主动控制的场景主要有这几类:
- 长事务操作:比如批量发优惠券、批量导入数据,操作时间超过1秒的,建议用可重复读级别,避免中途数据被修改。
- 高并发操作:比如秒杀、抢购,多个操作同时抢同一个商品的,建议用可重复读或者串行化级别,避免超卖。
- 涉及多个表的操作:比如转账、下单,同时要操作商品表、用户表、订单表的,要统一拿锁顺序,避免死锁。
六、技术优缺点与注意事项
6.1 主动控制隔离级别的优缺点
优点:
- 能避免脏读、不可重复读、幻读等问题,保证数据一致性。
- 能有效解决长事务、死锁等问题,提高系统稳定性。
- 能根据不同场景调整隔离级别,平衡性能和安全性。
缺点:
- 隔离级别越高,性能越低,需要开发者根据场景选择合适的级别,不能盲目用高隔离级别。
- 手动控制事务需要开发者自己处理提交、回滚,增加了代码的复杂度,容易出错(比如忘了回滚)。
- 高隔离级别下,锁占的时间越长,出现死锁的概率越高,需要配合缩短事务时间、统一拿锁顺序等方法一起用。
6.2 注意事项
- 事务必须在同一个DbContext里:EF Core的事务是和DbContext绑定的,不能跨DbContext用同一个事务,否则会报错。
- 事务里不能有耗时操作:比如调用第三方接口、读写文件,这些操作不要放在事务里,会导致锁占的时间太长,增加死锁的概率。
- 必须处理异常:事务里的任何操作出错,都必须回滚,否则会导致数据不一致。
- 不要滥用高隔离级别:比如普通的查询操作,用读已提交就够了,不要用串行化,否则会导致系统性能下降。
七、文章总结
EF Core里的事务隔离级别不是越严越好,也不是越高越好,核心是根据场景选择合适的级别,同时配合缩短事务时间、统一拿锁顺序等方法,避免死锁、长事务等问题。
很多开发者觉得EF Core的事务是黑盒,不敢主动控制,其实只要搞懂了隔离级别的本质,就能轻松应对各种数据库问题。记住三个核心原则:第一,事务时间尽量短,只做核心操作;第二,拿锁顺序尽量统一,避免互相等;第三,隔离级别尽量低,够用就行。只要做到这三点,大部分数据库问题都能解决。
评论
围绕“事务隔离级别在EF Core中的显式控制:处理长事务与死锁的实战排查手册”参与讨论