使用 ClickHouse 和 Aspire 搭建 .NET API 网关

 

 

本文字数:11798;估计阅读时间:30分钟

作者:Alex Soffronow Pagonidis

 

 

 

 

 

为何选择 ClickHouse 与 Aspire

 

如果你大部分时间都在 ASP.NET Core 中度过,这个演示中的两个组件你可能比较陌生:ClickHouse 和 Aspire。在我们深入探讨之前,先快速介绍一下:

Aspire 是微软的 .NET 技术栈,用于协调分布式服务。你只需编写一个 AppHost 项目,这是一个用 C# 编写的小程序,用于描述你的服务、数据库、容器以及它们之间的连接方式,然后运行 dotnet run 即可在本地启动整个服务拓扑。Aspire 还包含一个 Web UI,可以展示所有资源、实时流式传输日志,并渲染你的服务发出的 OpenTelemetry 跟踪和指标。一旦你使用过它,再回到手动管理启动配置文件和 docker-compose.yml 会让人觉得这种方式非常原始且效率低下。

ClickHouse 是一个极速的开源列式数据库,专为对海量数据进行分析查询而构建。当你需要在海量请求历史数据中,快速回答诸如“过去一小时内,各路由的 p95 延迟是多少?”这类分析问题时,ClickHouse 是你的理想选择。与 SQL Server 或 Postgres 等事务型数据库不同,ClickHouse 针对主要以追加方式写入的数据上的聚合操作进行了深度优化。贯穿本文的一个核心理念是:ClickHouse 之于分析型 SQL,正如 Redis 之于缓存:它们都专为特定任务而生,并能以极高的效率完成该任务。

我们为 Aspire 提供了两个 ClickHouse 集成,让你只需几行代码即可设置数据库及其对应的客户端。这也是本示例后续内容所依赖的基础。

 

 

演示

在本文中,我们将构建一个使用 Aspire 编排以下组件的应用程序:

• 一个 ClickHouse 容器

• 一个 YARP API 网关

• 两个用于 Products 和 Orders 的后端 API

• 一个调用后端 API 的后台负载生成器

• 一个分析 API

• 一个 Blazor 仪表盘

网关是本次演示的核心。它主要承担以下两项任务:

1. 使用 YARP 将流量路由到后端服务。

2. 将每个被代理的请求记录到 ClickHouse 中。

与此同时,网关会发出自定义的 OpenTelemetry 指标,这些指标将会在 Aspire 中显示:

• 请求计数

• 持续时间直方图

• 后端失败计数

这为我们提供了一个有用的分离:

• Aspire 展示实时运维遥测数据。

• ClickHouse 存储完整的请求历史记录以进行聚合分析。

 

 

亲自尝试

完整的源代码可在配套存储库中找到。你需要 .NET 10 SDK 和 Docker,然后:

dotnet run --project src/AppHost/AppHost.csproj --launch-profile http

 

AppHost (AppHost) 控制台会输出 Aspire (Aspire) 仪表板的登录 URL,地址为 http://localhost:15000。打开该链接即可查看所有服务的启动情况。

 

Aspire 还会生成一个服务映射图,展示了服务之间的相互关系。

 

您还可以通过 Aspire 仪表板查看结构化日志、追踪和指标。我们稍后将更详细地探讨这些内容。

一旦网关准备就绪,后台负载生成器便会自动启动。它会发送混合的产品和订单请求,其中包含一小部分故意降级的结账调用。让它运行一分钟后,打开分析仪表板 http://localhost:5100

现在,让我们深入了解这些服务背后的代码,以及 Aspire 如何对它们进行编排。

 

 

将 ClickHouse (ClickHouse) 集成到 AppHost 中

 

AppHost 的配置代码刻意保持简洁:

var clickhouse = builder.AddClickHouse("clickhouse")
    .WithDataVolume();

var analyticsDb = clickhouse.AddDatabase("gatewayanalytics");

builder.AddProject("gateway")
    .WithReference(analyticsDb);

下面是这段代码的作用:

• AddClickHouse("clickhouse") 会运行官方的 clickhouse/clickhouse-server 容器,并将其注册为一个资源。 Aspire 会自动分配端口并构建连接字符串。

• .WithDataVolume() 用于附加一个命名 Docker (Docker) 卷,确保应用程序关闭后数据得以保留。

• AddDatabase("gatewayanalytics") 在服务器内部创建一个逻辑数据库,并将其作为独立资源公开,可单独注入和进行健康检查。

• WithReference(analyticsDb) 将该数据库的连接字符串注入到网关进程中。网关随后通过 AddClickHouseDataSource("gatewayanalytics") 获取此连接字符串,无需手动配置 appsettings.json 文件。

Projects.Gateway 是由 AspireAppHost SDK 根据 AppHost.csproj 文件中的 <ProjectReference> (ProjectReference) 条目生成的强类型句柄,从而确保配置在编译时即可进行检查。

在实际的生产环境中,您通常会连接到一个现有的 ClickHouse 实例,而不是直接在本地容器中运行。同一个 AppHost 同样可以声明一个现有实例,无论是 ClickHouse Cloud (ClickHouse Cloud)、自托管集群,还是任何可通过连接字符串访问的实例,只需使用 builder.AddConnectionString("gatewayanalytics") 即可。其他配置保持不变。

 

 

配置其余服务连接

 

完整的 AppHost/Program.cs 文件对其他服务也遵循着类似的配置模式:

var products = builder.AddProject("products");
var orders   = builder.AddProject("orders");

var analytics = builder.AddProject("analytics")
    .WithReference(analyticsDb)
    .WaitFor(analyticsDb);

var gateway = builder.AddProject("gateway")
    .WithExternalHttpEndpoints()
    .WithReference(analyticsDb)
    .WithEnvironment("Services__ProductsUrl", products.GetEndpoint("http"))
    .WithEnvironment("Services__OrdersUrl",   orders.GetEndpoint("http"))
    .WaitFor(analyticsDb)
    .WaitFor(products)
    .WaitFor(orders);

var dashboard = builder.AddProject("dashboard")
    .WithHttpEndpoint(port: 5100, name: "http")
    .WithExternalHttpEndpoints()
    .WithEnvironment("Analytics__BaseUrl", analytics.GetEndpoint("http"))
    .WaitFor(analytics);

builder.AddProject("traffic")
    .WithEnvironment("Gateway__BaseUrl", gateway.GetEndpoint("http"))
    .WaitFor(gateway);

builder.Build().Run();

接下来,我们将更深入地研究一些关键调用,以理解其底层实现机制:

  • WaitFor(...) 机制确保每个服务的启动都严格依赖于其所依赖的组件。例如,分析服务 (Analytics) 只有在 ClickHouse (ClickHouse) 数据库资源报告健康后才会启动;网关 (Gateway) 则需等到数据库、产品 (Products) 和订单 (Orders) 服务全部就绪才能启动。这保证了所有服务启动过程的有序性。

  • GetEndpoint("http") 是 Aspire (Aspire) 实现服务发现的关键机制。它返回一个指向其他资源上指定端点的句柄,并在运行时由 Aspire 负责将其解析为该资源实际部署的地址。整个过程中,代码中不会出现任何硬编码的 URL。

  • WithEnvironment(key, endpoint) 将解析后的 URL (URL) 作为配置项注入到使用方进程中,而服务则通过标准的 IConfiguration (IConfiguration) 管道读取这些配置。

  • WithExternalHttpEndpoints() 允许资源从 Aspire 内部网络外部访问,这使得用户能够在浏览器中打开网关或 Blazor (Blazor) 控制面板。出于安全和架构考虑,products 和 orders 等后端 API (API) 明确不启用此功能,因此它们只能通过网关进行访问。

  • 为控制面板配置 WithHttpEndpoint(port: 5100, name: "http") 可以固定一个稳定的端口。通常情况下,Aspire 每次运行时都会分配一个全新的端口,这对于后端服务来说是可接受的,但如果你需要收藏分析控制面板的 URL,就会带来不便。此外,这个命名端点也为其他服务提供了一个稳定的调用入口。

在服务实现层面,所有必要信息都通过配置读取。任何服务都不会包含指向其对等服务的硬编码 URL,也无需知晓 ClickHouse 是部署在本地容器、云实例还是托管集群中。

 

 

在服务中集成驱动客户端

 

在客户端,我们采用 Aspire.ClickHouse.Driver (Driver) 包:

builder.AddClickHouseDataSource("gatewayanalytics");

此名称与 AppHost (AppHost) 中声明的数据库资源相对应。在服务启动时,该集成会读取注入的连接字符串,并在依赖注入 (DI) 容器中注册一个 IClickHouseClient (IClickHouseClient) 单例。同时,它还会为数据库查询添加健康检查和 OpenTelemetry (OpenTelemetry) 跟踪功能,所有这些都通过这一行代码实现。

 

 

网关日志层

 

核心中间件模式设计简洁明了:

• 启动计时器 (stopwatch)

• 调用下一个委托 (delegate)

• 规范化路由 (route)

• 捕获当前跟踪 ID (trace id)

• 发布网关指标 (metrics)

• 将一行数据加入队列,供后台摄取 (ingestion)

这最后一步值得深入探讨。被代理的请求不应该等待 ClickHouse 的响应;即便数据库运行缓慢或暂时不可用,我们也希望网关能够持续处理流量。因此,该中间件不会自行执行数据插入操作,而是简单地将数据行传递给一个有界内存队列:

if (!requestLogQueue.TryEnqueue(logEntry))
{
    GatewayMetrics.LogDrops.Add(1, tags);
}

 

队列使用有界 System.Threading.Channels 通道,并设置 FullMode = DropWrite

Channel.CreateBounded(new BoundedChannelOptions(Capacity)
{
    FullMode = BoundedChannelFullMode.DropWrite,
    SingleReader = true,
    SingleWriter = false,
});

 

DropWrite 是确保请求路径安全的关键。如果队列已满,TryEnqueue 会立即返回 false。中间件会递增一个 gateway.log_drops 计数器(采用与网关其他指标相同的标签方式),然后继续执行。代理请求绝不会等待数据库,而丢弃行计数器则成为一个可供报警的重要信号。

一个后台 IHostedService 负责排空通道。其处理循环非常简洁:等待一行数据,将所有立即可用的数据汇集到有上限的批次中,进行批量插入,然后重复此过程:

while (!stoppingToken.IsCancellationRequested)
{
    batch.Add(await queue.Reader.ReadAsync(stoppingToken));

    while (batch.Count < MaxBatchSize && queue.Reader.TryRead(out var more))
    {
        batch.Add(more);
    }

    try
    {
        await client.InsertBinaryAsync("request_logs", batch, InsertOptions, stoppingToken);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Failed to write {Count} request log rows to ClickHouse.", batch.Count);
    }
    finally
    {
        batch.Clear();
    }
}

每个 InsertBinaryAsync 调用都接受一个 POCO 列表,驱动程序以 ClickHouse 原生的 RowBinary 格式对其进行流式传输。GuidDateTime 和 uint 等值会直接以其原生二进制表示形式传输,无需任何字符串转换。

从 .NET 属性名称到 snake_case 列名称的映射关系直接在记录中声明:

internal sealed record RequestLogRow(
    [property: ClickHouseColumn(Name = "request_id")] Guid RequestId,
    [property: ClickHouseColumn(Name = "trace_id")] string TraceId,
    [property: ClickHouseColumn(Name = "timestamp")] DateTime Timestamp,
    // ... remaining columns
    [property: ClickHouseColumn(Name = "error_message")] string? ErrorMessage);

POCO 必须向客户端注册一次,以便驱动程序能够为该类型构建其列写入器。数据泵在启动时,紧随创建 schema(架构)之后执行此操作:

client.RegisterBinaryInsertType();

插入操作采用了两个 ClickHouse 侧的配置:

private static readonly InsertOptions InsertOptions = new()
{
    CustomSettings = new Dictionary<string, object>
    {
        ["async_insert"] = 1,
        ["wait_for_async_insert"] = 1,
    },
};

async_insert = 1 指示 ClickHouse 批量处理传入的插入请求,而不是每次插入调用都创建一个数据分片(part)。ClickHouse 在处理少量大型插入操作时表现最佳;异步插入允许服务器对传入数据进行批处理,以避免创建过多的新数据分片。

wait_for_async_insert = 1 意味着后台写入器会一直等待,直到插入的数据被刷新到磁盘。如果禁用此设置,ClickHouse 会在数据持久化到磁盘前就确认插入成功,而在此确认与实际写入磁盘之间的任何崩溃或重启都可能导致数据行悄无声息地丢失。在生产网关中,如果你能容忍这微小的数据丢失窗口期,可以将 wait_for_async_insert 设置为 0,但本示例为了安全起见保留了默认值。

 

 

Schema

原始表主要用于请求分析:

CREATE TABLE IF NOT EXISTS request_logs (
    request_id UUID,
    trace_id String,
    timestamp DateTime64(6, 'UTC'),
    method LowCardinality(String),
    route_pattern LowCardinality(String),
    path String,
    upstream_service LowCardinality(String),
    status_code UInt16,
    duration_ms Float64,
    request_size UInt32,
    response_size UInt32,
    error_message Nullable(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (upstream_service, route_pattern, timestamp)
TTL timestamp + INTERVAL 30 DAY;

 

有几种类型选择值得特别指出,因为它们在 SQL Server 或 Postgres 中没有直接对应的类型:

  • LowCardinality(String) 用于 methodroute_pattern 和 upstream_service 列,对其进行字典编码。ClickHouse 会为每行存储一个包含唯一值的小型字典以及整数索引,这不仅能显著压缩存储空间,还能大幅提升对低基数 (low-cardinality) 列进行 GROUP BY / WHERE 过滤的性能。

  • DateTime64(6, 'UTC') 提供微秒级精度,并将时区信息内置于列元数据中,从而有效避免了查询时常见的 UTC 与本地时间混淆问题。

  • 针对 status_code 使用 UInt16 类型,可将列的存储空间相较于默认的 32 位整型减半。ClickHouse 鼓励用户选择最紧凑且足以满足需求的整数类型。

  • Nullable(String) 采用可选(opt-in)机制:列默认情况下为 NOT NULL,而 Nullable 类型会为每行携带一个空值位图,因此仅应保留给空值确实具有实际语义的字段。

引擎子句(engine clauses)是决定查询性能和数据保留策略的关键:

  • PARTITION BY toYYYYMMDD(timestamp) 将表分割成每日分区。时间范围查询会自动跳过范围外的所有分区。

  • ORDER BY (upstream_service, route_pattern, timestamp) 定义了排序键。它不仅控制着数据在磁盘上的物理布局,还影响着稀疏主索引的构建。对排序键前缀列的过滤操作速度极快,而对 path 列的过滤则相对较慢。

  • TTL timestamp + INTERVAL 30 DAY 会在后台合并(background merges)过程中自动删除旧分区,从而省去了手动清理作业的需要。

该示例还创建了一个名为 request_stats_mv 的物化视图 (materialized view)。如果您之前没有使用过 ClickHouse 的物化视图,那么这里的模式(schema)定义将变得非常有趣。

ClickHouse 中的物化视图并非简单的查询缓存。它是一个独立的表,ClickHouse 会在数据行写入源表时自动填充该表。每次向 request_logs 表插入数据,都会触发视图中定义的 SELECT 语句,并将结果写入视图自身的存储。该视图使用 AggregatingMergeTree 引擎,这意味着存储在其中的行是部分聚合状态,而非最终的聚合值。

理解 DDL(数据定义语言)的关键在于 -State / -Merge 后缀约定。在视图定义中:

countState() AS request_count,
avgState(duration_ms) AS avg_duration,
quantilesTDigestState(0.5, 0.95, 0.99)(duration_ms) AS duration_quantiles

countState() 并不会直接存储最终的计数结果,而是存储一种中间聚合状态,ClickHouse 可以在后续将其与其他状态进行合并。当仪表盘查询此视图时,它会使用对应的 -Merge 组合器进行聚合查询:

countMerge(request_count) AS request_count,
avgMerge(avg_duration) AS avg_latency_ms,
quantilesTDigestMerge(0.5, 0.95, 0.99)(duration_quantiles) AS percentiles

这正是该模式高效之处。原始的 request_logs 表可能包含数百万行数据,但物化视图已将它们聚合为针对每个服务、路由、状态码和每分钟的一行部分聚合数据。仪表板查询直接合并这些小型中间状态,而非扫描全部日志。因此,无论网关处理了多少流量,百分位图和错误率面板都能保持快速响应。

 

 

可观察性实践

Aspire 仪表板能够从每个服务发布的 OTLP 数据流中,呈现 traces、metrics 和结构化 logs。通过在每个服务的 Program.cs 文件中,利用 builder.AddServiceDefaults() 进行配置,这一过程非常直接:

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
    logging.AddOtlpExporter();
});

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName))
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation();
        tracing.AddHttpClientInstrumentation();
        tracing.AddOtlpExporter();
    })
    .WithMetrics(metrics =>
    {
        metrics.AddAspNetCoreInstrumentation();
        metrics.AddHttpClientInstrumentation();
        metrics.AddRuntimeInstrumentation();
        metrics.AddOtlpExporter();
    });

AddOtlpExporter() 调用无需明确指定 URL,因为 Aspire 会在每个子进程上设置 OTEL_EXPORTER_OTLP_ENDPOINT,使其指向仪表板的采集器。AspNetCoreHttpClient 和 Runtime Instrumentation 覆盖了传入请求、传出 HTTP 调用以及进程级 metrics。

我们还声明了一些自定义 metrics,它们可以复用相同的管道,无需任何额外配置:

private static readonly Meter Meter = new("Gateway.Telemetry");

public static readonly Counter Requests =
    Meter.CreateCounter("gateway.requests", unit: "{request}");

public static readonly Histogram DurationMs =
    Meter.CreateHistogram("gateway.duration", unit: "ms");

public static readonly Counter BackendFailures =
    Meter.CreateCounter("gateway.backend_failures", unit: "{request}");

每个中间件层都会使用标准化路由、上游服务和 HTTP 状态类别(2xx4xx5xx)来标记这些 metrics,以便用户可以在仪表板中对它们进行筛选。

 

在 Aspire 中

让我们来看看 Aspire 仪表板中的自定义 metrics。在这里,我们可以看到之前注册的 API 调用耗时指标,并且能够利用自定义标签来筛选数据。

 

Logs、metrics 和 traces 是相互关联的。点击 metrics 图表中的任一 exemplar(或 logs 的 trace ID),即可跳转到对应的 trace,并展示所有相关的 spans:

 

持久性

在将这些应用于生产环境之前,有一点值得注意:Aspire 仪表板不会持久化其接收到的任何数据。仪表板视图中的 logs、traces 和 metrics 完全存储在内存中。当 AppHost 停止时,所有历史数据都将丢失。它是一个专为内部开发迭代而设计的系统,而非生产级可观察性堆栈。

好消息是,从开发环境迁移到生产环境时,上述连接方式保持不变。这些服务发出符合 OTLP (OpenTelemetry Protocol) 标准的数据,因此只需通过配置修改,即可将仪表盘的端点切换到生产级可观测性后端。ClickStack 作为原生支持 ClickHouse 和 OpenTelemetry 的日志、指标和追踪技术栈,是存储和查询这些数据的理想选择,它具备卓越的性能和数据压缩能力。

 

 

在 ClickHouse 中

最后,我们来看看基于 ClickHouse 构建的仪表盘。该视图与 Aspire 仪表盘的设计理念有所不同。它并非旨在展示每一个 Span 或每一个实时进程的详细信息,而是针对请求历史数据进行分析,回答以下问题:请求量、P95 延迟、按服务划分的错误率、路由级别的尾部延迟,以及最近最慢的请求及其对应的追踪 ID (trace ids)。

随着原始数据表的持续增长,物化视图 (materialized view) 是确保查询成本保持低廉的关键。ClickHouse 并非在每次仪表盘刷新时都扫描所有请求数据,而是已将数据流预先聚合为分钟级的状态数据。仪表盘的查询操作只需合并这些预计算好的状态。同时,当我们需要深入分析单个慢请求,或者将 trace_id 复制回 Aspire 追踪视图时,原始的 request_logs 表依然可用。

 

 

 

总结

通过构建此演示,我们已经了解了以下内容:

• Aspire 如何将 ClickHouse 作为一流的应用程序资源进行管理。

• AppHost 的连接方式如何在无需硬编码 URL 地址的情况下,实现服务发现、依赖排序和稳定的外部端点。

• Aspire.ClickHouse.Driver 如何简化 .NET 服务与 ClickHouse 的查询和写入操作。

• 如何利用有界队列和后台摄取机制,将网关请求日志的记录过程从“热路径” (hot path) 中分离。

• 如何在 ClickHouse 中存储请求遥测数据 (telemetry),并利用高效的 Schema (数据模式) 和预计算常用统计信息的物化视图。

您可以查看完整的示例项目,在本地运行该项目,并从 配套仓库 中根据需求进行调整和采用。

 

 

结语

Aspire 和 ClickHouse 为解决 .NET 领域的一个常见问题提供了天然契合的解决方案:即在分布式系统运行时实时洞察其状况,并在累积足够流量后发现运行模式和趋势。

在内部开发闭环(inner development loop)中,可从 Aspire 着手。当你需要记录历史数据、进行百分位分析以及路由级别比较时,可以添加一个 ClickHouse 表。请保持数据模式(schema)简洁,慎重使用低基数维度(low-cardinality dimensions),并对仪表盘(dashboard)将频繁查询的视图进行预聚合。

在此基础上,这套架构模式可轻松扩展至生产环境:将 Aspire 连接到现有的 ClickHouse 实例,将网关写入操作迁移至后台流水线(background pipeline),并在准备就绪时,将 OTLP 数据导出到 ClickStack。

 

ClickHouse 是面向 AI 时代打造的高性能实时分析数据库,能够以极致性能处理海量数据分析任务。凭借高并发、低延迟和云原生架构,ClickHouse 广泛应用于可观测性、数据仓库、实时分析及 AI 数据基础设施等场景。我们致力于帮助企业在公有云平台上构建安全、弹性且高性价比的实时分析与 AI 数据平台,加速释放数据价值,推动智能化创新与数字化转型。目前,Trip.com、DiDi、Meta、Sony、Netflix、Deutsche Bank、Sierra、Cloudflare 等全球领先企业均在使用 ClickHouse 支撑其关键业务和数据分析平台。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值