14.微服务架构实战

本文聚焦 .NET Aspire 的微服务落地实践,以官方最新模板和 API 为基线,串联服务拆分、网关、通信、事务一致性、弹性治理与版本演进的完整路径,并给出关键代码与讲解,确保读者能直接在 AppHost 中落地。文章保持分段讲述,不使用分点,并在代码后附带文字解释,帮助快速对照官方文档与实战实现。

一、微服务拆分原则

.NET Aspire 的 AppHost 与 ServiceDefaults 让各服务保持自治同时共享观测、配置与安全基线。拆分时遵循领域边界与变更频率,高内聚领域如下单、库存、计费各自独立,跨领域只保留少量共享契约(如 DTO、事件模式),避免共享数据库。同步调用用于强一致核心路径,跨领域优先事件驱动以降低耦合,并用 AppHost 明确依赖关系。下面的清单式代码展示了典型布局,每个 WithReference 都在声明运行期依赖,便于 Aspire Dashboard 显示拓扑。

csharp 复制代码
// AppHost Program.cs,集中声明服务、依赖与可观察性
var builder = DistributedApplication.CreateBuilder(args);

var catalogDb = builder.AddPostgres("catalog-db");
var catalog = builder.AddProject<Projects.CatalogService>("catalog").WithReference(catalogDb);
var ordering = builder.AddProject<Projects.OrderingService>("ordering").WithReference(catalog);
var gateway = builder.AddProject<Projects.ApiGateway>("gateway").WithReference(catalog).WithReference(ordering);

builder.Build().Run();

这段代码在 AppHost 中描述了数据库、目录服务、订单服务和网关的依赖图。构建完成后, Dashboard 会自动展示健康探针、日志与依赖拓扑,无需额外脚本。开发时增加新服务只需再添加一个 AddProject,并通过 WithReference 连接所需下游资源,即可让 Aspire 在本地编排端口、证书与观测配置。

二、API 网关设计

官方推荐在网关项目使用 YARP。Aspire 的服务发现会把服务名解析为容器内部地址,避免硬编码主机与端口。Program.cs 加载反向代理配置,实际路由写在 appsettings.json 中,并由 AppHost 注入实际的 service 名称。代码简洁的原因在于 Aspire 默认完成了 Kestrel 端口映射与 HTTPS 证书注入。

csharp 复制代码
// ApiGateway Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();
app.MapReverseProxy();
app.Run();

在上面的代码中,网关项目的 Program.cs 只需添加反向代理服务并加载配置,然后映射代理中间件。实际的路由规则和目标地址都在 appsettings.json 中定义,如下所示:

json 复制代码
// ApiGateway appsettings.json(Aspire 会把 catalog/ordering 的实际地址注入为占位符值)
{
	"ReverseProxy": {
	"Clusters": {
		"catalog": { "Destinations": { "d1": { "Address": "http://catalog" } } },
		"ordering": { "Destinations": { "d1": { "Address": "http://ordering" } } }
	},
	"Routes": [
		{ "ClusterId": "catalog", "Match": { "Path": "/catalog/{**catchall}" } },
		{ "ClusterId": "ordering", "Match": { "Path": "/orders/{**catchall}" } }
	]
	}
}

Program.cs 只负责启动代理管道,实际的路由与目标地址在配置中声明。Aspire 将 catalog 与 ordering 的服务名注册到 DCP,网关解析 "http://catalog" 时会得到容器内部地址,不需要写死端口。调试时 Dashboard 能看到网关到下游的实时依赖,便于排查 502 或熔断场景。

三、服务间通信模式

Aspire 默认的 ServiceDefaults 注入了 OpenTelemetry、健康检查、基础超时与重试,为服务间通信奠定了观测与弹性基础。开发者在此之上按场景选用 HTTP、gRPC 或消息驱动模式。HTTP 适合 RESTful 资源查询与简单命令,gRPC 用于高频强契约调用,消息则承载跨界异步事件。三种模式都能继承 ServiceDefaults 的诊断与弹性配置,并通过 Aspire 的服务发现自动解析目标地址,避免在代码中硬编码端口或主机名。

HTTP 客户端是最常见的同步通信方式。下面的示例展示了 OrderingService 如何通过 HttpClient 调用 CatalogService 的 REST API。首先在 Program.cs 中用 AddHttpClient 注册类型化客户端,BaseAddress 直接写成服务名 "http://catalog",Aspire 运行时会把它解析为容器内网地址或本地端口。这个注册动作会自动继承 ServiceDefaults 中配置的日志、追踪与基础重试策略,无需额外代码。

csharp 复制代码
// OrderingService Program.cs 注册 HTTP 客户端
builder.Services.AddHttpClient<CatalogClient>(client =>
{
	client.BaseAddress = new Uri("http://catalog"); // Aspire 注入的服务内网名
	client.DefaultRequestHeaders.Add("User-Agent", "OrderingService/1.0");
});

在上面的代码中,AddHttpClient 泛型参数指定了类型化客户端类 CatalogClient,这样依赖注入容器会自动为它创建 HttpClient 实例。BaseAddress 设置为 "http://catalog" 后,所有相对路径请求都会拼接到这个基地址上。DefaultRequestHeaders 可以添加公共请求头,比如 User-Agent 用于标识调用来源,方便下游服务日志分析。Aspire 的服务发现会在运行时把 "catalog" 解析为实际的容器 IP 和端口,本地调试时可能是 localhost:5001,部署到 Kubernetes 时则变成集群内部 DNS 名称,代码无需改动。

CatalogClient 的实现封装了具体的 HTTP 调用逻辑。构造函数注入 HttpClient,GetAsync 方法负责发起 GET 请求并反序列化响应。这里使用 EnsureSuccessStatusCode 让非 2xx 状态码抛出 HttpRequestException,上层可以捕获后决定是否重试或降级。ReadFromJsonAsyncSystem.Net.Http.Json 提供的扩展方法,内部使用 System.Text.Json 进行反序列化,性能优于传统的先读字符串再 Deserialize 的方式。

csharp 复制代码
public class CatalogClient
{
	private readonly HttpClient _http;
	public CatalogClient(HttpClient http) => _http = http;

	public async Task<Product?> GetAsync(Guid id, CancellationToken token)
	{
		using var response = await _http.GetAsync($"/products/{id}", token);
		response.EnsureSuccessStatusCode();
		return await response.Content.ReadFromJsonAsync<Product>(cancellationToken: token);
	}
}

这段代码的核心在于简洁性和可测试性。HttpClient 由框架注入,测试时可以用 HttpClientFactory 的 Mock 或者 WireMock 替换真实端点。GetAsync 的路径 "/products/{id}" 会与 BaseAddress 拼接成完整 URL,比如 "http://catalog/products/123e4567-e89b-12d3-a456-426614174000"。CancellationToken 在整个调用链路传递,确保上游取消时能及时中断网络请求,释放连接资源。EnsureSuccessStatusCode 会在状态码为 4xx 或 5xx 时抛异常,这个异常会被 ServiceDefaults 的诊断中间件捕获并记录到 OpenTelemetry,Dashboard 里能看到红色的 span 和错误堆栈。ReadFromJsonAsync 的返回类型是 Product?,表示可能为 null,调用方需要做空值检查或使用空合并运算符。

对于需要强类型契约和更高性能的场景,gRPC 是更好的选择。gRPC 基于 HTTP/2 和 Protobuf,传输效率高且支持双向流。Aspire 对 gRPC 的支持同样无缝,客户端注册时使用 AddGrpcClient,地址依然写成服务名,框架会自动处理 HTTP/2 协商和证书验证。下面的代码展示了如何在 OrderingService 中调用 CatalogService 的 gRPC 接口。

csharp 复制代码
// OrderingService Program.cs 注册 gRPC 客户端
builder.Services.AddGrpcClient<CatalogGrpc.CatalogGrpcClient>(o =>
{
	o.Address = new Uri("http://catalog");
	o.ChannelOptionsActions.Add(options =>
	{
		options.HttpHandler = new SocketsHttpHandler
		{
			EnableMultipleHttp2Connections = true,
			KeepAlivePingDelay = TimeSpan.FromSeconds(30),
			KeepAlivePingTimeout = TimeSpan.FromSeconds(5)
		};
	});
});

AddGrpcClient 的泛型参数是 gRPC 工具生成的客户端类,通常在 .proto 文件编译后自动生成。Address 同样写成 "http://catalog",Aspire 运行时会解析为实际地址。ChannelOptionsActions 允许自定义底层的 HttpHandler,这里使用 SocketsHttpHandler 并开启多连接复用,提升并发能力。KeepAlivePing 参数用于保持长连接活跃,防止负载均衡器或防火墙因空闲超时断开连接。在云环境中,这些设置能显著降低连接建立开销,提升 P99 延迟表现。

实际调用时,gRPC 客户端方法返回的是强类型的 Reply 对象。deadline 参数设置请求的绝对截止时间,服务端收到后会在超时前尽力返回或取消操作。这比传统的 HttpClient.Timeout 更精确,因为 deadline 是分布式系统的全局约定,能跨越多个服务边界传递。下面的代码演示了一个典型的 gRPC 调用,包含超时控制和错误处理。

csharp 复制代码
public class OrderService
{
	private readonly CatalogGrpc.CatalogGrpcClient _client;
	public OrderService(CatalogGrpc.CatalogGrpcClient client) => _client = client;

	public async Task<ProductInfo> GetProductInfoAsync(Guid productId, CancellationToken token)
	{
		try
		{
			var reply = await _client.GetProductAsync(
				new GetProductRequest { Id = productId.ToString() },
				deadline: DateTime.UtcNow.AddSeconds(2),
				cancellationToken: token
			);
			return new ProductInfo { Name = reply.Name, Price = reply.Price };
		}
		catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
		{
			// 超时降级逻辑,返回默认值或从缓存读取
			return ProductInfo.Default;
		}
	}
}

这段代码在 GetProductAsync 调用中设置了 2 秒的 deadline,如果 CatalogService 处理时间过长,gRPC 框架会自动取消请求并抛出 RpcException,StatusCodeDeadlineExceededcatch 块捕获这个异常后返回默认值,避免整个订单流程阻塞。RpcException 还包含其他状态码,比如 Unavailable 表示服务不可达,NotFound 表示资源不存在,调用方可以根据不同状态码做精细化降级。gRPC 的调用链同样会被 OpenTelemetry 追踪,Dashboard 能显示每次 RPC 的耗时、状态码和传输字节数,帮助定位性能瓶颈。

对于跨领域的松耦合场景,消息驱动是最佳实践。Aspire 通过 AppHost 集中声明消息队列资源,然后在服务中通过 SDK 订阅。下面的 AppHost 代码展示了如何添加 Azure Service Bus 队列,并让 OrderingService 引用它。WithReference 会把队列的连接字符串注入到 OrderingService 的配置中,服务代码只需读取配置即可获得已授权的客户端。

csharp 复制代码
// AppHost Program.cs 声明消息队列
var bus = builder.AddAzureServiceBus("messaging")
	.AddQueue("orders-queue");

var ordering = builder.AddProject<Projects.OrderingService>("ordering")
	.WithReference(bus);

AddAzureServiceBus 会在本地模拟环境中启动 Azurite 或使用开发连接字符串,部署到云上时自动切换为生产凭据。AddQueue 创建一个名为 "orders-queue" 的队列,Aspire 会在启动时检查队列是否存在,不存在则自动创建。WithReference(bus) 把队列的连接信息注入到 OrderingServiceIConfiguration 中,键名通常是 "ConnectionStrings:messaging",服务代码用 ServiceBusClient.CreateFromConnectionString 即可初始化发送或接收客户端。

消息的发送方通常采用 Outbox 模式来保证事务一致性。下面的 PlaceOrderAsync 方法在同一个数据库事务中写入订单记录和 Outbox 消息。事务提交前,消息不会被后台任务读取,确保订单和事件的原子性。Entity Framework Core 的 ExecutionStrategy 会自动处理瞬时连接失败,重试次数和延迟在 ServiceDefaults 中配置,避免硬编码。

csharp 复制代码
public class OrderService
{
	private readonly AppDbContext _db;
	public OrderService(AppDbContext db) => _db = db;

	public async Task PlaceOrderAsync(Order order, CancellationToken token)
	{
		await using var tx = await _db.Database.BeginTransactionAsync(token);
		try
		{
			_db.Orders.Add(order);
			_db.OutboxMessages.Add(new OutboxMessage
			{
				Id = Guid.NewGuid(),
				Type = "OrderCreated",
				Payload = JsonSerializer.Serialize(new OrderCreatedEvent
				{
					OrderId = order.Id,
					CustomerId = order.CustomerId,
					TotalAmount = order.TotalAmount,
					CreatedAt = order.CreatedAt
				}),
				CreatedAt = DateTime.UtcNow
			});
			await _db.SaveChangesAsync(token);
			await tx.CommitAsync(token);
		}
		catch
		{
			await tx.RollbackAsync(token);
			throw;
		}
	}
}

这段代码先开启数据库事务,然后依次添加 Order 实体和 OutboxMessage 实体。OutboxMessageType 字段标识事件类型,Payload 是序列化后的事件体,CreatedAt 用于后续的顺序消费和去重。SaveChangesAsync 把两个实体写入数据库,但此时事务还未提交,外部查询看不到这些数据。CommitAsync 提交事务后,Orders 表和 OutboxMessages 表的插入同时生效,保证了原子性。如果 SaveChangesAsyncCommitAsync 抛异常,catch 块会调用 RollbackAsync 回滚事务,然后重新抛出异常让上层感知失败。这个模式避免了分布式事务的复杂性,只需要单库事务即可实现订单与事件的强一致。

Outbox 消息的发布由后台服务 OutboxProcessor 负责。它持续轮询 OutboxMessages 表,每次取一批待发送的消息,逐条发布到 Service Bus,成功后删除记录。这个过程是幂等的,如果发布失败,消息会留在表中等待下次重试,最终实现至少一次投递。下面的代码展示了完整的后台处理逻辑,包含批量查询、逐条发送、删除和延迟等待。

csharp 复制代码
public class OutboxProcessor : BackgroundService
{
	private readonly IDbContextFactory<AppDbContext> _factory;
	private readonly IServiceBusSender _sender;
	private readonly ILogger<OutboxProcessor> _logger;

	public OutboxProcessor(
		IDbContextFactory<AppDbContext> factory,
		IServiceBusSender sender,
		ILogger<OutboxProcessor> logger)
	{
		_factory = factory;
		_sender = sender;
		_logger = logger;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("OutboxProcessor started");
		while (!stoppingToken.IsCancellationRequested)
		{
			try
			{
				await ProcessBatchAsync(stoppingToken);
			}
			catch (Exception ex)
			{
				_logger.LogError(ex, "Error processing outbox batch");
			}
			await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
		}
		_logger.LogInformation("OutboxProcessor stopped");
	}

	private async Task ProcessBatchAsync(CancellationToken token)
	{
		await using var db = await _factory.CreateDbContextAsync(token);
		var pending = await db.OutboxMessages
			.OrderBy(x => x.CreatedAt)
			.Take(50)
			.ToListAsync(token);

		if (!pending.Any()) return;

		foreach (var message in pending)
		{
			var serviceBusMessage = new ServiceBusMessage(message.Payload)
			{
				MessageId = message.Id.ToString(),
				Subject = message.Type,
				ContentType = "application/json"
			};

			await _sender.SendMessageAsync(serviceBusMessage, token);
			_logger.LogInformation("Published outbox message {MessageId} of type {Type}",
				message.Id, message.Type);

			db.OutboxMessages.Remove(message);
		}

		await db.SaveChangesAsync(token);
		_logger.LogInformation("Processed {Count} outbox messages", pending.Count);
	}
}

ExecuteAsyncBackgroundService 的核心方法,它会在应用启动后持续运行直到 CancellationToken 取消。while 循环每次调用 ProcessBatchAsync 处理一批消息,然后 Task.Delay 等待 2 秒再继续,避免空跑占满 CPU。ProcessBatchAsync 先用 IDbContextFactory 创建独立的 DbContext 实例,这样避免跨请求共享 DbContext 引发的并发问题。OrderByTake 确保按创建时间升序处理且每批最多 50 条,防止单次查询过大导致锁表。

遍历每条消息时,先构造 ServiceBusMessage 对象,MessageId 设为 OutboxMessageId,确保消息的唯一性和可追溯性。Subject 字段映射事件类型,订阅方可以根据它路由到不同的处理器。ContentType 声明 JSON 格式,方便下游反序列化。SendMessageAsync 把消息发送到 Service Bus,如果网络抖动或服务限流导致失败,异常会被上层 try-catch 捕获,消息留在数据库中等待下次重试。发送成功后立即 Remove,然后 SaveChangesAsync 批量提交删除操作,减少事务开销。

这个设计的优势在于简单和可靠。Outbox 表存在于业务数据库中,无需引入额外的消息存储。后台任务独立运行,失败自动重试,不阻塞业务接口。ILogger 记录每次发布的消息 ID 和类型,配合 Aspire Dashboard 的日志聚合能快速排查消息丢失或重复。如果需要提升吞吐,可以在 AppHost 中把 OutboxProcessor 拆成独立的 Worker 项目,用 WithReference 共享数据库和 Service Bus,然后水平扩展实例数,每个实例处理不同的分片或通过乐观锁避免竞争。

四、分布式事务处理

微服务之间的强一致性需求通常通过 Saga 编排或 Outbox 最终一致性来实现,避免分布式两阶段提交的复杂性和性能开销。传统的分布式事务需要协调器锁定多个数据库资源,在高并发场景下容易导致死锁和性能瓶颈,且一旦协调器故障会让所有参与方陷入不确定状态。Aspire 环境下推荐的做法是在单个服务内保证强一致,跨服务则通过事件驱动实现最终一致,每个服务只对自己的数据库负责,通过消息队列传递状态变更,接收方根据事件更新本地状态或执行补偿操作。

前面介绍的 Outbox 模式已经覆盖了单服务内的事务一致性,这里进一步展开跨服务的场景。当订单创建需要同步扣减库存和预扣款时,传统做法是开启分布式事务,同时锁定订单库、库存库和支付库的相关记录,等所有操作完成后一起提交或回滚。这种做法在微服务架构中不可行,因为每个服务的数据库独立部署,且可能使用不同的数据库引擎,无法共享事务上下文。更好的方案是用事件驱动的补偿流程,订单服务先在本地事务中创建订单并发布 OrderCreated 事件,库存服务订阅该事件后尝试扣减库存,成功则发布 InventoryReserved 事件,失败则发布 InventoryInsufficient 事件。订单服务订阅这些事件,根据结果决定继续支付流程或取消订单并发布 OrderCancelled 事件。整个过程中每个服务只操作自己的数据库,通过消息队列协调状态,不存在跨库锁定。

PlaceOrderAsync 的实现展示了如何在同一事务中写入订单和 Outbox 消息。Entity Framework Core 的 BeginTransactionAsync 开启数据库事务,这个事务会覆盖后续所有的 SaveChangesAsync 调用,直到显式调用 CommitAsyncRollbackAsync。事务的隔离级别默认是数据库的默认值,对于 SQL Server 是 Read Committed,对于 PostgreSQL 是 Read Committed 或 Repeatable Read,具体取决于配置。在事务内部,先调用 _db.Orders.Add 把订单实体标记为 Added 状态,然后调用 _db.OutboxMessages.Add 把事件消息标记为 Added 状态。这两个操作只是在 EF Core 的变更追踪器中记录变更,还没有发送 SQL 语句到数据库。接下来的 SaveChangesAsync 会把所有变更转换为 INSERT 语句,并在同一个事务中执行。如果 INSERT 成功,CommitAsync 会提交事务,让变更对其他连接可见。如果 INSERT 失败,比如主键冲突或外键约束违反,SaveChangesAsync 会抛出 DbUpdateException,catch 块捕获后调用 RollbackAsync 撤销事务,然后重新抛出异常让上层感知失败。这个模式确保了订单和事件消息的原子性,要么同时写入,要么同时失败,不会出现订单存在但事件丢失的情况。

ExecutionStrategy 是 EF Core 提供的自动重试机制,用于处理瞬时错误。瞬时错误通常是网络抖动、数据库重启或短暂的资源耗尽导致的连接失败或超时,这些错误在几秒钟后重试往往能成功。EF Core 默认的 ExecutionStrategy 只对 SqlException 的特定错误码重试,比如 SQL Server 的超时错误 -2 或死锁错误 1205,而对于逻辑错误如主键冲突不会重试。CreateExecutionStrategy 返回配置好的策略对象,ExecuteAsync 方法接受一个委托,把需要重试的操作包裹在委托中。如果委托执行过程中抛出瞬时错误,策略会等待一段时间后重新执行整个委托,包括事务的开启、操作和提交。重试的延迟采用指数退避算法,第一次重试等待 1 秒,第二次等待 2 秒,第三次等待 4 秒,以此类推,避免在数据库压力大时雪崩式重试。

csharp 复制代码
public async Task PlaceOrderAsync(Order order, CancellationToken token)
{
	var strategy = _db.Database.CreateExecutionStrategy();
	await strategy.ExecuteAsync(async () =>
	{
		await using var tx = await _db.Database.BeginTransactionAsync(token);
		try
		{
			_db.Orders.Add(order);
			_db.OutboxMessages.Add(new OutboxMessage
			{
				Id = Guid.NewGuid(),
				Type = "OrderCreated",
				Payload = JsonSerializer.Serialize(order.ToOrderCreatedEvent()),
				CreatedAt = DateTime.UtcNow
			});
			await _db.SaveChangesAsync(token);
			await tx.CommitAsync(token);
		}
		catch
		{
			await tx.RollbackAsync(token);
			throw;
		}
	});
}

这段代码首先通过 _db.Database.CreateExecutionStrategy 获取执行策略对象。在 Aspire 的 ServiceDefaults 中,通常会在 AddDbContext 时配置 EnableRetryOnFailure,指定最大重试次数和延迟参数。策略对象的 ExecuteAsync 方法接受一个异步委托,这个委托内部是完整的事务逻辑。BeginTransactionAsync 开启事务后,await using 关键字确保事务对象在作用域结束时自动释放,即使发生异常也能正确清理资源。事务对象 tx 实现了 IAsyncDisposable 接口,Dispose 时会检查事务是否已提交或回滚,如果都没有则自动回滚,防止资源泄漏。

_db.Orders.Add(order) 把订单实体加入到 EF Core 的变更追踪器,状态标记为 EntityState.Added。此时订单的主键 Id 可能是默认值 Guid.Empty,EF Core 会在 SaveChangesAsync 时根据数据库配置自动生成 GUID 或自增 ID。_db.OutboxMessages.Add 同样把事件消息加入追踪器,消息的 Id 在代码中显式生成,使用 Guid.NewGuid 确保全局唯一。Type 字段设为 "OrderCreated",是一个字符串常量,订阅方根据这个字段路由到对应的事件处理器。Payload 是序列化后的事件体,order.ToOrderCreatedEvent 是一个扩展方法,把订单实体转换为事件 DTO,只包含订阅方需要的字段,比如 OrderIdCustomerIdTotalAmountCreatedAt,避免暴露敏感字段或内部实现细节。JsonSerializer.Serialize 使用 System.Text.Json 序列化事件对象,默认的 JsonSerializerOptions 会把属性名转为 camelCase,数字和布尔值保持原始类型,DateTime 序列化为 ISO 8601 格式,确保跨语言互操作性。

await _db.SaveChangesAsync(token) 是事务的核心步骤,它会把变更追踪器中的所有变更转换为 SQL 语句并发送到数据库。对于 Orders 和 OutboxMessages 的 Added 状态,EF Core 会生成两条 INSERT 语句,并在同一个事务中执行。SQL 语句的参数化由 EF Core 自动处理,防止 SQL 注入攻击。如果数据库返回成功,SaveChangesAsync 返回受影响的行数,通常是 2。如果数据库返回错误,比如主键冲突、外键约束违反或超时,SaveChangesAsync 会抛出 DbUpdateException,异常的 InnerException 包含数据库驱动返回的具体错误信息,比如 SqlExceptionNpgsqlException。catch 块捕获所有异常后先调用 await tx.RollbackAsync(token) 回滚事务,然后 throw 重新抛出异常让上层处理。RollbackAsync 会发送 ROLLBACK 命令到数据库,撤销事务内的所有变更,包括 Orders 和 OutboxMessages 的插入。重新抛出异常后,ExecutionStrategy 会判断异常类型,如果是瞬时错误且未达到最大重试次数,会等待指数退避后重新执行整个委托,否则直接抛出让上层感知失败。

需要注意的是,重试会导致 OrderOutboxMessageId 重新生成。因为委托每次执行都会调用 Guid.NewGuid,生成的 GUID 不同,所以重试后插入的记录与第一次尝试的记录在逻辑上是不同的。如果业务要求幂等,比如同一个外部订单号不能重复创建订单,需要在 Order 实体中添加幂等键字段,并在数据库中创建唯一索引。在重试前先查询数据库检查幂等键是否已存在,如果存在则直接返回已有订单,不执行插入操作。这个检查可以放在委托外部,也可以放在委托内部事务开启后、插入操作前,取决于并发控制的需求。如果放在外部,可能在检查和插入之间有其他线程插入了相同幂等键的订单,导致主键冲突。如果放在内部,可以利用数据库的行锁或表锁保证原子性,但会增加事务持有时间,影响吞吐。

OutboxProcessor 的实现确保了消息至少发送一次。它是一个继承自 BackgroundService 的后台服务,在应用启动后持续运行直到应用停止。ExecuteAsync 方法是后台服务的入口点,框架会在应用启动时调用它,并传入一个 CancellationToken,当应用收到停止信号时这个 token 会被取消。方法内部是一个 while 循环,每次循环调用 ProcessBatchAsync 处理一批消息,然后 Task.Delay 等待 2 秒再继续。这个延迟避免了空跑占满 CPU,也给数据库和消息队列一定的喘息时间。如果 ProcessBatchAsync 抛出异常,catch 块会记录错误日志,然后继续下一次循环,不会导致整个后台服务崩溃。

ProcessBatchAsync 的第一步是创建独立的 DbContext 实例。IDbContextFactory 是 EF Core 提供的工厂接口,用于在多线程或长生命周期场景中创建短生命周期的 DbContext。后台服务的生命周期是 Singleton,如果直接注入 DbContext 会导致跨请求共享,引发并发问题和内存泄漏。使用工厂每次创建新的 DbContext,用完后 await using 自动释放,确保连接和资源及时归还连接池。CreateDbContextAsync 接受 CancellationToken,如果应用停止会立即取消异步操作,避免等待数据库响应。

查询待发布的消息时,Where 子句过滤 Published 字段为 false 的记录。这个字段是 OutboxMessage 实体的新增字段,用于标记消息是否已成功发布到消息队列。初始值是 false,发布成功后设为 true,下次查询时会被过滤掉,避免重复处理。OrderByCreatedAt 升序排序,确保消息按创建时间顺序发布,维护事件的因果关系。Take(50) 限制每批最多处理 50 条消息,防止单次查询返回过多数据导致内存溢出或锁表。ToListAsync 把查询结果加载到内存,后续的 foreach 遍历不会再访问数据库。

csharp 复制代码
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (!stoppingToken.IsCancellationRequested)
	{
		try
		{
			await using var db = await _factory.CreateDbContextAsync(stoppingToken);
			var pending = await db.OutboxMessages
				.Where(x => !x.Published)
				.OrderBy(x => x.CreatedAt)
				.Take(50)
				.ToListAsync(stoppingToken);

			foreach (var message in pending)
			{
				var busMessage = new ServiceBusMessage(message.Payload)
				{
					MessageId = message.Id.ToString(),
					Subject = message.Type
				};

				await _bus.PublishAsync(busMessage, stoppingToken);
				message.Published = true;
				message.PublishedAt = DateTime.UtcNow;
			}

			if (pending.Any())
			{
				await db.SaveChangesAsync(stoppingToken);
				_logger.LogInformation("Published {Count} messages", pending.Count);
			}
		}
		catch (Exception ex)
		{
			_logger.LogError(ex, "Outbox processing failed, will retry");
		}

		await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
	}
}

遍历每条消息时,先构造 ServiceBusMessage 对象。ServiceBusMessageAzure.Messaging.ServiceBus SDK 提供的消息类,构造函数接受消息体,这里传入 message.Payload,即序列化后的事件 JSON 字符串。MessageId 设为 message.Id.ToString(),把 GUID 转为字符串格式,这个 ID 会随消息一起发送到 Service Bus,订阅方可以用它做去重或追踪。Subject 字段设为 message.Type,即 "OrderCreated" 等事件类型字符串,Service Bus 支持根据 Subject 进行消息路由,订阅方可以创建多个订阅,每个订阅配置不同的过滤规则,只接收特定 Subject 的消息,实现事件的分类处理。

await _bus.PublishAsync(busMessage, stoppingToken) 把消息发送到 Service Bus。_bus 是注入的 IServiceBusSender 接口,由 Aspire 的 AppHostWithReference 时自动配置,连接字符串和队列名称都从配置中读取,代码中不需要硬编码。PublishAsync 是异步方法,底层使用 AMQP 1.0 协议与 Service Bus 通信,消息先发送到 Service Bus 的接收端点,经过持久化后返回确认。如果网络抖动或 Service Bus 限流,PublishAsync 会抛出 ServiceBusException,异常的 Reason 属性指示具体原因,比如 MessagingEntityNotFound 表示队列不存在,ServiceTimeout 表示请求超时,ServiceBusy 表示服务过载。这些异常会被外层的 catch 块捕获,错误日志记录后消息的 Published 保持 false,下次循环重新尝试。

发布成功后,立即设置 message.Published = true,并记录发布时间 message.PublishedAt = DateTime.UtcNow。这两个字段的更新只是在内存中的 EF Core 变更追踪器中,还没有写入数据库。foreach 循环结束后,if (pending.Any()) 检查是否有消息被处理,如果有则调用 await db.SaveChangesAsync(stoppingToken) 批量更新数据库。SaveChangesAsync 会为每条消息生成一条 UPDATE 语句,把 PublishedPublishedAt 字段的新值写入数据库。批量更新比逐条更新效率更高,减少了网络往返次数和事务开销。如果 SaveChangesAsync 失败,比如数据库连接断开或死锁,异常会被外层 catch 捕获,消息的 Published 在数据库中仍是 false,下次循环会重新查询并尝试发布。

这个设计存在一个边界情况需要注意。如果 PublishAsync 成功但 SaveChangesAsync 失败,消息已经发送到 Service Bus,但数据库中 Published 仍是 false,下次循环会再次发布相同的消息,导致重复消费。解决这个问题的办法是让订阅方实现幂等处理。常见做法是在订阅方的数据库中创建去重表,表结构包含 MessageIdProcessedAt 字段,处理消息前先查询去重表,如果 MessageId 已存在则跳过处理,否则在同一事务中处理消息并插入去重记录。另一种做法是在业务逻辑中用版本号或状态机保证操作的幂等性,比如库存扣减前检查订单状态,如果已扣减则直接返回成功,不重复扣减。

_logger.LogInformation 记录每批处理的消息数量,方便运维监控吞吐。在 Aspire 环境中,这些日志会自动进入 OpenTelemetry 管道,发送到 Dashboard 的日志视图。Dashboard 支持按时间范围、服务名称和日志级别过滤,还能搜索关键字。如果发现 "Published {Count} messages" 的频率异常,比如长时间没有日志或每次 Count 都是 0,可能是上游服务没有产生事件,或者 Outbox 表的写入有问题。如果 Count 持续很高,说明消息积压,需要增加处理速度或扩展实例数。

Task.Delay(TimeSpan.FromSeconds(2), stoppingToken) 在每批处理后等待 2 秒。这个延迟是可配置的,可以根据实际负载调整。延迟过短会增加数据库和消息队列的压力,延迟过长会增加消息的端到端延迟。2 秒是一个经验值,适合中等负载场景。如果消息量特别大,可以缩短到 500 毫秒或 1 秒,同时增加 Take 的批量大小到 100 或 200。如果消息量很小,可以延长到 5 秒或 10 秒,减少空查询的开销。stoppingToken 传入 Delay 后,如果应用停止,Delay 会立即返回并抛出 OperationCanceledException,外层的 while 循环检测到 IsCancellationRequestedtrue 后退出,ExecuteAsync 返回,后台服务优雅停止。

在 Aspire 环境中,OutboxProcessor 的指标和追踪同样会进入 OpenTelemetry 管道。每次调用 PublishAsync 都会生成一个 span,记录耗时、状态码和传输字节数。Dashboard 的追踪视图能显示完整的调用链,从 PlaceOrderAsync 的事务提交,到 OutboxProcessor 的查询和发布,再到订阅方的消费和处理。如果某个 span 的耗时异常,可以点击展开查看详细信息,包括异常堆栈、环境变量和自定义标签。这种端到端的可观察性是 Aspire 的核心优势,开发者不需要手动埋点或配置日志聚合,只要服务继承 ServiceDefaults,所有遥测数据自动上报。

若需水平扩展,可以在 AppHost 中将 OutboxProcessor 拆成独立的 Worker 项目。Worker 项目是一个只包含后台服务的主机,不监听 HTTP 端口,只运行 BackgroundServiceAppHost 的代码调整为 builder.AddProject<Projects.OutboxWorker>("outbox-worker").WithReference(db).WithReference(bus),这样 Worker 项目能访问订单数据库和消息队列,但不暴露 API。然后用 WithReplicas(3) 启动三个实例,每个实例独立运行 OutboxProcessor。为了避免多个实例竞争同一批消息,可以在查询时用分布式锁或分片策略。分布式锁可以用 Redis 的 SETNX 或数据库的 SELECT FOR UPDATE 实现,获取锁的实例才能处理消息,其他实例等待或跳过本批。分片策略是根据消息 ID 的哈希值对实例数取模,每个实例只处理属于自己分片的消息,比如实例 0 处理 ID % 3 == 0 的消息,实例 1 处理 ID % 3 == 1 的消息,以此类推。这种方式无需锁,但需要在查询时添加 Where 子句过滤分片条件,确保每条消息只被一个实例处理。

五、服务熔断和降级

Aspire 模板的 ServiceDefaults 已经为 HttpClient 配置了基础的超时与重试,但在复杂的微服务场景中,单纯的超时和重试不足以应对级联故障和雪崩效应。当下游服务出现持续性故障时,上游服务如果不断重试会加剧下游压力,最终导致整个调用链崩溃。这时需要引入熔断机制,在检测到故障率超过阈值后主动中断请求,给下游服务恢复的时间窗口。Polly v8 的 Resilience Pipeline 提供了声明式的弹性策略组合,支持超时、重试、熔断、限流等多种策略,并能通过 AddResilienceHandler 无缝集成到 HttpClient 的处理管道中。相比手动捕获异常和实现重试逻辑,Resilience Pipeline 的优势在于策略的可复用性和可观测性,所有策略执行的事件都会自动上报到 OpenTelemetry,Aspire Dashboard 能实时展示重试次数、熔断状态和降级比例。

ServiceDefaults 项目的 HostingExtensions.cs 中,可以集中定义一个名为 "http-default" 的弹性策略。这个策略会被所有引用 ServiceDefaults 的服务继承,确保全局一致的弹性行为。策略的定义采用流式 API,每个 Add 方法追加一个处理器到管道中,执行顺序与添加顺序一致,先添加的策略先执行。超时策略 AddTimeout 放在最外层,确保整个请求链路不会超过 2 秒,包括重试和熔断的时间开销。如果 2 秒内请求还没有完成,TimeoutRejectedException 会被抛出,管道立即中断,避免线程长时间阻塞。

csharp 复制代码
// ServiceDefaults/HostingExtensions.cs 中集中定义 Resilience Pipeline
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
	// 已有的 OpenTelemetry、健康检查等配置
	builder.AddOpenTelemetryExporters();
	builder.Services.AddServiceDiscovery();
	
	// 定义名为 "http-default" 的弹性管道
	builder.Services.AddResiliencePipeline("http-default", pipeline =>
	{
		// 第一层:全局超时 2 秒
		pipeline.AddTimeout(TimeSpan.FromSeconds(2));
		
		// 第二层:指数退避重试,最多 2 次
		pipeline.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
		{
			MaxRetryAttempts = 2,
			Delay = TimeSpan.FromMilliseconds(200),
			BackoffType = DelayBackoffType.Exponential,
			ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
				.Handle<HttpRequestException>()
				.HandleResult(r => r.StatusCode >= System.Net.HttpStatusCode.InternalServerError)
		});
		
		// 第三层:基于失败率的熔断器
		pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
		{
			FailureRatio = 0.5,
			MinimumThroughput = 10,
			SamplingDuration = TimeSpan.FromSeconds(30),
			BreakDuration = TimeSpan.FromSeconds(15),
			ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
				.Handle<HttpRequestException>()
				.HandleResult(r => r.StatusCode >= System.Net.HttpStatusCode.InternalServerError)
		});
	});
	
	return builder;
}

AddTimeout 的参数是 TimeSpan.FromSeconds(2),表示从请求发起到响应返回的最大时长为 2 秒。这个时长包含 DNS 解析、TCP 连接建立、TLS 握手、HTTP 请求发送、服务端处理和响应传输的全部时间。如果在 2 秒内任何环节超时,Polly 会抛出 TimeoutRejectedException,中断请求并释放资源。超时策略放在管道最外层的原因是它需要包裹所有内层策略的执行时间。如果重试策略每次重试耗时 1 秒,重试 2 次就是 2 秒,再加上熔断策略的判断时间,总耗时可能超过 2 秒。超时策略会在 2 秒到达时立即中断,不管内层策略是否还在执行。

AddRetry 定义了重试策略,RetryStrategyOptions<HttpResponseMessage> 的泛型参数是 HttpResponseMessage,表示这个策略处理的是 HTTP 请求的响应对象。MaxRetryAttempts 设为 2,表示最多重试 2 次,加上初始请求总共 3 次尝试。Delay 是初始延迟,第一次重试前等待 200 毫秒,BackoffType.Exponential 表示采用指数退避算法,第二次重试前等待 400 毫秒,第三次等待 800 毫秒,以此类推。指数退避的目的是在服务压力大时避免雪崩式重试,给下游服务恢复的缓冲时间。ShouldHandle 是一个谓词构建器,定义了哪些异常或响应需要重试。Handle<HttpRequestException> 表示捕获网络连接异常,比如 DNS 解析失败、连接超时或连接重置。HandleResult 是一个 lambda 表达式,检查响应的状态码,如果 StatusCode >= 500 表示服务端错误,比如 500 Internal Server Error 或 503 Service Unavailable,这些错误通常是临时性的,重试有可能成功。4xx 的客户端错误不会重试,因为客户端错误通常是请求参数或权限问题,重试不会改变结果。

AddCircuitBreaker 定义了熔断器策略,CircuitBreakerStrategyOptions<HttpResponseMessage> 同样处理 HTTP 响应。FailureRatio 设为 0.5,表示失败率阈值为 50%。失败率的计算方式是在 SamplingDuration 时间窗口内,失败请求数除以总请求数。SamplingDuration 设为 30 秒,表示熔断器统计最近 30 秒内的请求结果。MinimumThroughput 设为 10,表示只有当 30 秒内的总请求数大于等于 10 时,熔断器才会根据失败率决定是否熔断。这个参数避免了在低流量场景下误熔断,比如只有 2 个请求,1 个失败,失败率 50%,但样本量太小不具代表性。BreakDuration 设为 15 秒,表示熔断器打开后保持 15 秒,期间所有请求直接失败并抛出 BrokenCircuitException,不会真正发送到下游。15 秒后熔断器进入半开状态,放行一个试探请求,如果成功则关闭熔断器恢复正常,如果失败则重新打开并再等 15 秒。ShouldHandle 的配置与重试策略一致,只有网络异常和 5xx 错误才计入失败统计。

定义好全局策略后,服务项目在注册 HttpClient 时通过 AddResilienceHandler 绑定这个策略。AddHttpClient<CatalogClient> 注册了类型化客户端,泛型参数是 CatalogClient 类,框架会自动为它创建 HttpClient 实例并注入到构造函数。client.BaseAddress 设为 "http://catalog",Aspire 的服务发现会在运行时解析为容器或本地的实际地址。AddResilienceHandler("http-default") 把之前定义的 "http-default" 策略绑定到这个 HttpClient,每次 CatalogClient 发起请求时,请求会先经过 Polly 的管道处理,依次经过超时、重试和熔断的检查,最后才发送到网络。

csharp 复制代码
// OrderingService/Program.cs 中使用时绑定到 HttpClient
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

builder.Services.AddHttpClient<CatalogClient>(client =>
{
	client.BaseAddress = new Uri("http://catalog");
	client.DefaultRequestHeaders.Add("User-Agent", "OrderingService/1.0");
}).AddResilienceHandler("http-default");

var app = builder.Build();
app.MapDefaultEndpoints();
// 业务路由
app.Run();

AddResilienceHandler 的实现原理是在 HttpClient 的消息处理管道中插入一个 DelegatingHandler,这个 handler 内部调用 Polly 的 ResiliencePipeline.ExecuteAsync 方法,把实际的 HTTP 请求作为委托传入。如果请求成功,响应直接返回。如果请求失败且满足重试条件,Polly 会根据退避算法等待一段时间后重新执行委托。如果失败次数超过最大重试次数,异常会被传递到上层。熔断器在每次请求前检查当前状态,如果是打开状态直接抛出 BrokenCircuitException,如果是关闭或半开状态则放行请求并记录结果。所有这些逻辑对应用代码透明,CatalogClient 的代码不需要任何改动,只需注入 HttpClient 并调用 GetAsyncPostAsync 方法即可。

在实际调用中,应用代码需要捕获 BrokenCircuitException 并实现降级逻辑。降级的目的是在下游不可用时提供备选方案,避免用户看到错误页面或空白响应。常见的降级策略包括返回缓存数据、返回默认值、返回简化版响应或返回友好的错误提示。下面的代码展示了如何在 OrderService 中捕获熔断异常并从缓存读取商品信息。IMemoryCacheASP.NET Core 内置的内存缓存,生命周期是 Singleton,所有请求共享同一个缓存实例。缓存的键是商品 ID,值是 ProductInfo 对象,过期时间设为 5 分钟。

csharp 复制代码
public class OrderService
{
	private readonly CatalogClient _client;
	private readonly IMemoryCache _cache;
	private readonly ILogger<OrderService> _logger;

	public OrderService(CatalogClient client, IMemoryCache cache, ILogger<OrderService> logger)
	{
		_client = client;
		_cache = cache;
		_logger = logger;
	}

	public async Task<ProductInfo> GetProductInfoAsync(Guid productId, CancellationToken token)
	{
		var cacheKey = $"product:{productId}";
		
		// 先尝试从缓存读取
		if (_cache.TryGetValue(cacheKey, out ProductInfo? cached))
		{
			_logger.LogDebug("Product {ProductId} served from cache", productId);
			return cached!;
		}

		try
		{
			// 调用下游服务
			var product = await _client.GetAsync(productId, token);
			if (product == null)
			{
				_logger.LogWarning("Product {ProductId} not found", productId);
				return ProductInfo.NotFound;
			}

			// 成功后写入缓存
			var info = new ProductInfo { Name = product.Name, Price = product.Price };
			_cache.Set(cacheKey, info, TimeSpan.FromMinutes(5));
			_logger.LogDebug("Product {ProductId} cached for 5 minutes", productId);
			return info;
		}
		catch (BrokenCircuitException ex)
		{
			// 熔断器打开,从缓存降级
			_logger.LogWarning(ex, "Circuit breaker open for catalog service, attempting cache fallback for product {ProductId}", productId);
			
			if (_cache.TryGetValue(cacheKey, out ProductInfo? fallback))
			{
				_logger.LogInformation("Product {ProductId} served from cache as fallback", productId);
				return fallback!;
			}

			// 缓存也没有,返回默认占位
			_logger.LogError("No cache available for product {ProductId}, returning default", productId);
			return new ProductInfo 
			{ 
				Name = "Product Temporarily Unavailable", 
				Price = 0,
				IsPlaceholder = true 
			};
		}
		catch (TimeoutRejectedException ex)
		{
			// 超时也尝试缓存降级
			_logger.LogWarning(ex, "Request timeout for product {ProductId}, attempting cache fallback", productId);
			
			if (_cache.TryGetValue(cacheKey, out ProductInfo? fallback))
			{
				return fallback!;
			}

			return ProductInfo.Timeout;
		}
		catch (HttpRequestException ex)
		{
			// 网络异常,记录后返回错误占位
			_logger.LogError(ex, "Network error fetching product {ProductId}", productId);
			return ProductInfo.NetworkError;
		}
	}
}

GetProductInfoAsync 的逻辑首先检查内存缓存,键名是 "product:" 加上商品 ID。TryGetValue 是线程安全的方法,如果缓存存在且未过期,直接返回缓存值并记录 Debug 日志。这个路径是最快的,不涉及网络调用,通常在几微秒内完成。如果缓存未命中,调用 _client.GetAsync 发起 HTTP 请求。这个调用会经过 Polly 的管道处理,先检查超时,然后检查熔断器状态,如果都通过才发送到网络。如果下游返回 200 OK,响应被反序列化为 Product 对象,然后转换为 ProductInfo 并写入缓存,过期时间 5 分钟。_cache.Set 的第三个参数是绝对过期时间,5 分钟后缓存自动失效,下次请求会重新调用下游。

如果 Polly 的熔断器在 GetAsync 调用前检测到熔断状态已打开,会直接抛出 BrokenCircuitException,不会发送实际请求。catch 块捕获这个异常后记录 Warning 日志,日志消息包含 "Circuit breaker open for catalog service" 和商品 ID,方便运维人员从 Dashboard 的日志视图快速定位熔断事件。然后尝试从缓存读取备选数据,如果缓存中有这个商品的历史数据(可能是之前成功调用时缓存的),直接返回并记录 Information 日志,标记为 fallback。这个降级策略的优点是用户看到的是稍旧但完整的数据,而不是错误提示,用户体验更好。如果缓存也没有,说明这是第一次请求这个商品,或者缓存已过期且下游一直不可用,这时返回一个默认的 ProductInfo 对象,Name 设为 "Product Temporarily Unavailable",Price 设为 0,IsPlaceholder 标志设为 true,告诉上层这是占位数据。上层可以根据这个标志显示特殊的 UI,比如灰色的占位图和"商品信息暂不可用"的提示,而不是直接显示价格为 0 导致用户误解。

TimeoutRejectedException 的捕获处理超时场景。超时可能是因为下游处理慢,也可能是网络拥塞。无论哪种原因,超时后重试已经来不及,因为 2 秒的全局超时已经耗尽。这时同样尝试从缓存降级,如果缓存有数据就返回,没有则返回 ProductInfo.Timeout 静态属性,这是一个预定义的超时占位对象,上层可以统一处理超时情况,比如返回 HTTP 504 Gateway Timeout 并提示用户稍后重试。

HttpRequestException 的捕获处理网络异常,比如 DNS 解析失败、连接被拒绝或连接重置。这些异常通常是基础设施问题,重试可能有用但不一定成功。捕获后记录 Error 日志,包含完整的异常堆栈,帮助运维人员排查网络配置或防火墙问题。返回 ProductInfo.NetworkError 占位对象,上层可以返回 HTTP 503 Service Unavailable 并提示用户检查网络连接。

所有的日志都会自动进入 OpenTelemetry 的日志管道,Aspire Dashboard 能实时展示。在 Dashboard 的日志视图中,可以按 LogLevel 过滤,比如只看 Warning 和 Error,快速发现熔断和超时事件。每条日志都关联了 TraceIdSpanId,点击日志条目后能跳转到对应的追踪视图,查看完整的调用链。追踪视图会显示从 OrderService.GetProductInfoAsyncCatalogClient.GetAsync 的 span,以及 Polly 管道的每个策略执行情况。如果发生熔断,span 的状态会标记为 Error,标签中包含 resilience.event=CircuitBreakerOpened,方便搜索和统计。如果发生重试,会看到多个子 span,每个子 span 代表一次重试尝试,标签中包含 resilience.event=Retryresilience.retry.attempt=1

Polly 的遥测数据默认集成到 OpenTelemetry,不需要额外配置。AddResiliencePipeline 内部会注册 TelemetryListener,监听策略执行的各种事件,比如 OnRetryOnCircuitOpenedOnCircuitClosedOnTimeout。这些事件会转换为 OpenTelemetry 的 ActivityLogRecord,发送到 Aspire Dashboard。Dashboard 的指标视图能展示熔断器的状态分布,比如关闭、打开和半开的时长占比,以及重试次数的直方图。如果发现某个服务的熔断器频繁打开,说明下游不稳定,需要排查下游的健康状况或调整熔断阈值。如果重试次数持续很高,说明网络或服务质量差,需要优化基础设施或引入更激进的降级策略。

在生产环境中,弹性策略的参数需要根据实际负载调优。FailureRatio 设为 0.5 意味着允许 50% 的失败率,这个阈值适合大多数场景,但如果业务对可用性要求极高,可以降低到 0.3 或 0.2,让熔断器更敏感。MinimumThroughput 设为 10 是为了避免低流量误熔断,如果服务的 QPS 很高,比如每秒数千请求,可以提升到 100 或 1000,确保统计样本足够大。BreakDuration 设为 15 秒是经验值,如果下游恢复很快,可以缩短到 5 秒或 10 秒,减少不可用时长。如果下游恢复慢,可以延长到 30 秒或 60 秒,避免频繁试探加重下游压力。

超时时长 2 秒也需要根据业务场景调整。如果是用户交互的同步接口,2 秒可能偏长,用户体验不好,可以缩短到 1 秒甚至 500 毫秒,强制快速失败并降级。如果是后台批处理任务,2 秒可能偏短,可以延长到 5 秒或 10 秒,给下游更多处理时间。重试次数 2 次适合幂等的读操作,如果是写操作,需要确保接口幂等或者禁用重试,避免重复提交导致数据不一致。重试的延迟 200 毫秒和指数退避算法适合瞬时故障,如果故障是持续性的,重试再多次也无济于事,反而浪费资源,这时应该依赖熔断器快速失败。

对于需要更细粒度控制的场景,可以为不同的 HttpClient 定义不同的策略。比如调用核心服务的 HttpClient 使用严格的超时和低失败率熔断,调用非核心服务的 HttpClient 使用宽松的超时和高失败率熔断,甚至完全禁用重试和熔断,让请求直接失败并降级。Polly 支持为每个 HttpClient 绑定独立的策略名称,只需在 AddResilienceHandler 传入不同的策略名即可。策略的定义可以集中在 ServiceDefaults 中,也可以分散在各个服务项目中,取决于团队的组织偏好。集中定义的好处是策略可复用且易于审计,分散定义的好处是每个服务可以根据自己的 SLA 自由调整。

在单元测试中,可以用 Polly 的 NoOpResilienceStrategy 替换真实策略,避免重试和超时干扰测试。也可以用 IHttpClientFactory 的 Mock 返回预设的响应,模拟熔断或超时场景,验证降级逻辑是否正确。集成测试中可以启动真实的 Aspire 编排环境,用 Dashboard 观察策略的执行情况,确保配置生效且遥测数据正确上报。

六、服务版本管理

API 版本管理是微服务演进中的核心挑战。当业务需求变更导致接口契约需要调整时,直接修改现有接口会破坏已部署的客户端,导致兼容性问题。传统的做法是停机升级所有客户端,但在微服务架构中,客户端可能分布在不同团队、不同服务甚至不同组织,协调升级的成本极高且容易出错。更好的方案是通过版本管理让新旧接口共存,客户端按自己的节奏迁移,服务端在确认所有客户端升级完成后再下线旧版本。ASP.NET Core 的 API Versioning 库提供了声明式的版本控制能力,支持通过 URL 路径、查询字符串或请求头指定版本,与 Aspire 的服务发现和网关集成后,能实现灰度发布和流量分流,确保版本切换的平滑和可控。

OrderingServiceProgram.cs 中,首先通过 AddApiVersioning 注册版本服务。这个方法接受一个配置委托,用于设置版本的默认行为和报告方式。DefaultApiVersion 属性指定了当客户端未明确指定版本时使用的默认版本,这里设为 new ApiVersion(1, 0),表示版本 1.0。版本号由主版本号和次版本号组成,主版本号通常表示破坏性变更,次版本号表示向后兼容的增强。AssumeDefaultVersionWhenUnspecified 设为 true 后,如果客户端的请求中没有版本信息,框架会自动使用默认版本,避免返回 400 Bad Request。这个设置对迁移期的兼容性很重要,老客户端可能不知道版本机制,它们的请求会被路由到 v1,保持现有行为不变。ReportApiVersions 设为 true 后,所有响应的头部会包含 api-supported-versionsapi-deprecated-versions 字段,列出服务端支持的版本和已废弃的版本。客户端可以解析这些头部,提前知道哪些版本即将下线,主动升级到新版本。AddMvc 方法把版本服务集成到 MVC 框架中,让控制器和最小 API 都能使用版本注解。

csharp 复制代码
// OrderingService Program.cs 配置 API 版本
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

builder.Services.AddApiVersioning(options =>
{
	options.DefaultApiVersion = new ApiVersion(1, 0);
	options.AssumeDefaultVersionWhenUnspecified = true;
	options.ReportApiVersions = true;
	options.ApiVersionReader = ApiVersionReader.Combine(
		new UrlSegmentApiVersionReader(),
		new QueryStringApiVersionReader("api-version"),
		new HeaderApiVersionReader("X-Api-Version")
	);
}).AddMvc();

var app = builder.Build();
app.MapDefaultEndpoints();

var versionSet = app.NewApiVersionSet()
	.HasApiVersion(new ApiVersion(1, 0))
	.HasApiVersion(new ApiVersion(2, 0))
	.ReportApiVersions()
	.Build();

app.MapGroup("/api/v{version:apiVersion}/orders")
   .WithApiVersionSet(versionSet)
   .MapOrdersV1Endpoints();

app.MapGroup("/api/v{version:apiVersion}/orders")
   .WithApiVersionSet(versionSet)
   .MapOrdersV2Endpoints();

app.Run();

ApiVersionReader 属性定义了如何从请求中读取版本信息。ApiVersionReader.Combine 组合了三种读取器,按顺序尝试。UrlSegmentApiVersionReader 从 URL 路径段中读取版本,比如 /api/v1/orders 中的 v1QueryStringApiVersionReader 从查询字符串参数读取,比如 /api/orders?api-version=1.0HeaderApiVersionReader 从请求头读取,比如 X-Api-Version: 1.0。这三种方式可以同时支持,客户端根据自己的偏好选择。URL 路径段是最直观的方式,适合 RESTful API,路径中的版本号一目了然。查询字符串适合向后兼容,老客户端不需要修改 URL 结构,只需在原有 URL 后追加参数。请求头适合不想暴露版本信息的场景,版本号隐藏在头部,URL 保持简洁。

NewApiVersionSet 方法创建一个版本集对象,用于声明服务支持的所有版本。HasApiVersion 方法依次添加版本 1.0 和 2.0,表示这个服务同时支持两个版本。ReportApiVersions 方法让这个版本集的信息包含在响应头中,客户端能看到 api-supported-versions: 1.0, 2.0Build 方法构建版本集对象并返回,这个对象会被后续的 MapGroup 使用。

MapGroup 方法创建一个路由组,所有在这个组内注册的端点都会共享相同的路径前缀和版本约束。路径模板 /api/v{version:apiVersion}/orders 中的 {version:apiVersion} 是一个路由参数,:apiVersion 是约束类型,表示这个参数必须是有效的 API 版本号。当请求 /api/v1/orders 时,路由引擎会提取 v1 并解析为 ApiVersion(1, 0),然后与版本集中声明的版本匹配。如果匹配成功,请求会进入这个路由组的端点处理器。如果匹配失败,比如请求 /api/v3/orders 但版本集中没有 3.0,路由引擎会返回 400 Bad Request 或 404 Not Found,取决于配置。

WithApiVersionSet 方法把之前创建的版本集绑定到这个路由组,让组内的所有端点都受版本集约束。MapOrdersV1EndpointsMapOrdersV2Endpoints 是两个扩展方法,分别注册 v1 和 v2 的业务端点。这两个方法的实现通常在单独的文件中,用静态类封装,保持 Program.cs 的简洁。v1 和 v2 的端点可以使用相同的路径,比如都是 GET /,框架会根据请求中的版本号路由到不同的处理器。

csharp 复制代码
// OrderingService/OrdersV1Endpoints.cs 实现 v1 业务逻辑
public static class OrdersV1Endpoints
{
	public static RouteGroupBuilder MapOrdersV1Endpoints(this RouteGroupBuilder group)
	{
		group.MapGet("/", async (AppDbContext db, CancellationToken token) =>
		{
			var orders = await db.Orders
				.Select(o => new OrderV1Response
				{
					Id = o.Id,
					CustomerId = o.CustomerId,
					TotalAmount = o.TotalAmount,
					Status = o.Status,
					CreatedAt = o.CreatedAt
				})
				.ToListAsync(token);
			
			return Results.Ok(orders);
		})
		.HasApiVersion(1, 0)
		.Produces<List<OrderV1Response>>(StatusCodes.Status200OK)
		.WithName("GetOrdersV1")
		.WithTags("Orders");

		group.MapPost("/", async (CreateOrderV1Request request, AppDbContext db, CancellationToken token) =>
		{
			var order = new Order
			{
				Id = Guid.NewGuid(),
				CustomerId = request.CustomerId,
				TotalAmount = request.TotalAmount,
				Status = "Pending",
				CreatedAt = DateTime.UtcNow
			};

			db.Orders.Add(order);
			await db.SaveChangesAsync(token);

			return Results.Created($"/api/v1/orders/{order.Id}", new OrderV1Response
			{
				Id = order.Id,
				CustomerId = order.CustomerId,
				TotalAmount = order.TotalAmount,
				Status = order.Status,
				CreatedAt = order.CreatedAt
			});
		})
		.HasApiVersion(1, 0)
		.Accepts<CreateOrderV1Request>("application/json")
		.Produces<OrderV1Response>(StatusCodes.Status201Created)
		.WithName("CreateOrderV1")
		.WithTags("Orders");

		return group;
	}
}

MapGet 注册了一个 GET 端点,路径是组前缀加上 /,即 /api/v1/orders/。处理器是一个异步委托,注入了 AppDbContextCancellationToken。查询逻辑使用 LINQ 投影,把 Order 实体映射为 OrderV1Response DTO,只包含客户端需要的字段。HasApiVersion(1, 0) 显式声明这个端点属于版本 1.0,即使路由组已经绑定了版本集,这个声明仍然是必要的,因为同一个路由组可能包含多个版本的端点,显式声明能避免歧义。Produces 方法标注返回类型和状态码,用于生成 OpenAPI 文档,Swagger UI 能自动显示这个端点的响应结构。WithName 给端点命名,方便在代码中用 LinkGenerator 生成 URL。WithTags 给端点分组,Swagger UI 会按 tag 组织端点列表,便于浏览。

MapPost 注册了一个 POST 端点,处理器注入了 CreateOrderV1Request 请求体、AppDbContextCancellationTokenCreateOrderV1Request 是 v1 的请求 DTO,包含 CustomerIdTotalAmount 字段。处理器创建 Order 实体并写入数据库,然后返回 Results.Created,状态码 201,Location 头部指向新创建资源的 URL。Accepts 方法标注请求体的内容类型,Produces 标注响应体的类型和状态码,这些元数据都会进入 OpenAPI 文档。

v2 的端点实现在单独的文件中,契约可能有破坏性变更,比如 TotalAmount 字段改为 TotalPrice,或者增加了 Items 数组表示订单明细。v2 的处理器使用新的 DTO 和业务逻辑,与 v1 完全隔离,不会互相影响。如果 v2 需要访问 v1 的数据,可以在数据库层做兼容,比如用视图或存储过程封装差异,让两个版本的服务都能读写同一份数据但看到的结构不同。

csharp 复制代码
// OrderingService/OrdersV2Endpoints.cs 实现 v2 业务逻辑
public static class OrdersV2Endpoints
{
	public static RouteGroupBuilder MapOrdersV2Endpoints(this RouteGroupBuilder group)
	{
		group.MapGet("/", async (AppDbContext db, CancellationToken token) =>
		{
			var orders = await db.Orders
				.Include(o => o.Items)
				.Select(o => new OrderV2Response
				{
					Id = o.Id,
					CustomerId = o.CustomerId,
					TotalPrice = o.TotalAmount,
					Items = o.Items.Select(i => new OrderItemV2Response
					{
						ProductId = i.ProductId,
						Quantity = i.Quantity,
						UnitPrice = i.UnitPrice
					}).ToList(),
					Status = o.Status,
					CreatedAt = o.CreatedAt,
					UpdatedAt = o.UpdatedAt
				})
				.ToListAsync(token);
			
			return Results.Ok(orders);
		})
		.HasApiVersion(2, 0)
		.Produces<List<OrderV2Response>>(StatusCodes.Status200OK)
		.WithName("GetOrdersV2")
		.WithTags("Orders");

		group.MapPost("/", async (CreateOrderV2Request request, AppDbContext db, IPublisher publisher, CancellationToken token) =>
		{
			var order = new Order
			{
				Id = Guid.NewGuid(),
				CustomerId = request.CustomerId,
				TotalAmount = request.Items.Sum(i => i.Quantity * i.UnitPrice),
				Status = "Pending",
				CreatedAt = DateTime.UtcNow,
				UpdatedAt = DateTime.UtcNow,
				Items = request.Items.Select(i => new OrderItem
				{
					Id = Guid.NewGuid(),
					ProductId = i.ProductId,
					Quantity = i.Quantity,
					UnitPrice = i.UnitPrice
				}).ToList()
			};

			await using var tx = await db.Database.BeginTransactionAsync(token);
			try
			{
				db.Orders.Add(order);
				await db.SaveChangesAsync(token);

				await publisher.Publish(new OrderCreatedEventV2
				{
					OrderId = order.Id,
					CustomerId = order.CustomerId,
					TotalPrice = order.TotalAmount,
					Items = order.Items.Select(i => new OrderItemEventV2
					{
						ProductId = i.ProductId,
						Quantity = i.Quantity,
						UnitPrice = i.UnitPrice
					}).ToList()
				}, token);

				await tx.CommitAsync(token);

				return Results.Created($"/api/v2/orders/{order.Id}", new OrderV2Response
				{
					Id = order.Id,
					CustomerId = order.CustomerId,
					TotalPrice = order.TotalAmount,
					Items = order.Items.Select(i => new OrderItemV2Response
					{
						ProductId = i.ProductId,
						Quantity = i.Quantity,
						UnitPrice = i.UnitPrice
					}).ToList(),
					Status = order.Status,
					CreatedAt = order.CreatedAt,
					UpdatedAt = order.UpdatedAt
				});
			}
			catch
			{
				await tx.RollbackAsync(token);
				throw;
			}
		})
		.HasApiVersion(2, 0)
		.Accepts<CreateOrderV2Request>("application/json")
		.Produces<OrderV2Response>(StatusCodes.Status201Created)
		.WithName("CreateOrderV2")
		.WithTags("Orders");

		return group;
	}
}

v2 的 GET 端点查询逻辑使用了 Include(o => o.Items),加载订单的明细项,然后投影为 OrderV2Response,其中 TotalPrice 字段对应实体的 TotalAmount,Items 是一个 OrderItemV2Response 列表,包含每个明细的 ProductIdQuantityUnitPrice。v2 还新增了 UpdatedAt 字段,记录订单的最后修改时间,这是 v1 没有的字段,v1 的客户端不会看到它,v2 的客户端能获得更精细的时间信息。

v2 的 POST 端点接受 CreateOrderV2Request,包含 CustomerIdItems 数组。处理器计算 TotalAmount 为所有明细的金额之和,然后创建 Order 实体和关联的 OrderItem 实体。这个计算逻辑是 v2 的新增行为,v1 由客户端传入 TotalAmount,v2 由服务端计算,确保金额正确且不可篡改。创建订单后,处理器还调用 publisher.Publish 发布 OrderCreatedEventV2 事件,事件体包含订单明细,下游服务可以订阅这个事件执行库存扣减或积分计算。v1 的事件只包含订单 ID 和总金额,下游服务需要再次查询订单详情,v2 的事件包含完整明细,减少了下游的查询开销。

网关侧的路由配置需要根据版本号分流。YARP 的路由规则支持路径模式匹配,通过 {**catchall} 捕获所有后续路径段。下面的配置为 v1 和 v2 各自定义了一条路由,Match.Path 分别是 /v1/orders/{**catchall}/v2/orders/{**catchall},ClusterId 都指向 ordering,表示两个版本的请求都转发到同一个后端服务。这个配置的前提是后端服务同时支持 v1 和 v2,即上面展示的 Program.cs 同时注册了两个版本的端点。如果需要把 v1 和 v2 部署为独立的服务实例,可以在 AppHost 中添加两个不同的项目,比如 AddProject<Projects.OrderingServiceV1>("ordering-v1")AddProject<Projects.OrderingServiceV2>("ordering-v2"),然后在网关配置中把 ClusterId 分别指向 ordering-v1ordering-v2

json 复制代码
// ApiGateway appsettings.json 网关路由按版本分流
{
  "ReverseProxy": {
	"Clusters": {
	  "ordering": { 
		"Destinations": { 
		  "d1": { "Address": "http://ordering" } 
		} 
	  }
	},
	"Routes": [
	  { 
		"RouteId": "orders-v1",
		"ClusterId": "ordering", 
		"Match": { "Path": "/v1/orders/{**catchall}" },
		"Transforms": [
		  { "PathPattern": "/api/v1/orders/{**catchall}" }
		]
	  },
	  { 
		"RouteId": "orders-v2",
		"ClusterId": "ordering", 
		"Match": { "Path": "/v2/orders/{**catchall}" },
		"Transforms": [
		  { "PathPattern": "/api/v2/orders/{**catchall}" }
		]
	  }
	]
  }
}

路由规则中的 RouteId 是唯一标识符,用于日志和遥测。Match.Path 是入站路径,客户端请求网关时使用的 URL 路径。Transforms 数组定义了路径转换规则,PathPattern 把入站路径 /v1/orders/{**catchall} 转换为出站路径 /api/v1/orders/{**catchall},因为后端服务的实际路径带有 /api 前缀。如果不配置转换,YARP 会把入站路径直接转发到后端,导致 404 Not Found。{**catchall} 是通配符,匹配所有后续路径段,比如 /v1/orders/123 会匹配并转换为 /api/v1/orders/123,/v1/orders/123/items 会转换为 /api/v1/orders/123/items

这个配置实现了版本的路由隔离,客户端请求 /v1/orders/v2/orders 会分别进入不同的路由规则,转发到后端的不同端点。网关本身不理解版本语义,只是根据路径前缀做简单的字符串匹配和转换。版本的解析和验证由后端的 API Versioning 框架负责,网关只做透传。这种职责分离让网关保持简单和高性能,版本逻辑集中在后端,便于维护和扩展。

在灰度发布场景中,可以通过 AppHost 的环境隔离同时启动多个版本的服务实例。下面的代码在 AppHost 中分别添加 v1 和 v2 的 OrderingService 项目,用不同的服务名和端口区分。v1 的服务名是 ordering-v1,v2 的服务名是 ordering-v2,两个服务使用同一个数据库 orderDb,但可以配置不同的环境变量或配置文件,实现行为差异。网关的路由配置调整为根据流量权重分流,比如 90% 的请求路由到 v1,10% 的请求路由到 v2,观察 v2 的稳定性和性能指标,如果正常则逐步提升 v2 的权重到 50%、80% 最后 100%,然后下线 v1。

csharp 复制代码
// AppHost Program.cs 同时部署 v1 和 v2 服务实例
var builder = DistributedApplication.CreateBuilder(args);

var orderDb = builder.AddPostgres("order-db").AddDatabase("orderdb");

var orderingV1 = builder.AddProject<Projects.OrderingServiceV1>("ordering-v1")
	.WithReference(orderDb)
	.WithEnvironment("APP_VERSION", "1.0");

var orderingV2 = builder.AddProject<Projects.OrderingServiceV2>("ordering-v2")
	.WithReference(orderDb)
	.WithEnvironment("APP_VERSION", "2.0");

var gateway = builder.AddProject<Projects.ApiGateway>("gateway")
	.WithReference(orderingV1)
	.WithReference(orderingV2);

builder.Build().Run();

WithEnvironment 方法给服务实例注入环境变量,这里用 APP_VERSION 标识服务的版本号,服务代码可以读取这个变量并记录到日志或遥测标签中,帮助在 Dashboard 中区分不同版本的实例。WithReference 把 v1 和 v2 都引用到网关,网关的配置文件中可以定义两个 Cluster,分别指向 ordering-v1ordering-v2,然后在路由规则中配置流量权重。YARP 支持 LoadBalancingPolicy 配置,可以设为 WeightedRoundRobin,然后为每个目标地址配置 Weight 属性,实现按比例分流。

json 复制代码
// ApiGateway appsettings.json 灰度发布的流量权重配置
{
  "ReverseProxy": {
	"Clusters": {
	  "ordering-v1": { 
		"Destinations": { 
		  "d1": { "Address": "http://ordering-v1", "Metadata": { "Weight": "90" } } 
		},
		"LoadBalancingPolicy": "WeightedRoundRobin"
	  },
	  "ordering-v2": { 
		"Destinations": { 
		  "d1": { "Address": "http://ordering-v2", "Metadata": { "Weight": "10" } } 
		},
		"LoadBalancingPolicy": "WeightedRoundRobin"
	  }
	},
	"Routes": [
	  { 
		"RouteId": "orders-canary",
		"ClusterId": "ordering-v1", 
		"Match": { "Path": "/orders/{**catchall}" },
		"Transforms": [
		  { "PathPattern": "/api/v1/orders/{**catchall}" }
		]
	  },
	  { 
		"RouteId": "orders-canary-v2",
		"ClusterId": "ordering-v2", 
		"Match": { "Path": "/orders/{**catchall}" },
		"Transforms": [
		  { "PathPattern": "/api/v2/orders/{**catchall}" }
		]
	  }
	]
  }
}

Metadata.Weight 属性定义了目标地址的权重,v1 的权重是 90,v2 的权重是 10,YARP 会按 9:1 的比例分配请求。客户端请求 /orders/123 时,网关随机选择一个路由规则,90% 的概率选择 orders-canary 路由到 v1,10% 的概率选择 orders-canary-v2 路由到 v2。这个分流发生在网关层,对客户端透明,客户端不需要感知版本差异,只需请求统一的 /orders 路径即可。灰度期间,Dashboard 会同时展示 v1 和 v2 的指标,包括请求数、错误率、P99 延迟和资源占用。运维人员可以对比两个版本的表现,如果 v2 的错误率或延迟明显高于 v1,立即把权重调回 100:0,回滚到 v1。如果 v2 表现正常,逐步调整权重到 50:50,然后 10:90,最后 0:100,完成全量切换。

在数据一致性方面,v1 和 v2 共享同一个数据库,但契约可能不兼容。为了避免写入冲突,可以让 v1 变为只读模式,所有写操作都路由到 v2,v1 只处理查询请求。这个策略在灰度期很有用,新版本的写入逻辑可能有 bug,如果直接覆盖 v1 的数据会导致不可逆的损坏。让 v1 只读后,即使 v2 的写入有问题,用户仍能通过 v1 查询到旧数据,业务不会完全中断。v2 的写入可以记录到单独的事件表或 Outbox 表,用后台任务回放到 v1 的查询缓存或物化视图,保证查询的最终一致性。

csharp 复制代码
// OrderingServiceV1 Program.cs 配置只读模式
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

var isReadOnly = builder.Configuration.GetValue<bool>("ReadOnlyMode");

builder.Services.AddDbContext<AppDbContext>(options =>
{
	options.UseNpgsql(builder.Configuration.GetConnectionString("orderdb"));
	if (isReadOnly)
	{
		options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
	}
});

var app = builder.Build();

if (isReadOnly)
{
	app.Use(async (context, next) =>
	{
		if (context.Request.Method != "GET" && context.Request.Method != "HEAD")
		{
			context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
			await context.Response.WriteAsJsonAsync(new { Error = "This service is in read-only mode. Please use v2 for write operations." });
			return;
		}
		await next();
	});
}

app.MapOrdersV1Endpoints();
app.Run();

isReadOnly 从配置中读取,可以通过环境变量 ReadOnlyMode=true 启用只读模式。在只读模式下,DbContext 配置为 NoTracking,所有查询不会追踪实体变更,提升查询性能并避免意外的写入。中间件检查请求方法,如果是 POST、PUT、DELETE 等非幂等方法,直接返回 405 Method Not Allowed,响应体包含友好的错误提示,告诉客户端应该使用 v2 进行写操作。GET 和 HEAD 请求正常通过,查询逻辑不受影响。

v2 的写入逻辑除了更新数据库,还需要发布事件通知 v1 的缓存失效或数据更新。事件的消费者可以是 v1 的后台服务,订阅 OrderCreatedEventV2 后把新订单的摘要信息写入 Redis 或内存缓存,v1 的查询接口优先从缓存读取,缓存未命中再查数据库。这种模式让 v1 的查询保持快速响应,同时数据与 v2 保持最终一致。如果 v2 的写入失败,事件不会发布,v1 的缓存保持旧值,用户看到的是稍旧但一致的数据,而不是错误或不完整的数据。

在 Aspire Dashboard 中,v1 和 v2 的实例会显示为不同的服务节点,每个节点的指标独立统计。Dashboard 的依赖图能展示网关到 v1 和 v2 的调用路径,以及两个版本到数据库的连接。如果 v2 的数据库查询耗时明显高于 v1,可能是 v2 引入了复杂的联表查询或缺少索引,需要优化 SQL 或调整数据模型。Dashboard 的日志视图支持按服务名过滤,可以单独查看 v2 的错误日志,快速定位新版本引入的 bug。追踪视图能对比同一个请求在 v1 和 v2 中的执行路径,如果 v2 的某个 span 耗时异常,展开后能看到具体是哪个数据库查询或 HTTP 调用慢。

版本管理的最佳实践是保持向后兼容。如果新版本只是增加字段或端点,而不删除或修改已有字段,客户端可以平滑升级,不需要强制迁移。v2 的响应可以包含 v1 的所有字段,只是多出一些新字段,v1 的客户端解析响应时忽略未知字段,不会报错。如果必须做破坏性变更,比如字段重命名或类型改变,应该提前告知客户端并提供迁移指南,在响应头的 api-deprecated-versions 中标记 v1 为已废弃,给客户端足够的时间升级。废弃期通常是 3 到 6 个月,期间 v1 和 v2 同时在线,过期后 v1 返回 410 Gone 或重定向到 v2,强制客户端升级。

API Versioning 的另一个用途是 A/B 测试。可以为不同的用户群体返回不同版本的接口,比如内部用户使用 v2 体验新功能,外部用户继续使用 v1 保持稳定。路由规则根据请求头的 User-AgentAuthorization token 判断用户身份,动态选择目标版本。YARP 支持自定义的 IProxyLoadBalancer 实现,可以在代码中读取请求上下文,根据业务规则返回目标地址。这种动态路由比静态权重更灵活,适合需要精细控制的场景。

版本演进的终极目标是消除版本差异,让所有客户端使用统一的最新版本。这需要服务端和客户端的协同配合,服务端提供平滑的迁移路径和充足的兼容期,客户端及时响应废弃通知并主动升级。Aspire 的可观测性能力让版本管理变得透明和可控,Dashboard 实时展示每个版本的健康状况,团队可以数据驱动地决策何时升级、何时回滚、何时下线,避免凭经验或猜测,降低变更风险,提升服务质量。

七、总结

本文介绍了在 Aspire 微服务架构中,如何使用 Polly 实现弹性策略,并通过 OpenTelemetry 集成实现全面的可观测性。通过配置熔断器、重试和超时等策略,提升了服务对下游依赖故障的容忍度,减少了级联故障的风险。集成的日志和追踪让团队能够实时监控弹性策略的执行情况,快速定位和排查问题。最后,介绍了 API 版本管理的最佳实践,确保服务在演进过程中保持兼容性和稳定性。

相关推荐
数据与后端架构提升之路1 天前
Seata 全景拆解:AT、TCC、Saga 该怎么选?告别“一把梭”的架构误区
分布式·架构
檐下翻书1731 天前
在线绘制水流量示意图
论文阅读·架构·毕业设计·流程图·论文笔记
J2虾虾1 天前
Docker启动超时,吓得我一身汗
运维·docker·容器
一生只为赢1 天前
通俗易懂:ARM指令的寻址方式(三)
运维·arm开发·数据结构·嵌入式实时数据库
运维行者_1 天前
2026 技术升级,OpManager 新增 AI 网络拓扑与带宽预测功能
运维·网络·数据库·人工智能·安全·web安全·自动化
液态不合群1 天前
Nginx多服务静态资源路径冲突解决方案
运维·nginx
Getgit1 天前
Linux 下查看 DNS 配置信息的常用命令详解
linux·运维·服务器·面试·maven
dajun1811234561 天前
油气能源开采工艺流程示意图绘制
信息可视化·架构·流程图·能源
数通工程师1 天前
企业级硬件防火墙基础配置实战:从初始化到规则上线全流程
运维·网络·网络协议·tcp/ip·华为
Asher阿舍技术站1 天前
【5G无线接入技术系列】四、无线接口架构
5g·架构