
接手这个项目时团队内部其实纠结过一阵子。技术栈定的是YARP .NET 8 WebAPI Redis EF Core说实话第一眼看上去不是那种特别新奇的组合但真正把它们拼起来做一条完整的业务链路时才发现每一个环节都有不少值得反复琢磨的地方。尤其是YARP这一层网关很多人对它的印象还停留在微软出的反向代理库实际用下来它对.NET生态的贴合程度远超预期。这篇文章不打算写成一本书就把我们做这个内部业务系统一期时从选型、架构、核心代码实现到线上踩坑的整个过程做一个阶段性记录给正在考虑类似技术栈的朋友一个真实的参考。整个项目要解决的核心问题并不复杂几个不同业务模块需要统一入口、统一鉴权、统一缓存策略同时数据访问层要能支撑后续的复杂查询和迁移需求。选型讨论时其实也是围绕这三件事展开的最后落定的方案就是标题里这套组合。下面我按项目推进的实际顺序把每个环节的关键决策和实现细节拆开来讲。1. 先聊选型四件套各自抗的是什么活1.1 网关为什么要用YARP而不是Nginx当时网关层的备选方案有三个Nginx、Kong、还有YARP。Nginx我们都很熟配置灵活、性能强但问题是我们的路由规则不是完全静态的部分模块的转发目标会根据业务状态动态变化Nginx的配置热更新在容器化环境里没那么顺手。Kong功能全面但引入它等于多了一个需要独立维护的基础组件运维成本对于我们这个规模的小团队来说偏高。YARP的出现刚好把这个问题简化了。它是微软开源的.NET反向代理组件我们可以直接把它作为ASP.NET Core应用的一部分运行相当于把网关变成了我们业务代码里的一个模块。路由配置可以来自appsettings.json也可以走代码动态加载甚至后续想接配置中心也方便。负载均衡策略内置了轮询、随机、权重等多种URL改写、请求头处理这些也都支持。最关键的一点团队全部是.NET背景用YARP意味着不需要有人专门去啃一套独立的网关语法维护心理负担小很多。要补充一个亲身感受YARP的性能并不比Nginx差多少。我们压测过纯转发场景吞吐量差距在个位数百分比以内对于绝大多数内部系统来说完全够用。如果你的团队是.NET技术栈又恰好需要一个能写代码的网关YARP值得优先考虑。1.2 .NET 8 WebAPI业务代码的主战场.NET 8是长期支持版本发布时间已经不短生态和坑都已经被社区趟得差不多。我们选择它主要是因为两个原因一是性能基线比之前的版本有明显的提升二是不管是最小API还是路由组写起来都干净很多。自己项目里很多接口逻辑本身不复杂用最小API可以把路由定义和业务处理放在同一个文件里代码量缩得很明显。还有一点是AOT发布、原生镜像这些新特性虽然看着潮但我们一期并没有直接上原因后面会在踩坑部分详细说。生产环境我们用的还是常规的JIT运行模式配合Docker容器部署稳定性优先。1.3 EF Core负责和数据打交道数据访问层的选型其实比网关更早定下来因为EF Core是.NET生态里最主流的数据访问框架开发效率高得很对PostgreSQL、SQL Server都有很好的支持。团队内部也有人提过Dapper觉得性能好、可控性强。但我们在评估时还是选了EF Core核心原因是这个项目的业务模型之间关联关系比较复杂查询逻辑需要动态组合条件、分页、排序、导航属性加载。Dapper需要手写大量SQL和映射代码前期写起来爽后期维护起来累。EF Core虽然在一些极端复杂查询上有性能瓶颈但可以通过Raw SQL、编译查询来兜底。说白了选EF Core是选大部分场景下开发效率最均衡而不是所有场景下性能最强。1.4 Redis的定位比缓存要宽得多Redis在这个项目里绝不只是用来加速查询那么简单。除了常规的缓存我们还用它做了三件很重要的事分布式锁、幂等键、临时数据存储。后面核心代码部分会逐一展开。选型上没有什么悬念StackExchange.Redis就够用了没有引入额外的RedLock包只在需要分布式锁的几个核心接口上用自研的简单锁结构来实现避免依赖太重。2. 整体架构和一次请求的完整流转2.1 一个订单查询请求是怎么走完全程的可以先看一个最典型的查询场景前端发起GET /api/orders/10086。这个请求先进到YARP网关YARP根据路由规则把路径以/api开头的请求转发给WebAPI服务实例。WebAPI接收到请求后先走鉴权中间件检查Token然后进到订单查询的接口业务逻辑接口第一件事就是查Redis缓存有没有以order:10086为键的数据。如果命中直接反序列化返回如果没命中就通过EF Core去SQL Server里查查到的数据序列化后写入Redis设置过期时间再返回给调用方。YARP在这一层并没有做缓存因为当时的需求里网关是纯转发角色缓存策略放在每个业务接口里更灵活。如果你的场景是很多服务共用一个热点数据源也可以考虑在YARP层做响应缓存它本身是支持的但要注意缓存粒度不好控制。2.2 项目目录怎么分服务怎么注册我们采用了按服务垂直拆分的目录结构不是严格的微服务也不是简单的单体更像一个 modular monolith模块化单体MyApp/ ├── MyApp.Gateway/ │ ├── Program.cs │ └── appsettings.json ├── MyApp.Services/ │ ├── MyApp.ProductService/ │ │ ├── Controllers/ │ │ ├── Application/ │ │ ├── Domain/ │ │ ├── Infrastructure/ │ │ └── Program.cs │ ├── MyApp.OrderService/ │ └── MyApp.UserService/ └── docker-compose.yml每个服务独立运行在自己的容器里通过YARP统一对外暴露端口。这样做的考虑是既保留微服务后续独立扩展的能力又不需要一上来就处理服务发现、分布式事务这些复杂度。服务注册沿用.NET 8标准的依赖注入容器EF Core和Redis的连接配置都集中在各个服务的appsettings.json里通过IOptions读取。2.3 YARP网关层的配置长什么样网关层的核心配置其实非常简单。以下是我们实际用到的appsettings.json片段{ ReverseProxy: { Routes: { product-route: { ClusterId: product-cluster, Match: { Path: /api/products/{**catch-all} } }, order-route: { ClusterId: order-cluster, Match: { Path: /api/orders/{**catch-all} } } }, Clusters: { product-cluster: { Destinations: { product1: { Address: http://product-service:8080 } } }, order-cluster: { Destinations: { order1: { Address: http://order-service:8080 } } } } } }Program.cs里只需要两行代码builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection(ReverseProxy)); app.MapReverseProxy();配置简单到让团队里没接触过YARP的同学十分钟就能上手。当然这只是最基础的静态配置后面我们做动态路由时发现还可以在服务注册阶段通过LoadFromMemory直接传入内存中的路由配置对象灵活性比配置文件模式高出不少。3. 从零手写核心代码网关、接口、缓存、模型串起来3.1 YARP转发链路的关键细节YARP接入本身不难但有几个转发细节容易被文档掩盖。一个是请求头处理。默认情况下YARP转发请求时会保留原始Host头但有时候后端服务需要的是自己的服务名而不是客户端传来的Host。需要在Route配置里显式设置Route: { Order: 0, ClusterId: product-cluster, Match: { Path: /api/products/{**catch-all} }, Transforms: [ { RequestHeader: Host, Set: product-service } ] }另一个是尾斜杠问题。后端服务的Address末尾有没有斜杠会直接影响URL拼出来的路径。http://product-service:8080/和http://product-service:8080在转发/api/products/1时得到的结果不同前者可能拼出/api/products/1后面的内容后者则是/api/products/1直接拼在后面。我们统一约定后端Address不带尾斜杠避免心理预期和实际结果不一致。3.2 WebAPI与EF的增删改查示例拿订单服务举例DbContext大致长这样public class OrderDbContext : DbContext { public OrderDbContext(DbContextOptionsOrderDbContext options) : base(options) { } public DbSetOrder Orders SetOrder(); public DbSetOrderItem OrderItems SetOrderItem(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityOrder(entity { entity.ToTable(orders); entity.HasKey(o o.Id); entity.Property(o o.Status) .HasMaxLength(20) .IsRequired(); entity.HasMany(o o.Items) .WithOne() .HasForeignKey(i i.OrderId); }); } }对应的查询接口用到了EF Core 8里新加入的执行策略配置例如EnableRetryOnFailure它让数据库暂时不可用时能够自动重试几次而不是直接抛错。我们当时排查生产环境偶发连接故障时发现开了这个配置后确实缓解了不少问题。这里想多说一句EF Core的迁移流程。我们采用的是Migration 启动检查策略开发阶段每次改模型就dotnet ef migrations add生成迁移脚本部署时通过启动脚本执行dotnet ef database update。注意不要在多个实例同时启动时都执行update会有锁冲突我们当时就是踩了这个坑后来改成部署流水线里单独跑一个迁移Job。3.3 Redis缓存服务封装和击穿保护缓存服务我用了一个简单的泛型封装public class RedisCacheService : IRedisCacheService { private readonly IDistributedCache _cache; private readonly ILoggerRedisCacheService _logger; public RedisCacheService(IDistributedCache cache, ILoggerRedisCacheService logger) { _cache cache; _logger logger; } public async TaskT? GetAsyncT(string key) where T : class { var value await _cache.GetStringAsync(key); if (string.IsNullOrEmpty(value)) return null; try { return JsonSerializer.DeserializeT(value, new JsonSerializerOptions { PropertyNameCaseInsensitive true }); } catch (JsonException ex) { _logger.LogWarning(ex, 缓存反序列化失败: {Key}, key); return null; } } public async Task SetAsyncT(string key, T value, TimeSpan expiry) where T : class { var json JsonSerializer.Serialize(value); await _cache.SetStringAsync(key, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow expiry }); } }AddStackExchangeRedisCache时要注意InstanceName的坑它会在所有key前面加前缀比如InstanceName app:那么你实际往Redis里写的数据key会变成app:order:10086。如果不注意排查问题时很容易在Redis客户端里找不到自己刚写入的键。缓存击穿的保护我们用的是信号量当缓存未命中时同一key只让一个请求去数据库查询其余请求等待这个请求把缓存补齐后再读缓存。实现起来就是加一个SemaphoreSlim字典按key区分锁。代码不复杂但在高并发下非常管用没有采用第三方库。3.4 使用Redis做分布式锁防止重复提交这个分布式锁的需求来源于一个真实痛点客户端下单时连续点两次提交按钮结果生成了两笔重复订单。最简单的幂等方案就是在接口入口处基于订单号设置一个Redis分布式锁同一瞬间只有一个请求能继续执行。public class RedisLock { private readonly IDatabase _redis; private readonly TimeSpan _expiry; public RedisLock(IDatabase redis, TimeSpan expiry) { _redis redis; _expiry expiry; } public async Taskbool TryAcquireAsync(string lockKey, string requestId) { bool acquired await _redis.LockTakeAsync(lockKey, requestId, _expiry); return acquired; } public async Task ReleaseAsync(string lockKey, string requestId) { await _redis.LockReleaseAsync(lockKey, requestId); } }使用LockTakeAsync时requestId必须唯一释放时也带上它避免误释放了其他请求拿到的锁。LockTake底层就是SET key value NX EX这个原语很干净。之前看到有人直接用SETNX再用EXPIRE两段命令组合会有进程崩溃导致锁永不过期的风险所以尽量使用StackExchange.Redis封装好的原子方法。4. 踩坑记录开发中最花费时间的那几个问题4.1 YARP转发时路径和Header的坑先说路径匹配。我们一开始用Path: /api/{**catch-all}这种通配写法时把/api/orders和/api/orders/当成两种请求路由匹配度不同但不影响核心转发。真正的问题出现在某个接口本身返回的路由带了版本前缀/v1而YARP路由规则里没有定义对应的转发规则结果网关层直接返回404。排查时发现YARP的默认行为是找不到匹配的路由就直接404不会尝试模糊匹配。后来把所有前缀都显式列到Routes里才算消停。再说Header问题。某个后端服务需要读取X-Forwarded-For来记录真实客户端IPYARP默认会添加这个头但前提是我们要把ForwardedHeaders中间件放到网关管道里正确的位置并且KnownNetworks、KnownProxies配置要处理正确。我们当时因为ASPNETCORE_FORWARDEDHEADERS_ENABLED环境变量没设置导致转发的请求头里根本没有X-Forwarded-For排查起来绕了很大的圈子。4.2 EF查询性能的坑EF的经典三坑我们几乎都碰到了。第一个是N1查询。列表页展示订单和订单项时如果不加IncludeEF会对每个订单单独查一次订单项100个订单就是101条SQL。解决方式是在查询里用Include(o o.Items)或者投影成DTO时用Select。第二个是跟踪查询的开销。默认查询是跟踪状态的只读场景里这个开销纯属浪费。在只读接口的DbContext查询前一定要加AsNoTracking()。简单一行代码P99延迟能差20毫秒以上。第三个是分页性能。订单表数据过百万后传统的Skip/Take分页在页码越深时越慢。我们后来改成了基于WHERE Id lastId的键集分页效果立竿见影。如果你的列表是按主键或唯一索引排序的强烈建议用键集分页。4.3 Redis缓存和数据库一致性的坑缓存和数据库的一致性永远是分布式系统中的经典问题。我们的策略很朴素先更新数据库再删除缓存。为什么不先更新缓存因为并发场景下先更新缓存再写数据库一旦写库失败缓存里就已经是错误数据而先写库再删缓存即使缓存删除失败也可以通过缓存的过期时间兜底。不过先删缓存再更新数据库在某些假设下也可以但会有窗口期问题。我们当时出现过一个真实事故订单状态由待支付改为已支付先更新数据库成功删除缓存时Redis突然超时删除失败结果下游用户一直看到待支付持续了整整一个缓存过期周期才恢复。后来加的补救措施是延迟双删在事务提交后先删一次缓存过500毫秒再删一次。这样即使第一次删除失败第二次也能兜底。虽然不完美但在我们场景里够用。4.4 部署环境里的坑容器化的坑主要是两个。一是Docker镜像体积。使用官方SDK镜像直接发布一个服务镜像体积能到300MB以上。改成自包含发布加--no-restore还是大。最后用的方案是分成builder和runtime两阶段运行时镜像用aspnet:8.0把体积控制在200MB以内算是常规操作。第二个坑是启动健康检查。Spring有/actuator/health那样的约定ASP.NET Core里可以用AddHealthChecks()加探活。YARP网关转发到后端服务时如果后端还没就绪会返回502。后来编排里加了depends_on和健康检查稍微缓解了这个问题。其实更稳妥的做法是在业务代码里等待数据库连接和Redis连接可用后再开始接受请求但我们一期没做那么细是后续计划里的改进项。5. 部署上线与压测数据复盘5.1 用Docker Compose把这套东西跑起来开发环境我们用Docker Compose一键拉起来。整体编排大致长这样version: 3.8 services: redis: image: redis:7-alpine ports: - 6379:6379 sqlserver: image: mcr.microsoft.com/mssql/server:2022-latest environment: ACCEPT_EULA: Y SA_PASSWORD: Your_Strong_Password123! product-service: build: ./MyApp.Services/MyApp.ProductService depends_on: - redis - sqlserver order-service: build: ./MyApp.Services/MyApp.OrderService depends_on: - redis - sqlserver gateway: build: ./MyApp.Gateway ports: - 8080:8080 depends_on: - product-service - order-service本地开发时这个方式非常顺手。注意SQL Server首次启动需要初始化时间如果depends_on就绪检查没有配好业务服务启动时连接数据库会失败。我们后来给业务服务的启动逻辑加了重试避免依赖编排层面的健康检查。5.2 压测结果和连接池调优我们针对订单查询接口做了压测环境是2核4G的容器数据库和Redis都在同机房的另一台机器上。压测数据大概是这样的场景QPSP99延迟命中缓存的分页查询约420035ms未命中缓存并回源查询约900180ms网关纯转发不带业务约80008ms命中缓存和未命中缓存的差距相当直观。调优时重点放在数据库连接池上appsettings.json里显式配置了Max Pool Size 128避免默认的100连接不够用。同时Redis连接没有用连接池而是直接用StackExchange.Redis的复用连接对象它自己处理多路复用不需要我们手动管理多个连接。5.3 线上问题排查的几个工具与方法分享几个我们实际用着顺手的排查方法。Redis侧redis-cli --latency可以看实时延迟SLOWLOG GET 5可以查慢命令排查缓存超时问题很高效。EF侧的日志把LogTo输出到Console再传到集中日志平台查询SQL语句和参数一目了然。YARP侧它的日志会输出路由匹配到哪个Cluster这类信息转发失败时看日志能快速知道是路由没匹配上还是目标地址不通。还有一个经验压测或线上排查时记得用/metrics暴露Prometheus指标或者至少把AddHttpLogging打开。我们一期的监控还没完全铺开但仅是基本的HTTP状态码和延迟日志就已经定位过好几次由慢查询导致的问题了。产品上线后的一些个人体会整体跑下来这套技术栈组合经过全流程验证之后给我的最大感受是没有一处特别华丽但每一处都恰到好处。YARP负责入口路由.NET 8 WebAPI负责业务承载EF Core负责数据建模查询Redis负责性能和并发兜底。每个组件都能在团队现有能力范围内快速落地没有引入维护负担特别重的额外组件。最后分享一个小建议如果你是第一次搭这种架构不要一开始就追求动态路由、分布式锁、多级缓存这些高级功能先把最简的静态转发加CRUD跑通再逐步增加Redis缓存、EF性能优化、幂等控制这一层一层的东西。软件系统里能把常用方案做到极致已经能解决绝大多数问题。这个项目一期我们就是这么过来的后面二期准备把YARP的动态路由和配置热加载做进去顺便引入消息队列处理一部分异步任务等有结果了再记录一篇。