
本文字数: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 支撑其关键业务和数据分析平台。