Dapr 集成微服务

讲解 Dapr 边车模型在 .NET 微服务中的落地实践,覆盖服务调用与边车通信、状态存储与 ETag 乐观并发控制、发布订阅与重试死信处理、可观测性链路接入与分布式追踪,以及从本地开发到 Kubernetes 生产部署的环境差异与组件声明管理。

1. 边车模型与构建块

一句话总结: Dapr 把分布式系统的常见能力抽成独立的边车进程,应用通过 localhost 的 HTTP/gRPC 接口使用它们,从而把中间件选型从代码中剥离。

Dapr 的核心主张是「能力与实现解耦」。传统做法是应用直接引用 StackExchange.Redis、Confluent.Kafka、Azure.ServiceBus 的 SDK,一旦更换中间件就要改代码、改配置、重新测试。Dapr 把这类能力抽象成构建块(Building Block),应用只与边车通信,边车再与真实中间件交互。切换 Redis 到 PostgreSQL 状态存储,只需改一份 YAML。

架构上有三个角色:

  • 应用(Application):你的 ASP.NET Core 服务,通过 DaprClient 或 HTTP 端点调用边车。
  • 边车(Sidecar,daprd):与每个应用实例同生命周期的进程,负责实际的中间件交互、重试、加解密与遥测。
  • 控制平面(dapr-sentry、dapr-operator、dapr-placement):负责证书签发、组件管理与 Actor 放置。

主要构建块与用途:

构建块能力典型组件
Service Invocation服务间调用 + 服务发现 + mTLS内建
State Management键值状态读写与并发控制Redis、PostgreSQL
Pub/Sub发布订阅与投递重试Kafka、RabbitMQ
Bindings与外部系统双向集成Cron、S3、SMTP
Actors虚拟 Actor(与 Orleans 同源)内建
Secrets密钥读取Vault、K8s Secret
Configuration动态配置Redis、K8s ConfigMap

先要建立的认知是:Dapr 解决的是「跨语言、跨中间件的一致性」,代价是多一个进程与一跳网络。如果系统是纯 .NET 且中间件固定,直接用 SDK 往往更简单、延迟更低。Dapr 的价值在异构技术栈、频繁更换中间件、或需要统一可观测与安全策略的场景。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprClient();

var app = builder.Build();
app.UseCloudEvents();
app.MapSubscribeHandler();
app.Run();

AddDaprClient() 注册一个 DaprClient,它通过 http://localhost:3500(HTTP)或 localhost:50001(gRPC)与边车通信。

2. 服务调用与边车

一句话总结: 服务调用走边车代理,应用只需知道对方的应用 ID 与方法名,服务发现、mTLS 与重试由边车承担。

服务调用有两种方式。第一种是显式调用,应用直接使用 DaprClient:

public sealed class InventoryClient
{
    private readonly DaprClient _dapr;

    public InventoryClient(DaprClient dapr) => _dapr = dapr;

    public Task<StockLevel> GetAsync(string sku, CancellationToken ct = default)
        => _dapr.InvokeMethodAsync<StockLevel>(
            HttpMethod.Get, "inventory", $"api/stock/{sku}", ct);
}

第二种是通过 HttpClient 的透明代理,适合已有 HTTP 调用代码的迁移:

builder.Services.AddDaprClient();

var http = DaprClient.CreateInvokeHttpClient("inventory");
var level = await http.GetFromJsonAsync<StockLevel>($"api/stock/{sku}");

请求路径是 http://localhost:3500/v1.0/invoke/inventory/method/api/stock/SKU,边车负责解析 inventory 的实际地址、建立 mTLS 通道、转发并返回结果。

2.1 服务调用的重试与超时语义

一句话总结: 边车默认对服务调用做重试,但重试只覆盖网络与 5xx,业务错误不会重试;超时语义由边车与 HttpClient 两层叠加,必须分别配置。

服务调用的重试配置在边车侧,通过 Configuration 资源下发:

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: appconfig
spec:
  httpPipeline:
    handlers:
      - name: retry
        type: middleware.http.ratelimit
  nameResolution:
    component: "kubernetes"

默认的重试策略是 3 次,指数退避。几个必须理解的语义:

  1. 只重试可重试的错误。网络错误、连接超时、5xx 会重试;4xx(含 404、409)不会。因此业务上的「资源暂不可用」不应返回 404,而应返回 503。
  2. 重试意味着幂等要求。POST 调用在重试下可能被执行多次,服务端必须用幂等键去重。
  3. 超时是两层的。边车有自己的超时,HttpClient 也有;取两者中较小者生效。配置不一致时,HttpClient 先超时会导致边车仍在重试,出现「客户端已放弃、服务端仍在执行」的错位。
var http = DaprClient.CreateInvokeHttpClient("inventory");
http.Timeout = TimeSpan.FromSeconds(5); // 应大于边车单次超时 × 重试次数

一个务实的经验值:客户端总超时 ≈ 边车单次超时 × 重试次数 + 缓冲。忽略这条会导致大量「幽灵超时」——客户端报错但服务端最终成功。

调用链的容错策略应当与既有的弹性设计统一,避免两套重试叠加放大故障,相关实践见 HttpClient 弹性与容错 。

3. 状态存储与并发控制

一句话总结: 状态存储是键值抽象,支持 ETag 乐观并发与事务;正确使用 ETag 是避免并发覆盖的唯一手段。

状态管理的基本操作是键值读写:

public sealed class CartStore
{
    private readonly DaprClient _dapr;

    public CartStore(DaprClient dapr) => _dapr = dapr;

    public Task<Cart> GetAsync(string userId, CancellationToken ct = default)
        => _dapr.GetStateAsync<Cart>("statestore", userId, ct);

    public Task SaveAsync(string userId, Cart cart, CancellationToken ct = default)
        => _dapr.SaveStateAsync("statestore", userId, cart, ct);
}

这段代码有严重的并发缺陷:两个请求同时读、各自修改、各自写,后写者会覆盖前者的修改。修复方式是使用 ETag 乐观并发:

public async Task<bool> AddItemAsync(
    string userId, CartItem item, CancellationToken ct = default)
{
    for (var attempt = 0; attempt < 5; attempt++)
    {
        var (cart, etag) = await _dapr.GetStateAndETagAsync<Cart>(
            "statestore", userId, ct);

        cart ??= new Cart();
        cart.Items.Add(item);

        var saved = await _dapr.TrySaveStateAsync(
            "statestore", userId, cart, etag, ct);

        if (saved) return true;
        // ETag 冲突,退避后重试
        await Task.Delay(TimeSpan.FromMilliseconds(20 * (attempt + 1)), ct);
    }
    return false;
}

TrySaveStateAsync 在 ETag 不匹配时返回 false 而非抛异常,配合有限次重试即可实现乐观并发。不要用无限重试——高竞争下会导致活锁,应设置上限并向上返回冲突。

3.1 状态存储的选型与限制

一句话总结: 状态存储是键值模型,不支持查询与关联;需要按值检索时必须另建索引键或改用数据库,把它当缓存与状态容器而非数据库。

选型时的关键约束:

组件一致性适用限制
Redis最终/强(配置)会话、缓存、计数器内存成本高
PostgreSQL强需持久化的状态需自行建表与索引
Azure Cosmos DB多档可选全球分布成本与分区设计复杂

最重要的认知:状态存储不是数据库。它按 key 读写,不支持 SQL 查询、关联、聚合。如果你发现自己在想「按状态字段筛选」,说明用错了工具——应当把可查询的字段抽到关系数据库,状态存储只放热状态。

几个实践要点:

  • 键设计要有前缀。user:{id}:cart 而非裸 ID,便于按前缀清理与隔离多租户。
  • 并发控制不是事务。ETag 只保证单键的乐观并发,跨键原子性需要 Dapr 的事务 API,而事务支持取决于组件(Redis 支持有限)。
  • TTL 要显式设置。会话类状态应设置过期时间,否则内存无限增长。
await _dapr.SaveStateAsync("statestore", key, value,
    new StateOptions { Concurrency = ConcurrencyMode.FirstWrite },
    metadata: new Dictionary<string, string> { ["ttlInSeconds"] = "3600" });

跨实例共享状态时,与通用分布式缓存的策略(穿透、击穿、雪崩)是同一套问题,可参考 缓存与分布式并发 。

4. 发布订阅与重试

一句话总结: Pub/Sub 构建块把订阅关系声明在代码里、把中间件声明在组件里,投递失败按策略重试并最终进入死信队列。

发布侧:

await _dapr.PublishEventAsync("pubsub", "orders", new OrderPlaced(orderId, total));

订阅侧用 MapSubscribeHandler 配合 [Topic] 特性,把订阅关系声明为代码:

app.MapPost("/orders", [Topic("pubsub", "orders")] (OrderPlaced evt, ILogger<Program> log) =>
{
    log.LogInformation("收到订单 {OrderId}", evt.OrderId);
    return Results.Ok();
});

app.MapSubscribeHandler();

MapSubscribeHandler() 暴露 /dapr/subscribe,边车启动时拉取订阅清单并完成订阅注册。UseCloudEvents() 中间件负责把 CloudEvents 信封解包为强类型对象。

4.1 投递语义与死信处理

一句话总结: 订阅返回 2xx 表示成功、非 2xx 触发重试、重试耗尽进入死信;at-least-once 语义要求处理逻辑幂等。

投递流程与语义:

  1. 边车收到消息,按 CloudEvents 格式投递给应用的订阅端点。
  2. 应用返回 2xx → 确认;返回非 2xx 或超时 → 重试。
  3. 重试策略由 resiliency 资源定义,默认指数退避、上限若干次。
  4. 重试耗尽 → 进入死信队列(若配置了 deadLetterTopic)。
apiVersion: dapr.io/v1alpha1
kind: Resiliency
metadata:
  name: pubsub-resiliency
spec:
  policies:
    retries:
      pubsubRetry:
        policy: exponential
        maxInterval: 30s
        maxRetries: 5
    circuitBreakers:
      pubsubCB:
        maxRequests: 1
        timeout: 30s
        trip: consecutiveFailures >= 5
  targets:
    components:
      pubsub:
        inbound:
          retry: pubsubRetry
          circuitBreaker: pubsubCB

at-least-once 是默认语义,意味着同一条消息可能被投递多次(重试、边车重启、网络抖动)。因此订阅处理必须幂等:

app.MapPost("/orders", [Topic("pubsub", "orders")] async (
    OrderPlaced evt, IDb db, ILogger<Program> log) =>
{
    if (await db.AlreadyProcessedAsync(evt.EventId))
    {
        log.LogInformation("重复消息 {EventId},跳过", evt.EventId);
        return Results.Ok();
    }
    await db.ProcessAsync(evt);
    return Results.Ok();
});

幂等键应当使用 CloudEvents 的 id 字段而非业务 ID,因为同一次业务操作可能合法地产生多条事件。

死信队列的处理容易被忽略。配置了 deadLetterTopic 后,重试耗尽的消息会被投递到该主题,但没有人消费死信等于没有死信。务实的做法是:死信主题接一个告警,并提供一个管理端点支持人工重放。

关于后台任务与消息消费的整体模式(含毒消息隔离与重放),可参考 消息队列与后台任务 。跨服务的长流程协调则可与 微服务 Saga 编排 结合。

5. 可观测性与本地开发

一句话总结: Dapr 边车自动产出追踪与指标并支持 W3C 上下文传播,.NET 侧只需接入 OpenTelemetry 即可串起完整链路。

Dapr 在链路中的位置决定了它是天然的追踪汇聚点:请求进入边车 → 边车调用应用 → 应用调用其他服务 → 另一侧边车。它默认支持 W3C Trace Context 传播,因此只要应用侧正确注入与提取 traceparent,链路就是连续的。

.NET 侧的接入只需两步:

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource("Dapr.*")
        .AddOtlpExporter())
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter());

关键点是 AddSource("Dapr.*")——DaprClient 内部的 ActivitySource 以 Dapr. 开头,不加这一行会看到链路在应用内部断裂。

边车自身也暴露 Prometheus 指标,端口 9090:

dapr_http_server_request_count
dapr_grpc_io_server_completed_rpcs
dapr_component_pubsub_egress_count
dapr_runtime_actor_reminders_count

这些指标对排查「边车是否在重试」「组件是否健康」极有价值。特别是 dapr_component_* 系列,能直接看出某个组件的调用失败率。

日志方面,边车日志与应用日志分离,需分别采集。边车的日志级别可动态调整:

dapr run --app-id inventory --log-level debug -- dotnet run

关于 .NET 侧结构化日志与追踪的统一配置,核心是把 Activity 与 ILogger 的作用域绑定,让每条日志都带上追踪 ID,具体做法与采样策略另行展开。

6. 本地开发与部署

一句话总结: 本地用 dapr run 或 Aspire 拉起边车,组件用 YAML 声明;生产用 Kubernetes 注解注入边车,组件声明复用同一份 YAML。

本地开发的最小流程:

dapr init                                  # 安装边车并启动 Redis 等默认组件
dapr run --app-id inventory --app-port 5000 -- dotnet run

dapr init 会在本地启动一个 Redis 容器作为默认状态存储与 Pub/Sub,并把组件 YAML 写到 ~/.dapr/components/。生产环境的组件则通过 kubectl apply 或 Helm 部署。

Kubernetes 中通过注解启用边车:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory
spec:
  template:
    metadata:
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "inventory"
        dapr.io/app-port: "8080"
        dapr.io/config: "appconfig"
        dapr.io/log-level: "info"

几个部署要点:

  1. 边车注入依赖 dapr-sidecar-injector,需确认控制平面已就绪,否则 Pod 会卡在创建阶段。
  2. 组件 YAML 中的密钥用 secretKeyRef,不要把连接串明文写进组件定义。
  3. 边车资源限制要显式设置,默认无限制时边车可能抢占应用资源。
  4. 优雅关闭:应用收到 SIGTERM 时应先停止接收新请求,边车有 dapr.io/graceful-shutdown-seconds 控制等待时间。

本地与生产的差异集中在三处:组件实现(本地 Redis、生产 Kafka)、密钥来源(本地文件、生产 Secret)、服务发现(本地 localhost、生产 Kubernetes DNS)。把这三处参数化,代码无需区分环境。

7. 工程实践与常见坑

一句话总结: 明确 Dapr 的适用边界、控制边车开销、保证幂等与正确处理超时,是 Dapr 项目成功的关键。

实践建议:

  • 只在有明确收益时引入 Dapr。纯 .NET 单体或中间件固定的系统,直接用 SDK 更简单。
  • 边车是额外一跳。服务调用的 P99 延迟会增加 1~3 毫秒,高频内部调用需评估。
  • 不要用状态存储当数据库。需要查询就走数据库,状态存储只放热状态。
  • 幂等是默认要求。所有订阅处理与可能重试的调用都要幂等。
  • 组件版本要锁定。控制平面与边车版本不一致可能导致 API 行为差异。

排错清单:

  1. 边车未注入 → 检查命名空间是否被排除、注解是否正确、注入器是否运行。
  2. 组件未加载 → 边车启动日志中的 component loaded 列表是权威依据,缺失说明 YAML 路径或格式有误。
  3. 消息重复消费 → 属于 at-least-once 的正常表现,检查幂等逻辑而非投递配置。
  4. 服务调用 500 且应用无日志 → 边车未能连上应用端口,检查 app-port 与容器实际监听端口是否一致。
  5. 追踪断链 → 确认 AddSource("Dapr.*") 已添加,且 UseCloudEvents() 在管道中位置正确。

8. 总结

环节要点
模型边车进程承载能力,应用只与 localhost 通信
服务调用逻辑应用 ID + 方法,边车负责发现、mTLS 与重试
状态键值抽象,ETag 乐观并发,不是数据库
发布订阅CloudEvents 信封,at-least-once,必须幂等
死信重试耗尽入死信,需要有人消费与重放
可观测W3C 传播 + AddSource(“Dapr.*”) 才能串链路
部署注解注入边车,组件 YAML 本地与生产复用

Dapr 用一层边车抽象换来了中间件无关与跨语言一致,代价是额外进程、额外网络跳数与一套新的运维对象。它的收益与系统异构程度正相关:技术栈越杂、中间件越常换,收益越大;纯 .NET 且技术栈稳定的团队,收益可能不足以覆盖复杂度。判断标准是问自己一个问题——过去一年里,更换中间件或让非 .NET 服务复用同一套分布式能力,是否消耗了大量人力?如果答案是肯定的,Dapr 值得引入。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 从 WCF 迁移到 gRPC 与 REST
  2. .NET 多租户 SaaS 架构
  3. Avalonia 跨平台桌面 UI