1. 前情回顾与本章定位:从“能跑”到“能扛”
这个系列写到第三篇,前两篇我们聊完了整体架构选型、环境搭建,以及第一个C#微服务是怎么在Azure上跑起来的。收到不少读者的反馈,说照着前两篇能把服务部署上去,但一遇到真实流量就露馅:跨服务调用超时、日志乱成一锅粥、消息重复消费、配置改完不生效……这些问题单体时代基本不用操心,拆成微服务后全冒出来了。
这一篇我打算集中解决“微服务落地过程中最扎手的几个问题”,不是再铺一层新概念,而是把前两篇留下的坑填上。你会看到API网关与版本治理怎么做、异步消息队列到底该在什么时候引入、数据一致性怎么用最朴素的方式保证、线上问题怎么通过可观测性快速定位。整个过程依然围绕C#和Azure这套技术栈展开,适合已经有一个基础微服务骨架、正准备往生产环境推的团队参考,也适合那些在单体转型微服务过程中被各种“分布式副作用”折磨的开发者。
顺便说一句,最近热搜词里有很多C#上位机、C#串口通信相关的内容,看起来不少搞工控、设备采集的朋友也来关注微服务了。其实思路是通的:上位机那边解决的是“怎么把设备数据搬出来”,微服务这边解决的是“搬出来之后怎么稳定地处理、存储、对外提供”。你手里那套模块化、解耦、日志埋点的经验,换到服务端完全能用,只是把串口换成了HTTP和消息队列而已。
2. 服务间通信与API治理:别让Link调用成为事故现场
2.1 为什么不能直接HttpClient满天飞
很多从单体转过来的团队,第一个微服务往往是从“把Controller拆成独立项目”开始的。服务A要调服务B,很自然地就在代码里写了:
using var httpClient = new HttpClient(); var response = await httpClient.GetAsync("http://service-b/api/orders/1");这段代码在演示环境跑得好好的,上了生产就开始出问题:Socket耗尽、DNS解析延迟、下游一抖动整条链路跟着超时……而且你根本不知道service-b这个地址在Kubernetes或Azure Container Apps里到底该填什么。
先说结论:在Azure上跑C#微服务,跨服务调用有三件事必须做——服务发现、弹性策略、统一入口。服务发现解决“service-b到底在哪”,弹性策略解决“下游挂了或慢了怎么办”,统一入口解决“认证、限流、路由这些横切逻辑放哪”。
具体落地可以分两条路走。如果你用的是Azure Kubernetes Service,K8s自带的Service DNS天然就是服务发现,你在代码里写http://service-b没问题,但HttpClient必须交给依赖注入管理,不能每次new。如果你用的是Azure Container Apps这类PaaS,同样有内置的服务发现机制,也是用服务名当主机名。
关键代码不是写HttpClient,而是注册Typed Client:
builder.Services.AddHttpClient<OrderApiClient>(client => { client.BaseAddress = new Uri("http://order-service"); }) .AddPolicyHandler(GetRetryPolicy()) .AddPolicyHandler(GetCircuitBreakerPolicy());这里用了Polly做弹性策略,这是.NET生态里最成熟的库,后来合进了Microsoft.Extensions.Http.Resilience。重试策略要小心:只对幂等请求重试。拿订单服务举例,查询接口重试没问题,但“创建订单”“扣款”这种非幂等接口一旦客户端超时重试,就可能产生两笔订单。所以非幂等接口一定要在业务层做幂等校验,或者给请求加Idempotency-Key,服务端按Key去重。
熔断比重试更重要。下游服务如果已经处在过载状态,你再怎么重试都只会添乱。Polly的熔断策略会在一段时间内直接快速失败,给下游喘息的机会。
var circuitBreaker = Policy<HttpResponseMessage> .Handle<HttpRequestException>() .OrResult(response => (int)response.StatusCode >= 500) .CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30));这个策略的意思是:连续5次遇到异常或5xx响应,就打开熔断开关,30秒内所有请求快速失败,30秒后放一个试探请求,成功就恢复,失败就继续熔断。这个数字不是拍脑袋定的,要结合你的下游服务恢复时间。太短了下游还没缓过来又被打爆,太长了对用户体验不友好。
2.2 API版本管理与Swagger聚合:接口改了,别让调用方爆炸
微服务跑起来以后,最头疼的问题之一就是接口升级。今天给Order接口加个字段,明天把某个接口的参数从int改成string,下游团队跑来问你:“你们接口怎么又变了?”
在微服务架构里,API版本管理不是可选项,是必选项。ASP.NET Core这边用Asp.Versioning.Http这个库(注意老包叫Microsoft.AspNetCore.Mvc.Versioning,新版改名了),配置很简单:
builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = ApiVersionReader.Combine( new UrlSegmentApiVersionReader(), new HeaderApiVersionReader("X-Version")); }).AddApiExplorer();Controller里这样写:
[ApiController] [Route("api/v{version:apiVersion}/orders")] public class OrdersController : ControllerBase { [HttpGet("{id}")] public async Task<ActionResult<OrderDto>> GetOrder(string id, ApiVersion version) { // 实现 } }版本策略上我踩过不少坑,总结下来最实用的原则是:小改动升Minor版本,破坏性改动升Major版本,旧版本留足废弃周期再删。不要懒到所有改动都扔到v2,也不要每次都新建Controller副本。对新增字段这类兼容性改动,直接在原接口上加就好,响应模型里多一个字段对JSON反序列化是透明的。
Swagger这一块,每个微服务自带一份Swagger文档是基本操作,但你不可能让调用方每调一个服务就去翻一个Swagger页面。聚合文档有几个方案:如果用了Azure API Management,它自带OpenAPI导入,能让所有下游接口在一个门户里被检索;如果不想引入额外组件,简单做法是在网关层做一个代理,把各服务的Swagger JSON合并起来。
这里提醒一句:生产环境千万不要把Swagger端点直接暴露出去。我在客户现场见过不止一次,Swagger UI裸奔在公网上,接口结构、参数、内部字段全被看光。要么在网关层做身份认证,要么只在内部网络开放,Azure Container Apps可以直接用内置的ingress限制外部访问。
3. 异步消息与后台任务:同步调用保不住所有场景
3.1 什么时候该上消息队列:用尺子量,别用感觉
微服务面试题里高频出现“同步调用和异步消息怎么选”,实际项目里很多人选错了。有个很简单的判断标准:用户是否必须立刻拿到结果。
用户下单后,真正需要同步等待的是“订单创建成功”这个结果,至于“发短信通知”“积分累加”“库存预占失败后的补偿”,用户其实不关心具体什么时候完成。这类操作如果全部串在请求链路里,不仅拖慢响应时间,而且任何一个下游服务抖动都会导致下单失败。
在Azure上做异步消息,首选是Service Bus。它支持队列和主题两种模式,队列就是点对点,主题可以广播给多个订阅者。订单创建后,订单服务往Topic里发一条消息,通知服务、积分服务、报表服务各自订阅,谁处理失败都不影响别人。
发消息的代码很简单:
public class OrderMessageSender { private readonly ServiceBusSender _sender; public OrderMessageSender(ServiceBusClient client) { _sender = client.CreateSender("order-events"); } public async Task SendOrderCreatedAsync(OrderCreatedMessage message, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(message); var busMessage = new ServiceBusMessage(json) { MessageId = message.EventId.ToString(), ContentType = "application/json" }; await _sender.SendMessageAsync(busMessage, cancellationToken); } }这里有两件事必须注意。一是MessageId一定要设置成业务事件的唯一标识,比如EventId,这样Service Bus自带的消息去重才能生效;二是消息体里要包含业务数据和事件元数据,不能只放一个“订单ID”就让下游去查,查不到的时候下游一脸懵。
接收端在Azure Function里写最简单,Service Bus触发器自动帮你处理确认和死信逻辑。如果是自己宿主在后台服务里,用ServiceBusProcessor:
await using var processor = client.CreateProcessor("order-events", "notifications", new ServiceBusProcessorOptions { AutoCompleteMessages = false, MaxConcurrentCalls = 10 }); processor.ProcessMessageAsync += async args => { try { var message = args.Message; var orderEvent = JsonSerializer.Deserialize<OrderCreatedMessage>(message.Body.ToString()); await _notificationService.SendAsync(orderEvent); await args.CompleteMessageAsync(message); } catch (Exception ex) { _logger.LogError(ex, "处理订单事件失败"); await args.DeadLetterMessageAsync(args.Message, "processing-error", ex.Message); } }; processor.ProcessErrorAsync += args => { _logger.LogError(args.Exception, "Service Bus处理器异常"); return Task.CompletedTask; }; await processor.StartProcessingAsync();AutoCompleteMessages设成false,处理成功才调用CompleteMessage,失败了就进死信队列。这里有个经验:不要所有异常都重试,业务逻辑错误(比如数据格式不对)重试一百次也是错,直接丢死信队列让监控去报警;只有网络抖动这类瞬时错误才有重试价值。Service Bus本身支持最大投递次数,到了上限会自动进死信队列,所以不需要自己写重试循环。
3.2 Worker后台任务:别再用while(true)+Thread.Sleep了
热搜词里“C# worker用法”热度很高,看来很多人写后台任务还是老一套——一个while(true)循环里塞个Sleep。这样做的问题有两个:一是没有优雅停机能力,服务发布时任务可能被硬杀;二是依赖Sleep的精度没法保证,而且Sleep会占着线程不放,在高并发场景下容易造成线程池饥饿。
正确姿势是BackgroundService搭配PeriodicTimer。举个例子,我们要做一个定时扫描超时未支付订单的功能:
public class OrderTimeoutWorker : BackgroundService { private readonly ILogger<OrderTimeoutWorker> _logger; private readonly IServiceScopeFactory _scopeFactory; public OrderTimeoutWorker(ILogger<OrderTimeoutWorker> logger, IServiceScopeFactory scopeFactory) { _logger = logger; _scopeFactory = scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { try { using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService<OrderDbContext>(); var timeoutOrders = await dbContext.Orders .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < DateTime.UtcNow.AddMinutes(-15)) .ToListAsync(stoppingToken); // 处理超时订单 } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { _logger.LogInformation("订单超时任务正在停止"); break; } catch (Exception ex) { _logger.LogError(ex, "扫描超时订单异常"); } } } }两个细节说一下。第一,不要用Singleton直接注入DbContext,因为DbContext默认是Scoped生命周期,从单例的任务里拿Scoped服务会抛异常,用IServiceScopeFactory手动创建Scope是对的。第二,PeriodicTimer和老的Timer比起来,最大的好处是支持CancellationToken,服务停止时能干净退出,不会出现“明明发布了新版本,旧的还在跑”的情况。
3.3 分布式事务:能不用就不用,要用就用最朴素的
说到消息队列,就绕不开“本地事务和发消息不一致”的问题。经典场景:订单表写入成功,但消息没发出去,下游没收到通知。不少团队的第一反应是引入分布式事务框架,在Azure上搞一套Saga编排引擎。我的建议是:除非业务量真的到了必须上工作流引擎的程度,否则用Outbox模式就够了。
Outbox模式说白了就是“把发消息这件事也当成一条数据,和业务数据放在同一个数据库事务里”。订单创建时,在同一事务里往Order表和Outbox表各插一条记录。然后有一个后台任务扫描Outbox表,把状态为Pending的记录读出来发到Service Bus,发成功了把状态改成Sent。
这是一个把原子性从“跨网络”降维成“本地数据库事务”的思路。好处是:不需要额外引入一套分布式事务中间件;实现简单,几百行代码搞定;可靠性由数据库保证。代价是:多了Outbox表要清理;消息会有一定延迟(取决于轮询频率);消费端必须做幂等。
我用过最简单的实现就是前面说的Worker里加一个扫表逻辑:
var pendingEvents = await context.OutboxEvents .Where(e => e.Status == OutboxStatus.Pending) .OrderBy(e => e.CreatedAt) .Take(200) .ToListAsync(ct); foreach (var eventItem in pendingEvents) { try { await _sender.SendAsync(eventItem.Type, eventItem.Payload, ct); eventItem.Status = OutboxStatus.Sent; eventItem.SentAt = DateTime.UtcNow; } catch (Exception ex) { _logger.LogError(ex, "发送Outbox消息失败,EventId: {EventId}", eventItem.Id); // 这里不要标记为Sent,下轮重试,但要控制重试次数 eventItem.RetryCount++; if (eventItem.RetryCount >= 10) { eventItem.Status = OutboxStatus.Failed; } } } await context.SaveChangesAsync(ct);注意不要在一个事务里处理太多条,一次200条的量比较合适。另外一定要给Outbox表加RetryCount字段,重试超过阈值直接标记Failed,人工排查,不然一条坏消息卡在队首,后面所有消息都跟着堵车。
4. 数据一致性与性能优化:数据库是最容易翻车的环节
4.1 跨服务查询:别让前端为了一个页面调八个接口
微服务拆分后最先遇到的问题是:单体时代一个JOIN查出来的数据,现在分散在好几个服务里。订单列表页要显示订单基本信息、商品信息、物流信息,前端得串行调三个接口。这个“接口聚合”的问题如果不解决,性能会指数级下降。
解决方案按复杂度有三种。第一种是API网关层聚合,用Azure API Management的策略或者自定义BFF(Backend for Frontend)服务专门做编排,前端只调一个接口。第二种是数据冗余,订单服务里冗余一份商品名称、商品图片的镜像,下单时从商品服务同步过来,查询时直接查本地库。第三种是CQRS,读操作走单独的读模型,专门为查询优化。
我的建议是:中小型团队优先用数据冗余,把跨服务联查变成单服务查询。虽然牺牲了一定的数据实时性,但换来的性能收益是实打实的。比如下单时把商品快照存到订单表里,后续查询订单详情根本不用再调商品服务。商品改名了?历史订单还是显示下单时的名字,这在业务上反而更合理。
4.2 EF Core性能陷阱:贪婪加载引发的性能灾难
C#微服务里,EF Core是主力ORM,但用不好就是性能杀手。最常见的问题是导航属性贪婪加载。比如查订单时默认加载商品、物流、优惠券:
var orders = await context.Orders .Include(o => o.Items) .Include(o => o.Delivery) .ToListAsync();看起来很正常,但生成的SQL是三个LEFT JOIN,如果订单有50条,每条的Items有10条,返回的数据量就是50×10×N的笛卡尔积。数据量大了之后,这个查询能直接把数据库拖垮。
解法有三个手段。第一,用AsSplitQuery()把JOIN拆分成多条独立SQL,每次查询返回单独的数据集,避免笛卡尔爆炸:
var orders = await context.Orders .Include(o => o.Items) .AsSplitQuery() .ToListAsync();第二,查询列表时用AsNoTracking(),只读场景下EF Core不需要做变更跟踪,内存开销和查询时间都会明显下降:
var orders = await context.Orders .AsNoTracking() .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.CreatedAt) .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync();第三,用ProjectTo投影,只查需要的字段:
var orderDtos = await context.Orders .Where(o => o.CustomerId == customerId) .Select(o => new OrderListItemDto { Id = o.Id, TotalAmount = o.TotalAmount, Status = o.Status, ItemCount = o.Items.Count }) .ToListAsync();这一条最推荐,响应体变小、SQL不用SELECT *、没有导航属性加载问题,性能提升最明显。曾经有个客户线上接口P99延迟在800ms左右,把Include改成ProjectTo后降到了120ms,就是因为返回的数据量小了近10倍。
4.3 Redis缓存与分布式锁:缓存穿透和击穿怎么防
Azure Cache for Redis是微服务架构里标配的组件,但很多人应用缓存的方式非常原始:查数据库前先查缓存,有就返回,没有就查库然后写缓存。听起来没问题,但高并发下两个经典问题立刻出现。
第一是缓存穿透。恶意请求或查询不存在的数据,每次都会穿透缓存打到数据库。防的办法是缓存空值,比如查一个不存在的订单ID,也往缓存里写一个空对象,设置较短的过期时间。
第二是缓存击穿。某个热点Key过期的一瞬间,大量请求同时打到数据库。防的办法是加锁,让只有一个请求去查数据库并重建缓存,其他请求等锁。在Redis上实现分布式锁,StackExchange.Redis也提供了现成封装:
var cache = connection.GetDatabase(); var lockToken = Guid.NewGuid().ToString(); var lockAcquired = await cache.LockTakeAsync($"order:lock:{orderId}", lockToken, TimeSpan.FromSeconds(10)); if (lockAcquired) { try { var order = await QueryFromDatabase(orderId); if (order != null) { await cache.StringSetAsync($"order:{orderId}", JsonSerializer.Serialize(order), TimeSpan.FromMinutes(30)); } return order; } finally { await cache.LockReleaseAsync($"order:lock:{orderId}", lockToken); } } else { // 没抢到锁,稍微等一下再从缓存取 await Task.Delay(100); var cached = await cache.StringGetAsync($"order:{orderId}"); if (cached.HasValue) { return JsonSerializer.Deserialize<Order>(cached); } return await QueryFromDatabase(orderId); }锁超时时间一定要比业务执行时间长,不然业务还在跑锁就释放了,另一个线程拿到锁发现缓存还没重建,又查了一次库。保守起见,锁的超时时间建议是预估业务耗时的3倍。
至于缓存雪崩,处理思路是给不同的Key设置不同的过期时间,避免大量Key在同一时刻集体失效。可以在基础过期时间上加一个随机数,比如30分钟到60分钟之间的随机值,让过期时间分散开。
5. 可观测性与配置管理:出了问题能找到根因,才是真“能扛”
5.1 结构化日志:字符串拼接是排查问题的最大敌人
微服务出问题时,最气人的不是报错本身,而是日志里找不到有效信息。很多团队的日志还是这种写法:
_logger.LogInformation($"订单 {orderId} 创建成功,金额 {amount}");这样写在排查分布式问题时有几个致命缺陷:第一,如果orderId或amount是null,整个日志直接抛异常,把真正的日志信息吞掉了;第二,无法按字段检索,你想查“某个客户的所有订单日志”,只能全文搜字符串,在海量日志里等于大海捞针;第三,日志平台无法把orderId提取成维度字段,做不了聚合分析。
正确写法是结构化日志。Serilog在Azure上配Application Insights的做法:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.ApplicationInsights( instrumentationKey: connectionString, TelemetryConverter.Traces) .CreateLogger();业务代码里用占位符方式记录:
_logger.LogInformation("订单创建成功 OrderId={OrderId} CustomerId={CustomerId} Amount={Amount}", orderId, customerId, amount);Application Insights能自动识别花括号里的参数名,把OrderId、CustomerId变成独立的维度字段。在Azure Portal里可以直接写KQL查询:
traces | where message contains "OrderId" | where customDimensions.OrderId == "ORD-2024-0001"十几条日志一下就能揪出来,不用像以前一样一行一行翻。
5.2 链路追踪:一个请求到底经历了哪些服务
微服务环境里,一个用户请求可能经过网关、订单服务、支付服务、消息队列、Worker,任何一环慢了你都得知道是哪一环。链路追踪的核心是给每个请求分配一个TraceId,在服务间传递,把所有环节的日志串起来。
ASP.NET Core这边,System.Diagnostics.Activity天生支持,Application Insights的SDK会自动做Correlation。但有个很容易被忽视的坑:如果你在代码里手动发起HttpClient调用,但没有传播TraceId header,链路就断了。Azure的Application Insights SDK会自己处理大部分场景,但如果你用了自定义的HttpClientHandler,要确保没有把默认的TelemetryHandler覆盖掉:
builder.Services .AddHttpClient<OrderApiClient>(client => client.BaseAddress = new Uri("http://order-service")) .AddHttpMessageHandler<HttpClientTelemetryHandler>();如果用的是Azure Functions,在host.json里开启Application Insights的追踪,函数之间传递消息时会自动带上Correlation上下文。Azure Service Bus的SDK也支持在消息头里传播Diagnostic-Id,等于说从HTTP到消息队列的链路都能串起来,不用手动写代码传播。
5.3 配置管理:appsettings.json里别放连接字符串
微服务上了Azure之后,配置管理最容易犯的错是把连接字符串、密钥直接写进appsettings.json,甚至写进Docker镜像环境变量。密码一旦被别人获取,等于把整个数据库裸奔在公网上。
Azure上正确的姿势是Azure Key Vault + Azure App Configuration。Key Vault管机密(连接字符串、密码、API Key),App Configuration管普通配置(开关、阈值、连接地址)。最方便的是在Program.cs里用托管标识拉取:
var builder = WebApplication.CreateBuilder(args); if (builder.Environment.IsProduction()) { var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Uri"]!); builder.Configuration.AddAzureKeyVault(keyVaultUrl, new DefaultAzureCredential()); }DefaultAzureCredential会自动尝试多种身份认证方式,本地开发时用Azure CLI登录的账号,部署到Azure时用App Service或Container Apps的托管标识,不需要在代码里硬编码任何密钥。Key Vault里的机密可以通过“Key Vault引用”直接注入到App Service的应用设置里,代码里不需要感知配置源的切换。
这里说说IOptions的问题。很多人在微服务里用了IOptions的模式读取配置,但没用对。IOptions是单例的,应用启动时读取配置后就缓存了,不会感知配置变更。正确做法是IOptionsMonitor:
public class FeatureFlagService { private readonly IOptionsMonitor<FeatureFlagOptions> _options; public FeatureFlagService(IOptionsMonitor<FeatureFlagOptions> options) { _options = options; } public bool IsEnabled(string flagName) { return _options.CurrentValue.Flags.TryGetValue(flagName, out var enabled) && enabled; } }IOptionsMonitor订阅了配置变更事件,Azure App Configuration的值一变,应用内能实时感知,配合Azure App Configuration的Feature Manager还能做功能开关,不用重新发布就能控制某个功能在特定环境是否启用。
6. 典型问题排查实录:这些坑我替你踩过了
6.1 HttpClient使用不当导致Socket耗尽
现象是服务跑一段时间后,所有HTTP请求都开始超时,重启之后恢复,过几分钟又不行。用netstat一看,大量TIME_WAIT连接堆积,端口资源耗尽。
根因就是代码里到处new HttpClient()。HttpClient底层封装了Socket,每次new都会创建一个新的连接池,频繁创建导致大量Socket来不及释放。解决办法前面提过,用IHttpClientFactory管理生命周期,或者用SocketsHttpHandler配置连接池复用:
builder.Services.AddHttpClient("api") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), MaxConnectionsPerServer = 50 });PooledConnectionLifetime设置为5分钟,是为了让DNS和连接定期刷新,避免下游服务更换IP后连接还在用旧地址。
6.2 async/await使用不当导致线程池饥饿
现象是接口偶发性超时,CPU占用并不高,但线程池队列越来越长。
排查一看代码,发现有人在异步方法里写了Task.Result或者.Wait()。这是ASP.NET Core里的头号死锁成因:同步阻塞占用了线程池线程,异步请求又在等待线程释放,互相等待。解决方式只有一条:不要在异步代码路径上同步阻塞,从入口到出口全链路async/await:
// 错误的写法 public async Task<Order> GetOrderAsync(string orderId) { var order = _repository.GetOrderAsync(orderId).Result; // 阻塞! return order; } // 正确的写法 public async Task<Order> GetOrderAsync(string orderId) { return await _repository.GetOrderAsync(orderId); }6.3 消息消费端重复执行业务逻辑
Service Bus的AtLeastOnce语义决定了消息可能被重复投递。场景是这样:消费端处理完业务后,还没来得及调用CompleteMessage,服务宕机了。重启后消息被重新投递,业务逻辑又执行了一遍,产生重复数据。
解决办法是消费端幂等。最简单的做法是利用数据库唯一索引:
// 建表时把EventId设为唯一索引 protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<ProcessedEvent>() .HasIndex(e => e.EventId) .IsUnique(); }处理消息时先尝试插入ProcessedEvent,插入失败说明已经处理过,直接忽略。这个方法简洁有效,代价是多一次数据库写入,但对大多数业务场景完全够用。
6.4 配置刷新无效,还是旧值
有读者问:“我用了Azure App Configuration,改完配置,代码里读取到的还是旧值。”
这个问题的原因大概率是用了IOptions而不是IOptionsMonitor,或者IOptionsMonitor的配置源没有配置UseAzureAppConfiguration的刷新监听。正确的配置在Program.cs里应该是:
builder.Configuration.AddAzureAppConfiguration(options => { options.Connect(new Uri(appConfigEndpoint), new DefaultAzureCredential()) .Select("Demo:*") .ConfigureRefresh(refreshOptions => { refreshOptions.Register("Demo:Settings:Sentinel", refreshAll: true) .SetCacheExpiration(TimeSpan.FromSeconds(30)); }); });这里有个技巧:注册一个哨兵Key(Sentinel),每次刷新只检查这个Key是否变化,变了才拉取全部配置。如果不加哨兵Key,每个Key都会被单独轮询,性能和成本都不划算。
7. 一点经验沉淀
这篇文章写到这里,我个人最大的体会是:微服务架构的问题从来不是“搭不起来”,而是“跑不稳”。C#和Azure这套技术栈给了你很多现成的组件——Service Bus、Redis、Application Insights、Key Vault——但真正决定系统稳不稳的,是你在每个环节有没有踩对节奏。API版本管理从第一天做起,跨服务调用想清楚幂等和重试,消息队列只在异步场景使用,配置和密钥一律交给云端托管。这些经验不是看书看来的,是一次次线上事故换来的。你按这个思路去搭,前期的确会多花一点时间,但后面省下的排查时间是十倍百倍。下一篇我打算专门聊聊微服务的规模化部署,包括多环境管理、蓝绿发布和金丝雀发布,那部分是真正考验运维功力的地方。