ClickHouse .NET 驱动程序新特性:从 1.0 到 1.3 的演进之路

本文字数:12722;估计阅读时间:32分钟

作者:Alex Soffronow Pagonidis

编者按: 本文译自 ClickHouse 原博客(链接点文末-查看原文)。 原文围绕「相关技术主题」展开。 文章梳理了驱动在插入、读取和参数路径上的实际变化,POCO 工作流是其中最值得关注的体验升级。对仍停留在 1.0 的用户来说,这是一份清晰的迁移参考。

今年 2 月,我们发布了 .NET 驱动的首个稳定版本。该版本重点增强了类型支持、打包方式、配置及可观测性,并引入了一套独立于旧版 ADO.NET API 的全新主 API。

此后,我们陆续发布了三个次版本,在优化用户体验的同时,不断增强客户端的扩展性与可配置性,并进一步丰富了支持的数据类型。此外,我们还开发了多项 .NET 生态集成。这些进展离不开社区的贡献,感谢在此期间提交 Issue、复现 Bug 或提交 PR 的每一位开发者,特别感谢 Daniel Bunting、Vyacheslav Brevnov、Minh Vu、Ozan Hanedan 以及 Musa Musaev。

如果你对该驱动的印象还停留在 1.0 版本,那么现在的写入、读取及参数处理流程均已显著进化。具体变化如下。

基于 POCO 的类型安全工作流

这是开发体验上最显著的一项改进。在 1.0 版本中,常规操作依赖 object[]。现在,无论是读取还是写入,都可以直接使用自定义类:

之前 (1.0):手动转换的 object\[\]

复制代码
using var client = new ClickHouseClient("Host=localhost");

// You own the column list, the ordering, and the boxing.
await client.InsertBinaryAsync(
    "sensors",
    new[] { "Id", "SensorName", "Value", "RecordedAt" },
    readings.Select(r => new object[] { r.Id, r.SensorName, r.Value, r.RecordedAt }));

以前每次写入都要重复声明表结构。一旦 string[] 中的列顺序写错,就会引发运行时序列化错误,甚至导致数据被悄无声息地错位写入。

现在 (1.3):一次注册,直接读写自定义类型

复制代码
public class SensorReading
{
    public ulong Id { get; set; }
    public string SensorName { get; set; }
    public double Value { get; set; }
    public DateTime RecordedAt { get; set; }
}

using var client = new ClickHouseClient("Host=localhost");
client.RegisterPocoType<SensorReading>();   // sets up insert + read, validates both up front

// Write
long rows = await client.InsertBinaryAsync("sensors", readings);   // IEnumerable<SensorReading>

// Read
await foreach (var r in client.QueryAsync<SensorReading>(
                   "SELECT * FROM sensors WHERE Value > 20;"))
{
    Console.WriteLine($"{r.SensorName}: {r.Value:F2}");
}

类型注册在初始化阶段完成:它会校验类结构并尽早拦截映射问题。由于 Getter 和 Setter 经过预编译,查询时几乎没有额外开销。QueryAsync<T> 返回 IAsyncEnumerable<T>,支持数据行的延迟实例化。当枚举结束、报错或中途 break 时,读取器会自动释放。以下是几条匹配规则:

• 列匹配区分大小写( StringComparer.Ordinal )。

• 若结果集中的列没有对应的类属性,该列将被忽略。

• 若类中的属性没有对应的结果列,该属性将保持默认值。

• 类型需要一个公共无参构造函数,以及至少一个带公共非 init setter 的公共属性。支持 required 属性。

映射属性

你还可以使用特性来控制每个属性的列和类型映射:

复制代码
public class AuditEvent
{
    [ClickHouseColumn(Name = "event_id")]
    public ulong Id { get; set; }

    [ClickHouseColumn(Name = "event_type", Type = "LowCardinality(String)")]
    public required string Type { get; set; }

    [ClickHouseNotMapped]
    public string InternalCorrelationTag { get; set; }
}

驱动程序不会进行任何隐式转换。如果类型不匹配,会抛出 InvalidOperationException 并详细说明 POCO 类型、属性、列名以及实际返回的 CLR 类型,方便快速定位。为了辅助调试,映射器还提供了详细日志。只要配置了 LoggerFactory,注册时就会输出 Debug 级别的日志(类别为 ClickHouse.Driver.Client),列出属性与列的映射关系、被跳过的属性及其原因。查看日志通常比查阅文档规则更高效。

将 POCO 存入 JSON 列

这一特性同样适用于 ClickHouse 的 JSON 类型。配合 RegisterJsonSerializationType<T>() 以及 [ClickHouseJsonPath("...")] / [ClickHouseJsonIgnore],你可以直接将 POCO 写入 JSON 列并保持类型精度(需将 JsonWriteMode 设为 Binary):

复制代码
public class SensorAttributes
{
    [ClickHouseJsonPath("device.id")]
    public required string DeviceId { get; set; }

    public decimal Temperature { get; set; }

    [ClickHouseJsonIgnore]
    public string LocalDebugTag { get; set; }
}

using var client = new ClickHouseClient("Host=localhost;JsonWriteMode=Binary");
client.RegisterJsonSerializationType<SensorAttributes>();

// attributes JSON(`device.id` String, Temperature Decimal64(4))
await client.InsertBinaryAsync("readings", ["id", "attributes"],
    [[1UL, new SensorAttributes { DeviceId = "sensor-7", Temperature = 21.4375m }]]);

写入后的数据行为 {"Temperature":"21.4375","device":{"id":"sensor-7"}}。

端到端自定义流水线

第二个重点是控制权。虽然驱动程序提供了合理的默认值,但无法覆盖所有场景。为了让开发者更灵活地控制客户端行为,我们新增了三个扩展点,分别负责类型解析、参数序列化以及读取时的值转换。这些扩展点均为可选,不使用时不会产生额外开销。

接口 阶段 引入版本
IParameterTypeResolver CLR 类型 → ClickHouse 类型 1.2
IParameterFormatter 值 → 传输格式 1.3
IReadValueConverter 传输值 → 返回时的 CLR 值 1.3

这三个接口都可以在 ClickHouseClientSettings(全局客户端级别)或 QueryOptions(单次查询级别)中进行配置。

1. IParameterTypeResolver:控制类型推断

ClickHouse 要求发往服务端的参数带有类型声明,例如 {name:Type}。不过,驱动也支持无类型的 ADO.NET 风格参数(如 WHERE id = @id),这也是 Dapper 等 ORM 常用的形式。此时,驱动必须根据 CLR 值推断 ClickHouse 类型。内置的推断策略倾向于保守:decimal 映射为 Decimal128(scale),DateTime 映射为 DateTime('UTC'),以最大限度减少信息丢失。但如果你的表结构统一使用 Decimal64(4) 和 DateTime64(3),这种保守策略反而会报错,迫使你在代码中为每个 @ 参数手动添加注解。通过 IParameterTypeResolver,你可以全局定义映射约定。针对常见场景,驱动提供了一个内置的字典实现:

复制代码
var settings = new ClickHouseClientSettings("Host=localhost")
{
    ParameterTypeResolver = new DictionaryParameterTypeResolver(new Dictionary<Type, string>
    {
        [typeof(decimal)]  = "Decimal64(4)",
        [typeof(DateTime)] = "DateTime64(3)",
    }),
};

如果逻辑需要根据具体数值或参数名进行判断,可以直接实现该接口。例如,下面的代码会根据传入的具体值,选择能容纳它的最窄 decimal 类型:

复制代码
private class SmartDecimalResolver : IParameterTypeResolver
{
    public string ResolveType(Type clrType, object value, string parameterName)
    {
        if (clrType != typeof(decimal))
            return null; // let everything else use default inference

        var scale = (decimal.GetBits((decimal)value)[3] >> 16) & 0x7F;
        return scale <= 4 ? $"Decimal64({scale})" : $"Decimal128({scale})";
    }
}

如果返回 null,驱动会退回到默认推断逻辑,因此解析器只需处理特定情况即可。

2. IParameterFormatter:控制传输格式

仅解析出类型 有时还不够,因为即使类型正确,格式化输出 (rendering)也可能不符合预期。HTTP 参数以文本形式发送,驱动需要决定如何序列化每个值。这通常没有争议,但也有例外:例如,DateTime 列会接收带有亚秒精度的值并直接截断;你可能希望在本地显式处理这种截断,而不是交给服务端处理。

复制代码
var settings = new ClickHouseClientSettings("Host=localhost")
{
    ParameterFormatter = new DictionaryParameterFormatter(new Dictionary<Type, Func<object, string>>
    {
        // e.g. clamp to whole seconds regardless of the column's declared precision
        [typeof(DateTime)] = v => ((DateTime)v).ToString("yyyy-MM-dd HH:mm:ss",
                                                          CultureInfo.InvariantCulture),
    }),
};

值得注意的两点:

• 无论是顶层值,还是复合类型( Array 、 Tuple 、 Map 、 Nested )内部的元素,都会调用该格式化器。

• 透明包装器( Nullable 、 LowCardinality 、 Variant )会被预先解包,因此格式化器只需处理底层具体类型。此外,格式化器不会处理 null 或 DBNull ,它们会被统一序列化为 ClickHouse 的空值占位符。

3. IReadValueConverter:在返回数据时进行标准化

第三个接口用于处理数据返回。假设查询的 DateTime 列未指定时区,读取到的值 Kind 属性通常为 Unspecified。如果应用约定"所有时间戳均为 UTC",你可以在数据离开驱动前完成转换,避免在业务代码中到处调用 DateTime.SpecifyKind:

复制代码
var settings = new ClickHouseClientSettings("Host=localhost")
{
    ReadValueConverter = new DictionaryReadValueConverter()
        .For<DateTime>(dt => DateTime.SpecifyKind(dt, DateTimeKind.Utc))
        .For<string>(s => s.Trim()),
};

该转换器会拦截 GetValue() 和 GetFieldValue<T>(),并在 POCO 实例化时生效,因此 QueryAsync<T>() 也会得到转换后的值。注意:DictionaryReadValueConverter 仅按 CLR 类型匹配,而 DateTime 和 DateTime('UTC') 在 C# 中都表现为 System.DateTime,因此上述代码也会影响已带时区的列。如果需要区分,请实现该接口并根据 ClickHouse 返回的类型名称进行逻辑分支,此时获取的数据与服务器原始报告完全一致:

复制代码
private class UtcOnlyForNoTzDateTimeConverter : IReadValueConverter
{
    public object ConvertValue(object value, string columnName, string clickHouseType)
        => value is DateTime dt && clickHouseType == "DateTime"
            ? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
            : value;

    public T ConvertValue<T>(T value, string columnName, string clickHouseType)
        => typeof(T) == typeof(DateTime) && value is DateTime dt && clickHouseType == "DateTime"
            ? (T)(object)DateTime.SpecifyKind(dt, DateTimeKind.Utc)
            : value;
}

该接口有两个限制:

• 不得改变值的运行时类型。 列元数据( GetFieldType 等)不会根据转换器的输出重新推导。它并非数据映射层。

• 不会递归处理复合类型。 它针对每行每列调用一次,传入完整的已反序列化单元格数据:例如对于 Array(Int32) 列,你获取到的是完整的 int\[\] 。提供泛型重载是为了保证热路径性能:通过 typeof(T) 检查配合 Unsafe.As ,即可在不装箱的情况下直接转换值类型。

更细粒度的配置项

秉承同样的理念,我们还优化了一些配置细节:

• 单次查询设置 Accept-Encoding : QueryOptions.AcceptEncoding (对应 ClickHouseCommand.AcceptEncoding )可覆盖单个请求默认的 gzip, deflate 编码。此操作会强制设置 enable_http_compression=1 以确保服务端生效。 请注意,如果使用 BCL 无法解码的压缩格式(如 zstd、lz4),必须在 HttpClient 中设置 AutomaticDecompression = None ,并通过 ExecuteRawResultAsync 读取响应体。具体编码可通过 ClickHouseRawResult.ContentEncoding 获取。

• GetFieldValue<T>(string name) :支持按列名获取数据,作为按索引获取的补充。

• ApplicationInfo : ClickHouseClientSettings 新增了自定义标签字典 IReadOnlyDictionary<string, string> (如 app 、 ver 、 env 等)。这些标签会以注释形式附加在 HTTP User-Agent 标头中,例如 (app:MyApp; ver:2.3.1; env:prod) 。这在通过数据库查询日志追溯来源时非常有用。

更丰富的类型支持

我们持续优化了 CLR 与 ClickHouse 之间的类型映射,新增了对多维数组、ValueTuples 以及 Identifier 参数的支持。

嵌套与多维数组

感谢 @DanielBunting 的贡献,现在可以将 Array(Array(T))(及更深层嵌套)作为多维数组读写。C# 支持两种 N 维数组:交错数组(int[][],数组的数组,行长可变)和矩形数组(int[,],连续内存,行长固定)。ClickHouse 的 Array(Array(T)) 在结构上对应交错数组。但在处理矩形数据时,强制使用交错数组会增加额外的对象分配。现在,写入操作已同时支持交错数组(T[][]、List<List<T>>)和矩形数组(T[,]、T[,,] 等)。读取时默认仍为交错格式,但 GetFieldValue<T[,]> 可以直接实例化为矩形数组并校验形状。

复制代码
// Write: jagged or rectangular, your choice
int[][] jagged      = [[1, 2], [3], [4, 5, 6]];
int[,]  rectangular = new int[2, 3] { { 1, 2, 3 }, { 4, 5, 6 } };

await client.InsertBinaryAsync("matrices", ["id", "data"],
    [[1UL, jagged], [2UL, rectangular]]);

// Read: GetValue gives you jagged...
var asJagged = (int[][])reader.GetValue(1);

// ...but if you know it's rectangular, ask for that shape directly
var asRect = reader.GetFieldValue<int[,]>(1);   // throws on ragged data

写入链路中的 ValueTuple

在二进制插入、HTTP 参数及类型推断中,现在可以直接使用 C# 元组字面量:

复制代码
await client.InsertBinaryAsync("t", ["id", "pair"],
    [[1UL, (42, "hello")]]);   // -> Tuple(Int32, String)

针对元素超过 7 个的元组,客户端也能从编译器生成的 TRest 嵌套结构中将其正确展平。

服务端标识符参数

现在可以将数据库名、表名或列名作为参数绑定,无需手动拼接 SQL 字符串:

复制代码
var p = new ClickHouseParameterCollection();
p.AddParameter("tbl", "my-table`with`backticks");
await client.ExecuteReaderAsync("SELECT * FROM {tbl:Identifier};", p);

参数值会原样发送,由服务端负责标识符的引号包裹与转义。因此,包含特殊字符(含反引号)的名称均可正常传递,无需在客户端处理转义逻辑。此前该操作会抛出 Identifier 类型未知的异常。

正确性

我们在本次迭代中投入了大量精力来提升正确性并修复已知缺陷。

破坏性变更:DateTime 时区处理

本版本中最关键的破坏性变更涉及 DateTime 处理:

复制代码
var p = new ClickHouseParameterCollection();
p.AddParameter("ts", DateTime.UtcNow);
await client.ExecuteNonQueryAsync("INSERT INTO events VALUES (@ts);", p);

• 之前: 驱动仅发送基础的 {ts:DateTime} 提示。服务端根据 session_timezone 解析传输的挂钟时间(wall-clock),并依据服务端时区偏移量进行平移。在 UTC 服务器上一切正常,但在 Europe/Amsterdam 等时区,夏令时会导致两小时的偏差。

• 之后: DateTime { Kind: Utc or Local } 和 DateTimeOffset 会被推断为 DateTime('UTC') 发送。无论服务端处于何种时区,都能确保绝对时间点(instant)的一致性。

• 显式类型提示不受影响;若参数类型为 {ts:DateTime} ,时区语义仍完全由调用方控制。

客户端中还包含许多细节优化:

• Fixed/UTC±HH:MM:SS 时区: ClickHouse 的合成时区名称不在 IANA TZDB 中,此前驱动会将其误认为 UTC。现在已能正确解析为固定偏移量时区。

• 复合类型序列化: 通过 HTTP 传输时, Array 、 Tuple 、 Map 、 Variant 内的日期时间类型现在会正确添加引号。

• Variant 类型的 NULL 值: 修复了读取 NULL 时抛出 IndexOutOfRangeException 以及写入 NULL 时未

更少的网络往返,更低的 GC 压力

这两项改动侧重于性能优化,旨在减少资源开销。

Insert 体验优化:跳过表结构探测

默认情况下,每次调用 InsertBinaryAsync 都会执行一次 SELECT ... WHERE 1=0 以探测表结构。这会产生额外的网络开销。现在可以通过以下两种方式规避:

复制代码
// Know the schema at compile time? Declare it and skip the probe entirely.
var options = new InsertOptions
{
    ColumnTypes = new Dictionary<string, string>
    {
        ["Id"]         = "UInt64",
        ["SensorName"] = "LowCardinality(String)",
        ["Value"]      = "Float64",
    },
};

// Or probe once and reuse it for the lifetime of the client.
var cached = new InsertOptions { UseSchemaCache = true };

ColumnTypes 的优先级高于 UseSchemaCache,且需要显式列出字段。UseSchemaCache 会在客户端生命周期内按 (数据库, 表) 缓存结构,因此如果执行了破坏性的 ALTER TABLE,需留意缓存失效问题。此外,如果 POCO 中所有 映射属性都标记了 [ClickHouseColumn(Type = ...)],驱动会自动跳过探测。

ReadBufferSize 默认值从 512 KiB 降至 8 KiB

此前,响应读取缓冲区固定为 512 KiB。由于超过了 85,000 字节的大对象堆(LOH)阈值,每次查询都会在 LOH 上分配内存(LOH 默认不进行碎片整理,易导致内存碎片)。这带来了显著的 GC 压力。在 1000 次小型 SELECT 查询的基准测试中,单次查询的内存分配减少了 80% 以上,消除了 Gen 2 回收,GC 总暂停时间缩短了 90% 以上。1.3 版本允许通过 ClickHouseClientSettings.ReadBufferSize 或连接字符串自定义缓冲区大小,并将默认值降至 8 KiB。如需流式处理海量响应并减少缓冲区重填次数,可手动调高此值。

Entity Framework Core

作为 .NET 社区呼声最高的集成,EF Core 支持现已正式发布:ClickHouse.EntityFrameworkCore 支持查询、插入、表引擎配置及迁移,并能将 GROUP BY 聚合、字符串方法、数学函数及 JSON 列转换为原生 ClickHouse SQL。

DDL

配置方式与其他 EF Core 提供程序类似:

复制代码
public class AnalyticsContext : DbContext
{
    public DbSet<PageView> PageViews { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseClickHouse("Host=localhost;Port=8123;Database=analytics");
}

modelBuilder.Entity<SensorReading>(b =>
{
    b.HasKey(e => e.Id);
    b.Property(e => e.Temperature).HasCodec("Delta, ZSTD");
    b.Property(e => e.Location).HasColumnComment("Installation site");
    b.HasIndex(e => e.Timestamp)
        .HasSkippingIndexType("minmax")
        .HasGranularity(4);
    b.ToTable("sensor_readings", t => t
        .HasReplacingMergeTreeEngine("Version")
        .WithOrderBy("Id", "Timestamp")
        .WithPartitionBy("toYYYYMM(Timestamp)")
        .WithPrimaryKey("Id")
        .WithTtl("Timestamp + INTERVAL 1 YEAR")
        .WithSetting("index_granularity", "4096"));
});

查询构建

随后即可使用 LINQ,所有内容都会如预期般转换为 ClickHouse 语法:

复制代码
var topPages = await ctx.PageViews
    .Where(v => v.Date >= new DateOnly(2024, 1, 1))
    .GroupBy(v => v.Path)
    .Select(g => new { Path = g.Key, Views = g.Count() })
    .OrderByDescending(x => x.Views)
    .Take(10)
    .ToListAsync();

批量插入

此外,你无需为了性能而放弃 EF,该集成提供了高性能的 .BulkInsertAsync() 方法:

复制代码
long rowsInserted = await ctx.BulkInsertAsync(events);

// tune the batch size at configuration time
optionsBuilder.UseClickHouse("Host=localhost", o => o.MaxBatchSize(5000));

该集成还提供了表引擎 Fluent API,支持多种表引擎、索引以及列级 DDL。

日益完善的生态系统

除 EF Core 外,我们还推出了另外三个官方集成:

• Serilog: 通过 WriteTo.ClickHouse() 提供流式列配置和集群支持。Serilog 用户只需一行代码即可接入。详见: 使用 Serilog 和 ClickHouse 在 .NET 中实现结构化日志 。

• Aspire: Aspire.Hosting.ClickHouse 为应用模型添加了 ClickHouse 容器资源; Aspire.ClickHouse.Driver 在 DI 中注册了 ClickHouseDataSource ,并集成健康检查、OpenTelemetry 追踪及配置绑定。详见: 使用 ClickHouse 和 Aspire 构建 .NET API 网关 。

• Semantic Kernel: 针对 Microsoft.Extensions.VectorData 的向量存储连接器。通过标准 SK 接口将 ClickHouse 作为向量数据库,支持 CRUD、过滤查询及向量相似度计算。示例项目展示了基于 IMDb 数据集的语义搜索。

展望未来

1.4:提升速度

下一个版本(1.4)将聚焦性能,通过减少内存分配来提升速度。预告特性包括:

• 大幅减少内存分配: 移除部分缓冲区并引入池化机制,显著降低开销。

• 可插拔压缩: 提供 IClickHouseCompressor 接口,内置 GZip、Brotli、LZ4 和 Zstd 支持。

• 免装箱的 POCO 读写: 驱动将直接通过泛型接口操作,跳过 object 转换,节省大量分配开销。

• ......以及更多特性!

1.5: Native/TCP 客户端

我们目前正全力开发基于 TCP 协议和 Native(列式)格式的新客户端,旨在实现更极致的性能。TCP 协议的优势包括:

保持列式结构。 数据以"块"为单位传输,采用与数据库存储一致的列式格式。这消除了服务端的 RowBinary 序列化开销。读取百万行 Int64 时,不再需要百万次解码,而是转化为高效的单次内存复制。

服务端状态实时反馈。 在查询执行期间,Native 协议能在查询执行期间穿插返回进度更新、性能分析事件及服务端日志,包括已读行数、字节数及执行进度预估。

连接具有长生命周期且带有状态,因此驱动无需再通过 session id 参数去模拟会话、临时表以及单连接级别的配置。

链接

• 安装: dotnet add package ClickHouse.Driver

• 文档: https://clickhouse.com/docs/integrations/csharp

• 示例: https://github.com/ClickHouse/clickhouse-cs/tree/main/examples

• 如有任何反馈,请在 GitHub 上联系我们(https://github.com/ClickHouse/clickhouse-cs),或或加入 社区 Slack(https://clickhousedb.slack.com/join/shared_invite/zt-44wvsqbjb-53ggHzNoaCyG5k3dFDyOQA#/shared-invite/email) 。

关于我们

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

相关推荐
流烟默1 小时前
MCP第 6 篇:Spring AI Alibaba 封装 MCP:注册中心、网关与企业治理
java·mcp封装
bitbrowser1 小时前
Gmail注册要求手机号如何解决?哪些情况可以不填手机号
前端·指纹浏览器·gmail
LCG米1 小时前
STM32 HardFault故障定位实测:压栈帧寄存器解析、CFSR故障源判读与出错地址回溯
前端·javascript·stm32·单片机·嵌入式
夏幻灵1 小时前
Express 静态资源托管详解:从本地 HTML 到 express.static()
前端·html·express
Light602 小时前
企业自建 AI 智能体,成本差 8 倍还是 46 倍?
java·人工智能·算法·知识图谱·rag·ai agent 上下文·token 成本
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 阿里云 OSS 对象存储介绍
java·阿里云·交友
yyuuuzz2 小时前
云服务器部署 web 项目的进程常驻记录
运维·服务器·前端·阿里云·云计算·aws
Yyyyyy~2 小时前
【Maven】基础
java·maven