接手这个项目时,团队内部其实纠结过一阵子。技术栈定的是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(DbContextOptions<OrderDbContext> options) : base(options) { } public DbSet<Order> Orders => Set<Order>(); public DbSet<OrderItem> OrderItems => Set<OrderItem>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>(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 ILogger<RedisCacheService> _logger; public RedisCacheService(IDistributedCache cache, ILogger<RedisCacheService> logger) { _cache = cache; _logger = logger; } public async Task<T?> GetAsync<T>(string key) where T : class { var value = await _cache.GetStringAsync(key); if (string.IsNullOrEmpty(value)) return null; try { return JsonSerializer.Deserialize<T>(value, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (JsonException ex) { _logger.LogWarning(ex, "缓存反序列化失败: {Key}", key); return null; } } public async Task SetAsync<T>(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 Task<bool> 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来记录真实客户端IP,YARP默认会添加这个头,但前提是我们要把ForwardedHeaders中间件放到网关管道里正确的位置,并且KnownNetworks、KnownProxies配置要处理正确。我们当时因为ASPNETCORE_FORWARDEDHEADERS_ENABLED环境变量没设置,导致转发的请求头里根本没有X-Forwarded-For,排查起来绕了很大的圈子。
4.2 EF查询性能的坑
EF的经典三坑我们几乎都碰到了。
第一个是N+1查询。列表页展示订单和订单项时,如果不加Include,EF会对每个订单单独查一次订单项,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都在同机房的另一台机器上。压测数据大概是这样的:
| 场景 | QPS | P99延迟 |
|---|---|---|
| 命中缓存的分页查询 | 约4200 | 35ms |
| 未命中缓存并回源查询 | 约900 | 180ms |
| 网关纯转发(不带业务) | 约8000 | 8ms |
命中缓存和未命中缓存的差距相当直观。调优时重点放在数据库连接池上,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的动态路由和配置热加载做进去,顺便引入消息队列处理一部分异步任务,等有结果了再记录一篇。