一、你遇到过的“诡异”数据库问题,大概率和事务隔离有关

你有没有过这种情况:自己写的代码在测试环境跑着好好的,一上生产就出乱子?比如给用户发优惠券时,明明判断了库存够,结果最后库存扣成负数;或者同时处理两个订单时,程序突然卡死,过几十秒才报“死锁”错误,重启后又莫名其妙好了?

别慌,这些问题大概率不是你代码逻辑写错了,而是没管好数据库的“事务隔离”——说白了就是数据库里多个操作同时跑时,互相干扰的程度。很多人用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. 长事务操作:比如批量发优惠券、批量导入数据,操作时间超过1秒的,建议用可重复读级别,避免中途数据被修改。
  2. 高并发操作:比如秒杀、抢购,多个操作同时抢同一个商品的,建议用可重复读或者串行化级别,避免超卖。
  3. 涉及多个表的操作:比如转账、下单,同时要操作商品表、用户表、订单表的,要统一拿锁顺序,避免死锁。

六、技术优缺点与注意事项

6.1 主动控制隔离级别的优缺点

优点:

  • 能避免脏读、不可重复读、幻读等问题,保证数据一致性。
  • 能有效解决长事务、死锁等问题,提高系统稳定性。
  • 能根据不同场景调整隔离级别,平衡性能和安全性。

缺点:

  • 隔离级别越高,性能越低,需要开发者根据场景选择合适的级别,不能盲目用高隔离级别。
  • 手动控制事务需要开发者自己处理提交、回滚,增加了代码的复杂度,容易出错(比如忘了回滚)。
  • 高隔离级别下,锁占的时间越长,出现死锁的概率越高,需要配合缩短事务时间、统一拿锁顺序等方法一起用。

6.2 注意事项

  1. 事务必须在同一个DbContext里:EF Core的事务是和DbContext绑定的,不能跨DbContext用同一个事务,否则会报错。
  2. 事务里不能有耗时操作:比如调用第三方接口、读写文件,这些操作不要放在事务里,会导致锁占的时间太长,增加死锁的概率。
  3. 必须处理异常:事务里的任何操作出错,都必须回滚,否则会导致数据不一致。
  4. 不要滥用高隔离级别:比如普通的查询操作,用读已提交就够了,不要用串行化,否则会导致系统性能下降。

七、文章总结

EF Core里的事务隔离级别不是越严越好,也不是越高越好,核心是根据场景选择合适的级别,同时配合缩短事务时间、统一拿锁顺序等方法,避免死锁、长事务等问题。

很多开发者觉得EF Core的事务是黑盒,不敢主动控制,其实只要搞懂了隔离级别的本质,就能轻松应对各种数据库问题。记住三个核心原则:第一,事务时间尽量短,只做核心操作;第二,拿锁顺序尽量统一,避免互相等;第三,隔离级别尽量低,够用就行。只要做到这三点,大部分数据库问题都能解决。