尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#/.NET微服务实战:通信、配置与可观测性避坑指南

C#/.NET微服务实战:通信、配置与可观测性避坑指南 先聊个比较实际的感受。C# 做微服务在很多团队眼里还是个“偏门”选项一提微服务就是 Java 那一套Spring Cloud、Nacos、Sentinel 满天飞。我在生产环境里用 .NET 跑了三年多的微服务从单体拆到 20 多个服务中间也接了不少 Azure Functions 做削峰填谷和事件处理今天这篇就接着系列往下写。前面几篇讲的是架构选型、服务拆分边界、基础设施搭建和 CI/CD 管道这一篇我重点想聊的是最容易让人翻车的三块服务之间到底该怎么通信、配置管理和注册发现怎么落地、以及服务多了之后日志和链路追踪怎么搞。如果你正在用 C# 做微服务或者正准备从单体切到微服务这篇应该能帮你少踩几个坑。1. 服务边界与无服务器函数在微服务里的定位1.1 微服务不是越碎越好先说一个我在很多项目里反复看到的错误团队拿到微服务架构第一件事就是把原来的单体按“功能”劈成十几个服务每个服务 3 个人维护结果联调成本直接爆炸线上问题一排一个不吱声。拆服务是有代价的网络调用、数据一致性、分布式事务、日志追踪每一项都比单体复杂一个量级。C# 生态里没有像 Spring Cloud 那样一套全家桶全覆盖很多组件需要自己拼装这种复杂性会被进一步放大。我自己在项目里常用的拆分原则是“按业务能力拆不按代码分层拆”。比如电商类项目会拆出用户服务、商品服务、订单服务、库存服务、支付服务、营销服务而不会拆出“Controller 服务”“DAL 服务”这种技术分层。另一个补充原则是如果两个模块的数据是强一致的比如下单和扣库存那就尽量放在同一个服务里用本地事务解决别为了微服务而把简单问题复杂化。这个思路在 C# 里落地其实很自然因为 .NET 的模块化能力本来就不弱。你用 ABP 框架的话一个解决方案里就可以用模块Module来组织业务能力后续真要拆服务模块边界就是天然的服务边界。即使是普通 ASP.NET Core 项目用整洁架构Clean Architecture划分 Core、Application、Infrastructure 各层也比一上来就按服务乱切要稳妥得多。1.2 Serverless 函数该放在哪个位置无服务器Serverless在微服务架构里不是替代品而是补充。我一般不把核心业务状态放在函数里而是把函数当作“事件消费者”和“短任务执行者”。举几个典型场景秒杀/大促场景下的兜底处理用户下单后订单服务写库成功即返回后续的发券、发短信、更新统计等操作全部丢到消息队列由 Azure Functions或 AWS Lambda消费处理。这样核心下单链路的 RT 不被拖垮高峰期削峰填谷的效果非常明显。定时任务比如每天凌晨汇总报表、清理过期数据、对账。这类任务频率低、耗时不稳定放常驻服务里既占资源又要处理定时调度的并发问题用 Function 的 TimerTrigger 一分钟配置就搞定。事件驱动比如图片上传后生成缩略图、文件解析、邮件发送。这类任务天然适合函数计算按调用次数计费的模式。在 C# 这边Azure Functions 是主流选择尤其是 .NET 6 以上推荐用“隔离模式”Isolated Worker Process而不是旧版的 In-Process 模型。隔离模式的优点是函数宿主进程和你自己的代码进程完全隔离依赖冲突少而且可以直接使用 .NET 的依赖注入、配置系统跟 ASP.NET Core 的编程体验非常接近。我做过对比隔离模式启动时间会稍微多几十毫秒但对大多数业务场景来说无感。函数服务跟常驻微服务配合时最关键的约定是“数据通过消息传递不直接调用函数”。因为函数实例是弹性的、生命周期短的你没法确定它实例在哪个地址上通过消息队列解耦函数消费消息处理处理失败还能自动重试比 RPC 调用可靠得多。2. 服务间通信从 HttpClient 到消息队列的选型与实践2.1 IHttpClientFactory 的坑与正确用法服务间同步最直接的方式就是 HTTP但这里藏着 .NET 开发最容易踩的坑直接 new HttpClient()。我见过不少项目上线后出现端口耗尽、Socket 连接泄漏查到最后都是 HttpClient 没有复用导致的。在微服务架构里服务间调用频繁这个问题会被放大得非常明显。正确做法是使用IHttpClientFactory。它在 ASP.NET Core 里内置核心作用是把 HttpClient 的连接管理交给框架连接会被池化复用避免每次请求都新建 Socket。另外它还天然支持命名客户端和类型化客户端配合 Polly 做重试、熔断、超时控制非常顺手。我在项目里通常会为每个下游服务注册一个类型化客户端比如订单服务调用库存服务就在 Program.cs 里注册AddHttpClientInventoryClient()然后在 InventoryClient 内部封装调用逻辑。Polly 重试要特别注意“幂等性”。重试只适用于 GET、PUT、DELETE 这类幂等操作POST 请求重试很可能导致重复下单或重复扣款。解决办法是要么业务接口设计成天然的幂等键——请求头带上全局唯一的X-Request-ID服务端按这个键做去重要么只在网络层错误比如超时、5xx时重试4xx 一律不重试。超时设置也别太乐观我在项目里常用的组合是“单次请求超时 3 秒 重试 2 次 熔断 30 秒”具体数值要根据下游 P99 延迟调整不能拍脑袋。2.2 异步解耦MemoryBus、RabbitMQ 还是 Service Bus同步 HTTP 调用解决不了三个问题突发流量削峰、长耗时任务的异步化、以及跨服务的数据最终一致性。这时候就要上消息队列。C# 项目里我常用的有三套方案按场景选型进程内消息MemoryBus/MediatR仅限单体或服务内部使用比如订单服务内部的领域事件。好处是零成本和低延迟坏处是进程崩溃消息就丢了不能做跨服务通信。RabbitMQ MassTransit这是 .NET 社区用得很广泛的组合。MassTransit 是一个开源的消息总线框架屏蔽了 RabbitMQ、Azure Service Bus、Amazon SQS 的差异开发体验非常好。它在发布/订阅、消息重试、死信队列、Saga 分布式事务这几个方面都有开箱即用的支持。Azure Service Bus / Event Grid如果你已经上了 Azure 全家桶直接用平台托管的消息服务最省心。Service Bus 适合点对点和严格顺序消息Event Grid 更适合纯事件广播。我在多个项目里的经验是优先用 MassTransit RabbitMQ因为不管部署在云上还是自建环境都很灵活社区资料也多如果公司本来就有 Azure 订阅那 Service Bus 会减少很多运维成本。至于 KafkaC# 下虽然有 Confluent.Kafka 客户端但它更适合大数据量日志和流处理场景普通业务消息用它有点过重。2.3 选型思路什么场景用同步、什么时候必须走异步这里分享一个我总结的判断规则场景特征推荐方式原因调用方需要立即拿到结果比如下单时查用户余额同步 HTTP/gRPC链路清晰超时重试可控调用方不关心结果比如下单后发通知消息队列异步解耦提升吞吐削峰填谷数据需要跨服务最终一致比如订单状态和积分变更消息队列 Saga避免分布式事务锁定资源定时任务或批量处理比如日终对账Serverless 定时函数弹性伸缩按量计费高频查询但数据可以被缓存比如商品详情同步 HTTP 缓存性能优先接口可降级同步和异步不是互斥的。我在订单服务里就同时用了两种方式查询类接口走同步 HTTP写操作的核心链路走同步但下游的非关键业务全部丢消息队列异步处理。这既保证了用户体验又避免了服务间强耦合。3. 配置中心、注册发现与 API 文档聚合让服务“可运维”3.1 配置中心的取舍Nacos、Consul 还是 App Configuration服务一多配置管理立刻成为运维痛点。如果每个服务的appsettings.json都本地维护部署时要手动改连接串、改日志级别那微服务根本跑不到生产环境。配置中心的核心目标是配置与代码分离配置变更实时生效配置按环境隔离。很多从 Java 转过来的团队问Nacos 在 .NET 里能不能用答案是能但没那么舒服。Nacos 的官方 C# SDK 存在而且可用社区有nacos-sdk-csharp这个项目支持配置管理和服务发现。我在一个项目里用过一段时间配置拉取和监听都能正常工作但跟 Java 生态那种文档齐备、问题响应迅速的状态比还是有差距。如果团队 .NET 是主力我更推荐 Consul配合Consul.AspNetCore包配置和注册发现一套解决社区案例也多。微软原生方案是 Azure App Configuration如果你在 Azure 上跑服务这个最省事天然支持 Key Vault 引用敏感信息不用明文放配置中心。但它跟本地环境集成略麻烦开发调试时要用appsettings.Development.json兜底。配置管理的实操里有三个细节值得注意。第一配置变更实时生效靠的是客户端轮询或长连接监听要确认你的框架支持 WebSocket 推送或间隔刷新比如IOptionsMonitorT就能监听配置变更并触发回调。第二所有配置项必须分类连接字符串类、功能开关类、业务参数类分别控制是否允许动态修改。第三敏感信息永远不要放配置中心明文用 Key Vault 或者环境变量注入否则一次配置中心泄露就是全线沦陷。3.2 注册发现与负载均衡Steeltoe 的实践服务实例在微服务里是动态变化的扩容缩容、故障重启、发布滚动更新都意味着 IP 会变。注册发现解决的就是“服务消费者如何找到提供者”。C# 这边Steeltoe是 .NET 社区承接 Spring Cloud 那套理念的重要项目它支持接入 Consul、Eureka 等注册中心还提供了负载均衡器。我在生产项目里的做法是所有 ASP.NET Core 服务启动时向 Consul 注册自己的服务名和地址客户端调用时用DiscoveryClient从 Consul 拉取实例列表然后通过内置负载均衡策略默认轮询选择一个实例发起请求。这里容易踩的坑是“注册了但没注销”服务进程被 kill 时如果没有优雅停机Consul 会残留失效实例。解决办法是注册服务前实现IHostApplicationLifetime的ApplicationStopped事件在停止时调用 Consul 的注销接口同时把 Consul 的健康检查间隔调短一点比如 5 秒这样异常实例最多存活十秒就会被摘除。还有一个容易被忽略的点服务间调用用服务名而不是用 IP。很多团队图省事配置文件里写死下游服务 IP结果一扩容就懵了。服务名 注册发现才是正确模型IP 变化对调用方完全透明。3.3 API 文档聚合Swagger 与网关侧的统一文档单体时代Swagger 往项目里一加所有接口自动生成文档浏览器一开就能调试。到了微服务问题就来了服务拆成十几个每个服务都有自己的 Swagger 地址前端和测试人员要记住十几个 URL体验极差。Java 生态里有 Knife4j Nacos 的聚合方案在 .NET 侧没有完全对应的开箱即用组件但思路完全可以借鉴。我在项目里常用的方案是每个服务保留自己的 Swagger 端点但在 API 网关层Ocelot 或 YARP做一个统一入口反向代理到各个服务的 swagger.json。具体实现很简单网关收到/docs/{service}/swagger.json的请求动态路由到目标服务的 swagger.json 地址前端页面可以做一个简单的静态 HTML下拉选择服务加载对应的 OpenAPI 文档。如果用的是 Azure API Management也可以直接把后端服务的 OpenAPI 文档导入到 APIM由它统一管理和发布。这样不仅文档统一了还顺带解决了跨域、限流、认证的问题。在 .NET 里做这个聚合关键点是要先把每个服务的 OpenAPI 文档做规范配上清晰的中文描述、示例请求、响应码不规范的说明文字后端接口再全也是废的。4. 可观测性日志、链路追踪与 Metrics4.1 结构化日志与 Serilog服务一多日志刷屏是最常见的问题。开发环境下 console 一行行看没问题生产环境几十个服务实例同时打日志非结构化的文本日志根本无法检索。我的建议是从一开始就上结构化日志推荐Serilog这是 .NET 生态事实上的标准。Serilog 的核心思路是“日志是数据不是文本”。你记录一条日志时除了消息字符串还可以带上结构化属性比如UserId、OrderId、ServiceName、DurationMs。这些属性会被序列化成 JSON 写入 Elasticsearch、SQL Server、Seq 或 Azure Monitor后续查询时可以直接按属性过滤、聚合。我在项目里的最小落地方案是Serilog Seq开发环境 Elasticsearch生产环境Elasticsearch 如果觉得重可以先用 Seq 顶到一千万条日志的量级。日志级别要定好约定DEBUG 只在开发环境开生产环境默认 Information每个服务入站和出站的请求日志至少带 TraceId、耗时和状态码业务异常比如验证失败用 Warning 记录只有未捕获的系统级异常才用 Error。日志不是越多越好我在实测中发现生产环境的错误日志里大量是业务异常被当成 Error 打了导致真正影响全局的故障被淹没这个坑要提前从规范上堵住。4.2 分布式链路追踪OpenTelemetry 在 C# 的落地微服务下最痛苦的排查场景就是“一个请求跨了五个服务最后查不出是哪个环节慢”。链路追踪Distributed Tracing就是为了解决这个问题。.NET 现在做链路追踪基本就是OpenTelemetry它跟 Java 那边的 Micrometer Tracing 思路类似主打一个“标准 厂商无关”。我在 ASP.NET Core 项目中的接入方式是安装OpenTelemetry.Extensions.Hosting、OpenTelemetry.Instrumentation.AspNetCore、OpenTelemetry.Exporter.Jaeger或 Exporter 到 Zipkin/OTLP然后配置 ServiceName、采样率、导出地址。接入后每个请求会自动生成 TraceId 和 SpanId并通过 HTTP Header 在服务间传递——A 服务调用 B 服务时B 服务能通过traceparent头拿到同一个 TraceId最终在 Jaeger UI 里就能看到完整调用链每个环节的耗时清清楚楚。这里有个容易忽视的细节消息队列的 TraceId 传递。如果用 HTTP 从 A 调 BOpenTelemetry 的 Instrumentation 会自动帮你传播但如果你把消息丢进 RabbitMQ幂等消费还是异步消费TraceId 必须在发消息时手动塞进消息头消费时再取出来作为当前 Activity 的 ParentId。很多人链路追踪做了一半只覆盖 HTTP消息链路全断排查问题依然靠猜。MassTransit 对 OpenTelemetry 有内置支持但要在配置里显式开启。4.3 Serverless 场景下的监控短板与补充Serverless 函数的监控是另一个“看起来简单、做起来容易漏”的地方。函数计算平台一般都自带基础指标调用次数、错误数、平均耗时但你基本看不到冷启动耗时、每次调用的内存快照、以及函数内部自定义的业务指标。我的经验是在函数代码里显式打点而不是依赖平台默认监控。自定义 Metrics 我推荐两种轻量方式一是函数内直接向 Application InsightsAzure 环境发送MetricTelemetry二是用 OpenTelemetry Metrics API 暴露给 Prometheus 抓取。前者跟 Azure 平台集成最顺后者适合你已经有现成 Prometheus Grafana 监控体系的团队。另外函数日志必须聚合到同一个日志平台别让Console.WriteLine的频率膨胀成日志系统的负担。Serverless 还有一个特殊监控点函数执行是非持久化的进程退出后临时文件、内存数据全部丢失。如果函数依赖缓存要么用 Redis 这类外置存储要么确认不重要、丢了能重来。我在业务函数里全部默认“无状态、失败可重试”的设计这个意识非常重要。5. 常见问题与排查心得5.1 冷启动超时现象函数第一次调用特别慢超过平台默认超时时间比如 5 秒前端直接报错。第二次调用就正常了。原因冷启动时函数运行时需要加载程序集、初始化依赖注入容器、建立数据库连接池这些操作累积可能超过平台超时阈值。排查与解决确认代码里是否有同步阻塞调用比如.Result、.Wait()在函数场景里极度容易造成线程池饥饿。初始化逻辑尽量放到静态构造函数或单例中避免每次调用都重新加载。 -如果超时仍然频繁用“预热触发”方案——在函数应用里加一个定时触发的预热函数每 5 分钟 ping 一次业务函数保持实例常驻。考虑把依赖项合并裁剪PublishSingleFile、ReadyToRun减少启动时 JIT 编译的开销。5.2 消息重复消费现象RabbitMQ 消费者收到同一条消息两次业务侧出现重复积分、重复通知。原因消息队列的 at least once 投递语义决定了网络异常或消费者处理时间过长时队列会重新投递而 C# 客户端如果处理完消息没正确 Ack也会导致消息回到队列。解决思路业务侧做幂等推荐用数据库唯一约束或 Redis 分布式锁做消息去重。我在项目里常用一张message_receive_log表以消息 ID 为主键消费前先插一条插入成功才处理业务插入冲突说明已经消费过。确认 Ack 时机MassTransit 默认是处理完成后自动 Ack如果业务里手动切换了 BasicAck 请反复测试异常分支。重试和死信队列配置要合理消息处理抛异常时MassTransit 会按策略重试重试耗尽后进入死信队列。要保持“重试做业务失败死信做人工介入”的分层思路不要把重试设成无限的。5.3 配置中心不生效现象改了 Nacos 或 Consul 里的配置服务没有实时更新。原因客户端监听配置变更的通道没建立成功或者代码里用的是IConfiguration直接取值而不是IOptionsMonitorT。排查步骤先确认配置中心客户端的日志在 Consul 里是否已建立/kv监听Nacos 则检查长轮询是否正常。检查服务启动时拉取配置的路径是否正确。很多配置中心的 key 是分环境前缀的比如config/{env}/{appName}如果环境名没对上服务读到的是默认值。代码层面排查构造函数里注入IOptionsT是静态快照只有注入IOptionsMonitorT并订阅OnChange才能拿到实时变更。很多团队在这里栽了跟头以为配置中心“自动生效”结果只是启动时拉取了一次。5.4 链路追踪里找不到 TraceId现象同一次用户请求日志系统里相关服务的日志对不上TraceId 不一致或看不到。排查检查 OpenTelemetry 的 Propagator 是否启用了TraceContextPropagator默认会启用但如果自定义了配置容易漏。消息队列场景确认发消息时是否注入了 W3C TraceContext 到消息头消费时是否从消息头恢复 Activity。日志输出里是否把 TraceId 做成了结构化字段Serilog 里要配置Enrich.WithTraceId()或自行从Activity.Current.TraceId取值否则日志里根本没有这字段索引了也没法关联。5.5 服务间调用超时乱象现象没有设置超时下游服务卡死上游请求线程全部阻塞最后整个服务雪崩。这个我在项目初期吃过苦头。解决方案总结成一句话所有 HTTP 调用必须显式设置超时和熔断策略且超时时间分层设置。网关层的超时要比服务间调用的长服务间调用的超时要比数据库查询的长。比如数据库 SQL 1 秒服务间 HTTP 3 秒网关 5 秒。这样每一层都能主动断开不会无限等。实操总结与扩展建议这篇文章涉及的实践我在真实项目里都是一步步踩出来的。微服务没有银弹C# 做微服务更没有一套“照着做就行”的标准答案。如果你刚开始接手一个 .NET 微服务项目我建议先从这三件事入手把服务间通信和异常处理规范定下来把日志和链路追踪建起来把配置中心接上。这三个基础打牢后面的服务拆分、Serverless 接入才有意义否则服务越多线上越像一个黑匣子出事只能靠猜。另外还有一个我觉得值得多花时间的方向gRPC 在 C# 服务间通信的高性能场景。HTTP/JSON 在内部服务间调用时序列化开销和连接开销都不小.NET 对 gRPC 的支持已经很成熟性能相比 REST 有明显提升。但 gRPC 的调试和负载均衡比 HTTP 麻烦建议等项目稳定后再逐步引入不要在大改造的同时叠加新通信协议。后续如果大家感兴趣我可以继续写一篇关于 .NET 微服务数据一致性Saga 和本地消息表的落地实践。这篇先到这有问题欢迎在评论区交流。
返回列表