本篇把前面第16篇的"通信与一致性设计"、第17/18篇的"核心服务实现"真正串成一条可运行、可观测、可扩展的链路。我们会用 .NET Aspire 把服务发现、韧性、遥测这类横切能力收敛到统一入口,再分别落地同步调用、异步消息、网关转发与缓存策略,让系统在开发环境和未来生产环境的演进路径都清晰可控。
一、服务间调用
1.1 从"能调用"到"调用得稳"
在微服务架构里,服务间调用的难点从来不是"写一个 HTTP 请求",而是如何让调用在高并发、依赖抖动、网络闪断、下游变更时仍然稳定可控。第16篇已经明确了两条主线:需要强一致反馈的场景采用同步调用,需要削峰填谷与最终一致的场景采用异步消息。本篇的目标是把"选择通信模式"落到代码与配置上,让每个服务既能找到对方,也能在对方不稳定时优雅降级,同时还能把链路追踪完整地串起来。
本节的结尾先给一个结论:在 .NET Aspire 的体系里,推荐把"地址解析、重试/超时/断路器、遥测传播"这三件事做成默认能力,业务代码里只关心业务契约与错误语义,而不是反复手写连接细节。
1.2 服务发现与地址管理:让代码只写"服务名"
在传统做法里,调用方往往需要在配置里写死下游地址,例如某个服务的 https://localhost:5003 或者某个环境里的私网域名。这会导致环境切换时配置膨胀,也会让本地调试与容器运行的差异变得难以管理。服务发现的价值在于把"物理地址"变成"逻辑名字",调用方只需要知道目标服务的逻辑名,剩下的解析交给基础设施层来完成。
在 .NET 生态里,服务发现能力可以通过 Microsoft.Extensions.ServiceDiscovery 与 HttpClient 集成。它支持一种非常实用的"Scheme 优先级"写法:https+http://basket。这意味着调用方优先尝试解析 HTTPS 端点,若找不到再回退到 HTTP 端点。这个写法非常适合我们这种既要本地开发便利、也要保留生产安全默认值的项目。
下面用一个典型的"订单服务调用商品服务查询商品快照"的例子说明推荐写法。代码片段展示的是在服务的 Program.cs 里配置默认的服务发现与韧性策略,并通过强类型客户端发起请求。
csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddServiceDiscovery();
http.AddStandardResilienceHandler();
});
builder.Services.AddHttpClient<ProductClient>(client =>
{
client.BaseAddress = new Uri("https+http://productservice");
});
var app = builder.Build();
app.MapGet("/debug/product/{id:guid}", async (Guid id, ProductClient client, CancellationToken ct) =>
{
var dto = await client.GetByIdAsync(id, ct);
return Results.Ok(dto);
});
app.Run();
这段代码里,builder.AddServiceDefaults() 是我们在前面文篇已经反复强调的"统一入口",它把 OpenTelemetry、健康检查、服务发现与 HttpClient 的韧性默认值收敛到同一套约定里。紧接着 AddServiceDiscovery() 把服务发现能力注册到容器里,而 ConfigureHttpClientDefaults(...) 则把服务发现与标准韧性处理器设为全局默认,这样你后面再创建任何 HttpClient,都不用重复写重试与超时的细节。
client.BaseAddress = new Uri("https+http://productservice") 是本节最重要的落点:我们不在代码里写端口,也不绑定某个环境的真实域名,只写服务名 productservice。在 Aspire 编排下,这个服务名与 AppHost 注册的资源名保持一致,就能让调用在本地、容器、以及后续的云环境里都能用同一份代码工作。
本节的结尾再强调一次:服务发现不是"魔法 DNS",它的核心是把"寻找地址"从业务代码里抽离出来,并且把解析结果纳入可观测性与韧性体系之中,这样当依赖抖动时你能在 Dashboard 里看到真正发生了什么。
1.3 强类型客户端:把"HTTP 细节"收进一个类
当服务数量增长后,如果每个业务接口都在端点里手写 HttpClient.GetAsync、拼 URL、反序列化 JSON,很快会出现两个问题:一是调用逻辑到处散落,难以统一处理错误与重试边界;二是契约演进时缺少集中入口,容易出现"某些调用忘了改"。因此更推荐的方式是为每个被调用服务定义一个强类型客户端,把 URL、DTO、错误语义都封装在一个位置。
下面给出一个最小但可扩展的 ProductClient。它使用 HttpClient 发送请求,用 System.Net.Http.Json 反序列化响应,并在收到非成功状态码时把错误上抛,让上层决定是返回 404、还是降级为缓存数据。
csharp
public sealed class ProductClient
{
private readonly HttpClient _http;
public ProductClient(HttpClient http)
{
_http = http;
}
public async Task<ProductDto> GetByIdAsync(Guid id, CancellationToken ct)
{
using var resp = await _http.GetAsync($"/products/{id}", ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new InvalidOperationException($"Product not found: {id}");
}
resp.EnsureSuccessStatusCode();
var dto = await resp.Content.ReadFromJsonAsync<ProductDto>(cancellationToken: ct);
return dto ?? throw new InvalidOperationException("Empty response body");
}
}
public sealed record ProductDto(Guid Id, string Name, decimal Price);
这段实现的关键点在于边界清晰。构造函数只接受一个 HttpClient,这让它天然适配 IHttpClientFactory,也适配我们在上一节设置的全局服务发现与韧性策略。GetByIdAsync 里先判断 404 并转成更贴近业务的异常,再调用 EnsureSuccessStatusCode() 处理其他错误,这样上层端点可以用统一的异常映射策略来决定最终返回给调用方的 HTTP 状态。
本节的结尾给一个实践建议:强类型客户端的返回值尽量使用"面向调用方的 DTO",不要直接暴露下游服务的数据库实体模型。这样当下游实体字段调整时,你更容易控制契约的兼容性与版本策略。
1.4 链路追踪与上下文传播:让每一次调用都"看得见"
服务间调用一旦变多,最容易出现的现象是"用户报错了但你不知道卡在哪个服务"。要解决这个问题,单点日志远远不够,必须依赖分布式追踪把一次请求跨服务的调用链串起来。Aspire 的 ServiceDefaults 能把 OpenTelemetry 的基础配置做成默认值,调用链中只要使用标准的 HttpClient、ASP.NET Core 管道,TraceContext 就能自动传播。
为了把"看得见"落实到你自己的业务,我们通常还会在关键边界打一些结构化日志,并把业务相关的标识(例如订单号)作为标签写入 Activity。这样在 Aspire Dashboard 里你既能看到 Span 的耗时,也能直接跳转到关联日志。
csharp
using System.Diagnostics;
var activitySource = new ActivitySource("Shop.OrderService");
app.MapPost("/orders", async (CreateOrderRequest req, ProductClient productClient, CancellationToken ct) =>
{
using var activity = activitySource.StartActivity("CreateOrder");
activity?.SetTag("order.userId", req.UserId);
activity?.SetTag("order.items", req.Items.Count);
var first = req.Items[0];
var product = await productClient.GetByIdAsync(first.ProductId, ct);
activity?.SetTag("order.firstProduct", product.Id);
return Results.Ok(new { Message = "ok" });
});
public sealed record CreateOrderRequest(Guid UserId, List<CreateOrderItem> Items);
public sealed record CreateOrderItem(Guid ProductId, int Quantity);
这段代码并不是为了"手动做追踪",而是为了在自动追踪的基础上补充业务语义。ActivitySource 会在 OTel 管道里被采集,SetTag 的内容会成为可查询的维度。当你遇到某个用户下单变慢时,你可以先用标签过滤出这一类请求,再看它们共同慢在商品服务、还是慢在数据库。
本节到这里可以收束:同步调用要做到"能找到、能兜底、能观测",最省心的路径是把地址解析与韧性收进默认配置,把调用细节收进强类型客户端,把业务语义补进 Activity 与结构化日志。
二、消息队列集成
2.1 异步通信的核心目标:解耦与最终一致
在"服务间通信设计"里已经把异步消息的价值讲得很清楚:当订单完成支付后,后续会触发库存扣减、发票、通知、积分等一系列动作,如果全部用同步链式调用,任何一个下游抖动都会把主链路拖慢甚至拖垮。异步消息把"主链路返回"与"下游处理"解耦开来,让系统在高峰期能够削峰填谷,并且能用重试与死信队列应对临时故障。
从实现角度看,异步通信想要做到"可靠",必须同时满足两件事:消息不能丢,消费者不能重复执行导致数据错乱。第18篇已经用 Outbox 与幂等消费表给出了可靠闭环的方向,本节会把消息中间件与 Aspire 编排对齐,给出一套可跑的 RabbitMQ 集成骨架。
本节的结尾先给一句"原则提醒":消息队列不是为了取代 HTTP,而是为了把"可异步的事"从"必须同步完成的链路"里剥离出去。
2.2 在 AppHost 中添加 RabbitMQ:把基础设施变成资源
在 Aspire 里,消息队列和数据库、缓存一样,都被视为分布式应用的一等资源。你不需要在每台开发机上手动安装 RabbitMQ,也不需要把连接串复制粘贴到每个服务的配置里。你只需要在 AppHost 的 Program.cs 里声明一个 RabbitMQ 资源,然后让需要它的服务引用它。
下面的代码展示了一个常用写法:添加 RabbitMQ 容器资源,开启管理插件端口,随后让订单服务、支付服务、通知服务都引用同一个 RabbitMQ 连接。
csharp
var builder = DistributedApplication.CreateBuilder(args);
var rabbitmq = builder.AddRabbitMQ("rabbitmq")
.WithDataVolume()
.WithManagementPlugin(port: 15672);
var orders = builder.AddProject<Projects.OrderService>("orderservice")
.WithReference(rabbitmq);
var payments = builder.AddProject<Projects.PaymentService>("paymentservice")
.WithReference(rabbitmq);
builder.AddProject<Projects.NotificationService>("notificationservice")
.WithReference(rabbitmq);
builder.Build().Run();
这段配置的价值在于"统一注入"。AddRabbitMQ("rabbitmq") 创建了一个名为 rabbitmq 的资源,后续 .WithReference(rabbitmq) 会把连接信息以连接字符串的形式注入到服务的配置里。服务侧并不需要知道 RabbitMQ 是容器还是云托管服务,更不需要知道宿主机端口怎么映射,它只需要知道"我要一个名为 rabbitmq 的连接"。
本节的结尾补一个非常实用的调试体验:管理插件端口打开后,你可以通过管理界面观察交换机、队列、消费速率与堆积情况,把"消息系统到底发生了什么"变成可视化信息,而不是纯靠猜。
2.3 在服务中注册 RabbitMQ 客户端:让连接可观测、可健康检查
仅仅拿到连接字符串并不等于"集成完成"。我们还希望 RabbitMQ 连接具备统一的重试、健康检查、日志与遥测。Aspire 提供了 RabbitMQ 客户端集成扩展,可以把 RabbitMQ.Client.IConnection 注册到 DI 容器里,并自动启用相关的可观测与健康检查能力。
下面的片段展示了服务侧的典型写法。它从 ConnectionStrings 读取名为 rabbitmq 的连接,然后注册为可注入的连接实例。
csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRabbitMQClient("rabbitmq");
var app = builder.Build();
app.MapPost("/events/test", (RabbitMQ.Client.IConnection conn) =>
{
using var channel = conn.CreateModel();
channel.ExchangeDeclare(exchange: "shop.events", type: "topic", durable: true);
channel.QueueDeclare(queue: "shop.notifications", durable: true, exclusive: false, autoDelete: false);
channel.QueueBind(queue: "shop.notifications", exchange: "shop.events", routingKey: "payment.succeeded");
var body = System.Text.Encoding.UTF8.GetBytes("{\"orderId\":\"demo\"}");
var props = channel.CreateBasicProperties();
props.DeliveryMode = 2;
props.ContentType = "application/json";
channel.BasicPublish(exchange: "shop.events", routingKey: "payment.succeeded", basicProperties: props, body: body);
return Results.Accepted();
});
app.Run();
这段代码里,builder.AddRabbitMQClient("rabbitmq") 的意义在于把连接纳入统一治理。你在业务端点里注入 IConnection 就能直接使用,而连接的创建、重试策略、健康检查与遥测都由集成层提供。发布消息时我们把交换机与队列设为 durable: true,并把消息的 DeliveryMode 设为 2,表示持久化投递,这样 RabbitMQ 在重启后仍能保留关键数据。
本节的结尾提醒一个常见误区:不要在每次发布消息时创建新连接。连接是重量级资源,应当复用;如果你确实需要按业务隔离不同的连接实例,可以使用 keyed 注册方式为不同 name 注册不同连接。
2.4 消费者与幂等:让"重复投递"变成可接受事实
在分布式系统里,消息重复投递并不是异常,而是一种常态。网络闪断、消费者超时、Broker 重投递都可能让同一条消息被消费多次。正确的做法不是试图"绝对避免重复",而是让消费逻辑天然幂等。第18篇我们已经实现了"消费记录表"的思路,本节用一个 BackgroundService 把消费骨架补齐,并解释 ACK 的时机与失败处理。
csharp
using Microsoft.Extensions.Hosting;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;
public sealed class PaymentSucceededConsumer : BackgroundService
{
private readonly IConnection _connection;
public PaymentSucceededConsumer(IConnection connection)
{
_connection = connection;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var channel = _connection.CreateModel();
channel.ExchangeDeclare("shop.events", "topic", durable: true);
channel.QueueDeclare("shop.notifications", durable: true, exclusive: false, autoDelete: false);
channel.QueueBind("shop.notifications", "shop.events", "payment.succeeded");
channel.BasicQos(prefetchSize: 0, prefetchCount: 16, global: false);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (_, ea) =>
{
try
{
var json = Encoding.UTF8.GetString(ea.Body.ToArray());
_ = json;
channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
}
catch
{
channel.BasicNack(deliveryTag: ea.DeliveryTag, multiple: false, requeue: true);
}
};
channel.BasicConsume(queue: "shop.notifications", autoAck: false, consumer: consumer);
return Task.CompletedTask;
}
}
这段消费者实现首先通过 BasicQos 控制预取数量,避免消费者一次拿太多消息导致内存压力。接着在 Received 回调里处理业务,并在成功后 BasicAck,失败时 BasicNack 并 requeue: true 让消息回到队列等待重试。这里故意没有把"幂等"写在示例里,是因为幂等逻辑通常依赖数据库或 Redis 的去重记录,它应该发生在你真正执行业务副作用之前,而不是简单 try/catch。
本节的结尾把关键点说透:ACK 的时机决定了"至少一次投递"与"最多一次投递"的权衡。我们在电商场景里更倾向于至少一次,然后用幂等把重复变成可接受,这比"尽量不重复但可能丢消息"更可控。
2.5 把 Outbox 与消息队列接起来:可靠事件的最后一公里
当你真正把消息队列用于关键业务时,只靠"先写库再发消息"会遇到经典一致性难题:数据库提交成功但消息发布失败会导致下游永远收不到事件。第18篇的 Outbox 解决的就是这个问题,它把"要发布的事件"也作为同一笔本地事务的一部分落库,然后由后台任务稳定地把 Outbox 里的消息投递到 RabbitMQ,并在投递成功后标记已发送。
本节的结尾给出一个实践落点:Outbox 的发布任务需要与业务服务同进程部署还是独立部署,要看你对扩展性的要求。教学项目里同进程更简单,生产化时独立化会更容易扩容与隔离故障域。
三、API 网关配置
3.1 网关的角色:统一入口与横切能力下沉
当服务数量增加后,如果让客户端直接调用每个微服务,会带来明显的复杂度与安全风险。客户端必须知道每个服务的地址、每套认证方式,还要自行处理跨域、限流、重试与熔断等策略。API 网关把这些横切能力收敛到一个入口,让后端服务更聚焦业务,同时也让安全策略与流量治理具备一致性。
在第16篇我们把网关作为同步通信链路的关键角色,本节的目标是把网关落地成一个可运行的工程:它既能做路由转发,也能把认证授权与观测统一起来,最后再交给 Aspire 进行编排与本地体验优化。
本节结尾先给一句取舍原则:网关要做"通用能力",不要做"业务编排"。业务聚合若不可避免,建议做成 BFF 或聚合服务,而不是把大量业务逻辑塞进网关。
3.2 使用 YARP 构建反向代理网关:最小可用骨架
YARP 是 ASP.NET Core 生态里常用的反向代理组件,适合用于实现 API Gateway 的"请求转发"能力。它的核心非常简单:在 Program.cs 里注册 Reverse Proxy,从配置加载路由与集群,然后把代理端点映射到应用管道中。
csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
这段代码的关键在于 LoadFromConfig(...),它把路由规则和目标集群交给配置系统,这样你可以在不同环境里使用不同的后端地址,而代码保持不变。网关真正的"路由表"一般写在 appsettings.json 的 ReverseProxy 节点里,下面给一个非常典型的结构,它把 /api/orders/** 转发到订单服务,把 /api/products/** 转发到商品服务。
json
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "orders-cluster",
"Match": {
"Path": "/api/orders/{**catch-all}"
}
},
"products-route": {
"ClusterId": "products-cluster",
"Match": {
"Path": "/api/products/{**catch-all}"
}
}
},
"Clusters": {
"orders-cluster": {
"Destinations": {
"d1": { "Address": "https://localhost:5005/" }
}
},
"products-cluster": {
"Destinations": {
"d1": { "Address": "https://localhost:5003/" }
}
}
}
}
}
在教学项目里我们先用 localhost 端口把骨架跑通,等到用 Aspire 编排时,再把这些地址交给 AppHost 统一注入或统一生成。你会发现一个很重要的工程收益:网关的行为由配置驱动,业务服务的扩容、变更地址、甚至增加实例都不必改网关代码。
本节的结尾补一句常见坑:YARP 转发本质上是"二次 HTTP 调用",如果你在网关里启用了认证授权、限流等中间件,务必注意中间件顺序,否则可能出现请求未认证就被转发,或者认证通过但路由未匹配的情况。
3.3 在网关层统一认证授权:让后端服务更轻
在第17篇我们已经实现了 JWT 的登录与授权,本节把它搬到网关层的目标不是"偷懒",而是让认证授权这类横切能力集中治理。网关验证 Token 后,可以把用户身份以 Header 透传给后端,也可以只在后端做细粒度鉴权,从而形成"网关做粗粒度门禁,服务做细粒度授权"的分层。
下面的代码展示了一个可运行的顺序:先加入认证与授权,再把 MapReverseProxy 映射到一个带中间件的代理管道里,确保转发前会经过鉴权。
csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy(proxyPipeline =>
{
proxyPipeline.UseAuthentication();
proxyPipeline.UseAuthorization();
});
app.Run();
这段代码的要点在于让认证授权同时出现在应用管道与代理子管道里,这样你既可以保护网关自身的端点,也可以确保所有被代理的请求都经过授权检查。实际项目里你通常会结合路由元数据或路径规则做"哪些路由需要鉴权、哪些路由允许匿名"的控制,从而避免把登录、健康检查之类的端点也拦截掉。
本节的结尾给一个实践经验:即使网关已经做了 JWT 验证,后端关键服务仍然应该保留必要的授权校验,至少要能防御"绕过网关直连服务"的场景。
3.4 用 Aspire 编排网关:让本地启动变成一键体验
网关与后端服务一起启动时,最容易出现的问题是"先启动网关但后端没起来,导致启动时就报错"或"换端口后配置忘了改"。Aspire 的 AppHost 能把网关与后端服务的依赖关系表达清楚,同时把连接信息与端点信息注入到正确位置。
下面给一个概念性的编排片段:把网关作为一个普通服务注册,并声明它依赖订单服务与商品服务。网关是否对外暴露端点,也由 AppHost 决定。
csharp
var builder = DistributedApplication.CreateBuilder(args);
var orders = builder.AddProject<Projects.OrderService>("orderservice");
var products = builder.AddProject<Projects.ProductService>("productservice");
builder.AddProject<Projects.ApiGateway>("apigateway")
.WithExternalHttpEndpoints()
.WithReference(orders)
.WithReference(products)
.WaitFor(orders)
.WaitFor(products);
builder.Build().Run();
这段编排的价值在于把"启动顺序"与"依赖关系"显式化。网关等待后端服务就绪后再启动,能显著降低本地调试时的偶发错误。更重要的是,当你后续把后端服务改成容器、或改成云托管资源时,编排层的表达方式仍然成立,工程结构不会因为环境变化而重写。
本节到这里可以收束:网关的本质是统一入口与横切能力下沉,YARP 帮你解决转发,JWT 帮你解决门禁,Aspire 帮你解决编排与本地体验。
四、缓存策略实现
4.1 缓存不是"加快一点点",而是"保护系统的稳定性"
很多同学第一次引入缓存只盯着"响应更快",但在电商系统里缓存更重要的价值是"保护数据库与下游服务"。商品详情、类目树、首页推荐位这类读多写少的数据,如果每次都直打数据库,在促销场景会把数据库拖入雪崩。缓存把热点读流量吸收掉,让数据库只处理真正需要强一致或写入的请求。
同时缓存也带来一致性与失效的问题。本节不会只给出"缓存一下"的代码,而是把 Cache-Aside、失效策略、热点与穿透的处理方式讲清楚,并把 Redis 与 Aspire 的资源注入对齐,确保你写下的代码能在编排环境里直接跑起来。
本节的结尾先给一句原则:缓存要围绕"数据特性与一致性需求"设计,别让缓存成为另一个难以调试的数据源。
4.2 Cache-Aside:最适合业务服务的通用模式
Cache-Aside 的做法是"读先查缓存,未命中再查数据库并回填缓存;写先写数据库,再删除或更新缓存"。它之所以适合微服务,是因为它把缓存当作可丢弃的加速层,数据真实来源仍然是数据库,这让你在缓存丢失、Redis 重启时不会出现不可恢复的问题。
下面以商品查询为例展示一个典型实现。示例使用 IDistributedCache 存储 JSON,命中时直接返回,未命中则访问数据库并设置 TTL。
csharp
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.EntityFrameworkCore;
app.MapGet("/products/{id:guid}", async (Guid id, AppDbContext db, IDistributedCache cache, CancellationToken ct) =>
{
var cacheKey = $"product:{id}";
var cached = await cache.GetStringAsync(cacheKey, ct);
if (!string.IsNullOrWhiteSpace(cached))
{
var dto = JsonSerializer.Deserialize<ProductDto>(cached);
return dto is null ? Results.Problem("Cache payload invalid") : Results.Ok(dto);
}
var entity = await db.Products.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (entity is null)
{
return Results.NotFound();
}
var result = new ProductDto(entity.Id, entity.Name, entity.Price);
var json = JsonSerializer.Serialize(result);
await cache.SetStringAsync(
cacheKey,
json,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) },
ct);
return Results.Ok(result);
});
这段代码里,AsNoTracking() 是一个很常用的优化,表示这是读操作,不需要 EF Core 的变更跟踪。回填缓存时我们设置了绝对过期时间,避免缓存永不过期导致数据长期陈旧。你会注意到我们没有在缓存里存实体,而是存 ProductDto,这样缓存格式更稳定,也更适合做版本兼容。
本节的结尾补上两个工程化问题。第一是缓存穿透,也就是大量请求查询不存在的商品 ID,这会把压力直接打到数据库。常见处理是对"确实不存在"的结果也短 TTL 缓存一个空标记。第二是缓存击穿,也就是某个热点 Key 同时过期导致大量并发回源,常见处理是给热点 Key 加一点随机抖动的 TTL,或者在服务内部为单个 Key 做轻量的互斥回源。
4.3 写入与失效:用"删除缓存"换一致性空间
在写路径上,最容易走入歧途的是"先更新缓存再更新数据库"。这样一旦数据库写入失败,缓存里就会出现不存在的数据,后果比"短时间读到旧数据"更难收拾。更稳妥的策略是写库成功后删除缓存,让下一次读请求自然回源并回填。这会带来短暂的回源压力,但一致性语义更清晰。
对于跨服务的数据一致性,缓存失效往往还要和事件系统配合。例如商品服务更新了价格,不仅要删除自己的商品缓存,还可能需要通知搜索索引、推荐系统更新相关视图。这时你可以复用第18篇的 Outbox,把"商品已更新"事件可靠地发布出去,让订阅者各自刷新自己的缓存或物化视图。
本节的结尾给一个经验:缓存失效策略不要追求"强一致",而要追求"可控的最终一致"。只要你能把陈旧窗口控制在业务可接受范围,并且出现问题时能通过事件重放或强制清缓存恢复,就已经达到了工程可用的目标。
4.4 用 Aspire 注入 Redis:让缓存能力像数据库一样"引用即可用"
在 Aspire 里,Redis 同样是资源。AppHost 负责提供 Redis 实例并把连接信息注入到服务,服务侧使用 Aspire 的集成扩展把 IDistributedCache 注册好,然后业务代码只注入接口即可。
下面是 AppHost 侧添加 Redis 并让商品服务引用它的写法。
csharp
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache");
builder.AddProject<Projects.ProductService>("productservice")
.WithReference(cache);
builder.Build().Run();
接着是服务侧注册 Redis 分布式缓存的写法。这里使用 AddRedisDistributedCache,它会从连接字符串里读取名为 cache 的连接,并把 IDistributedCache 与底层的 Redis 连接一起注册,同时也会启用日志、遥测与健康检查。
csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
var app = builder.Build();
app.MapGet("/debug/cache", async (IDistributedCache cache, CancellationToken ct) =>
{
await cache.SetStringAsync("hello", "world", new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
}, ct);
var value = await cache.GetStringAsync("hello", ct);
return Results.Ok(new { value });
});
app.Run();
这段代码的重点不是调试端点,而是展示"连接名驱动"的一致体验:AppHost 里资源叫 cache,服务里就用同一个名字引用,连接信息的注入由 Aspire 完成。这样你从本地容器 Redis 切换到云托管 Redis 时,服务代码无需改动,只需要在编排或部署层替换资源实现。
本节到这里结束:缓存策略要围绕读写特性设计,Cache-Aside 是最稳妥的通用方案,而 Aspire 帮你把 Redis 这类基础设施的接入成本降到"声明资源并引用"。
五、总结
本问完成了电商微服务从"单个服务能跑"到"多个服务协作且可治理"的关键一跃。同步调用部分,我们用服务发现把地址从代码里抽离,用标准韧性策略把重试与超时变成默认能力,再用强类型客户端把调用边界收敛,让业务逻辑更专注。异步通信部分,我们用 RabbitMQ 作为事件通道,并强调了至少一次投递与幂等消费的工程现实,同时把 Outbox 作为可靠投递的最后一公里,保证数据库提交与事件发布之间不再靠运气。网关部分,我们用 YARP 提供统一入口与反向代理能力,并把认证授权与观测纳入网关治理,再交给 Aspire 进行编排与依赖管理。缓存部分,我们把 Cache-Aside 的读写策略讲清楚,并用 Redis 与 Aspire 的资源引用方式让缓存接入真正做到环境无关。
当你把本文的内容全部跑通后,后续无论是第20篇开始的生产化配置与部署,还是更复杂的跨服务业务编排,你都会发现基础已经具备:服务知道如何发现彼此、如何在依赖抖动时自我保护、如何用遥测定位问题、如何用消息与缓存在一致性与性能之间做可控取舍。