首页 / 资讯中心 / 文章详情

EF Core全局查询筛选器与并发控制实战:从软删除到多租户隔离

EF Core全局查询筛选器与并发控制实战:从软删除到多租户隔离 ★ FEATURED ARTICLE
做 EF Core 这几年有两样东西我是后知后觉才真正吃透的一个是全局查询筛选器一个是并发控制。这两者在面试题里几乎必问在实际项目里也处处是坑。先说个我自己的真实翻车经历早期项目所有业务表都有IsDeleted软删除标记每个 Service 里都要手写Where(x !x.IsDeleted)写得多也就算了有一次新同事漏掉了一个查询条件软删除的数据直接出现在统计报表里。后来系统改成多租户架构又叠加了TenantId条件问题成倍放大。全局查询筛选器就是用来收口这一类“所有查询都该带上”的公共条件而并发控制则是解决“两个人同时改同一条数据”时谁说了算的问题。这篇文章围绕这两个主题展开讲清楚配置方法、背后机制、生产环境里容易踩的坑以及两者组合使用时一个被很多人忽略的陷阱。适合正在用 EF Core 做真实业务、想深入了解这两个特性的朋友也适合准备面试查漏补缺的同学。1. 起因软删除和租户隔离逼出来的功能1.1 手写 Where 的日子最早的项目还停留在 EF6 时代每张表都要求带软删除标记。查询代码逃不出这种模板var blogs db.Blogs .Where(b !b.IsDeleted) .OrderByDescending(b b.CreateTime) .ToList();当时觉得挺好逻辑直白。但项目滚动到几十张表之后问题来了每个仓储类、每个 Service 方法里都散落着同样的条件Code Review 里最常出现的评论就是“这里少了 IsDeleted 过滤”。那时候年轻觉得只要靠规范约束就行直到某天系统上线后客户在报表里看到了已删除的数据我大晚上爬起来修数据才意识到靠人盯人根本不可靠。后来项目升级成多租户 SaaS 架构噩梦升级。每张有租户概念的表都增加了TenantId查询变成这样var orders db.Orders .Where(o o.TenantId _currentTenant.TenantId) .Where(o !o.IsDeleted) .ToList();你没看错一个查询里要同时维护两个“业务强制条件”。最危险的是跨租户数据泄漏只要哪个接口漏写TenantId条件A 租户的数据就会出现在 B 租户的页面里。这种问题单靠测试很难抓全因为正常的接口大概率都写了条件漏掉的那一两个往往藏得很深。全局查询筛选器正是为这两种场景设计的核心价值就一句话把“业务层面的查询强制条件”提升到模型配置层让所有查询自动带上不用也不可能忘记。1.2 HasQueryFilter 出现后我改掉了所有查询EF Core 2.0 开始支持全局查询筛选器。思路很朴素在OnModelCreating里给实体配置一个表达式后续所有查询生成 SQL 时这个表达式会被自动合并进 WHERE 子句。比如最经典的软删除配置protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityBlog() .HasQueryFilter(b !b.IsDeleted); }配置完成之后原先Where(b !b.IsDeleted)这类代码全部可以删掉。只要是通过 DbSet 发起的查询生成的 SQL 都会自动带上SELECT ... FROM Blogs AS b WHERE b.IsDeleted FALSE我改造老项目那段时间大概删掉了上千行重复的 Where 条件。需要特别说明的是筛选器不只作用于顶层查询Include的导航属性、CountAsync、AnyAsync、SumAsync等聚合操作都会自动加上筛选条件。也就是说这不是只挡了一个入口而是把 EF Core 能感知到的所有查询入口都堵上了。不过要用好它得理解一个底层机制全局筛选器的本质是表达式树的注入。HasQueryFilter把你给的表达式保存在模型元数据里每次查询解析时EF Core 会把这个表达式和原始查询的表达式树合并最终翻译成 SQL。这就解释了为什么筛选器里可以用实体属性、可以用EF.Property甚至可以用构造函数传入的变量但所有内容最终都要能被“表达式树翻译器”理解。2. 全局查询筛选器的正确配置姿势2.1 基础模型软删除就是最简单的一课软删除是全局筛选器最常见的应用场景。实体里加一个IsDeleted布尔字段然后配置HasQueryFilter(b !b.IsDeleted)所有查询自动排除已删除数据。如果你想让代码更干净一点可以把这个字段隐藏成 shadow propertymodelBuilder.EntityBlog() .Propertybool(IsDeleted); modelBuilder.EntityBlog() .HasQueryFilter(b !EF.Propertybool(b, IsDeleted));shadow property 的意思是字段只存在 EF 模型中不映射到实体类的公开属性。业务代码根本看不到IsDeleted这个属性也就不会有人误用或误赋值。这套方案适合对实体纯净度有要求的团队实体类里只放真正的业务字段审计、软删除标记这类基础设施放底层。删除操作也不再需要Remove直接更新 shadow propertydb.Blogs .Where(b b.Id id) .ExecuteUpdate(s s .SetProperty(b EF.Propertybool(b, IsDeleted), true));这里的ExecuteUpdate是 EF Core 7 的新特性可以绕过“先查出来再改”的状态跟踪路径直接发 UPDATE 语句在大批量软删除时非常顺手。2.2 租户隔离构造函数注入当前租户全局筛选器支持引用上下文里的构造参数这就为多租户隔离提供了完美的出口。每个请求都会创建一个 DbContext而租户信息在创建时就已经确定public class AppDbContext : DbContext { private readonly int _tenantId; public AppDbContext(DbContextOptionsAppDbContext options, ITenantProvider tenantProvider) : base(options) { _tenantId tenantProvider.GetTenantId(); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder() .HasQueryFilter(o o.TenantId _tenantId); } }注意一个细节_tenantId在生成的 SQL 里是以参数形式出现的而不是直接内联成常量SELECT ... FROM Orders AS o WHERE o.TenantId __tenantId_0这样做有两个好处第一安全性上天然防 SQL 注入第二不同租户执行同样的查询时SQL 文本一样参数不同数据库可以复用查询计划不会因为每个租户一个查询计划而把计划缓存池打爆。如果做的是 SaaS 产品一定会有“超级管理员跨租户查询”的需求。这种场景不要试图在筛选器里做复杂的“当前用户是不是管理员”判断而是保持筛选器只做租户隔离管理员需要跨租户时显式用IgnoreQueryFilters绕过。2.3 多条件叠加与继承关系下的用法全局筛选器的条件是可以叠加的但注意一个规则每个实体类型只能配置一次HasQueryFilter如果配置多次后配置的会覆盖先配置的而不是合并。所以多条件要写在一个表达式里modelBuilder.EntityBlog() .HasQueryFilter(b !b.IsDeleted b.Status BlogStatus.Published);如果你用过 TPH 继承映射全局筛选器可以在基类上配置子类查询会自动继承。这一点做内容系统时很有用基类Content统一做软删除筛选Article、Video这些子类查出来天然就是未删除状态。但反过来要注意一个坑如果筛选器表达式里出现类型转换比如b is Article生成的 SQL 会带上IsDeleted判定和类型判定可能让你的查询结果和你预想的 LINQ 语义不一致尤其是搭配OfTypeT使用时要多留个心眼。3. 筛选器不是加了就完事这六个坑我全踩过3.1 Include 的导航属性也会被过滤全局查询筛选器会自动应用到Include的导航属性查询。这个设计大部分时候是好事但也会带来意想不到的“丢数据”现象。假设Blog跟Post是父子关系Post也配置了软删除筛选器。执行var blogs db.Blogs .Include(b b.Posts) .ToList();如果某个博客下的文章全部被软删了这个博客的Posts导航属性就是空集合。业务方可能会问“为什么这个博客的文章列表是空的数据库里明明有文章记录。”这其实就是筛选器在起作用。遇到这种需求要分清场景如果是面向 C 端用户展示筛选掉已删文章是对的如果是内部管理后台希望看到全部文章就必须明确使用IgnoreQueryFilters。3.2 IgnoreQueryFilters 必须收口到特权入口IgnoreQueryFilters会移除当前查询中所有实体的筛选器包括导航属性上的。名字写得很清楚就是让你忽略筛选器。但它也是一把双刃剑一旦调用开关租户隔离条件也被移除跨租户数据就裸露了。我的项目里定了一条铁律只有固定的管理员服务层可以调用IgnoreQueryFilters普通业务方法一律禁止。而且管理员接口也不直接暴露这个 API而是封装在专门的数据访问方法里比如public ListBlog GetAllBlogsForAdmin() { return _db.Blogs.IgnoreQueryFilters().ToList(); }这样至少能保证调用栈是可审计的不会在业务代码里随手一个IgnoreQueryFilters把租户边界弄穿。3.3 导航属性筛选器可能让 LEFT JOIN 静默变 INNER JOIN这是我在生产环境里排查得最久的一个问题。筛选器表达式如果引用了导航属性的状态情况会变得复杂。比如modelBuilder.EntityBlog() .HasQueryFilter(b b.Owner.IsActive);这个筛选器要判断 Blog 的 Owner 是否有效EF Core 翻译 SQL 时就得 JOIN 到 Owner 表。如果你的业务查询本来用的是 LEFT JOIN允许 Blog 没有 Owner但筛选器里的 JOIN 是“强制的”结果就是没有 Owner 的 Blog 行被直接过滤掉LEFT JOIN 变成了事实上的 INNER JOIN。这个问题的麻烦之处在于错误不在眼前业务代码里看着是正常的 LEFT JOIN 语义结果数据却少了。我的建议是尽量避免在筛选器里直接引用可空导航属性的状态。如果业务上确实需要“仅返回有有效所有者的博客”那就改成在业务查询里显式写 JOIN 条件别藏在筛选器里。3.4 筛选字段不加索引慢查询迟早找上门筛选器是逻辑层的神器但别忘了它给你的每条 SQL 都加了 WHERE 条件。如果表很大而筛选字段没有索引全表扫描是逃不掉的。我一般建议两个级别的优化。基本级给筛选列加普通索引modelBuilder.EntityBlog() .HasIndex(b b.IsDeleted);进阶一点如果是 SQL Server可以用筛选索引modelBuilder.EntityBlog() .HasIndex(b b.IsDeleted) .HasFilter([IsDeleted] 0);HasFilter里的是原生 SQL 片段这个索引只包含未删除的行索引体积小很多写性能和查询性能都有改善。多租户场景更要关注复合索引的顺序一般按(TenantId, IsDeleted)建而不是分别建两个单列索引。索引顺序按查询条件的过滤粒度排列能最高效地缩小扫描范围。3.5 FromSqlRaw 不会自动应用筛选器有坑专门留在这里提醒大家FromSqlRaw和FromSqlInterpolated查询不会自动带上全局筛选器。原因不复杂筛选器是 EF Core 在表达式树翻译阶段注入到查询模型里的而FromSqlRaw直接绕过表达式树把一段原生 SQL 塞给数据库。如果你在仓储层混合使用 LINQ 和原生 SQL原生 SQL 部分就要自己手动补上软删除、租户条件var blogs db.Blogs .FromSqlRaw(SELECT * FROM Blogs WHERE IsDeleted 0) .ToList();这个坑的隐蔽性在于项目初期往往没问题等有人写了第一条FromSqlRaw后后续维护者看到就跟着用了筛选器在这些查询里完全失效。最好在团队规范里约定能用 LINQ 就 LINQ原生 SQL 只留给复杂分页或特殊性能需求而且必须标准化地补充筛选条件。3.6 软删除与级联删除的相互作用最后说个软删除加级联删除的坑。如果父表实体配置了软删除子表实体也做了软删除数据库外键又设置了ON DELETE CASCADE容易产生误解——以为软删父记录子记录也会被“级联软删”。事实是应用层软删除只是执行UPDATE ... SET IsDeleted true数据库不会感知到它是一个“删除”所以ON DELETE CASCADE完全不生效。子记录依然完好地留在表里只是在后续业务查询中因为父记录被筛选掉而变得“看不见”了。更危险的是如果某个孩子表没有被软删除筛选器它的数据仍然会被查询到但父对象是 null就可能触发空引用异常。我的处理方式是凡是软删除实体有子表一律在业务代码里显式批量软删子记录不依赖数据库级联。4. 并发控制与其事后补偿不如先搞懂为什么会冲突4.1 两种并发模型和适用场景并发控制在 EF Core 里要解决的真实问题很具体两个用户同时读同一行数据各自修改了字段后都想保存数据库里最终应该保留谁的先说两种模型。悲观并发读取数据时直接加锁直到事务结束才释放。在这种模式下第二个用户想读同一行就得等。适合写多读少、冲突率极高的场景库存扣减、座位预订这类。缺点是锁持有时间长数据库连接和事务时间都会拉长扩展性一般。乐观并发读取时不加锁只在保存时检查数据有没有被改过。EF Core 默认就是这种模型它没有锁的成本却能在 UPDATE 时通过“影响行数是否为零”检测出冲突。大多数 Web 应用都该用乐观并发因为读写比例高真正同时写同一条记录的概率并没有那么高用乐观模型可以把性能压在最低。如果真遇到库存这类高冲突场景我建议也不要直接给整个系统上悲观锁。可以在对应的单个事务里显式使用SELECT ... FOR UPDATEawait using var transaction await db.Database.BeginTransactionAsync(); var stock await db.Products .FromSqlRaw(SELECT * FROM Products WITH (UPDLOCK) WHERE Id {0}, productId) .SingleAsync();这种锁粒度只针对单行影响面小比全表锁的悲观方案靠谱得多。4.2 EF Core 乐观并发的底层机制影响行数检测理解乐观并发核心是搞清楚并发令牌是怎么工作的。EF Core 生成 UPDATE 语句时不仅用主键定位记录还会把你配置的并发令牌的“原值”放进 WHERE 子句。假设RowVersion是并发令牌EF Core 生成的语句大致是UPDATE Blogs SET Title p1, Content p2 WHERE Id p0 AND RowVersion p3;你读取数据时拿到的是RowVersion 版本A另一个用户抢先更新后数据库的RowVersion已经变成版本B。你提交 UPDATE 时WHERE 条件里要求RowVersion 版本A数据库匹配不到任何行于是影响行数返回 0。EF Core 检测到保存操作影响行数为 0就抛DbUpdateConcurrencyException。注意关键点并发令牌不是给数据库加了个什么“锁”而是靠“预期影响行数”和“实际影响行数”不一致来完成判断的。5. 配置并发令牌的两种方式和应对冲突的完整流程5.1 RowVersion vs ConcurrencyToken配置并发令牌实际项目里常见三种玩法。第一种SQL Server 的 rowversion 类型最省心。实体里定义一个byte[]属性public class Blog { public int Id { get; set; } public string Title { get; set; } ; public byte[] RowVersion { get; set; } []; }配置用IsRowVersionmodelBuilder.EntityBlog() .Property(b b.RowVersion) .IsRowVersion();IsRowVersion是一个复合配置它同时设置了IsConcurrencyToken()、数据库自动生成策略以及每次 UPDATE 后自动刷新值。只要数据库更新了这一行RowVersion 就会自动变化完全不用业务代码操心。第二种普通字段做并发令牌比如UpdatedAtmodelBuilder.EntityBlog() .Property(b b.UpdatedAt) .IsConcurrencyToken();这种方案适合数据库没有 rowversion 类型的场景或者已经有UpdatedAt字段不想再改表结构。缺点是UpdatedAt要你自己更新而且如果两个用户在同一个毫秒内写同名时间戳冲突可能检测不到。第三种把多个业务字段同时配成并发令牌modelBuilder.EntityBlog() .Property(b b.Content) .IsConcurrencyToken();这适合旧表改造没有现成版本号列。缺点是只能校验“配置了并发令牌的字段”其他字段被改了不会触发并发检测。5.2 面对 DbUpdateConcurrencyException 的标准动作冲突发生时ex.Entries里有触发冲突的实体条目每个csharpEntityEntry里有三组值OriginalValues是读取时记录的原值CurrentValues是当前实体上的待保存新值GetDatabaseValues()能查到数据库此刻的真实值。我处理冲突的固定套路是先把有没有被删除判断出来再做策略分支。try { await db.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues await entry.GetDatabaseValuesAsync(); if (databaseValues null) { entry.State EntityState.Detached; // 业务提示该数据已被其他人删除 continue; } // 进入冲突解决策略 entry.OriginalValues.SetValues(databaseValues); } if (ex.Entries.Count 0) { await db.SaveChangesAsync(); } }GetDatabaseValuesAsync()返回null代表数据库里主键已经不存在了说明记录被删。很多新手会把detached状态漏掉导致后续SaveChanges继续把不存在的实体当作正常实体提交。5.3 三种冲突解决策略策略一以本次客户端修改为准。逻辑是把原值刷新为数据库当前值当前值保持不变然后重试保存。foreach (var entry in ex.Entries) { entry.OriginalValues.SetValues(await entry.GetDatabaseValuesAsync()); } await db.SaveChangesAsync();重试时 WHERE 条件里的并发令牌用的是刚拿到的数据库值所以 UPDATE 肯定能成功。适合编辑页面“我的修改要覆盖别人”的场景。策略二放弃客户端修改完全以数据库值为准。foreach (var entry in ex.Entries) { entry.CurrentValues.SetValues(await entry.GetDatabaseValuesAsync()); }执行完后实体当前值已经和数据库一致不需要再SaveChanges。适合“保存时提示让用户刷新页面重新编辑”的场景。策略三自定义合并。比如“订单数量以本次改的为准但订单状态以数据库为准”。做法就是逐个属性判断用哪个值作为新的原值foreach (var entry in ex.Entries) { var dbValues await entry.GetDatabaseValuesAsync(); if (dbValues null) continue; foreach (var property in entry.Metadata.GetProperties()) { var currentValue entry.CurrentValues[property]; var dbValue dbValues[property]; if (ShouldUseCurrent(property.Name)) { entry.OriginalValues[property] dbValue; } else { entry.OriginalValues[property] currentValue; } } }ShouldUseCurrent是定义在业务层的一个判断函数。实际项目里一定要控制复杂度大多数情况下只需要对三五个业务字段做特殊合并剩下的字段统一以某一方为准。6. 当筛选器遇上并发一个多租户系统的实战复盘6.1 这个组合为什么会出问题全局查询筛选器和并发控制放在一起讲不只是因为它们在 EF Core 7 系列里都是重点更因为它们在真实业务里经常同时出现而且组合起来有一个非常隐蔽的坑。我的多租户项目里所有业务表都配置了TenantId全局筛选器订单表又加了RowVersion并发令牌。测试反馈一个诡异现象A 租户提交订单修改B 租户页面反而提示保存冲突。排查链路是这样的先看 A 租户的 UPDATE 语句发现 WHERE 子句只有Id 1 AND RowVersion 旧值压根没有TenantId条件。全局筛选器只作用在查询阶段它不会自动加入 UPDATE 的 WHERE。也就是说如果业务代码因为某种原因拿到了 B 租户的同主键订单并发令牌根本拦不住跨租户写入租户隔离被架空了。再深挖一层为什么业务代码会拿到 B 租户的数据因为某个查询里用了IgnoreQueryFilters且忘了补租户条件。两个问题叠加就出现了数据边界失守。正确做法是敏感数据查询必须显式带租户条件不能单纯依赖全局筛选器兜底保存路径里如果需要做并发校验也要把租户字段纳入 WHERE 条件。6.2 完整代码租户订单编辑流程的推荐写法下面是一个简化过的、我验证过的租户订单编辑流程public class OrderService { private readonly AppDbContext _db; private readonly ITenantProvider _tenant; public OrderService(AppDbContext db, ITenantProvider tenant) { _db db; _tenant tenant; } public async Taskbool UpdateOrderTitle(int orderId, string newTitle) { var order await _db.Orders .FirstOrDefaultAsync(o o.Id orderId o.TenantId _tenant.TenantId); if (order null) return false; order.Title newTitle; try { await _db.SaveChangesAsync(); return true; } catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var dbValues await entry.GetDatabaseValuesAsync(); if (dbValues null) return false; var dbTenantId dbValues.GetValueint(TenantId); if (dbTenantId ! _tenant.TenantId) { // 理论上不应出现数据已经跨界 return false; } entry.OriginalValues.SetValues(dbValues); } await _db.SaveChangesAsync(); return true; } } }有两个细节值得强调。第一查询显式写了o.TenantId _tenant.TenantId虽然全局筛选器也会加这个条件但显式写出来的好处是这段代码的语义读者一眼就能看懂不依赖“背后有筛选器”这个隐性约定。第二并发冲突处理里再查一次TenantId并和当前租户比对这是一道额外的防御防止上下文复用或者租户切换引发问题。6.3 软删除与并发令牌组合的另一个隐蔽陷阱最后必须说一下软删除和并发令牌组合时的坑这个坑我在项目里踩过并且已经确认了机制。假设Blog同时配置了HasQueryFilter(b !b.IsDeleted)RowVersion并发令牌用户甲正在编辑某篇博客用户乙这时把这篇文章软删除了。注意软删除执行的是 UPDATEIsDeleted true不是 DELETE。接着甲点了“保存”会发生什么按直觉想文章已经被删了甲应该保存失败才对。但实际上EF Core 给甲生成的 UPDATE 语句是UPDATE Blogs SET Title p1 WHERE Id p0 AND RowVersion p2;这条 UPDATE 不关心IsDeleted字段因为全局筛选器不影响 UPDATE 语句。只要甲的 RowVersion 还是数据库里当前的值——乙软删时如果 RowVersion 没变甲手里的旧版本号也和数据库一致——更新就会成功被软删的文章就这样被甲的这次修改“复活”了。我排查这个问题时最开始以为是并发令牌没起作用后来才发现是“筛选器只管查询、不管更新”这个机制导致的。解决办法是在保存前主动查一次实体的状态var exists await _db.Blogs .IgnoreQueryFilters() .AnyAsync(b b.Id blogId !b.IsDeleted); if (!exists) { return false; // 已被删除 }如果坚持用筛选器语义来理解这个世界这条坑确实不容易意识到。筛选器解决的是“查的时候带条件”它管不了“写的时候校验状态”。业务对删除状态有严格要求时检查逻辑必须显式做。项目做到后期我对这两个特性的体会是全局筛选器是“把公共查询条件收口”并发控制是“把写入冲突显性化”两者都很好用但都必须清楚它们的边界。筛选器管查询并发令牌管写入校验组合使用时需要自己补上交叉防御。最后再分享一个小技巧维护一个小的测试项目专门覆盖“筛选器 并发令牌 软删除 多租户”四种组合场景这类问题最容易在组合中暴露单测能救你很多次。
阅读完成 · 觉得有帮助?
咨询建站