使用 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 httpAppHost(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是由AspireAppHostSDK 根据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 DropWriteChannel.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格式对其进行流式传输。Guid、DateTime和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 Dictionarystring, 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)用于method、route_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_quantilescountState()并不会直接存储最终的计数结果而是存储一种中间聚合状态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使其指向仪表板的采集器。AspNetCore、HttpClient和RuntimeInstrumentation 覆盖了传入请求、传出 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 状态类别2xx、4xx、5xx来标记这些 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 支撑其关键业务和数据分析平台。