ARTICLE DETAIL

资讯详情

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

EFCore+MySQL+Web API四层架构源码:设计、迁移与避坑指南

EFCore+MySQL+Web API四层架构源码:设计、迁移与避坑指南 简介在.NET生态中EF Core与MySQL是构建数据驱动Web服务的常见组合。这套ASP.NET Web API源码适合初中级.NET开发者学习RESTful接口与ORM映射。压缩包仅213KB共22个文件以9个C#源文件、4个JSON配置、项目/解决方案文件为核心覆盖入口、模型、数据上下文与控制器另外包含迁移脚本、.gitignore、LICENSE及版本记录并提供解决方案与用户配置便于在Visual Studio中直接加载和跟踪变更结构完整规范。目前已有365人学习下载。通过研读可掌握EF Core实体映射、迁移及控制器注入DbContext的操作方式并理解多环境配置与HTTP请求响应链路。整体示例可帮助快速搭建可运行的MySQL后端服务直接体现从零到一的工程思路。项目依赖清晰适合作为课程设计或企业内部项目的基础骨架便于扩展业务与数据表。1. 基于EFCore和Mysql的ASP.NET.Web.API项目设计源码为什么这个组合交付业务系统最务实基于 EFCore 和 MySQL 的 ASP.NET Web API 项目设计源码讲的是业务系统里最常见的一套服务端骨架EFCore 把 C# 实体映射成 MySQL 表结构Web API 对外暴露 JSON 接口源码工程再按层拆开让迁移、测试、替换数据库都有退路。很多人第一反应是“都 2025 年了为什么不用更时髦的 ORM 或 NoSQL”但真实项目里交付速度、招人成本、运维熟悉度往往比技术新鲜度更值钱。EFCore 加 MySQL 的组合正好卡在“够用”和“不笨重”之间——绝大多数后台管理系统、订单系统、SaaS 租户后台百分之八十的接口就是增删改查加分页筛选这套骨架能最快跑通迁移脚本可控开发者也容易接手。这篇笔记面向三类人把服务端当黑匣子的前端同学、刚转到 .NET 栈想抄作业的同事、以及要给老项目做架构收口的维护者。我会从四层工程怎么拆讲起一直落到 MySQL 的五个真实踩坑点和查询性能验证手法。2. 架构分层与源码目录四层工程怎么拆才不翻车拿到“项目设计源码”这四个字先别急着看控制器怎么写。根据我的经验源码工程质量的高低九成取决于一开始的目录分层而不是某段查询写得有多漂亮。如果 Controller 里直接 new DbContext、业务逻辑散落在各个 Action 里前三个月开发确实快甲方一改需求就开始翻车。常见做法是把解决方案拆成四层Api 提供 HTTP 接口Application 放服务接口和 DTODomain 放实体和业务规则Infrastructure 放 EFCore 的 DbContext、仓储实现和迁移脚本。这样拆的收益是Infrastructure 可以整体替换测试环境想换 SQLite 或 PostgreSQL 时Api 和 Application 层几乎不用动Domain 层不引用任何数据库相关包实体只描述业务不背连接字符串的锅。还有一个容易被忽略的好处——EFCore 的迁移文件会集中在 Infrastructure 里代码评审时一眼能看出表结构变动而不是散在启动项目里不好找。2.1 领域实体设计主键、审计字段与可空约定先看一个最普通的业务实体应该长什么样。我一般会从“商品”这种简单对象开始但设计原则是通用的。namespace MyApp.Domain.Entities; public class Product { public int Id { get; set; } // 主键默认自增 public string Name { get; set; } string.Empty; public decimal Price { get; set; } // decimal 映射 decimal(18,2) public string? Description { get; set; } // 可空字符串映射 NULL public DateTime CreatedAt { get; set; } // 审计字段 public DateTime? UpdatedAt { get; set; } // 可空更新时间 // 领域行为改价必须带操作人后续可以加审计日志 public void UpdatePrice(decimal newPrice, string operatorName) { if (newPrice 0) throw new ArgumentException(价格必须大于 0); Price newPrice; UpdatedAt DateTime.UtcNow; } }这段代码里有两个容易被新手忽略的点。第一string? Description和string Name string.Empty的区别直接影响 MySQL 表结构是NULL还是NOT NULL。EFCore 依据引用类型的可空性推断列是否允许为空所以实体里有没有问号不是风格问题是表结构问题。第二CreatedAt这类审计字段如果靠每个开发手动赋值早晚有人漏掉。我一般会在 DbContext 的SaveChangesAsync里统一处理而不是让 Controller 去填。这属于 Entity Framework Core 里最常见的“全局约定”玩法后面讲 DbContext 时再展开。实体设计的另一个原则是属性类型尽量贴近数据库语义。价格用decimal不用double金额字段用decimal(18,2)映射否则 MySQL 的浮点误差会直接在报表里爆炸。日期字段我习惯统一存 UTCMySQL 的datetime不带时区信息混着存本地时间和 UTC 时间查出来的数据早晚差八个小时。2.2 仓储、服务与控制器谁的职责归谁三层的职责边界我用一句话就能说清Controller 只做参数绑定和响应包装Service 只做业务编排DbContext 只做数据访问。很多时候项目翻车不是技术不行而是 Controller 里写了三屏业务代码连个能单测的方法都抽不出来。关于仓储模式我的观点比较务实中小项目直接用 DbContext 就够了不必为了“设计感”再包一层 IRepository。因为 DbContext 本身就是工作单元事务、变更追踪、批量提交都内置了你再包一层仓储无非是把db.Products.Where(...)改成_repository.GetProducts().Where(...)多一层间接层少一分可读性。只有当你有明确需求——比如要对数据访问做单元测试替身、要屏蔽 ORM 细节、或者有多数据库切换的硬指标——才值得上仓储抽象。标题里的“项目设计源码”如果按四层工程组织我一般会把仓储抽象留在 Application 层把实现放到 Infrastructure 层给将来留余地但不在每个实体上都强行套接口。Controller 和 Service 的分工靠命名就能看出来Controller 里的方法名是GetProducts、CreateOrder这类 HTTP 语义Service 里的方法名是CalculateTotalPrice、CheckInventory这类业务语义。一旦方法命名开始混乱说明分层已经守不住了。我见过最典型的翻车现场是 Controller 直接_db.Products.Where(p p.Price 100).ToListAsync()测试时想 Mock 数据访问只能被迫把数据库跑起来。2.3 从零搭一个四层源码骨架命令清单与文件职责新建解决方案时我习惯用 dotnet CLI 而不是 Visual Studio 界面因为命令可复现、可写进团队文档。下面的命令在 .NET 8 环境里直接可以跑。dotnet new sln -n MyApp dotnet new webapi -n src/MyApp.Api -o src/MyApp.Api dotnet new classlib -n src/MyApp.Application -o src/MyApp.Application dotnet new classlib -n src/MyApp.Domain -o src/MyApp.Domain dotnet new classlib -n src/MyApp.Infrastructure -o src/MyApp.Infrastructure dotnet sln add src/MyApp.Api src/MyApp.Application src/MyApp.Domain src/MyApp.Infrastructure # 引用关系Domain 不引用任何项目Application 引用 Domain # Infrastructure 引用 Domain 和 ApplicationApi 引用 Application 和 Infrastructure dotnet add src/MyApp.Application reference src/MyApp.Domain dotnet add src/MyApp.Infrastructure reference src/MyApp.Domain src/MyApp.Application dotnet add src/MyApp.Api reference src/MyApp.Application src/MyApp.Infrastructure这组命令生成的目录结构是MyApp.sln src/ ├─ MyApp.Api/ # 控制器、Filter、Program.cs、appsettings.json ├─ MyApp.Application/ # 服务接口、DTO、业务规则 ├─ MyApp.Domain/ # 实体、枚举、领域异常 └─ MyApp.Infrastructure/ # DbContext、迁移文件、仓储实现引用关系是最容易抄错的地方。Domain 必须保持干净不引用 EFCore 包Infrastructure 引用 Domain 和 Application因为 DbContext 需要知道实体还要实现 Application 里定义的服务接口Api 不要直接引用 Domain否则 Controller 会忍不住直接用实体当响应模型。这一层引用关系一旦被打破项目的“可替换性”就名存实亡。文件职责上我的习惯是DTO 全放 Application 的Dtos文件夹实体全放 Domain 的Entities文件夹迁移文件放 Infrastructure 的Migrations文件夹。EFCore 默认会把迁移生成到 DbContext 所在项目如果要指定输出目录在迁移命令里加--output-dir Migrations即可后面第 3 章会用到。单元测试项目MyApp.Tests我通常最后加引用 Application 和 Domain不引用 Infrastructure这样测试永远不碰数据库。3. EFCore 连接 MySQL从连接字符串到迁移脚本的可复现步骤这一章是整个方案里最容易出幺蛾子的环节。EFCore 连 SQL Server 几乎是零配置连 MySQL 就要面对驱动选型、版本号、排序规则、认证方式等一系列问题。先说结论我一般直接用 Pomelo.EntityFrameworkCore.MySql不是因为它完美而是因为它对 MySQL 的方言支持最全社区活跃遇到坑时搜索到的解决方案最多。微软官方也有一个 MySql.EntityFrameworkCore 包但它迭代慢对一些 MySQL 8.0 新特性反应迟缓生产项目里我不太敢押注。3.1 驱动选型Pomelo 与官方 Provider别选错对比项Pomelo.EntityFrameworkCore.MySqlMicrosoft.EntityFrameworkCore.MySql官方维护方社区 Pomelo 团队微软MySQL 方言覆盖全支持 MariaDB基础功能与 EFCore 版本同步速度较快较慢常见坑版本要跟 EFCore 匹配部分几何类型、JSON 函数支持不完整社区资料多遇到问题好搜相对少选型不是越新越好是坑越少越好。Pomelo 的包名里会带上 EFCore 主版本号比如Pomelo.EntityFrameworkCore.MySql对应 .NET 8 的 EFCore 8.x安装时留意 NuGet 依赖版本即可。我在本机测试还踩过另一个坑机器上 MySQL 是 5.7.44但连接字符串里写ServerVersion时偷懒写成了 8.0结果 Pomelo 按 8.0 的语法生成分页 SQL5.7 直接报语法错误。版本号不是给人看的是给 SQL 生成器看的必须和你实际安装配置的 MySQL 大版本一致。环境准备这里多说一句。无论你是照 mysql 安装教程里的解压版配置还是用 rpm 包安装装完第一件事是确认mysql --version能输出版本号。团队里经常有人在 Windows 上装了解压版却忘记初始化 data 目录在 Linux 上用 systemctl 启动失败——这些都不是 EFCore 的问题但会卡住整个链路。容器方式跑 MySQL 也常见docker 安装 mysql 失败多半卡在端口映射或认证插件上后面第 5 章会详细讲认证坑。3.2 连接字符串 5 个关键参数ServerVersion、Charset、SslMode、TreatTinyAsBoolean、Timeout连接字符串看起来只是一行配置但里面藏着五个最容易出事的参数。先看一个生产环境常用的写法{ ConnectionStrings: { Default: Serverlocalhost;Port3306;DatabaseMyAppDb;Userapp_user;PasswordYour#Pass;Charsetutf8mb4;SslModeNone;ConnectionTimeout30;TreatTinyAsBooleanTrue; } }逐项说Server和Port不用解释但要注意Server127.0.0.1和Serverlocalhost在 MySQL 权限表里可能被当成两个不同主机本地能连、测试环境连不上这种玄学问题多半出在这里。Charsetutf8mb4一定要显式声明MySQL 8.0 默认字符集已经是 utf8mb4但 5.7 的默认还是 latin1不声明的话中文存进去直接变问号。SslModeNone是本地开发的常用配置生产环境必须换成Preferred或Required否则数据库连接走明文审计一查一个准。TreatTinyAsBooleanTrue控制 MySQL 的TINYINT(1)是否映射成 C# 的bool如果表里用TINYINT(1)存状态值这个参数改了EFCore 对实体属性的解析就完全不同。ConnectionTimeout30是连接超时秒数默认 15 秒网络抖动时会不够用。DbContext 注册代码里还必须显式传入 MySQL 版本using Microsoft.EntityFrameworkCore; using Pomelo.EntityFrameworkCore.MySql.Infrastructure; builder.Services.AddDbContextAppDbContext(options { var serverVersion new MySqlServerVersion(new Version(8, 0, 46)); // 和实际 MySQL 大版本一致 options.UseMySql(builder.Configuration.GetConnectionString(Default), serverVersion, mysqlOptions { mysqlOptions.MigrationsAssembly(MyApp.Infrastructure); }); });MigrationsAssembly指定迁移文件输出到 Infrastructure 程序集。如果不写迁移会默认生成到 Api 启动项目里这跟前面约定的四层目录就冲突了。另外不要把连接字符串里的密码存到 git 里常见做法是本地放在appsettings.Development.json生产环境用环境变量或配置中心覆盖后面第 4 章的多环境配置会展开。3.3 第一次迁移Add-Migration、database update 与反向脚手架迁移是 EFCore 里最值得依赖的功能之一但很多人把它用成了“一键同步工具”在开发环境跑Update-Database在测试环境也跑上了生产还是跑这不叫迁移叫裸奔。我推荐的流程是开发环境用迁移命令迭代生产环境用Script-Migration生成 SQL 脚本DBA 审完再执行。# 在 src 目录下执行指定启动项目和输出目录 dotnet ef migrations add Init --project src/MyApp.Infrastructure --startup-project src/MyApp.Api --output-dir Migrations # 开发环境直接更新数据库 dotnet ef database update --project src/MyApp.Infrastructure --startup-project src/MyApp.Api # 生成从空库到最新版本的幂等 SQL 脚本 dotnet ef migrations script --project src/MyApp.Infrastructure --startup-project src/MyApp.Api --output migration.sql --idempotentmigrations script命令的最后那个--idempotent很关键。它生成的 SQL 会判断每张表、每个列是否已存在已存在的跳过这样同一份脚本在已更新的库上重复执行不会报错。生产环境的发布流程里这个特性就是后悔药回滚时至少知道数据库到过哪个版本。如果项目是数据库先行表结构已经存在要反向生成实体和 DbContext用脚手架命令dotnet ef dbcontext scaffold Serverlocalhost;DatabaseMyAppDb;Userapp_user;PasswordYour#Pass;Charsetutf8mb4; Pomelo.EntityFrameworkCore.MySql --context AppDbContext --context-dir Data --output-dir Entities这个命令生成的实体类偏啰嗦而且会把导航属性建得特别全我一般只把它当起点生成完立刻手工清理把不需要的导航属性删掉把DateTime的可空性按业务调整。反向脚手架的精髓是“生成一时爽清理火葬场”这句话的反面生成后必须花半天整理否则实体数量一多代码评审会爆炸。4. Web API 控制器与依赖注入把业务逻辑从 Controller 里挪出去到了这一层项目已经从“能跑”进入“能维护”。Controller 是 HTTP 和业务代码之间的翻译官不是业务逻辑的容器。我接过的烂项目里Controller 里最多见过几百行代码里面混着发邮件、解析 Excel、直接改写数据库字段单元测试根本无从下手。正确的做法是 Controller 保持薄Service 保持厚。4.1 统一响应与 DTO 映射约定先行的接口边界前后端联调时最怕每个接口返回格式都不一样。这个接口成功时返回{ data: ... }那个接口失败时返回{ msg: ... }前端就要在每个请求里做兼容。统一响应包装虽然有人认为“多此一举”但项目一旦有多个前端管理后台、小程序、开放 API统一响应能省下大量口水。namespace MyApp.Api.Models; public class ApiResponseT { public int Code { get; set; } // 0 表示成功非 0 表示业务错误 public string Message { get; set; } OK; public T? Data { get; set; } public static ApiResponseT Success(T data) new() { Code 0, Data data }; public static ApiResponseT Fail(string message, int code 1) new() { Code code, Message message }; }控制器里只做三件事接收参数、调用服务、包装响应。[ApiController] [Route(api/[controller])] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService productService; } [HttpGet({id:int})] public async TaskActionResultApiResponseProductDto GetById(int id, CancellationToken ct) { var product await _productService.GetByIdAsync(id, ct); if (product is null) { return NotFound(ApiResponseProductDto.Fail($商品 {id} 不存在)); } return Ok(ApiResponseProductDto.Success(product)); } }注意这里有三个约定第一接口返回的是ProductDto而不是Product实体避免把CreatedAt、数据库内部字段直接暴露给前端第二CancellationToken ct一路从 Controller 传到 Service 再传到 EF Core 查询客户端断开时数据库查询能被取消不至于白白占着连接第三NotFoundException这类业务异常不要在 Controller 里 try-catch全局异常过滤器统一处理Controller 保持干净。DTO 映射我一般手写不引入 AutoMapper。中小项目里手写映射也就十几行AutoMapper 的配置调试成本反而更高等 DTO 数量多到让手写代码烦躁时再考虑引入也不迟。4.2 Program.cs 依赖注入DbContext、服务与拦截器的注册顺序依赖注入的注册集中在 Program.cs这是 .NET 6 之后的标准写法。注册顺序不会影响功能但会影响阅读体验。我习惯按“基础设施 → 应用服务 → HTTP 管线”的顺序排。using MyApp.Infrastructure.Data; using MyApp.Application.Interfaces; using MyApp.Application.Services; using Microsoft.EntityFrameworkCore; var builder WebApplication.CreateBuilder(args); // 1. 基础设施DbContext 和拦截器 builder.Services.AddDbContextAppDbContext(options { var serverVersion new MySqlServerVersion(new Version(8, 0, 46)); options.UseMySql(builder.Configuration.GetConnectionString(Default), serverVersion); }); // 2. 应用服务 builder.Services.AddScopedIProductService, ProductService(); // 3. HTTP 管线 builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.MapControllers(); app.Run();这里最值得说道的是生命周期。AddScopedIProductService, ProductService()是默认选择因为一次 HTTP 请求对应一个 ProductService 实例它依赖的 AppDbContext 也在这个请求作用域内。很多人把 DbContext 注册成AddSingleton认为“一个上下文全局复用性能更好”这是把数据库连接池和 DbContext 对象混为一谈了。DbContext 实例不是线程安全的两个请求同时往里塞查询EFCore 会直接抛“A second operation was started on this context instance”异常这个坑我在第 5 章还会细说。反过来AddTransient也不建议DbContext 生命周期比请求短时同一个请求里多个服务各自 new 一个上下文事务就跨上下文了提交时数据一致性没人保证。4.3 多环境配置appsettings.Production.json 与连接字符串覆盖连接字符串、日志级别、需要给外部调试的接口开关这类配置绝不能写死在代码里。ASP.NET Core 的配置系统按环境自动加载appsettings.{Environment}.json靠ASPNETCORE_ENVIRONMENT环境变量切换。// appsettings.json基础配置 { Logging: { LogLevel: { Default: Information } }, ConnectionStrings: { Default: Serverlocalhost;DatabaseMyAppDb;Userapp_user;Passworddev_only;Charsetutf8mb4; } }// appsettings.Production.json生产覆盖 { ConnectionStrings: { Default: Server10.0.0.5;DatabaseMyAppDb;Userprod_user;Password__FROM_ENV__;Charsetutf8mb4;SslModeRequired; } }运行时指定环境# Linux / macOS export ASPNETCORE_ENVIRONMENTProduction dotnet MyApp.Api.dll # Windows PowerShell $env:ASPNETCORE_ENVIRONMENTProduction dotnet MyApp.Api.dll我在生产部署时的做法是appsettings.Production.json里只放非敏感配置数据库密码靠环境变量ConnectionStrings__Default注入配置系统会自动覆盖同名字段。这能避免密码进 Git 历史——Git 历史里的密码改一次代码就永远留在仓库里了想删都不好删。另外UseMySQL里的ServerVersion不要从配置文件读它应该跟随代码版本走数据库升级时连代码一起发布配置和代码脱节会在发布后半小时内让人抓狂。5. MySQLEFCore 避坑排查五个最容易血泪的坑这一章的内容是从真实项目的故障记录里捞出来的。每一条都有人花过半天时间排查最后发现是极其基础的配置或习惯问题。按“现象 → 原因 → 解决”写方便你直接对照。5.1 mysql 8.0 默认认证方式导致的连接失败现象连接字符串完全正确MySQL 服务也起来了但程序启动时报Authentication method caching_sha2_password not supported或者The server requested authentication method unknown to the client。原因MySQL 8.0 默认认证插件是caching_sha2_password而老版本的 MySql.Data 或 Connector/NET 不认识它。很多 mysql 安装教程装完 8.0.46 后没有调整认证插件连接方驱动又是旧版两边的方言对不上。解决优先升级 NuGet 依赖到支持caching_sha2_password的版本Pomelo 6.0 及以上配合 MySqlConnector 都支持如果数据库是历史项目不能换驱动再考虑把用户认证方式改回mysql_native_passwordALTER USER app_user% IDENTIFIED WITH mysql_native_password BY Your#Pass; FLUSH PRIVILEGES;但要注意后者不是长久之计MySQL 官方已经宣布未来版本会移除mysql_native_password新项目直接换驱动才是正路。排查时可以先在命令行里用同一个账号连一次 MySQL能连上说明数据库侧没问题问题必然在驱动版本。5.2 排序规则 utf8mb4 把唯一索引变“不唯一”现象表里建了唯一索引字段是Name插入apple成功再插入Apple居然也成功唯一约束形同虚设。原因字符集排序规则默认不区分大小写。MySQL 8.0 默认排序规则是utf8mb4_0900_ai_ci其中ai表示不区分重音ci表示不区分大小写。唯一索引在比较时用的是排序规则不是二进制值所以“apple”和“Apple”被判定为同一个值受阻但反过来如果你期望它们不同也会发现唯一索引并没有拦截。解决用 mysql 数据库常用命令确认当前表的排序规则SHOW CREATE TABLE Products;然后把排序规则改成区分大小写ALTER TABLE Products MODIFY Name VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;utf8mb4_bin是二进制比较大小写敏感唯一索引立刻恢复“唯一”的含金量。8.0 里也可以用utf8mb4_0900_as_cs但_bin语义最简单不容易被误解。这里有个连带坑如果实体里对Name建了索引修改排序规则后要重新生成迁移脚本否则开发库和测试库的规则不一致同一个 bug 在测试环境复现不出来。5.3 SQL 模式 only_full_group_by 让分组查询直接报错现象EFCore 生成的分组查询或者手写的 Raw SQL 在本地跑得好好的到测试环境报this is incompatible with sql_modeonly_full_group_by。原因MySQL 5.7 之后默认开启ONLY_FULL_GROUP_BY要求SELECT里出现的非聚合列必须出现在GROUP BY里。开发库可能是 5.7.44 之前的老版本或手动改过sql_mode测试库是干净的新装 8.0行为就分叉了。解决先看当前 SQL 模式SELECT sql_mode;临时调整会话级别SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;但这只是临时手段重启 MySQL 就没了。更务实的做法是改查询逻辑。比如把非聚合列放进ANY_VALUE()或者干脆用子查询把分组后的主键先查出来再关联明细表。EFCore 里遇到这种场景我的建议是不要硬用 LINQ 翻译直接FromSqlRaw写 SQL语句里写明分组逻辑DBA 也看得懂。mysql 排序也常在这类场景里出问题ORDER BY的字段必须要么在GROUP BY里要么是聚合函数的结果否则严格模式下同样报错。5.4 DbContext 在异步请求中并发复用导致的“A second operation”异常现象系统跑着跑着日志里开始刷A second operation was started on this context instance before a previous operation was completed接口偶发性 500。原因DbContext 被注册成了单例或者被放在静态字段里复用。EFCore 的 DbContext 不是线程安全的它内部维护着变更追踪器两个请求同时对同一个上下文做查询上下文状态就乱了。这个坑我自己踩过当时觉得“一个上下文连接复用效率高”结果压测一上来就现原形。解决确认 Program.cs 里用的是AddDbContext默认生命周期是 Scoped一个 HTTP 请求一个上下文实例。排查时重点找两处一是有没有人把AppDbContext直接new出来放在静态变量里二是有没有在后台定时任务里长时间持有同一个 DbContext。定时任务应使用IServiceScopeFactory创建独立作用域用完即销毁。using var scope _scopeFactory.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceAppDbContext();一句话DbContext 是“短命”对象创建成本很低不要让它长命百岁。想复用一个东西去复用数据库连接池不是复用 DbContext。5.5 并发更新丢数据没有并发令牌谁都拦不住现象两个管理员同时编辑同一条商品记录A 改了价格B 改了库存两个请求先后提交后提交的人把先提交的人覆盖了价格被改回旧值数据悄悄丢了一段。原因EFCore 默认按主键定位要更新的行UPDATE ... WHERE Id 1完全不关心这行数据在读取之后有没有被别人改过。两个请求都读到旧价格分别改不同字段后提交的SaveChangesAsync把整行覆盖前一个人的改动就没了。解决在实体上加并发令牌列。MySQL 没有 SQL Server 的rowversion常用做法是用DateTime更新时间戳或者用一个每次更新都自增的int版本号。EFCore 里用IsConcurrencyToken()标记modelBuilder.EntityProduct() .Property(p p.Version) .IsConcurrencyToken();更新时生成的 SQL 会自动变成UPDATE Products SET Price newPrice, Version Version 1 WHERE Id 1 AND Version oldVersion;影响行数为 0 时EFCore 抛DbUpdateConcurrencyException业务层捕获后提示“数据已被他人修改请刷新后重试”。这属于乐观并发适合多数 Web 场景如果冲突率特别高再谈 mysql 锁的分类——悲观锁SELECT ... FOR UPDATE、间隙锁等等。但并发锁要谨慎行锁和间隙锁在高并发下可能引发死锁能用乐观令牌解决的不要轻易上悲观锁。真实项目里加一个Version列的成本极低收益极高算是性价比最高的防翻车手段。6. 查询性能进阶批量写入、事务与 SQL 追踪的三种验证手法6.1 批量写入与事务什么时候该绕开 SaveChangesEFCore 的SaveChangesAsync会把所有变更打包成一个事务这在小批量下没问题但几千条数据逐条 Add 再一次性 SaveChanges性能会非常难看。我的习惯是单次提交超过 200 条就分批每批 200 条左右批间用事务包住。这里有个细节——要实现真正的 mysql 事务处理直接注入AppDbContext并调用BeginTransactionAsync不要用嵌套的SaveChanges去模拟事务否则中途失败时部分数据已落库想回滚都没有帮手。复杂报表场景我会保留存储过程的位置FromSqlRaw调存储过程绕过 ORM 的查询翻译这不是妥协是让 ORM 做它擅长的事。6.2 验证手法EXPLAIN 与日志追踪性能问题最怕黑匣子我看一个查询好坏最快的方法是抓 SQL 出来 EXPLAIN。在开发环境打开 SQL 日志options.LogTo(Console.WriteLine, LogLevel.Information) .EnableDetailedErrors() .EnableSensitiveDataLogging();抓到的 SQL 丢到 MySQL 命令行执行EXPLAIN SELECT * FROM Products WHERE Name apple;重点看type列——是ALL全表扫描还是ref/const走了索引rows列估算扫描多少行。如果typeALL且rows上万说明索引没建对或查询写法有问题。EFCore 生成的 SQL 没有魔法数据库怎么执行、索引怎么走全靠这 15 秒的 EXPLAIN 就能定位。我做项目复盘时最深的教训是不要相信“EFCore 慢”这种结论多数慢查询是缺索引和 N1 查询造成的跟 ORM 本身无关。单表查询先看执行计划再考虑缓存和读写分离。希望帮到你。本文还有配套的精品资源点击获取
返回列表