如果你同时管过网关、缓存和数据访问这三块大概会有同感单个服务写起来不难难的是把多个实例、外部流量、热点数据和关系型存储拧成一个稳定可维护的整体。最近在做一个基于 .NET 8 的 WebAPI 项目技术栈就是标题里那几样YARP 做反向代理Redis 做缓存和分布式协调Entity Framework Core 管持久化。这套组合不算冷门但真正从零搭起来、压测、灰度、排查问题走一圈之后有很多细节值得留下来当作参考。这个项目本身是个常规的业务系统对外提供 REST API后端同时部署多个 API 实例要求统一入口、负载均衡、热点数据缓存还要保证订单这类强一致数据能可靠落库。适合正在从单体服务切到多实例部署的团队或者想给现有 WebAPI 加网关和缓存层的人。下面我就按实际落地的顺序把选型、配置、代码、坑点一次说完。1. 整体架构与选型思路1.1 为什么选 YARP 而不是 Nginx 或 Ocelot很多团队一提到反向代理第一反应是 Nginx。我不否认 Nginx 在静态资源、成熟运维、高性能转发上很强但这个项目里我坚持用了 YARP核心原因是它和 .NET 生态是一体的。YARP 全名 Yet Another Reverse Proxy是微软开源的 .NET 反向代理组件。它不是那种独立部署的进程而是以 NuGet 包的形式寄生在 ASP.NET Core 应用里。这意味着你想加一台代理服务器不需要额外维护一套独立服务直接在现有 WebAPI 网关进程里把代理能力加进去复用依赖注入、配置系统、日志管道和监控体系。对于以 .NET 为主的技术团队这个优势非常实际写一次性配置文件的人可以是后端开发排一次“路由不正确”的问题时也不用来回切上下文。Ocelot 也用过但它的定位更偏向 API Gateway内置了大量网关语义比如聚合、限流、认证。而 YARP 更强调纯粹、高性能的反向代理把路由匹配、负载均衡、转发和 Transform 这些底层能力做得很扎实。项目里我们网关并不需要重业务只要把/api/**的流量按策略转发给后端实例YARP 的灵活度远比 Ocelot 好调。下表是我当时对比的简化版本维度YARPNginx运行方式NuGet 包嵌入 .NET 进程独立进程额外部署配置修改支持配置文件或代码动态修改修改配置文件后 reload二次开发可用 C# 写自定义中间件、Transform需要 Lua 或 OpenResty生态集成与 ASP.NET Core 日志、DI、监控无缝需要额外接入适合场景.NET 团队、需要灵活定制的代理层独立运维体系、纯流量分发如果团队已经有专门的运维基础设施和 Nginx 专家选 Nginx 没问题。但如果是纯 .NET 团队想要一个能写代码调优的网关层YARP 是更省事的选择。1.2 Redis 和 EF 的分工缓存与持久化不是替代关系不少新人容易把 Redis 和数据库放在对立面觉得“用了 Redis 就不需要数据库了”这是误解。Redis 是内存型键值存储擅长低延迟读写比如热点商品详情、登录态、分布式锁但它的持久化机制、事务模型和复杂查询能力和关系数据库完全不是一回事。项目里我始终把 Redis 定位成“缓存层和协调层”把 EF 定位成“事实数据的持久层”。举个例子商品列表页的浏览次数、热搜词、详情页这种高频读取且可以容忍短暂不一致的数据走 Redis 缓存。而订单、支付流水、库存操作记录这种强一致要求的数据必须走 EF 写入 SQL Server依靠数据库事务保证原子性。两者是配合关系请求先打网关网关转给 API 实例API 读 Redis 拿热点数据拿不到就查 EF查完回填缓存。写入则先操作数据库再删缓存保证最终一致。选 EF Core 做持久层一是团队熟悉 LINQ开发效率高二是 EF Core 的迁移工具可以把实体模型变更管理起来避免手工维护 SQL 脚本三是它对 SQL Server 的支持很成熟。虽然原生 SQL 和 Dapper 在某些极端查询下更快但项目里 90% 的 CRUD 场景用 EF 足够剩下 10% 再手写 SQL 或 Dapper 也不迟。2. YARP 反向代理设计与落地2.1 基础配置从 NuGet 包到第一台代理YARP 的接入方式非常简单。先安装包Microsoft.ReverseProxy然后在Program.cs里注册服务var builder WebApplication.CreateBuilder(args); builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection(ReverseProxy)); // 其他服务注册 builder.Services.AddControllers(); var app builder.Build(); app.MapReverseProxy(); app.MapControllers(); app.Run();上面的代码已经让当前 WebAPI 进程具备反向代理能力。关键在于appsettings.json里的配置段。我使用了一个独立网关项目作为入口配置大概像这样{ ReverseProxy: { Routes: { api-route: { ClusterId: webapi-cluster, Match: { Path: /{**catch-all} } } }, Clusters: { webapi-cluster: { LoadBalancing: { Policy: LeastRequests }, Destinations: { api1: { Address: http://localhost:5080 }, api2: { Address: http://localhost:5081 } }, HealthCheck: { Active: { Enabled: true, Interval: 00:00:10, Timeout: 00:00:05, Path: /health } } } } } }几点说明Match.Path我写的是/{**catch-all}意思是所有请求都先进 YARP再根据后续规则转发。ClusterId表示一组后端实例。这里我配了两个地址模拟同一服务部署了两台机器。LoadBalancing.Policy选的是LeastRequests也就是“当前活跃请求数最少”的实例优先接收新请求。这个策略在接口耗时差别比较大的场景下比轮询更均衡。Active.HealthCheck是主动健康检查YARP 会每 10 秒请求一次后端的/health接口如果连续超时就会把那个实例从转发列表里摘掉。这一步非常重要我见过有人忘了配健康检查结果某台实例挂了之后网关还在往它转发请求前端大面积超时。2.2 路由与请求头的自定义处理光有基础转发不够。实际项目里网关和后端之间有大量需要处理的信息客户端 IP、原始 Host、请求路径、JWT Token。YARP 的 Transform 机制就是干这个的。比如我想让后端始终看到客户端的真实 IP而不是网关的 IP可以这样加Transforms: [ { RequestHeader: X-Forwarded-For, Append: {remoteip}, When: Always } ]也可以把路径做改写。比如网关对外暴露/api/v2/products/{id}后端实际接口是/api/products/{id}用 PathPattern 变换Transforms: [ { PathPattern: /api/products/{id} } ]这里我分享一个实际踩过的坑YARP 默认会附带X-Forwarded-*头链但如果后端实例本身也要接收来自其他调用方的请求头信息会越叠越长。网上有人直接把“所有请求头全部透传”当成默认行为结果后端拿到一堆重复的X-Forwarded-For。我的建议是显式定义需要转发的头而不是放任默认行为。写清楚反而容易维护。2.3 网关层做鉴权很容易翻车YARP 转发时HttpClient 会把原始请求中的Authorization头带过去所以 JWT 的鉴权天然是通的。但是不要在网关层做太重的权限校验。我最初的方案是把用户信息在网关层解析出来然后要求转发请求时带上网关签发的“内部 Token”。后来发现这会让后端和网关强耦合一旦网关逻辑改了所有 API 都要跟着联调。后来我调整成网关只检查请求来源、必要的白名单、基础频率限制真正的用户授权全部放在后端 API 里完成。网关和后端的职责边界一定要清晰否则后期很难维护。如果确实要在网关层做中间件可以这样写app.Use(async (context, next) { if (context.Request.Path.StartsWithSegments(/internal)) { context.Response.StatusCode 404; return; } await next(); });这类中间件必须放在app.MapReverseProxy()之前因为它要在代理转发前拦截请求。中间件顺序在 ASP.NET Core 里的设计和其他管道框架一样前后颠倒会导致逻辑完全不生效这个顺序问题值得写进团队规范里。3. Redis 使用实战缓存、分布式锁和热点防护3.1 缓存接入与 Cache-Aside 模式Redis 在 .NET 里的常规接入方式是Microsoft.Extensions.Caching.StackExchangeRedis它直接提供了IDistributedCache的实现。注册方式builder.Services.AddStackExchangeRedisCache(options { options.Configuration builder.Configuration.GetConnectionString(Redis); options.InstanceName demo:; });对应的连接字符串类似localhost:6379,passwordxxx,connectTimeout5000。我用的是最经典的 Cache-Aside 模式也叫旁路缓存。读取时先查 Redis查不到再查数据库最后回填写入时先改数据库再删缓存。一个简化版代码如下[HttpGet({id:int})] public async TaskActionResultProduct GetProduct(int id, CancellationToken ct) { var cacheKey $product:{id}; string? cached await _cache.GetStringAsync(cacheKey, ct); if (cached ! null) { return Ok(JsonSerializer.DeserializeProduct(cached)); } var product await _db.Products .AsNoTracking() .FirstOrDefaultAsync(p p.Id id, ct); if (product null) { return NotFound(); } await _cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(product), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(10) }, ct); return Ok(product); }缓存时间我一般不给太长。商品类数据可能 10 到 30 分钟还行配置类数据 5 分钟就足够。TTL 设得过大会让用户看到太久之前的数据业务上不一定能接受。有人喜欢把所有接口都加缓存但缓存也要考虑成本写频繁的数据不适合放 Redis否则每次写库还要删缓存白白增加一次网络开销。3.2 缓存删除优于更新写接口的缓存处理很多人第一反应是“更新缓存”也就是修改数据库后顺手把 Redis 里的值也改了。这个做法有问题如果数据库更新和缓存更新不是原子的中途失败就会留下脏数据。我的演示代码里没有更新缓存而是删除缓存[HttpPut({id:int})] public async TaskIActionResult UpdateProduct(int id, ProductUpdateRequest request, CancellationToken ct) { var product await _db.Products.FirstOrDefaultAsync(p p.Id id, ct); if (product null) { return NotFound(); } product.Title request.Title; product.Price request.Price; await _db.SaveChangesAsync(ct); var cacheKey $product:{id}; await _cache.RemoveAsync(cacheKey, ct); return NoContent(); }为什么是删除而不是更新因为下次读取时缓存未命中会从数据库查出最新值重新回填这样可以避免“写两个存储”导致的不一致窗口。删除操作也比更新操作简单不需要关心缓存里的序列化结构是否和当前实体版本匹配。这个看起来很小的取舍在一致性排查时能省很多事。3.3 分布式锁用 Lua 保证不会误删多实例部署后某些业务操作必须互斥。比如同一个商品的库存扣减两台 API 实例同时操作单靠数据库行锁可以解决但如果有更复杂的“先查再算后写”场景分布式锁就很有必要。我用StackExchange.Redis的StringSet配合When.NotExists实现加锁再配合 Lua 脚本释放避免误删其他请求的锁public class RedisLockService { private readonly IConnectionMultiplexer _connection; public RedisLockService(IConnectionMultiplexer connection) { _connection connection; } public async Taskbool TryLockAsync(string key, string token, TimeSpan expiry) { IDatabase db _connection.GetDatabase(); return await db.StringSetAsync(key, token, expiry, When.NotExists); } public async Taskbool UnlockAsync(string key, string token) { IDatabase db _connection.GetDatabase(); var script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; var result await db.ScriptEvaluateAsync( script, new RedisKey[] { key }, new RedisValue[] { token }); return (long)result 1; } }token我用Guid.NewGuid().ToString(N)生成作为当前请求的唯一标识。释放锁时脚本先检查当前 value 是不是自己的 token是才删除。如果不加这个判断一个请求超时后锁自动过期另一个请求拿到锁然后第一个请求突然执行完去释放锁就会把别人的锁删掉这就是“误删锁”问题。锁的过期时间也要认真算。设太短业务还没执行完锁就过期了设太长服务真正宕机时锁要很久才释放。我的经验是先统计业务操作的平均耗时然后取平均耗时的 3 到 5 倍作为过期时间。不要拍脑袋写 60 秒因为分布式锁一旦过期你就失去互斥保障了。3.4 缓存击穿、穿透和雪崩的简单应对这三个问题经常被面试考但在真实项目里也真能碰到。穿透查询一个不存在的 ID缓存里没有数据库也没有每次都打到数据库。应对方式是把空值也缓存起来比如product:-1存一个空对象TTL 设 1 分钟。击穿大量请求同时访问某个热点缓存结果缓存刚好过期所有请求同时打到数据库。应对方式可以在回填缓存前加锁或者用GetOrAdd之类的封装保证只有一个请求去查库。雪崩大量 key 在同一时间批量失效数据库瞬间压力激增。应对方式是在缓存时间上增加随机数让过期时间分散开。Redis 不是用来背概念的。我建议在设计缓存时就规定好 key 的格式比如业务:对象:主键:扩展。我项目里统一用product:123,order:user:456:list这种风格好处是排查时能一眼看出缓存内容还能用redis-cli --scan --pattern批量清理。4. EF Core 持久层设计与性能调优4.1 DbContext 配置连接池和并发控制EF Core 在 .NET 8 里做了一次升级叫 EF8。项目里比较常用的是AddDbContextPool它可以让 DbContext 实例像数据库连接池一样复用避免频繁创建和销毁带来的开销。注册方式builder.Services.AddDbContextPoolAppDbContext(options { options.UseSqlServer(builder.Configuration.GetConnectionString(Default)); });并发控制方面我在实体上加了 SQL Server 的行版本字段public class Product { public int Id { get; set; } public string Sku { get; set; } string.Empty; public string Title { get; set; } string.Empty; public decimal Price { get; set; } [Timestamp] public byte[] RowVersion { get; set; } Array.Emptybyte(); }[Timestamp]会在每次更新时自动变更EF Core 生成UPDATE语句时会把原来的 RowVersion 放在WHERE条件里。如果两条并发请求同时改同一行后面提交的那条会抛出DbUpdateConcurrencyException。这个异常要捕获并转成 409 冲突返回给前端前端提示用户刷新后再试而不是让用户看到 500。4.2 查询性能把 DTO 和实体分开EF Core 最容易犯的错是把整个实体直接返回前端。实体内部有导航属性序列化时可能循环引用还可能把数据库的敏感字段暴露出去。正确做法是用 DTO 投影。比如商品分页列表我不查整张表而是用Select只取需要的字段var items await _db.Products .AsNoTracking() .Where(p p.CategoryId request.CategoryId p.Status 1) .OrderByDescending(p p.CreatedAt) .Select(p new ProductListItemDto { Id p.Id, Title p.Title, Price p.Price, CoverUrl p.Images .OrderBy(i i.Sort) .Select(i i.Url) .FirstOrDefault() ?? }) .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToListAsync(ct);这段查询有三点值得说AsNoTracking()告诉 EF 不要跟踪实体状态因为这里纯读取省了快照开销。投影发生在 SQL 层不把整张大对象加载到内存再丢弃。导航属性p.Images预先按Sort排序取第一张避免在 C# 里再排序。如果发现某些查询特别慢我会先开启 EF 的 SQL 日志来看实际生成的 SQL而不是凭感觉加索引。在开发环境可以这样配置options.LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging();EnableSensitiveDataLogging会显示参数的具体值调试方便但生产环境绝不能开否则 SQL 日志里会带用户数据。4.3 迁移与多环境配置EF Core 的迁移工具很好用。每次改完模型我习惯在测试库上跑一次迁移脚本确认无误再合入主干。命令大概是dotnet ef migrations add AddProductStatus dotnet ef database update要注意的是迁移不是万能的。如果线上数据量已经很大迁移可能会锁表。我的处理方式是小表变更直接迁移大表变更先生成 SQL 脚本DBA 评审后在低峰期执行。EF 生成的迁移脚本可以导出dotnet ef migrations script这个脚本是纯 SQL直接在数据库管理工具里执行也可以。团队里如果既有 .NET 开发又有 DBA这一步能让两边都放心。5. 常见问题排查与避坑记录5.1 网关层面的三连问路径、健康检查、超时有一次压测发现部分请求返回 503排查到最后是 YARP 主动健康检查超时时间太短。后端某个接口在特定条件下会慢 6 秒而健康检查超时是 5 秒于是整个实例被判定为不健康摘出负载均衡列表。流量全打到另一台另一台也跟着慢引发雪崩。健康检查不是给开发看得它决定流量分发是否安全。推荐的配置是Interval略大于检查接口平均耗时Timeout至少给到平均耗时的两倍。检查接口建议单独做一个/health不要复用业务接口。业务接口一旦出现偶发慢请求你就分不清是实例挂了还是业务卡了。YARP 转发超时我使用的是默认 HttpClient 超时实际表现不稳定。后来显式设置了builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection(ReverseProxy)) .AddHttpClient(client client.Timeout TimeSpan.FromSeconds(30));超时时间要长于后端业务接口的 p99 耗时但又不能太长否则网关线程容易被拖住。30 秒是业务接口平均 1 秒p99 3 秒场景下的一个稳妥值。5.2 Redis 的高频坑连接管理和序列化StackExchange.Redis 的连接推荐使用懒加载单例模式不要在每次请求时new ConnectionMultiplexer。很多人刚接触时这样写var conn ConnectionMultiplexer.Connect(configuration);在服务启动阶段这样写没问题但如果 Redis 临时宕机abortConnect默认行为会导致连接尝试中断后续请求全部失败。我的推荐配置是给连接字符串加上abortConnectfalse并让ConnectionMultiplexer以单例方式存活。这样 Redis 恢复后可以自动重连。缓存序列化方面我用的是System.Text.Json默认会序列化实体全部属性。如果某个实体里有一个 5MB 的图片 base64 字段缓存大小会爆炸。建议缓存对象单独建一个 DTO只放真正需要缓存的字段或者改用压缩工具对超大 value 做压缩。缓存空间是成本不能随意浪费。5.3 EF 性能排查清单我整理了一张排查 EF 慢查询的自查表每次接口性能出问题就对照检查现象可能原因处理方式同一个实体被反复查询N1改用Include或投影更新操作特别慢没有行版本控制并发重试加Timestamp字段分页首屏很慢深分页Skip太多改用 keyset 分页基于 ID内存占用高跟踪实体过多查询使用AsNoTracking查询慢但看不出问题缺索引查看 SQL 执行计划针对性加索引EF Core 是个好工具但它不是银弹。复杂报表、大批量数据同步我会绕过 EF 直接走原生 SQL 甚至 DBCopy 工具。用对工具比强行统一用一种工具更重要。5.4 可观测性日志链路和缓存命中率最后说一个最容易被忽略的部分——可观测性。网关、Redis、EF 三块各自都能记录日志但如果没有一个统一标识串起来出了问题根本不知道是哪一环。我的项目里在 YARP 网关和 API 实例之间的请求头中传递了一个TraceId。网关入口生成后端 API 从请求头读取并放进日志上下文。这样一次请求从入口到落库的全链路日志可以用同一个 ID 拉出来。排查超时问题时我只需要在日志平台搜索 TraceId就能看到网关转发用了多长时间、Redis 命中没有、EF 执行了几条 SQL、每条 SQL 多少毫秒。另外Redis 的缓存命中率我会定期看。如果命中率低于 70%说明很多缓存没有生效检查缓存 key 是否规范、TTL 是否合理、回填逻辑是否有 bug。YARP 也有自己的日志和指标比如每分钟转发请求数、失败率、每个后端的平均耗时。只要在网关层把这些指标追加到现有监控看板上整体运行状态就会透明很多。我自己迭代这套组合时最深的体会是技术选型不是越新越好而是要和团队能力匹配。YARP、Redis、EF 这套组合能快速搭起来但如果中间没有监控和日志链路出问题时会非常被动。无论你是刚开始迁移到多实例部署还是已经在用其中一部分我建议先把网关健康检查、缓存 key 规范、EF SQL 日志这三件事搞定再谈业务迭代。基础的排查能力和日志设计到位了这套架构的稳定性才会真正体现出来。