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 次,指数退避。几个必须理解的语义:
- 只重试可重试的错误。网络错误、连接超时、5xx 会重试;4xx(含 404、409)不会。因此业务上的「资源暂不可用」不应返回 404,而应返回 503。
- 重试意味着幂等要求。POST 调用在重试下可能被执行多次,服务端必须用幂等键去重。
- 超时是两层的。边车有自己的超时,
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 语义要求处理逻辑幂等。
投递流程与语义:
- 边车收到消息,按 CloudEvents 格式投递给应用的订阅端点。
- 应用返回 2xx → 确认;返回非 2xx 或超时 → 重试。
- 重试策略由
resiliency资源定义,默认指数退避、上限若干次。 - 重试耗尽 → 进入死信队列(若配置了
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"
几个部署要点:
- 边车注入依赖
dapr-sidecar-injector,需确认控制平面已就绪,否则 Pod 会卡在创建阶段。 - 组件 YAML 中的密钥用
secretKeyRef,不要把连接串明文写进组件定义。 - 边车资源限制要显式设置,默认无限制时边车可能抢占应用资源。
- 优雅关闭:应用收到 SIGTERM 时应先停止接收新请求,边车有
dapr.io/graceful-shutdown-seconds控制等待时间。
本地与生产的差异集中在三处:组件实现(本地 Redis、生产 Kafka)、密钥来源(本地文件、生产 Secret)、服务发现(本地 localhost、生产 Kubernetes DNS)。把这三处参数化,代码无需区分环境。
7. 工程实践与常见坑
一句话总结: 明确 Dapr 的适用边界、控制边车开销、保证幂等与正确处理超时,是 Dapr 项目成功的关键。
实践建议:
- 只在有明确收益时引入 Dapr。纯 .NET 单体或中间件固定的系统,直接用 SDK 更简单。
- 边车是额外一跳。服务调用的 P99 延迟会增加 1~3 毫秒,高频内部调用需评估。
- 不要用状态存储当数据库。需要查询就走数据库,状态存储只放热状态。
- 幂等是默认要求。所有订阅处理与可能重试的调用都要幂等。
- 组件版本要锁定。控制平面与边车版本不一致可能导致 API 行为差异。
排错清单:
- 边车未注入 → 检查命名空间是否被排除、注解是否正确、注入器是否运行。
- 组件未加载 → 边车启动日志中的
component loaded列表是权威依据,缺失说明 YAML 路径或格式有误。 - 消息重复消费 → 属于 at-least-once 的正常表现,检查幂等逻辑而非投递配置。
- 服务调用 500 且应用无日志 → 边车未能连上应用端口,检查
app-port与容器实际监听端口是否一致。 - 追踪断链 → 确认
AddSource("Dapr.*")已添加,且UseCloudEvents()在管道中位置正确。
8. 总结
| 环节 | 要点 |
|---|---|
| 模型 | 边车进程承载能力,应用只与 localhost 通信 |
| 服务调用 | 逻辑应用 ID + 方法,边车负责发现、mTLS 与重试 |
| 状态 | 键值抽象,ETag 乐观并发,不是数据库 |
| 发布订阅 | CloudEvents 信封,at-least-once,必须幂等 |
| 死信 | 重试耗尽入死信,需要有人消费与重放 |
| 可观测 | W3C 传播 + AddSource(“Dapr.*”) 才能串链路 |
| 部署 | 注解注入边车,组件 YAML 本地与生产复用 |
Dapr 用一层边车抽象换来了中间件无关与跨语言一致,代价是额外进程、额外网络跳数与一套新的运维对象。它的收益与系统异构程度正相关:技术栈越杂、中间件越常换,收益越大;纯 .NET 且技术栈稳定的团队,收益可能不足以覆盖复杂度。判断标准是问自己一个问题——过去一年里,更换中间件或让非 .NET 服务复用同一套分布式能力,是否消耗了大量人力?如果答案是肯定的,Dapr 值得引入。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。