☰
ASP.NET Core Web API 整合 EF Core 的实战指南与性能优化
2026/9/28 6:01:51 网站建设 项目流程

很多人在做 ASP.NET Core Web API 的时候,最纠结的一件事就是数据访问层到底怎么选。原生 ADO.NET 太啰嗦,Dapper 虽然轻量但所有 SQL 都得自己维护,而 EF Core 又总被说“性能差”“不好驾驭”。这几个观点我都听过,也都在真实项目里验证过。这篇文章我就从实际开发的角度,把 ASP.NET Core API 项目里用 EntityFramework Core 这件事从头到尾捋一遍,包括为什么选它、项目结构怎么搭、DbContext 怎么设计、增删改查的规范姿势、性能怎么优化、常见的坑怎么排查。不管你是刚接触 ORM 的新手,还是准备把现有项目从别的数据访问方案迁移过来的老手,都应该能从里面找到能直接用的东西。

我把这套实践用在好几个生产环境的 API 服务上,最长的已经稳定跑了两年多,数据量在千万级别,接口响应和数据库压力都在可接受范围内。EF Core 不是洪水猛兽,真正出问题的往往是使用姿势不对。这篇文章里写的每一条注意事项,基本都是我踩过坑之后才记住的。

1. 为什么 ASP.NET Core API 项目里 EF Core 是默认首选

1.1 ORM 到底解决了什么

先别急着争论性能,先搞清楚 ORM 存在的意义。数据访问层最烦人的不是写 SQL,而是把数据库里的行记录变成 C# 对象、再把 C# 对象的状态变化同步回数据库。这一来一回的映射工作,占了数据访问层至少一半的代码量。EF Core 作为 ORM,核心就是帮你做这个映射,它把数据库表映射成实体类,把查询结果映射成对象,把对象的增删改映射成 SQL 语句。

我用生活化的例子说明一下。传统写法的思路是“我要数据库帮我查什么,我就写什么 SQL,然后自己逐列读取结果集”,这相当于你每次去食堂都要自己拿着碗到每个窗口打菜,过程可控但琐碎。EF Core 的思路是“我告诉你我想吃什么菜,食堂帮你配好套餐送过来”,你面对的是对象而不是 DataTable,代码会变得直观很多。

更重要的是,EF Core 的 LINQ 查询是在 C# 代码里写的,有强类型检查。你写错一个列名,编译器直接报错,而不是等到运行时数据库才告诉你列不存在。这一点在项目规模变大以后价值极大,重构实体结构的时候,编译器能把所有受影响的查询都揪出来,省了无数排查时间。

1.2 什么时候该用 EF Core,什么时候该换 Dapper

这个问题几乎每次技术讨论都会出现。我的判断标准很简单:看你的业务是“增删改查为主”还是“复杂查询报表为主”。

如果你的 API 业务大部分是标准 CRUD,实体关系明确,有大量联表操作和状态流转,那 EF Core 是效率最高的选择。它帮你处理了实体之间的关系映射、级联操作、并发控制、迁移版本管理,这些如果用 Dapper 全部要手写,工作量会非常恐怖。

如果你的项目充满报表类查询、大屏数据统计、多维度聚合分析,查询场景复杂且多变,那么 Dapper 这类轻量级方案确实更灵活。因为你可以直接写针对性的 SQL,不受 LINQ 表达能力的限制,也不用担心查询复杂度过高导致 EF Core 生成低效 SQL。

很多团队是两者混用的,EF Core 管业务主链路,Dapper 管统计报表。这个思路我也认同,但要注意别让 Dapper 的 SQL 散落在各个 Service 里,最好还是统一封装。我见过最乱的项目是每个 Controller 里都有一段 Connection 和 SQL,后续维护简直是灾难。

2. 项目搭建与 DbContext 设计

2.1 最小化项目结构

搭建一个 ASP.NET Core API 项目不用搞太复杂的架构,但数据访问层一定要和 API 层分开。很多教程喜欢上来就分四个项目:Api、Application、Domain、Infrastructure。对于小中型项目,这个结构有点过度设计。我个人的习惯是至少分两层:API 项目和数据访问项目。

MyApi.sln ├── src/ │ ├── MyApi.Api/ # Controller、Filter、中间件 │ └── MyApi.Data/ # DbContext、实体、迁移、仓储

数据访问项目引用 EF Core 相关包,API 项目引用数据访问项目。这样做的直接好处是:API 层不需要关心 EF Core 的配置细节,数据库迁移文件也不会污染 API 项目。以后如果要把数据访问层换成别的技术,API 层的改动面可以控制到最小。

如果你做的是产品级项目,可以考虑引入仓储模式和工作单元模式,但现在 EF Core 的 DbContext 本身已经实现了工作单元,很多情况下没必要再包一层。我建议不要机械套用 Repository,如果团队对 EF Core 足够熟悉,直接用 DbContext 更顺手。只有当你需要屏蔽 ORM 细节、方便做单元测试的时候,再加抽象层才值得。

2.2 实体与配置:从 Fluent API 到约定

实体类的设计要遵循几个基本原则。首先是表名和列名尽量和实体类及属性名保持一致,减少额外配置。其次是主键字段建议叫 Id 或者 实体名+Id,EF Core 默认约定能识别。再次是时间字段如果数据库用的是 SQL Server,建议用 datetime2 类型,避免精度问题。

public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string? Description { get; set; } public decimal Price { get; set; } public DateTime CreatedAt { get; set; } public bool IsDeleted { get; set; } }

实体写好后,我习惯在 DbContext 里使用 Fluent API 做显式配置,而不是大量依赖 Data Annotation 特性。Data Annotation 写在实体属性上,看起来很直观,但会把实体类和持久化细节耦合到一起。Fluent API 集中在 DbContext 的 OnModelCreating 里,所有表映射关系一目了然,后续要调整也方便。

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.ToTable("Products"); entity.HasKey(e => e.Id); entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); entity.Property(e => e.Price).HasPrecision(18, 2); entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETDATE()"); entity.HasQueryFilter(e => !e.IsDeleted); }); }

这里有个容易被忽略的点:全局查询过滤器。上面代码里的HasQueryFilter(e => !e.IsDeleted)是实现软删除的利器。一旦配置了这个过滤器,所有针对 Product 的查询都会自动带上WHERE IsDeleted = 0,不需要在每个查询里手动加条件。这个功能极大减少了漏过滤的问题。

2.3 连接字符串与环境配置

连接字符串不要硬编码在代码里,也不要提交到代码仓库。ASP.NET Core 自带的配置系统已经足够好用,appsettings.json 里存非敏感的本地连接串,生产环境用环境变量或者密钥管理服务注入。

"ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=MyApiDb;User Id=sa;Password=your_password;TrustServerCertificate=True;MultipleActiveResultSets=true" }

MultipleActiveResultSets=true这个参数我建议默认加上,特别是在同一个 DbContext 实例上有多条结果集同时读取的场景。然后TrustServerCertificate=True是开发环境用的,避免本地开发被证书校验卡住。生产环境还是要走正规的加密连接。

还有一个细节是数据库供应商的选择。你不用一开始就绑定 SQL Server,EF Core 支持 SQL Server、PostgreSQL、SQLite、InMemory 等。开发阶段可以用 SQLite 或 InMemory 快速跑通,上线前再切到正式数据库。但要注意不同数据库对 SQL 的行为有差异,比如分页语法、字段类型映射,如果测试和生产数据库不一致,有些坑只会在生产环境才暴露。我的建议是能保持一致就保持一致,实在不行再妥协。

3. API 层数据访问实操

3.1 依赖注入 DbContext

ASP.NET Core 内置依赖注入容器对 EF Core 的支持非常成熟,直接在 Program.cs 里注册即可。

builder.Services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); });

注册之后,Controller 的构造函数里就可以注入 AppDbContext 实例。这里最需要注意的一点是:DbContext 默认生命周期是 Scoped,也就是说同一个 HTTP 请求内,所有地方注入的都是同一个实例。这个设计很巧妙,因为一个请求通常对应一个业务单元,共享同一个 DbContext 意味着你可以在多个 Service 里操作同一组实体,最后统一 SaveChanges,保证事务一致性。

不要试图把 DbContext 注册成 Singleton。DbContext 不是线程安全的,多个请求并发使用同一个实例,轻则查询结果错乱,重则直接抛异常。也不要注册成 Transient,否则每个 Service 拿到的都是不同实例,你在这个 Service 里 new 出来的实体,到另一个 Service 里就变成了游离状态,SaveChanges 时根本不会保存。

正确姿势是:Controller 内只做参数校验和响应包装,业务逻辑给 Service 层,DbContext 通过构造函数注入 Service,一个请求走完一个服务链路,最后统一 SaveChanges。

public class ProductService { private readonly AppDbContext _db; public ProductService(AppDbContext db) { _db = db; } public async Task<ProductDto?> GetByIdAsync(int id) { var product = await _db.Products .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id); if (product == null) { return null; } return new ProductDto { Id = product.Id, Name = product.Name, Price = product.Price }; } }

3.2 异步查询与注意事项

ASP.NET Core API 的性能之一在于 IO 异步化。数据库查询是典型的 IO 操作,EF Core 提供了一整套异步方法:ToListAsync、FirstOrDefaultAsync、CountAsync、SaveChangesAsync等。在 Controller 和 Service 层尽量用这些异步方法,避免线程池线程在等待数据库响应时被占用。

使用异步方法时有个容易踩的坑:在有多个并行的异步查询时,要小心同一个 DbContext 实例不能同时执行多个并行操作。EF Core 的 DbContext 不是为并行设计,它在同一时刻只允许一个操作在执行。有时候你会在一个请求里写出这样的代码:

var productsTask = _db.Products.ToListAsync(); var categoriesTask = _db.Categories.ToListAsync(); await Task.WhenAll(productsTask, categoriesTask);

这种写法在某些情况下会抛异常,因为两个查询同时在同一个 DbContext 上执行。解决办法是拆成两个 DbContext 实例,或者串行执行。不过串行又会导致请求时间变长,实际上更好的方式是使用独立的 DbContext 工厂注册方式,这个后面性能部分会讲到。

异步方法还有一层意义是对取消令牌的支持。EF Core 的异步方法大多可以传入 CancellationToken,当客户端断开请求时,用令牌及时取消数据库操作,避免不必要的资源浪费。ASP.NET Core 会把 HttpContext.RequestAborted 自动传给你,只要你在调用异步方法时把它传下去即可。

3.3 增删改的标准姿势

新增操作最简单,Add 一个实体后 SaveChanges 即可。但要注意关联实体的处理。比如新增一个带多个子项的订单,你只需要把子项添加到订单的导航属性里,然后 Add 订单根实体,EF Core 会自动把整个对象图都标记为 Added,批量插入时会用一条 SQL 插入父表,再用多条 SQL 插入子表。

var order = new Order { OrderNo = "NO20250101", Items = new List<OrderItem> { new OrderItem { ProductId = 1, Quantity = 2 }, new OrderItem { ProductId = 3, Quantity = 1 } } }; _db.Orders.Add(order); await _db.SaveChangesAsync();

更新操作要区分两种情况。一种是实体已经从数据库查出来,你修改属性后直接 SaveChanges,EF Core 的变更跟踪器能检测到变化并生成 UPDATE 语句。另一种是你只拿到了 ID 和前端传的 DTO,想更新某个实体,这时候如果用 new 一个实体并 Attach 的方式,要特别注意哪些属性是已修改的。

var product = new Product { Id = dto.Id }; _db.Products.Attach(product); product.Name = dto.Name; product.Price = dto.Price; await _db.SaveChangesAsync();

这种写法只更新 Name 和 Price 两个字段,其他字段不会被覆盖。在很多更新场景下,这是比先查出来再更新更高效的方案,因为少了一次 SELECT。但有个前提是你清楚实体的完整字段语义,否则容易漏更新。我自己的习惯是,如果业务逻辑不复杂,直接查出来改再保存,代码更直观,性能差距对绝大多数接口来说可以忽略。

删除操作也不是只有 Remove。如果你的实体使用了软删除,正确姿势是设置 IsDeleted 为 true 然后 SaveChanges,配合前面说的全局查询过滤器,这样数据不会物理消失,后续还有恢复的余地。物理删除通常用于关联数据清理、用户主动清除等确定性的场景。如果实体有关联子表,删除时还要考虑外键约束和级联删除配置,建议在数据库层面就定义好级联策略,不要全靠 EF Core 的 DeleteBehavior。

3.4 分页、筛选、排序

Web API 返回列表数据,分页是刚需。EF Core 的 Skip/Take 是大家最熟悉的方式,但如果你直接写_db.Products.Skip(pageIndex * pageSize).Take(pageSize),量大了会出现深分页问题。数据库要扫描前面所有被跳过的行,才能返回目标页。数据量超过十万级后,越到后面越慢。

我的经验是,单表查询用 Skip/Take 加索引就够了,但如果是复杂查询,建议用基于游标的分页方式。游标分页的核心是“where 条件里带上上一页最后一条记录的唯一标识”,比如按 Id 排序时,下一页的查询条件是WHERE Id > lastId,这种分页不随页码增加而变慢。

在多条件筛选的场景里,动态组合查询条件也是 EF Core 的强项。你可以在查询后面按条件逐个添加 Where,不用拼 SQL 字符串。EF Core 最终生成的是参数化 SQL,天然防 SQL 注入,这一点比拼字符串的方案安全很多。

var query = _db.Products.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(keyword)) { query = query.Where(p => p.Name.Contains(keyword)); } if (minPrice.HasValue) { query = query.Where(p => p.Price >= minPrice.Value); } if (sortBy == "price") { query = orderByAscending ? query.OrderBy(p => p.Price) : query.OrderByDescending(p => p.Price); } var result = await query .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync();

排序时如果用户传入的字段名是字符串,你又想避免用 switch-case 写死,可以借助 System.Linq.Dynamic.Core 这个库,它支持query.OrderBy("Price desc")这样的动态表达式。这个库我用了很久,稳定性没问题,而且避免了反射或表达式拼接的复杂度。

4. 性能优化与常见坑

4.1 N+1 查询问题

N+1 查询是 EF Core 性能问题里最经典的一个。场景是这样的:你查了 N 条主表记录,然后代码里又逐条访问每条记录的子表导航属性,EF Core 就会为每条主记录额外执行一次子查询,总共变成 N 加 1 次查询。

我举一个真实案例。订单列表接口里,前端需要展示每个订单所属客户的名称。如果订单有 100 条,你是这样写的:

foreach (var order in orders) { var customerName = order.Customer?.Name; }

而 order 的 Customer 导航属性没有被加载,那么 EF Core 会执行 100 次SELECT * FROM Customers WHERE Id = ...。加上前一次的订单查询,一共 101 次数据库往返,接口响应直接变慢。

解决办法是利用 Include 预先加载。

var orders = await _db.Orders .Include(o => o.Customer) .ToListAsync();

这样生成的是 JOIN 查询,一次往返就把数据全部取回来。如果只需要关联表的某几个字段,并且不想把整行数据加载出来,可以改用投影查询:

var dtoList = await _db.Orders .Select(o => new OrderListItemDto { Id = o.Id, OrderNo = o.OrderNo, CustomerName = o.Customer != null ? o.Customer.Name : string.Empty }) .ToListAsync();

投影查询往往是最优方案,因为它不会加载多余的列,也不会触发导航属性的延迟加载问题。判断一个查询有没有 N+1 问题的简单方法,是在后台开启 SQL 日志,看一次请求的输出日志里出现了多少条查询语句。

4.2 跟踪与不跟踪

EF Core 默认对查询到的实体启用变更跟踪,这意味着实体会被放进 DbContext 的跟踪器里,保存状态快照,后续如果修改属性,SaveChanges 时能检测变化。这个功能很强大,但也有代价。跟踪器需要维护状态快照,实体会占用额外内存,而且查询和提交是同一个线路。

如果接口只是查数据返回给前端,不涉及后续修改,建议用AsNoTracking()明确告诉 EF Core 不需要跟踪。绝大多数 GET 接口都可以不跟踪,这样可以减少内存占用,查询速度也会有明显提升。

不过要注意:如果你在同一个请求里先查了一个实体,然后在后续业务逻辑里想要修改它,那就不能使用 AsNoTracking,否则修改不会被 SaveChanges 检测到。这种情况要么去掉 AsNoTracking,要么用 Update 方法手动标记状态。

EF Core 5.0 之后还提供了IgnoreQueryFilters()方法,可以用来在查询时临时忽略全局过滤器。比如需要把软删除的数据查出来做数据修复时,这个功能很实用。但要谨慎使用,因为一旦忽略了过滤器,所有数据都会暴露出来,必须放在有权限控制的代码路径里。

4.3 连接池、DbContext 生命周期与工厂模式

默认情况下,每创建一个 DbContext 实例,底层就可能要建一个数据库连接,虽然 ADO.NET 的连接池会复用物理连接,但创建 DbContext 本身还有模型缓存、连接获取等开销。在请求量大的情况下,每次请求都 new DbContext 也是不小的压力。

ASP.NET Core 的 AddDbContext 默认注册已经足够好,但如果你有一些后台任务、消息消费者、并行批处理等场景,建议使用AddDbContextFactory。它的注册方式是这样的:

builder.Services.AddDbContextFactory<AppDbContext>(options => { options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); });

业务代码里通过注入 IDbContextFactory 来创建短生命周期的 DbContext:

public class BatchService { private readonly IDbContextFactory<AppDbContext> _factory; public BatchService(IDbContextFactory<AppDbContext> factory) { _factory = factory; } public async Task ProcessAsync() { await using var db = await _factory.CreateDbContextAsync(); // 使用 db 执行批量操作 } }

工厂模式创建的 DbContext 完全由你控制生命周期,用完就释放,既可以用在后台任务里,也可以在一个请求里创建多个短上下文,避免并行查询冲突。在 API 项目里,如果某个请求里有多个互不相关的查询模块,用工厂创建独立上下文,比复用一个长上下文更安全。

还有一个容易忽略的点:连接字符串里有Pooling=true时连接池才有意义。对 SQL Server 来说,默认是开启的,只要连接字符串一致,连接可以复用。别轻易关闭连接池,否则高并发下连接建立的开销会直接拖垮性能。

4.4 迁移与数据库版本管理

EF Core 的迁移机制是它相比 Dapper 一个很大的优势。团队协作开发时,数据库结构的变更可以通过迁移文件记录,代码评审可以看到数据库的变化,部署时自动执行迁移即可,不用手工在测试库和生产库执行几十条 SQL。

迁移的基本操作我列在这里:

dotnet ef migrations add AddProductTable dotnet ef database update

如果项目使用 CI/CD,部署时可以用Database.Migrate()在应用启动时自动执行迁移。这个方法简单方便,但要注意并发问题。多个实例同时启动时,可能同时尝试执行迁移,导致数据库锁冲突。更好的做法是在部署流水线里单独执行迁移命令,发布过程结束后再启动应用实例。

迁移文件里的 Up 和 Down 方法可以手动微调,不一定要完全依赖生成的代码。比如你要给某个字段加索引、加默认值,可以在迁移文件里补充CreateIndex和AddDefaultValue方法。每次生成迁移后,建议先读一遍迁移代码,确认没有生成意外的操作。

我遇到过一个真实问题:某个迁移把一张大表的所有行都更新了一遍,生成了一个巨大的 UPDATE 语句,导致部署时数据库卡死。后来我把这种数据修改操作从迁移中移除,改成部署后的独立脚本,问题就解决了。迁移应该尽量只做结构变更,不要塞入大量数据清洗逻辑。如果真的有数据迁移需求,用独立的 Console 任务处理会更好控制。

5. 常见问题与排查技巧实录

5.1 错误速查表

我在多个项目里整理了一份 EF Core 在 ASP.NET Core API 中经常遇到的问题速查表,基本能覆盖 80% 的日常故障。

问题现象根本原因解决建议
查询返回数据为空,但数据库里有数据全局查询过滤器误伤,或实体配置的表名映射错误检查 OnModelCreating 中的过滤器,确认表和列映射
更新时提示无法跟踪实体同一个实体被多个 DbContext 实例跟踪,或使用 AsNoTracking 后再 Update统一使用同一个 DbContext,或正确使用 Attach
数据库超时查询未加索引、深分页、N+1 查询优化索引,改用游标分页,用 Include/投影解决 N+1
并发操作时提示并发冲突两个请求同时修改同一条记录,或配置了并发令牌设置 RowVersion 并发令牌,捕获 DbUpdateConcurrencyException
保存失败提示“数据库正忙”连接池耗尽或长事务持有锁检查连接池设置,缩短事务时间,避免事务内做耗时 IO
迁移生成错误多个 DbContext 存在时未指定目标上下文使用--context参数指定上下文

并发冲突这块特别值得单独说。EF Core 的并发控制常见方案是给实体增加一个RowVersion字段,类型是 byte[]。SQL Server 下每次更新流水号都会自动递增,EF Core 在生成 UPDATE 语句时会把 RowVersion 放到 WHERE 条件里。如果另一个请求已经改过这行,你的 UPDATE 影响行数为 0,EF Core 抛出 DbUpdateConcurrencyException。

处理这个异常的思路是:读取最新数据、决定是否覆盖,或者给用户提示刷新后重试。我在订单类业务里用的是“以最终写入为准”的策略,但在库存、扣费这类场景里必须严格处理,一旦并发冲突就返回 409,否则会超卖。

5.2 实操心得与避坑经验

很多刚接触 EF Core 的人会犯一个错误:急着把所有查询都写在一个 Controller 里,导致 Action 又长又乱,调试的时候根本分不清是 Controller 的问题还是查询的问题。我自己的习惯是,Controller 只做 Http 上下文相关的事情,比如模型绑定、参数校验、返回状态码。所有数据访问逻辑放 Service,这样测试也好写,代码也好读。

另一个建议是规范 API 返回结构。EF Core 返回的是实体对象,但你不应该直接把实体序列化给前端。实体里可能包含敏感字段、导航属性、内部的影子属性,直接暴露不仅泄漏数据,还容易导致循环引用序列化异常。更推荐的做法是定义 DTO,只输出前端需要的内容。这个习惯能少踩很多坑。

我曾经在一个项目里把所有实体都直接送到前端,结果前端渲染页面时想获取关联的客户名称,我把 Customer 导航属性也一并序列化了。后来接口数据量越来越大,序列化时间越来越长,前端却只用了两个字段。改成 DTO 投影后,接口响应体积降了接近一半,速度提升非常明显。

最后想说的是,EF Core 虽然叫 ORM,但你不应该完全不懂 SQL。遇到复杂查询时,先用 EF Core 的 ToQueryString 方法看看它生成的 SQL,再决定要不要优化。这个方法可以从 IQueryable 直接拿到将要执行的 SQL,非常方便。如果生成的 SQL 明显有问题,比如多了你没预期的子查询,宁可写成原生 SQL 查询,也别硬凑 LINQ。

我自己的原则是:简单到中等的查询用 LINQ,复杂到说不清楚的查询用原生 SQL 或视图。考虑到 EF Core 支持查询视图和映射到无键实体,很多复杂统计场景也能优雅解决。

如果你准备在一个新的 ASP.NET Core API 项目里使用 EF Core,我强烈建议把上面这些实践固化到团队的编码规范里。尤其是软删除、全局过滤器、AsNoTracking、DTO 映射、迁移管理这几点,它们是我在项目维护过程中省下最多时间的部分。我到现在还记得第一次在线上环境排查 N+1 查询的场景,日志一打开,几十条 SQL 刷屏,当时一下就明白了 EF Core 性能问题是怎么来的。后来的项目里,我都是先检查 SQL 日志,再谈优化方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询