基于EFCore与MySQL的ASP.NET Web API项目分层设计与实战指南 简介这份资源是基于 EF Core 与 MySQL 的 ASP.NET Web API 项目源码适合正在学习 .NET Core 后端开发、希望掌握 RESTful API 与 ORM 整合实践的初中级开发者。项目以清晰结构展示了从项目搭建、数据库上下文配置到控制器暴露 HTTP 接口的完整链路可作为独立练习或业务项目的前期脚手架。压缩包共 22 个文件大小约 213KB其中包含 9 个 C# 源文件涉及入口配置、模型定义、数据访问与控制器逻辑4 个 JSON 文件用于环境和应用配置还包括解决方案文件、项目文件、版本控制与开源协议文件等便于直接打开和二次修改。已有 365 人学习下载。通过学习该源码可以直观理解 EF Core 迁移机制、MySQL 接入方式以及 Web API 分层思路尤其适合想快速上手基于 .NET 6/7 的 API 服务开发、又希望少走弯路的读者。1. 基于EFCore和Mysql的ASP.NET.Web.API项目设计源码先看这四层怎么拆.NET 后端团队接新项目时leader 常常甩过来一个压缩包说明文档里只有一句话基于EFCore和Mysql的ASP.NET.Web.API项目设计源码。这句话看着像标题其实已经把技术栈钉死了EFCore 做对象关系映射MySQL 做数据落地ASP.NET Web API 暴露 HTTP 接口。你要做的不是猜它有多复杂而是尽快跑起来、理清分层、把第一个业务接口写进去并部署上线。这篇文章按拿到源码后的真实流程推进先立住数据访问层再谈仓储和业务层然后到 API 层的鉴权、统一响应和 Swagger最后一章讲交付前的性能检查。适合刚接触 EFCore 的 .NET 后端开发也适合拿这套模板接项目的同学。核心原则只有一句话源码是起点能在线上稳定跑出查询和事务才算真的接手。2. EFCore MySQL 数据访问层DbContext 注册、实体映射与迁移命令2.1 为什么是 EFCore MySQL三种 ORM 方案的取舍拿到一套标注基于EFCore和Mysql的源码先别急着打开 Program.cs先回答一个更基础的问题这套组合到底比 Dapper 和 SqlSugar 好在哪你只有理解了选型逻辑后面改代码时才不会动不动想把整个数据访问层换掉。方案映射方式上手成本适合场景ADO.NET手写 SQL高对性能极致要求的报表或批量任务Dapper半自动SQL 实体映射低查询简单、表关系少的接口SqlSugar自动功能全低.NET Framework 老项目或团队已有习惯EFCore全自动LINQ 导航属性中需要迁移、需要多表关联、长期迭代EFCore 在这套组合里的定位是完整 ORM你定义实体类它负责生成建表脚本和增删改查 SQL配合 MySQL 的 InnoDB 和事务绝大多数业务场景可以完全不用手写 SQL。MySQL 能做存储层核心就两个字省心。部署一个 MySQL 8.0 比部署 SQL Server 省掉一半沟通成本云厂商的托管 RDS 也便宜中小团队和外包项目都很吃这一套。选择 EFCore 也意味着招人压力小能写 LINQ 的候选人比能写存储过程的更好找。这个组合当然有代价。EFCore 的延迟执行和导航属性会让新手误以为查询不要钱结果线上 N1 问题成片出现。这也是为什么后面专门用一整章讲排查——大多数EFCore 很慢的结论最后都发现是用法问题不是框架问题。2.2 解决方案分成几层Api / Application / Domain / Infrastructure拿到源码后先看解决方案结构确认是不是四层。一个标准的 EFCore MySQL Web API 项目依赖方向单向流动不应出现循环引用MyApp.sln ├─ src │ ├─ MyApp.Api // Controllers、Filter、Middleware、Program.cs │ ├─ MyApp.Application // Application Service、DTO、校验逻辑 │ ├─ MyApp.Domain // 实体类、仓储接口、领域异常 │ └─ MyApp.Infrastructure // DbContext、仓储实现、Migrations、配置映射 └─ tests └─ MyApp.Api.TestsApi 是入口只引用 ApplicationApplication 编排业务只引用 DomainInfrastructure 实现 Domain 里定义的接口和数据访问反过来引用 Domain。四个项目之间多余的引用都值得警惕。常见的翻车姿势是把 DbContext 直接放到 Domain 里或者让 Infrastructure 反向引用 Api这两者都会让后续迁移和测试变得很难收拾。这里有一个很简单的判断标准Domain 项目里不应该出现 MySQL、SqlConnection、DbContext 的字样。如果出现了说明仓储边界没有守住。动这种源码之前先把引用关系理顺后面改起来才不会牵一发动全身。2.3 DbContext 注册与连接串五个关键参数决定能不能连上先看 Program.cs 里 DbContext 的注册。很多源码跑不起来问题不在代码而在连接串参数组合不对。在 Infrastructure 项目里安装社区主流的 Pomelo.EntityFrameworkCore.MySql 包然后用下面的方式注册// Program.cs - 注册 EFCore 数据上下文 builder.Services.AddDbContextAppDbContext(options { var connStr builder.Configuration.GetConnectionString(Default); // AutoDetect 会主动连一次数据库探测版本 // 如果目标库版本固定直接写 ServerVersion.Parse(8.0.32-mysql) 更省事 options.UseMySql(connStr, ServerVersion.AutoDetect(connStr), mysql { // 云数据库闪断或连接池重建时自动重试 3 次间隔最长 10 秒 mysql.EnableRetryOnFailure( maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null); }); });对应的 appsettings.json 连接串{ ConnectionStrings: { Default: Server127.0.0.1;Port3306;Databasemyapp_db;Userroot;Passwordyourpass;Charsetutf8mb4;SslModenone;AllowPublicKeyRetrievaltrue;MaxPoolSize128;ConnectionTimeout30; } }逐个说参数。ServerVersion.AutoDetect是双刃剑它在启动时连一次数据库探测真实版本省去手写版本号的麻烦但如果数据库还没建好或权限不够会在最不该出错的时刻抛异常。我的建议是生产连接串直接写ServerVersion.Parse(8.0.32-mysql)把探测从启动流程里去掉。Charsetutf8mb4不写中文能正常但遇到 emoji 或生僻字直接变问号。SslModenone只能放在开发和内网环境公网数据库务必去掉这一条并配置证书。AllowPublicKeyRetrievaltrue与 MySQL 8.0 的 caching_sha2_password 认证强相关不加它连 Add-Migration 都过不去。MaxPoolSize默认 100多实例共用同一个 RDS 时按连接数估算后再调。改完连接串还是连不上先用 MySQL 客户端单独连一次确认账号、IP 白名单和端口都通再让dotnet ef去跑迁移。这个排查顺序能省半小时弯路。2.4 实体类映射与迁移从 Order 表看列名和索引规范数据访问层的另一半是实体和映射。以订单表为例看一个稳妥的实体设计// Domain/Entities/Order.cs public class Order { public long Id { get; set; } public string OrderNo { get; set; } null!; public decimal Amount { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } public long UserId { get; set; } public User? User { get; set; } }// Infrastructure/Data/AppDbContext.cs protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder(entity { entity.ToTable(orders); entity.Property(e e.OrderNo) .HasColumnName(order_no) .HasMaxLength(32); entity.Property(e e.Amount) .HasColumnName(amount) .HasColumnType(decimal(12,2)); entity.Property(e e.Status) .HasColumnName(status) .HasDefaultValue(1); entity.HasIndex(e e.OrderNo) .IsUnique(); }); }这里有两个经验。第一表名和列名统一为小写下划线风格。MySQL 在 Linux 下lower_case_table_names1是常见配置Windows 开发、Linux 部署时大小写混用会出现本地能跑线上找不到表的玄学问题统一小写直接绕开。第二金额用decimal(12,2)而不是 float避免精度翻车Status 用 int 加默认值 1比 enum 直接落库更稳因为线上改枚举要发版改数字不用。实体定义好后生成并更新数据库结构# 先安装工具如果没有 dotnet tool install --global dotnet-ef # -p 指定迁移所在项目-s 指定启动项目-o 指定迁移文件输出目录 dotnet ef migrations add InitDatabase -p src/MyApp.Infrastructure -s src/MyApp.Api -o Migrations # 将迁移应用到数据库 dotnet ef database update -p src/MyApp.Infrastructure -s src/MyApp.Api-p指向 Infrastructure 是因为 DbContext 在那里-s指向 Api 是因为它要读取 Program.cs 里的配置。这两个参数反了工具会报 Unable to create DbContext因为它找不到启动项目。跑到这一步数据访问层才算真正立住。3. 仓储与业务层把 CRUD 从 Controller 拆出来事务和 DI 一起理清3.1 泛型仓储接口与实现通用 CRUD 怎么写才不啰嗦Controller 直接注入 AppDbContext 也能做完增删改查但项目一复杂就乱了所有业务 SQL 挤在同一层同一个 SaveChanges 被多个接口各调一遍事务边界没人说得清。所以设计源码里几乎必带一层泛型仓储把所有实体的通用操作收拢到一个实现里。// Domain/IRepository.cs public interface IRepositoryT where T : class { TaskT? GetByIdAsync(long id); Taskbool ExistsAsync(ExpressionFuncT, bool predicate); TaskListT ListAsync(ExpressionFuncT, bool? predicate null); Task AddAsync(T entity); void Update(T entity); void Remove(T entity); Taskint SaveChangesAsync(); }// Infrastructure/Data/Repository.cs public class RepositoryT : IRepositoryT where T : class { protected readonly AppDbContext Context; protected readonly DbSetT DbSet; public Repository(AppDbContext context) { Context context; DbSet context.SetT(); } public async TaskT? GetByIdAsync(long id) { return await DbSet.FindAsync(id); } public Taskbool ExistsAsync(ExpressionFuncT, bool predicate) { return DbSet.AnyAsync(predicate); } public async TaskListT ListAsync(ExpressionFuncT, bool? predicate null) { IQueryableT query DbSet; if (predicate is not null) { query query.Where(predicate); } return await query.ToListAsync(); } public async Task AddAsync(T entity) { await DbSet.AddAsync(entity); } public void Update(T entity) { DbSet.Update(entity); } public void Remove(T entity) { DbSet.Remove(entity); } public Taskint SaveChangesAsync() { return Context.SaveChangesAsync(); } }容易被忽略的一点ListAsync没有默认加AsNoTracking这是故意的。业务层拿到实体后可能要修改再Update如果查询时已跟踪实体SaveChanges 会重复更新。纯只读查询让调用方加AsNoTracking即可两个场景各取所需。3.2 业务层的事务控制多表写入不能靠单条 SaveChanges仓储只负责单表 CRUD真正的业务规则和事务边界在 ApplicationService。订单创建是经典案例主表写入加明细写入必须在一个事务里要么都成功要么都回滚。public class OrderService : IOrderService { private readonly IRepositoryOrder _orderRepo; private readonly IRepositoryUser _userRepo; private readonly AppDbContext _context; public OrderService( IRepositoryOrder orderRepo, IRepositoryUser userRepo, AppDbContext context) { _orderRepo orderRepo; _userRepo userRepo; _context context; } public async TaskCreateOrderResponse CreateOrderAsync(CreateOrderRequest request) { await using var transaction await _context.Database.BeginTransactionAsync(); try { var user await _userRepo.GetByIdAsync(request.UserId); if (user is null) { throw new DomainException(用户不存在订单不能创建); } var order new Order { OrderNo O DateTime.Now.ToString(yyyyMMddHHmmss), Amount request.Amount, Status 1, CreatedAt DateTime.Now, UserId user.Id }; await _orderRepo.AddAsync(order); await _orderRepo.SaveChangesAsync(); await transaction.CommitAsync(); return new CreateOrderResponse(order.Id); } catch { await transaction.RollbackAsync(); throw; } } }BeginTransactionAsync拿到数据库层面的显式事务后续 SaveChangesAsync 在这个事务里执行。为什么不在仓储层开事务因为事务边界属于业务用例一个用例可能操作三个仓储如果每个仓储自己开事务只有一个会提交成功其余自动回滚。这是单据保存了但明细丢了最常见的根因。3.3 Controller 调业务层的依赖注入Scoped 生命周期为什么重要Controller 不直接接触仓储而是注入应用服务[ApiController] [Route(api/v1/[controller])] public class OrdersController : ControllerBase { private readonly IOrderService _orderService; public OrdersController(IOrderService orderService) { _orderService orderService; } [HttpPost] public async TaskIActionResult Create(CreateOrderRequest request) { var result await _orderService.CreateOrderAsync(request); return Ok(ApiResponse.Ok(result)); } }Service 注册在 Program.csbuilder.Services.AddScoped(typeof(IRepository), typeof(Repository)); builder.Services.AddScopedIOrderService, OrderService();AddScoped意味着每个 HTTP 请求范围内DbContext、仓储和应用服务是同一个实例这也是 EF Core 最推荐的模式。如果业务服务注册成AddSingleton现象很典型第一次请求正常第二次立即报DbContext 已被释放或连接池被占满。原因是 Singleton 服务注入的 Scoped DbContext 在第一个请求结束后就被容器释放后面所有请求拿到的都是失效实例。这种重启一下又好了过一会儿又崩的情况十有八九是生命周期注册错了。3.4 不要过度设计仓储层不是万能的上面架了一整套仓储加业务层但如果项目真的只有一张配置表、十几个只读接口就不要照搬这套模板。泛型仓储解决的是重复代价是每层多一次抽象。一个几百行的小工具接口直接用 DbContext 反而更好读。判断标准是如果仓储接口里只有那四个通用方法没有任何自定义方法说明抽象没有价值。反过来业务层有十几个方法都在用同一个仓储做组合查询就应该放开手定义自定义仓储接口比如IOrderRepository增加一个GetTodayCreatedOrdersAsync而不是在业务层用IRepositoryOrder去拼表达式树。仓储层保持薄只做数据访问和聚合业务规则永远留在服务里这样后续换库或优化 SQL 时才不会牵连 Controller。4. API 层与 DTO 设计统一响应、JWT 鉴权与 Swagger 调试4.1 统一响应模型与 DTO前端不再把 HTTP 状态码当业务码很多源码跑起来接口能通但前端对接时抱怨你们接口返回结构怎么每个都不一样这就是没有统一响应模型。API 层第一个规范是让所有接口返回同一个壳子。public class ApiResponseT { public int Code { get; set; } public string Message { get; set; } string.Empty; public T? Data { get; set; } public static ApiResponseT Ok(T data, string message success) new() { Code 0, Message message, Data data }; public static ApiResponseT Fail(int code, string message) new() { Code code, Message message, Data default }; }HTTP 状态码保留给传输层语义业务结果用 Code 表达。比如订单金额不能为负是业务错误HTTP 200 Code10001 比 HTTP 400 加一段描述更利于前端弹窗和埋点。接口入参同样建议用 record DTOpublic record CreateOrderRequest(long UserId, decimal Amount, string? Remark);源码里如果出现 Controller 直接接收实体类作为参数的做法建议立刻改掉。实体字段如果带导航属性或并发标记前端传进来的 JSON 会触发意外反序列化甚至造成覆盖更新。DTO 才是 API 的边界。4.2 JWT 鉴权中间件顺序和 Token 校验参数API 层第二个标配是鉴权。EFCore MySQL 项目里最常见的是 JWT Bearer 认证。先在 Program.cs 注册builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Secret]!)), ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }; });{ Jwt: { Issuer: myapp, Audience: myapp-client, Secret: replace-with-a-32-byte-or-longer-random-secret } }ValidateLifetime true让框架校验 token 过期时间ClockSkew默认 5 分钟太大我一般压到 30 秒避免登出后的 token 还能继续用五分钟。Secret必须是 32 字节以上的随机串拿 MD5 或短字符串签名的项目基本都逃不过被爆破。注册之后中间件顺序特别容易踩坑// Program.cs - 中间件顺序不能乱 app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();UseAuthentication必须在UseAuthorization之前且两者都在MapControllers之前。如果顺序反了鉴权中间件拿不到用户 Claims所有[Authorize]接口都会 401。4.3 Swagger 集成让接口文档带上 Bearer TokenSwagger 是 API 层调试的基本盘。源码里如果没集成 Swagger跑起来连这个接口要传什么都只能靠猜。集成方式builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options { options.SwaggerDoc(v1, new OpenApiInfo { Title MyApp API, Version v1 }); options.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Type SecuritySchemeType.Http, Scheme bearer, BearerFormat JWT }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, Array.Emptystring() } }); });if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }关键点AddSecurityDefinition和AddSecurityRequirement必须成对写。只写前者Swagger 页面上的 Authorize 按钮形同虚设配上 Requirement每个接口才会带锁图标点击授权后可以直接调试受保护接口。生产环境默认不开放 Swagger如果内网运维需要一份最新接口清单建议加 IP 白名单或简单鉴权原则是文档可以看但不能给外网无授权访问。4.4 路由版本与命名接口从一出生就带好身份证接口一多路径和命名就乱。常见做法是api/v1/orders、api/v2/orders版本号直接放路径里而不是靠 URL 参数或 Header 区分。这样前端升级时能保留老版本调用通道。[ApiController] [Route(api/v1/[controller])] public class OrdersController : ControllerBase { }[controller]占位符自动取控制器名去掉 Controller 后缀的结果订单相关接口全部落在api/v1/orders下。如果同一个实体要同时服务 App 端和 Admin 端宁可拆成两个 Controller也不要在一个 Controller 里堆十几对[HttpPost(admin/create)]这种路径。接口从一出生就带好版本和分组后面做权限、限流、日志都会省很多事。5. 避坑排查EFCore MySQL 从源码到跑通的 5 个高频问题5.1 现象Add-Migration 报连不上 MySQL连接串检查了好几遍也连不通第一次跑dotnet ef migrations add日志报 MySqlConnector 的认证类错误比如Authentication method caching_sha2_password not supported或Access denied。原因MySQL 8.0 默认新建用户走 caching_sha2_password 认证MySqlConnector 在非安全连接下拿不到公钥必须显式允许提取公钥本机开发一般没有 SSLSslMode 不关客户端校验直接失败。解决连接串改成Server127.0.0.1;Port3306;Databasemyapp_db;Userroot;Passwordyourpass;SslModenone;AllowPublicKeyRetrievaltrue;确保两个参数都在。如果你本机还没有装好 MySQL先把安装配置做对字符集选 utf8mb4端口保持 3306root 密码记牢这一步别跳。如果老账号用的是 mysql_native_password在客户端里执行SELECT user, plugin FROM mysql.user;查认证插件类型再对症下药。这个查询能解决八成连不上的排查时间。5.2 现象数据库日期全是 0000-00-00EFCore 一读取就崩同一张表里多数行日期正常某几行显示0000-00-00EFCore 一执行查询就抛MySqlException: Unable to convert MySQL date/time value to System.DateTime。原因MySQL DATETIME 允许存零值日期但 .NET 的 DateTime 最小只支持 0001-01-01两边零值语义不对齐。这类脏数据一般来自老系统导入或程序 bug。解决最省事的方式是连接串加Convert Zero DatetimeTrue让 MySqlConnector 自动把零值转成DateTime.MinValue。如果业务上能接受 NULL也可以把列改成DATETIME NULL实体用DateTime?映射。注意加Convert Zero DatetimeTrue后前端展示日期会看到 0001-01-01所以更根本的做法是数据清洗时把零日期统一刷成 NULL 或业务默认日期别把脏数据留给 ORM。5.3 现象并发一上来连接池耗尽日志全是 Timeout expired接口低并发时一切正常压测或上线后几十个并发就把服务打满日志大量出现Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.原因连接池默认上限约 100池被打满是结果不是根因。根因通常两处业务服务注册成 SingletonDbContext 被当单例复用请求结束后连接不归还或者有人手动new AppDbContext(options)拿数据库用完没 Dispose物理连接被占着不放。解决AddDbContext保持默认的 Scoped业务服务也全部 Scoped。全局搜一下代码里有没有new AppDbContext的写法有就全改成构造器注入。同时检查事务是否在异常路径被挂起——BeginTransactionAsync之后 try/catch 不完整连接会一直持有到超时。这时候看数据库连接数大量 Sleeping 连接就是事务泄漏。5.4 现象同一份源码在 MySQL 5.7 上能跑部署到 8.0 上报认证错误开发机是 MySQL 5.7测试环境是 8.0同一份源码一部署就连不上。原因MySQL 8.0 把默认认证插件改成 caching_sha2_password5.7 时代默认的 mysql_native_password 已不是新用户默认项。连接串里如果用的还是ServerVersion.AutoDetect认证都过不去版本探测自然失败。解决先把连接串按 5.1 的参数补齐用客户端工具在目标库建好账号并验证登录再让dotnet ef去连。若想更稳把ServerVersion.AutoDetect换成固定的ServerVersion.Parse(8.0.32-mysql)给迁移脚本和运行环境统一一个 MySQL 版本基线。5.7 和 8.0 的 SQL 生成、索引行为和锁机制都有差异版本长期错位比认证错误更麻烦。5.5 现象EFCore 生成的 SQL 又慢又怪异列表接口卡到怀疑人生查询一张 10 万行的表EFCore 生成了几十条 SQL每条都很轻但加起来慢或者一条 SQL 联了七八张表把所有字段全查出来。原因第一是被导航属性延迟加载带出的 N1 查询第二是 Include 用顺手把所有关联表一次全加载MySQL optimizer 面对大范围 JOIN 直接放弃索引。这是新手最容易把EFCore 很慢归因于 ORM 的误判。解决只读接口统一加AsNoTracking()需要关联数据的接口用 Select 投影成 DTO只挑必要列。分页查询务必带上 OrderBy否则 OFFSET 越大越慢。下面是一个规范的只读查询写法var list await _context.Orders .AsNoTracking() .Where(o o.Status 1) .OrderByDescending(o o.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .Select(o new OrderListItemDto { Id o.Id, OrderNo o.OrderNo, Amount o.Amount, UserName o.User ! null ? o.User.Name : }) .ToListAsync();这段代码不会触发 N1因为 Select 翻译成 SQL JOIN 而不是逐条加载导航属性。判断查询是否走了正常路径最直接的办法是打开 EFCore 的 SQL 日志看它到底向 MySQL 发了多少条语句。6. 交付前性能检查慢查询拦截器、Keyset 分页与并发控制6.1 慢查询拦截器用 50 行代码把 SQL 和耗时捞出来线上接口慢怎么定位是不是 SQL 问题我习惯挂一个 DbCommandInterceptor把所有执行超过阈值比如 200ms的语句打到日志再跟 MySQL 的 slow_query_log 交叉验证。代码核心是拿 Stopwatch 包住每次查询执行public class SlowSqlInterceptor : DbCommandInterceptor { private readonly ILoggerSlowSqlInterceptor _logger; private readonly long _thresholdMs; public SlowSqlInterceptor(ILoggerSlowSqlInterceptor logger, long thresholdMs 200) { _logger logger; _thresholdMs thresholdMs; } public override async ValueTaskDbDataReader ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResultDbDataReader result, CancellationToken cancellationToken default) { var sw Stopwatch.StartNew(); var reader await base.ReaderExecutingAsync(command, eventData, result, cancellationToken); sw.Stop(); if (sw.ElapsedMilliseconds _thresholdMs) { _logger.LogWarning(Slow SQL ({Ms} ms): {Sql}, sw.ElapsedMilliseconds, command.CommandText); } return reader; } }EFCore 的 SQL 解析黑匣子打开后你才能知道哪条 LINQ 被翻译成了什么 SQL再决定加索引、改查询还是调阈值。6.2 分页优化OFFSET 深翻页改成 Keyset 游标大部分项目用 Skip/Take 分页前几百页没问题翻到一万页时 MySQL 要扫描前面所有行再丢弃耗时指数上涨。如果列表接口真会翻到那么深换成 Keyset 游标var page await _context.Orders .AsNoTracking() .Where(o o.Id lastId) // lastId 是上一页最后一条的 Id .OrderByDescending(o o.Id) .Take(pageSize) .ToListAsync();用Id lastId代替 OFFSET数据库直接走主键索引区间扫描翻页再深也只是一页的代价。代价是前端需要传回上一页最后一条 Id没法随机跳页。后台管理列表大多接受这种模式。6.3 并发控制乐观锁字段在 MySQL 下的陷阱最后一个检查点是并发。EFCore 多 update 场景默认是最后写入覆盖业务不能接受丢失更新时就要上乐观锁。注意IsRowVersion()在 MySQL 下没有等价物常见做法是加一个long Version字段并标记为并发令牌entity.Property(e e.Version) .HasColumnName(version) .IsConcurrencyToken();每次更新生成的 SQL 会带WHERE version oldVersion影响行数为 0 时EFCore 抛DbUpdateConcurrencyException业务层捕获后提示数据已被他人修改。我在这方面吃过教训库存扣减项目里加了锁字段却漏了IsConcurrencyToken线上出现两个订单同时扣同一件库存。加上之后并发问题才真正被数据库挡住。我现在接手任何一个这类源码都会把这轮检查当成交付前的固定动作先看日志有没有慢 SQL再翻一次深分页最后压一组并发更新。多数玄学问题其实都是在这三步暴露的验证完心里才有底。希望你接手类似源码时能少走我踩过的这些弯路——祝顺利希望帮到你。本文还有配套的精品资源点击获取