写这篇东西的念头来自一个很实际的场景:我近两年接手的几个 .NET 后端项目,无一例外都把 ASP.NET Core API 和 EntityFramework Core 作为标准组合。新团队上手快,老团队也挑不出大毛病,但真正让我觉得值得写下来的,是在这几个项目里反复踩过的坑——比如导航属性被误用导致的查询膨胀、Repository 模式被过度设计、分布式环境下并发令牌失效、迁移脚本在多人协作时频繁冲突。这些问题是官方文档里不会系统讲的,但几乎每个实践者都会撞上。
如果你正准备在一个新的 API 项目里引入 EF Core,或者已经在用但总觉得查询慢、代码乱、莫名报错,这篇指南应该能帮你少走不少弯路。我会从项目结构设计、实体建模、DbContext 生命周期、事务并发、性能优化、问题排查这几个维度,把我实际验证过的做法完整拆开来讲。全文不会有那种“照着敲就能跑”的玩具代码,更多是工程化的取舍和背后逻辑。
1. 在 API 项目里上 EF Core,先想清楚这三件事
1.1 为什么偏偏是 EF Core,而不是 Dapper 或纯 SQL
很多人一谈 ORM 就陷入性能焦虑,总觉得手写 SQL 才高效。日常开发里,API 层的大部分操作其实是单表或多表的常规 CRUD,这类代码用 EF Core 写出来的可维护性远高于拼接 SQL。另一个被低估的因素是类型安全:实体类就是强类型的 C# 对象,字段改名、类型调整在编译期就能暴露问题,重构成本比字符串 SQL 低一个量级。
但 EF Core 也有明显不适用的场景:报表类查询、复杂的聚合分析、超大数据量批量迁移。这类场景我会把 Dapper 或原生 SQL 作为补充,而不是让 EF Core 硬扛。一个务实的原则是:API 的常规业务读写交给 EF Core,读多写少的报表路径用 Dapper 直查,两条腿走路。这个原则在下面几个项目里都被验证过,性能与开发效率的平衡最好。
1.2 先从项目结构和版本选型说起
先确认选型:目前稳定版本已经到 EF Core 9,但多数生产项目还在 8 LTS 上,我在新项目里也优先选 LTS。原因很简单——官方支持周期长,社区踩坑沉淀多,第三方库的兼容性也稳定。不建议在生产环境追新,除非你有明确需求,比如 JSON 列增强或新的查询翻译特性。
项目结构上,我的习惯是三层起步,但不盲目分层:
MySolution.Api // 控制器、中间件、启动配置 MySolution.Infrastructure // DbContext、实体配置、迁移 MySolution.Application // Service 层、DTO、业务逻辑 MySolution.Domain // 纯实体、领域接口Api 层只做模型绑定、校验和响应格式化,具体业务逻辑放到 Application 层。Infrastructure 层只暴露仓储或抽象接口,禁止其他项目直接引用 DbContext。Domain 层保持零依赖,不引用 EF Core,这样实体和数据库映射完全解耦。这套结构在中小型项目里也不会显得笨重,反而让依赖方向非常清晰。
2. DbContext 和实体映射的核心细节
2.1 DbContext 生命周期与依赖注入的坑
DbContext 在 ASP.NET Core 里的默认生命周期是 Scoped,这意味着同一个 HTTP 请求会复用同一个实例。你不需要手动管理连接释放,DI 容器会在请求结束时调用 Dispose。这个机制本身很优雅,但有两个容易踩的坑。
第一个坑是在构造函数里塞 DbContext 到单例服务。比如用 IMemoryCache 或后台任务时,如果 Service 注册成了 Singleton 而构造函数注入 DbContext,Scoped 实例会被吞成单例,造成跨请求共享同一个 DbContext,然后出现“connection already open”或“cannot be used after dispose”之类的诡异报错。如果后台任务确实需要查询,正确做法是注入 IServiceScopeFactory,在任务执行时显式创建 scope。
第二个坑是跨 Service 多次操作同一个实体但在不同方法里分别调用 SaveChangesAsync。在一个请求里如果你开了多个仓储或服务,它们引用的是同一个 DbContext 实例,实体状态会被共享。我在一个订单创建流程里就遇过:先新增订单,再新增明细,两个操作各调一次 SaveChangesAsync,结果中间抛异常时第一个已经入库了,产生脏数据。解决方式是保证一个业务场景只调用一次 SaveChangesAsync,或在事务包裹下统一提交。
2.2 实体映射的注解与 Fluent API 选择
EF Core 支持 Data Annotation 和 Fluent API 两种配置方式。我的原则是:主键、必填、最大长度这类简单约束用注解,方便快速阅读;涉及关系、索引、级联删除、精度配置这类复杂映射用 Fluent API,集中在单个配置类里,避免实体类被特性堆满。
看一个实际配置示例,比如一个常见的订单头和订单明细:
public class OrderEntity { public long Id { get; set; } public string OrderNo { get; set; } = ""; public long CustomerId { get; set; } public decimal TotalAmount { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } public byte[]? RowVersion { get; set; } public ICollection<OrderItemEntity> Items { get; set; } = new List<OrderItemEntity>(); } public class OrderItemEntity { public long Id { get; set; } public long OrderId { get; set; } public long ProductId { get; set; } public int Quantity { get; set; } public decimal Price { get; set; } public OrderEntity? Order { get; set; } }对应的配置文件:
public class OrderConfiguration : IEntityTypeConfiguration<OrderEntity> { public void Configure(EntityTypeBuilder<OrderEntity> builder) { builder.ToTable("orders"); builder.HasKey(x => x.Id); builder.Property(x => x.OrderNo).HasMaxLength(64).IsRequired(); builder.Property(x => x.TotalAmount).HasPrecision(18, 2); builder.Property(x => x.Status).HasDefaultValue(0); builder.HasIndex(x => x.OrderNo).IsUnique(); builder.HasIndex(x => x.CustomerId); // 这是一个容易忽略的关键点:级联删除 builder.HasMany(x => x.Items) .WithOne(x => x.Order) .HasForeignKey(x => x.OrderId) .OnDelete(DeleteBehavior.Cascade); } }几个值得说的细节:decimal 类型必须配 HasPrecision,否则迁移后生成的数据库列精度不对,容易出现“rounding”类错误;长字符串字段如果不标 MaxLength,SQL Server 会默认映射成 nvarchar(max),索引完全用不上,查询性能会很难看;HasIndex 放在 Fluent API 里而不是用注解,是为了让 DBA 审查数据库索引时有一个集中的地方可看。
2.3 导航属性的正确书写姿势
导航属性是 EF Core 方便的地方,也是查询性能最容易翻车的地方。我的建议是:一对一或一对多关系可以保留导航属性,但多对多和深层次链式导航尽量避免默认加载。
比如在 OrderEntity 里加上 Customer 导航属性,使用时本意是只想查订单状态,但懒加载(如果开启了)会把客户数据也拉出来。你根本不知道哪个查询会触发额外 SQL,问题排查时非常痛苦。我后来在项目里直接禁用懒加载,统一用 Include 显式控制,代码可读性和 SQL 可预期性都好了很多。
另一个常见坑是循环序列化。如果实体有 Order → Customer → Orders 这种双向导航,直接返回实体给 JSON 序列化会导致栈溢出或产生巨大的循环引用结构。我通常不直接返回实体对象,而是映射成 DTO,这既避免循环引用问题,也让 API 的响应结构更稳定。
2.4 迁移的生成与基线管理
Code First 模式下,迁移就是团队的数据库结构版本历史。我用dotnet ef命令比较多,开发期用dotnet ef migrations add,连续加字段就像 git 提交一样频繁。但这里有一个大坑:每次生成迁移前必须先确认本机数据库结构是最新的,否则 diff 会误判字段,生成带删除列的迁移,到 CI 环境一执行就把数据清了。
多人协作时,我推荐把每个迁移脚本都 review。EF Core 生成的迁移通常包含表结构变更和索引操作,偶尔还会带上一些意外的数据更改。我对迁移文件的策略是:一个迭代周期内尽量合并成一个迁移,只有经过测试的迁移才允许 merge 到主干,避免无意义的迁移链堆积。
另外生产环境千万不要用EnsureCreated()或Database.Migrate()自动更新数据库。我在一个客户现场就遇到权限不足导致启动失败的问题,因为 App Pool 账号没有建表权限。正确做法是用专门的迁移工具(比如 CI 里的dotnet ef database update步骤,或官方提供的 migrator 工具)在发布窗口执行。
3. Service 层的边界:不写仓储反而更清爽
3.1 Repository 模式的必要性讨论
市面上大量教程会把仓储层作为标准结构,但实际情况是,EF Core 本身已经是一个完备的“仓储 + 工作单元”实现。DbContext 对实体集的操作就是仓储,SaveChangesAsync 就是工作单元提交。为每个实体再包一层 IRepository ,往往会变成纯透传代码,反而增加了无意义的抽象层。
我不建议一刀切:如果项目里查询逻辑非常少、实体结构简单,直接用 DbContext 落到 Service 层最清晰;如果项目有清晰的数据访问需求(比如多数据源切换、需要依赖注入模拟、需要将查询逻辑从业务中隔离出来),一个轻量仓储是合理的。
看一个实用折中方案——只对“复杂查询”做封装,不对全表做泛型仓储:
public interface IOrderRepository { Task<OrderAggregate?> GetOrderWithItemsAsync(long orderId, CancellationToken ct); Task<PageResult<OrderListItem>> SearchAsync(OrderSearchQuery query, PageArgs page, CancellationToken ct); } public class OrderRepository : IOrderRepository { private readonly OrderDbContext _db; public OrderRepository(OrderDbContext db) { _db = db; } public async Task<OrderAggregate?> GetOrderWithItemsAsync(long orderId, CancellationToken ct) { return await _db.Orders .Include(x => x.Items) .AsSplitQuery() .FirstOrDefaultAsync(x => x.Id == orderId, ct); } }这样既保留了服务层调用侧的统一入口,又不会陷入“每个实体一套 CRUD 模板”的机械重复。Service 层拿到的永远是你主动暴露的查询方法,数据库结构变更时只需改仓储内部。
3.2 Service 层的典型切面
Service 层是真正写业务逻辑的地方,我习惯把事务控制放在这一层。EF Core 默认的 ChangeTracker 在同一个 DbContext 里会跟踪所有实体,所以一个 Service 方法中做多次增删改后统一 SaveChangesAsync 就是一个可靠的事务,根本不需要显式开启 SQL 事务。只有跨多个 DbContext(比如多库操作)或者是需要在业务中间强制执行某些操作时才需要显式事务。
显式事务的写法:
await using var transaction = await _db.Database.BeginTransactionAsync(ct); try { // 业务操作1 // 业务操作2 await _db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); } catch { await transaction.RollbackAsync(ct); throw; }这里有一个细节:BeginTransactionAsync 之后到 Commit 之间的所有数据库操作都在同一连接的事务里,但这个事务内如果调了其他数据库的 API(比如 HTTP 调用远程服务),务必不要把它也包进来。分布式事务在普通项目里就是陷阱,无法回滚外部调用,只会制造假安全。
3.3 DTO 与 AutoMapper 选型
DTO 映射这块,我的态度是:成员少时手写,成员多或嵌套深时用 AutoMapper。AutoMapper 看似省事,但它的“约定映射”有一个成本——调试时你无法直接看到映射过程,配置一复杂就容易出现“只映射了上半部分字段”的隐藏 bug。
我见过一个保险理赔项目,AutoMapper 配置有五个自定义 Profile,后来一个字段重命名导致线上订单数据返回 null,排查花了一整天。自那之后我严格控制 Profile 数量,并在映射使用点做单元测试,明确约定:每个 Profile 必须配一个冒烟测试,验证源类型和目标类型之间非同名字段映射正确。
如果你不想引入额外的映射库,更高性能的写法是手写静态扩展方法:
public static OrderDto ToDto(this OrderEntity entity) { return new OrderDto { Id = entity.Id, OrderNo = entity.OrderNo, Status = entity.Status, TotalAmount = entity.TotalAmount }; }这种方式零依赖、可读性满分、性能最好,就是字段多时写起来无聊。对于大部分 CRUD API,手写完全不会成为瓶颈。
4. 事务、并发与软删除的工程化处理
4.1 并发控制:为什么乐观锁比悲观锁更适合 API
API 场景天然是弱事务、高并发的,悲观锁(SELECT FOR UPDATE)在大量读多写少的场景里会造成不必要的连接阻塞。乐观锁是 EF Core 原生支持良好的方案,核心思路就是用 RowVersion 或并发 Token 检测冲突。
我最常用的是 SQL Server/SQLite 上的 RowVersion(rowversion/timestamp),配置方式:
builder.Property(x => x.RowVersion) .IsRowVersion() .IsConcurrencyToken();每次 update 操作 EF Core 会自动在 WHERE 条件里带上 RowVersion,如果执行期间有人先改过,影响行数为 0,EF Core 抛 DbUpdateConcurrencyException。处理这个异常的通用模式:
try { await _db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException ex) { if (ex.Entries.Any()) { throw new ConflictException("数据已被他人修改,请刷新后重试"); } throw; }前端拿到 409 状态码后重新拉取最新数据即可。
需要留意的是,某些数据库(如 MySQL 的 timestamp 字段)行为略有差异,但 EF Core 只是把并发冲突信息体现在异常里,处理模型是统一的。这个方案的优点是无需锁,缺点是并发高时冲突率上升,属于“写入失败后重试”的友好模型,与 API 的幂等设计理念契合。
4.2 软删除的全局过滤器方案
软删除是业务系统的标配需求,我不建议每个查询都手动写Where(x => !x.IsDeleted),那样迟早会漏,让已删除数据泄漏到明细列表里。EF Core 的全局查询过滤器能一劳永逸:
builder.Property(x => x.IsDeleted).HasDefaultValue(false); builder.HasQueryFilter(x => !x.IsDeleted);配置之后,所有查询自动过滤掉已删除记录。有三个注意点:
- 全局过滤会影响计数查询:统计时想包含已删除数据,需要用
IgnoreQueryFilters(),但这会破坏过滤规则,所以建议额外的“回收站查询”单独走一个隔离的查询方法。 - 外键关联的过滤:如果主表全局过滤了 IsDeleted,但关联表没有,联表查询时已删除子记录仍可能出现,需要给关联表也加上同样的过滤器。
- 索引优化:给 IsDeleted 字段加上过滤索引(
HasFilter),避免“一半数据被过滤”时扫描全表。
4.3 批量操作:别再循环调用 SaveChanges
日常开发中批量更新或删除是绕不开的。老写法是查出实体列表,逐个修改,再 SaveChangesAsync,但几千条数据时会放大性能问题,还会让事务时间过长。EF Core 7 之后的ExecuteUpdate/ExecuteDelete是真正的直连数据库批操作,不经过 ChangeTracker,也不加载实体,效率高一个量级:
await _db.Orders .Where(x => x.Status == 0 && x.CreatedAt < cutoff) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.Status, 10) .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);这个 API 生成的是单条 UPDATE 语句,完全绕开上下文跟踪状态,性能提升明显。注意它不会触发 EF 的拦截器或联动 SaveChanges 里的额外业务逻辑,所以在有审计字段或更新关联数据时,要手动把这些逻辑带上。
5. 性能优化:查询与分页的七个实操习惯
5.1 永远优先排查 N+1 查询
N+1 是 ORM 最常见的性能杀手。用一个包含订单明细的列表页做例子,最原始的写法是查出订单列表后再循环访问order.Items,此时每个订单明细都会触发一条独立 SQL——订单 100 条,明细 100 条,SQL 总数 101 条。
正确做法是用 Include 预加载:
var orders = await _db.Orders .Include(x => x.Items) .Where(x => x.Status == 1) .ToListAsync(ct);但 Include 也有一个副作用:它会生成一个大的 JOIN 查询,拉取的数据行数等于所有订单明细的总数,如果明细特别多(比如一个订单 500 条明细),行数膨胀很厉害。这就是为什么维护轻量列表页时,我更倾向于用投影的方式只取需要的字段:
var items = await _db.Orders .Where(x => x.Status == 1) .Select(x => new OrderListDto { Id = x.Id, OrderNo = x.OrderNo, ItemCount = x.Items.Count, TotalAmount = x.TotalAmount }) .ToListAsync(ct);这样生成的 SQL 要么是子查询,要么是针对具体列的精确 SELECT,不会把明细的所有字段都拖进来。实际测试里同样的列表页,投影方式的耗时大约只是 Include 方式的三分之一到四分之一。
5.2 AsNoTracking 是只读查询的好朋友
EF Core 默认会跟踪每个实体的状态,这会带来内存占用和变更检测开销。如果查询只是用来显示数据、不准备修改,应加上AsNoTracking()。写法:
var list = await _db.Orders .AsNoTracking() .Where(...) .ToListAsync(ct);有一个经验:在一个报表模块,原来 3 万条数据的查询要 1.8 秒,加上 AsNoTracking 后降到 400 毫秒左右。这个差异的原因是每行实体都要进 Identity Map 做快照,3 万条时对比成本很可观。不过要注意,AsNoTracking后的实体要被修改并保存时,需要额外调用Update或Attach重新跟踪,否则会报“entity is not tracked”的错。
5.3 分页的正确打开方式:Skip/Take 还是 Keyset
大部分团队的分页都用Skip(pageIndex * pageSize).Take(pageSize),这种偏移量分页在数据量小的时候没问题,但数据量达到几十万页后,Skip 在数据库层会扫描并丢弃前面所有行,翻页越深越慢。
如果要认真优化大表分页,用 keyset(游标)分页更靠谱:
var page = await _db.Orders .Where(x => x.Id < lastCursor) // 每页传入上一页最后一条的 Id .OrderByDescending(x => x.Id) .Take(pageSize) .ToListAsync(ct);这样数据库能直接通过索引定位到游标位置,翻页深度不受影响。缺点是需要前端配合传一个游标值,而不是页码,对 API 调用的友好度稍差,适合内部后台系统或包含复杂排序参数的数据接口。折中方案是:数据量确定超过 10 万行的列表页用游标,其他用 Skip/Take 即可,别过度设计。
5.4 数据库端的必要配合
ORM 生成的 SQL 也只能说“不差”,真正要跑得快,还是得靠索引。我一般会在迁移后做一次索引评审,重点检查:
- 所有外键列必须有非聚集索引;
- 所有
WHERE高频字段必须有等值索引; - 排序字段如果是
ORDER BY高频,最好跟 WHERE 条件的索引组合成联合索引; - 联合索引的顺序遵循“等值在前、排序在后”原则。
比如订单查询经常按Status + CreatedAt过滤排序,索引应该建在(Status, CreatedAt)而不是反过来。索引使用不当的典型场景是:先建了单列索引,再在查询中对时间字段做函数转换(比如日期格式化),索引直接失效。尽量避免在 EF 查询里把字段包在函数里。
5.5 编译查询:极端高频查询的加速器
EF Core 每次查询都需要把表达式树编译成 SQL,虽然 CPU 开销不大,但某个查询每秒执行上百次时,确实能感受到差别。高频且参数固定的查询可以用编译查询:
private static readonly Func<OrderDbContext, long, Task<OrderEntity?>> GetOrderById = EF.CompileAsyncQuery((OrderDbContext ctx, long id) => ctx.Orders.FirstOrDefault(x => x.Id == id));使用方式:var order = await GetOrderById(_db, id);。这个技巧适合“只通过主键查询”的高频热点,比如用户中心查用户、鉴权中间件查 session。复杂查询一样可以编译,但表达式必须在编译期确定,泛型组合也有限制,所以只对“高频、固定形状”的查询用。
5.6 连接池与 Command Timeout 调优
EF Core 底层走 ADO.NET,连接池由数据库驱动管理。很多“偶发超时”其实不是查询慢,而是连接池耗尽或等待连接的空闲超时。排查经验有两个:
- 确认所有 async 方法是否全程 async。如果有同步阻塞(比如
await之前插入了.Result),线程池会让连接释放延迟,高峰期很容易耗尽连接。 - 给重点接口单独配置 Command Timeout,但不要全局滥用。数据库默认超时一般是 30 秒,批量导入、报表重查询可以宽松些:
optionsBuilder.UseSqlServer(connStr, opt => opt.CommandTimeout(120));全局 Command Timeout 不建议改大,因为它会让慢查询失控,把拖垮数据库的隐患推迟暴露。
5.7 不要忽略查询计划缓存与参数化
EF Core 默认用参数化查询,这是一个非常好的设计,能让数据库复用执行计划。但一个常见反模式是:把所有条件拼接到一个长字符串里传给数据库(比如用EF.Functions.Like拼 SQL)或者给不同条件的查询动态构建完全不同的表达式树,导致 SQL 文本不断变化,数据库无法缓存执行计划。
如果业务中的组合查询特别多,我建议先理出一批高频组合,把组合参数固定下来;低频、长尾的组合查询数量太大时,老实按动态 LINQ 处理,但要在数据库端设置“强制参数化”或定期清理 plan cache。
6. 常见问题与排查技巧实录
6.1 “The specified cast is not valid” 异常
这类异常最常见的来源是数据库列类型与实体属性类型不匹配。比如 SQL Server 里把整数列定义成了decimal(18, 2),但实体属性是int,查询时 EF 翻译正常,读取时转换失败。排查步骤是先看末行 InnerException 里的 SQL 和执行计划,再对比实体定义与数据库表结构。
我习惯在每个主要实体配置后做一次细粒度校验:decimal配HasPrecision,DateTime统一约定 UTC 存储(存 UTC,显示时转换成当地时区),bool字段确保数据库层不是bit与int混用。这样能避免 90% 的映射转换异常。
6.2 多租户场景的查询过滤遗漏
我用 EF Core 做过一个多租户 SaaS 项目,最痛的是“跨租户数据泄漏”。当时靠每个查询手动加TenantId条件,后来加新功能时忘了加,直接导致严重事故。解决方案很简单,就是在实体配置里加全局过滤器:
builder.HasQueryFilter(x => x.TenantId == _currentTenantId);但要注意,_currentTenantId在 DbContext 构造时确定,因此每个请求必须显式传递租户上下文(通常是 middleware 解析 Token 后注入 DbContext 属性),否则过滤器会拿到空值。官方文档没怎么讲这个组合,但实际项目里这个方案非常好用。
6.3 迁移脚本在 CI 里执行顺序混乱
微服务或模块化单体里,多个模块都有自己的 DbContext,CI 自动迁移时可能因为依赖顺序混乱导致失败。我的策略是用一个独立的迁移项目聚合所有模块的迁移:
- 每个模块的 DbContext 放在自己的程序集里;
- CI 步骤统一从一个入口执行
database update; - 迁移脚本全部用 SQL 文件版本管理,按文件名前缀排序执行,避免依赖 EF 自导自演。
这个做法让我在多个服务共用一个数据库的场景里,避免了迁移相互覆盖的问题。如果你只有一个单体 API,遵守“每个迭代一次合并”的原则就够了。
6.4 遇到了“Cannot insert explicit value for identity column”
这个错误出现在对标识列显式赋值时。可能的原因有三种:
- DTO 绑定把客户端传入的 Id 写到了实体主键上;
- 手动设置了
Id = 0之外的值; - 多实体主键类型一致但在同一请求中多次 Add。
排查时重点看实体主键是否有ValueGeneratedOnAdd,以及模型绑定里是否把不可信客户端字段直接映射到了实体。我的做法是所有新增接口的入参 DTO 都不含 Id 字段,或者用[JsonIgnore]在写入模型上忽略 Id。
6.5 分页数据量统计与性能的平衡
传统分页数据量统计(COUNT(*))在大表上开销不小,尤其是配合过滤条件时。一个工程化折中方案:列表接口只在第一页请求时返回总数,后续页直接用“是否有下一页 + 游标”替代。前端一次性拿到总数,翻页不再重复统计,后端也少一次 COUNT 查询。
如果前端必须有总数,但表庞大,可以维护一个计数器表,增量更新,而不是每次 COUNT 大表。这个适合消费量级较大、数据只增不改的业务,比如订单数和流水数。
6.6 单元测试与数据库隔离测试
EF Core 项目的测试要区分“逻辑测试”和“集成测试”。逻辑测试应把仓储和 Service 的抽象接口 mock 掉,不碰数据库;集成测试才真连测试库跑迁移。如果用了 SQLite InMemory 或 EF InMemory 做测试,要注意它们与真实数据库的 SQL 翻译差异巨大,不适合验证包含Include、GroupBy、ExecuteUpdate的查询逻辑。
我比较推崇“Testcontainers”方案:用 Docker 起一个真实 SQL Server/PostgreSQL 容器,测试前执行迁移,测试后销毁。虽然速度比 InMemory 慢,但测试结果可信度高,能真正确认 SQL 逻辑没有在数据库端报错。
一些体会
EF Core 走到今天,已经不是那个“性能差、坑多”的 ORM。它的查询管道、全局过滤器、编译查询、批处理 API 已经相当成熟,真正拉开项目差距的往往不是框架本身,而是实践者的建模和边界设计能力。
我个人最大的体会是:给 Entity 和 DTO 之间保持清晰的边界,给查询路径设置固定形状,给变更检测显式声明,给过滤条件集中管理,这四件事做好,EF Core 项目的维护成本会降到极低。
最后分享一个小技巧:在开发环境开启EnableSensitiveDataLogging()能看到参数值,对调 SQL 非常有帮助,但生产环境一定关掉。如果你正被某个诡异的 EF Core 查询困扰,先打开日志看生成的 SQL,百分之六十的问题都能一眼定位。