ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASP.NET Core Web API 整合 EF Core 的实战指南与性能优化

ASP.NET Core Web API 整合 EF Core 的实战指南与性能优化 很多人在做 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 或者 实体名IdEF 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.EntityProduct(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: Serverlocalhost;DatabaseMyApiDb;User Idsa;Passwordyour_password;TrustServerCertificateTrue;MultipleActiveResultSetstrue }MultipleActiveResultSetstrue这个参数我建议默认加上特别是在同一个 DbContext 实例上有多条结果集同时读取的场景。然后TrustServerCertificateTrue是开发环境用的避免本地开发被证书校验卡住。生产环境还是要走正规的加密连接。还有一个细节是数据库供应商的选择。你不用一开始就绑定 SQL ServerEF Core 支持 SQL Server、PostgreSQL、SQLite、InMemory 等。开发阶段可以用 SQLite 或 InMemory 快速跑通上线前再切到正式数据库。但要注意不同数据库对 SQL 的行为有差异比如分页语法、字段类型映射如果测试和生产数据库不一致有些坑只会在生产环境才暴露。我的建议是能保持一致就保持一致实在不行再妥协。3. API 层数据访问实操3.1 依赖注入 DbContextASP.NET Core 内置依赖注入容器对 EF Core 的支持非常成熟直接在 Program.cs 里注册即可。builder.Services.AddDbContextAppDbContext(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 TaskProductDto? 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 ListOrderItem { new OrderItem { ProductId 1, Quantity 2 }, new OrderItem { ProductId 3, Quantity 1 } } }; _db.Orders.Add(order); await _db.SaveChangesAsync();更新操作要区分两种情况。一种是实体已经从数据库查出来你修改属性后直接 SaveChangesEF 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 N1 查询问题N1 查询是 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();投影查询往往是最优方案因为它不会加载多余的列也不会触发导航属性的延迟加载问题。判断一个查询有没有 N1 问题的简单方法是在后台开启 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.AddDbContextFactoryAppDbContext(options { options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection)); });业务代码里通过注入 IDbContextFactory 来创建短生命周期的 DbContextpublic class BatchService { private readonly IDbContextFactoryAppDbContext _factory; public BatchService(IDbContextFactoryAppDbContext factory) { _factory factory; } public async Task ProcessAsync() { await using var db await _factory.CreateDbContextAsync(); // 使用 db 执行批量操作 } }工厂模式创建的 DbContext 完全由你控制生命周期用完就释放既可以用在后台任务里也可以在一个请求里创建多个短上下文避免并行查询冲突。在 API 项目里如果某个请求里有多个互不相关的查询模块用工厂创建独立上下文比复用一个长上下文更安全。还有一个容易忽略的点连接字符串里有Poolingtrue时连接池才有意义。对 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数据库超时查询未加索引、深分页、N1 查询优化索引改用游标分页用 Include/投影解决 N1并发操作时提示并发冲突两个请求同时修改同一条记录或配置了并发令牌设置 RowVersion 并发令牌捕获 DbUpdateConcurrencyException保存失败提示“数据库正忙”连接池耗尽或长事务持有锁检查连接池设置缩短事务时间避免事务内做耗时 IO迁移生成错误多个 DbContext 存在时未指定目标上下文使用--context参数指定上下文并发冲突这块特别值得单独说。EF Core 的并发控制常见方案是给实体增加一个RowVersion字段类型是 byte[]。SQL Server 下每次更新流水号都会自动递增EF Core 在生成 UPDATE 语句时会把 RowVersion 放到 WHERE 条件里。如果另一个请求已经改过这行你的 UPDATE 影响行数为 0EF 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 映射、迁移管理这几点它们是我在项目维护过程中省下最多时间的部分。我到现在还记得第一次在线上环境排查 N1 查询的场景日志一打开几十条 SQL 刷屏当时一下就明白了 EF Core 性能问题是怎么来的。后来的项目里我都是先检查 SQL 日志再谈优化方案。
返回列表