ClickHouse .NET驱动1.0到1.3更新概览
DataHot 速览
ClickHouse官方发布了.NET驱动从1.0到1.3的演进说明,涵盖类型安全POCO工作流、可扩展序列化、更广的类型支持、性能改进及官方生态集成。此前1.0版本已提供独立于传统ADO.NET API的新主API,后续三个次要版本持续优化用户体验和可配置性。文章示例展示了如何从手写object[]改为注册POCO类型后直接插入和查询。
为什么值得关注:ClickHouse是常用的实时分析数据库,驱动的API演进直接影响数据接入和查询开发效率,值得相关开发者关注。
本文目录 24 节
- 使用POCO进行类型安全的工作流
- 之前(1.0):手动编组的object[]
- 之后(1.3):注册一次,插入和读取你的类型
- 将POCO写入JSON列
- 端到端自定义管线
- 1. IParameterTypeResolver:控制类型推断
- 2. IParameterFormatter:控制线上表示
- 3. IReadValueConverter:在返回时规范化
- 更细粒度的旋钮
- 更丰富的类型支持
- 嵌套和多维数组
- ValueTuple在写入路径上
- 服务器端 Identifier参数
- 正确性
- 破坏性变更:DateTime时区处理
- 更少的往返,更少的GC压力
- 插入便利性:跳过模式探测
- ReadBufferSize默认值从512 KiB降低到8 KiB
- Entity Framework Core
- 不断增长的生态系统
- 展望未来
- 1.4:Go Fast
- 1.5:Native/TCP 客户端
- 链接
译文
AI 逐段翻译今年二月份,我们发布了.NET驱动程序的第一个稳定版本。该版本侧重于扩展类型支持、打包、配置、可观测性,并引入了与旧版ADO.NET API分离的全新主要API。
此后,我们陆续在三个新的次要版本中发布了改进,改善了用户体验,使客户端更具可扩展性和可配置性,并扩展了类型覆盖范围。我们还在.NET生态系统中构建了许多集成功能。没有社区的贡献,我们不可能做到这一点。我们要感谢所有在这个周期中提交问题、复现错误或发送拉取请求的人,包括Daniel Bunting、Vyacheslav Brevnov、Minh Vu、Ozan Hanedan和Musa Musaev。
如果你上次查看这个驱动程序是在1.0版本,那么插入路径、读取路径和参数路径都已经发生了变化。以下是发生的变化。
使用POCO进行类型安全的工作流
最大的易用性变化。在1.0中,标准路径是object[]。现在,它是你自己的类,双向都是如此:
之前(1.0):手动编组的object[]
1usingvar client = new ClickHouseClient("Host=localhost");23// You own the column list, the ordering, and the boxing.4await client.InsertBinaryAsync(5"sensors",6new[] { "Id", "SensorName", "Value", "RecordedAt" },7 readings.Select(r => newobject[] { r.Id, r.SensorName, r.Value, r.RecordedAt }));每次插入都重新声明架构。交换string[]中的两列顺序,最终会导致运行时序列化错误,甚至数据被静默转置。
之后(1.3):注册一次,插入和读取你的类型
1publicclassSensorReading2{3publiculong Id { get; set; }4publicstring SensorName { get; set; }5publicdouble Value { get; set; }6public DateTime RecordedAt { get; set; }7}89usingvar client = new ClickHouseClient("Host=localhost");10client.RegisterPocoType<SensorReading>(); // sets up insert + read, validates both up front1112// Write13long rows = await client.InsertBinaryAsync("sensors", readings); // IEnumerable<SensorReading>1415// Read16awaitforeach (var r in client.QueryAsync<SensorReading>(17"SELECT * FROM sensors WHERE Value > 20;"))18{19 Console.WriteLine($"{r.SensorName}: {r.Value:F2}");20}注册提前进行:它会验证类并尽早暴露任何映射问题。获取器和设置器是预编译的,因此在查询时没有额外开销。QueryAsync<T> 返回 IAsyncEnumerable<T>,并且行是惰性物化的。当枚举完成、出错或你提前break时,读取器会被释放。一些值得知道的匹配规则:
- 列匹配区分大小写(
StringComparer.Ordinal)。 - 没有匹配属性的结果列会被忽略。
- 没有匹配结果列的属性将保留其默认值。
- 类型需要公共无参构造函数和至少一个具有公共非
initsetter的公共属性。支持required属性。
映射属性
你还可以使用属性来控制每个属性的列和类型映射:
1publicclassAuditEvent2{3 [ClickHouseColumn(Name = "event_id")]4publiculong Id { get; set; }56 [ClickHouseColumn(Name = "event_type", Type = "LowCardinality(String)")]7publicrequiredstring Type { get; set; }89 [ClickHouseNotMapped]10publicstring InternalCorrelationTag { get; set; }11}驱动程序不会进行任何静默转换。当存在不匹配时,你会收到一个InvalidOperationException,其中会指出POCO类型、属性、列以及实际返回的CLR类型,因此很容易修复问题。为了帮助调试,映射器还会发出详细的日志。配置了LoggerFactory后,注册时会发出一个Debug级别的日志(类别为ClickHouse.Driver.Client),列出哪些属性映射到哪些列,以及哪些被跳过以及原因。该日志解答大多数映射问题的速度比阅读上述规则更快。
将POCO写入JSON列
同样的想法也适用于ClickHouse的JSON类型。你可以使用RegisterJsonSerializationType<T>()加上[ClickHouseJsonPath("...")] / [ClickHouseJsonIgnore](注意必须将JsonWriteMode设置为Binary)将具有完整类型保真度的POCO直接写入JSON列:
1publicclassSensorAttributes2{3 [ClickHouseJsonPath("device.id")]4publicrequiredstring DeviceId { get; set; }56publicdecimal Temperature { get; set; }78 [ClickHouseJsonIgnore]9publicstring LocalDebugTag { get; set; }10}1112usingvar client = new ClickHouseClient("Host=localhost;JsonWriteMode=Binary");13client.RegisterJsonSerializationType<SensorAttributes>();1415// attributes JSON(`device.id` String, Temperature Decimal64(4))16await client.InsertBinaryAsync("readings", ["id", "attributes"],17 [[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},例如SELECT count() FROM sensor_readings WHERE id = {id:Int64};。然而,驱动程序也接受无类型的ADO.NET风格参数(WHERE id = @id),这是Dapper等ORM发出的。在这种情况下,必须从CLR值推断ClickHouse类型。内置推断故意保守:decimal变为Decimal128(scale),DateTime变为DateTime('UTC'),因为它们信息损失最少。但如果你的模式是Decimal64(4)和DateTime64(3),保守意味着错误,你需要在代码库中注释每个@风格的参数来说明。IParameterTypeResolver允许你一次性声明约定。对于常见情况,有一个内置的字典实现:
1var settings = new ClickHouseClientSettings("Host=localhost")2{3 ParameterTypeResolver = new DictionaryParameterTypeResolver(new Dictionary<Type, string>4 {5 [typeof(decimal)] = "Decimal64(4)",6 [typeof(DateTime)] = "DateTime64(3)",7 }),8};对于需要感知值或名称的逻辑,直接实现该接口。此示例按值为每个值选择最小的十进制数:
1privateclassSmartDecimalResolver : IParameterTypeResolver2{3publicstringResolveType(Type clrType, objectvalue, string parameterName)4 {5if (clrType != typeof(decimal))6returnnull; // let everything else use default inference78var scale = (decimal.GetBits((decimal)value)[3] >> 16) & 0x7F;9return scale <= 4 ? $"Decimal64({scale})" : $"Decimal128({scale})";10 }11}返回null会回退到默认推断,因此解析器只需处理它关心的案例。
2. IParameterFormatter:控制线上表示
解析类型并不总是足够的,有时类型正确但渲染是错误的。HTTP参数以文本形式发送到服务器,驱动程序必须决定如何写出每个值。这通常没有争议,但并非总是如此:声明为DateTime的列会乐意接受具有亚秒精度并丢弃余数,你可能希望使这种截断明确和局部,而不是服务器端的意外。
1var settings = new ClickHouseClientSettings("Host=localhost")2{3 ParameterFormatter = new DictionaryParameterFormatter(new Dictionary<Type, Func<object, string>>4 {5// e.g. clamp to whole seconds regardless of the column's declared precision6 [typeof(DateTime)] = v => ((DateTime)v).ToString("yyyy-MM-dd HH:mm:ss",7 CultureInfo.InvariantCulture),8 }),9};有两点值得强调:
- 格式化器会被调用用于顶级值和复合值内的每个元素(
Array、Tuple、Map、Nested)。 - 透明包装(
Nullable、LowCardinality、Variant)会首先被解开,因此你的格式化器只需看到一次具体类型,无需知道它们。而且它永远不会被查询null/DBNull,它们总是序列化为ClickHouse的null哨兵。
3. IReadValueConverter:在返回时规范化
第三个涵盖返回行程。假设你有ClickHouseDateTime列且没有显式时区。这些列返回为Kind = Unspecified。如果你的应用不变量是“所有时间戳均为UTC”,你可以在它们离开客户端之前进行转换,这样就不必在整个代码库中到处使用DateTime.SpecifyKind了:
1var settings = new ClickHouseClientSettings("Host=localhost")2{3 ReadValueConverter = new DictionaryReadValueConverter()4 .For<DateTime>(dt => DateTime.SpecifyKind(dt, DateTimeKind.Utc))5 .For<string>(s => s.Trim()),6};该转换器拦截GetValue()和GetFieldValue<T>(),并在POCO物化期间应用,因此QueryAsync<T>()也会看到转换后的值。一个注意事项:DictionaryReadValueConverter仅根据CLR类型进行分派,而DateTime和DateTime('UTC')列都表现为System.DateTime,因此上面的代码片段也会将Kind = Utc标记到已经带有时区的列上。当差异重要时,直接实现该接口,并根据ClickHouse类型名称(或值)进行切换,该名称与服务器报告完全一致:
1privateclassUtcOnlyForNoTzDateTimeConverter : IReadValueConverter2{3publicobjectConvertValue(objectvalue, string columnName, string clickHouseType)4 => valueis DateTime dt && clickHouseType == "DateTime"5 ? DateTime.SpecifyKind(dt, DateTimeKind.Utc)6 : value;78public T ConvertValue<T>(T value, string columnName, string clickHouseType)9 => typeof(T) == typeof(DateTime) && valueis DateTime dt && clickHouseType == "DateTime"10 ? (T)(object)DateTime.SpecifyKind(dt, DateTimeKind.Utc)11 : value;12}此接口有两个限制:
- 它不得更改值的运行时类型。 列元数据(
GetFieldType、GetSchemaTable)不会根据转换器的输出重新推导。这不是映射层。 - 它不会递归到复合类型中。 它对每行每列只调用一次,传入整个反序列化的单元格:对于
Array(Int32)列,你得到的是int[],而不是每个int。泛型重载的存在是为了让热路径保持零分配:typeof(T)检查加上Unsafe.As让你无需装箱即可转换值类型。
更细粒度的旋钮
同样精神的一些较小补充:
- 注意,对于BCL无法解码的编解码器(zstd、lz4),你必须配置
HttpClient使用AutomaticDecompression = None并通过ExecuteRawResultAsync消费主体。ClickHouseRawResult.ContentEncoding告诉了你得到的内容。
GetFieldValue<T>(string name):按名称的列重载,补充了按序号的版本。 ApplicationInfo:一个自由形式的IReadOnlyDictionary<string, string>标签集合,位于ClickHouseClientSettings上(app、ver、env,随你喜欢),作为注释标记附加到HTTPUser-Agent: (app:MyApp; ver:2.3.1; env:prod)。如果你需要在数据库查询日志中追踪查询来源,这会很有用。
更丰富的类型支持
我们还在改进CLR和ClickHouse两侧的类型支持,增加了对多维数组、ValueTuple和Identifier参数的支持。
嵌套和多维数组
感谢@DanielBunting,Array(Array(T))(以及更深的嵌套)现在可以作为多维数组读写。C#有两种N维数组:锯齿数组(int[][],数组的数组,行可以长度不同)和矩形数组(int[,],单个块,所有行等长)。ClickHouse的Array(Array(T))结构上是锯齿的,但有时你处理的数据是自然矩形的,强制通过锯齿中间层意味着每行分配一个行数组。现在写入时接受锯齿(T[][],List<List<T>>)和矩形(T[,],T[,,],…)CLR形状。读取仍默认锯齿,但GetFieldValue<T[,]>直接物化矩形并验证形状。
1// Write: jagged or rectangular, your choice2int[][] jagged = [[1, 2], [3], [4, 5, 6]];3int[,] rectangular = newint[2, 3] { { 1, 2, 3 }, { 4, 5, 6 } };45await client.InsertBinaryAsync("matrices", ["id", "data"],6 [[1UL, jagged], [2UL, rectangular]]);78// Read: GetValue gives you jagged...9var asJagged = (int[][])reader.GetValue(1);1011// ...but if you know it's rectangular, ask for that shape directly12var asRect = reader.GetFieldValue<int[,]>(1); // throws on ragged dataValueTuple在写入路径上
C#元组字面量在二进制插入、HTTP参数和类型推断中都能直接工作:
1await client.InsertBinaryAsync("t", ["id", "pair"],2 [[1UL, (42, "hello")]]); // -> Tuple(Int32, String)超过7个元素的元组会从编译器生成的TRest嵌套中正确展开。
服务器端 Identifier参数
你现在可以将数据库、表或列名作为参数绑定,而不是将其拼接进SQL:
1var p = new ClickHouseParameterCollection();2p.AddParameter("tbl", "my-table`with`backticks");3await client.ExecuteReaderAsync("SELECT * FROM {tbl:Identifier};", p);该值现在按原样发送,服务器执行标识符引用和转义,因此包含特殊字符(包括反引号)的名称可以完全无需客户端转义逻辑进行往返。此前这会抛出ArgumentException: Unknown type: Identifier。
正确性
我们还花了一段时间在这个周期内处理正确性和修复客户端中的错误。
破坏性变更:DateTime时区处理
你需要知道的最重要的破坏性变更涉及DateTime处理:
1var p = new ClickHouseParameterCollection();2p.AddParameter("ts", DateTime.UtcNow);3await client.ExecuteNonQueryAsync("INSERT INTO events VALUES (@ts);", p);- 之前: 驱动程序发出一个裸的
{ts:DateTime}提示。服务器在session_timezone中解析线上的挂钟时间,按服务器的偏移量移动值。在UTC服务器上你永远不会注意到;在Europe/Amsterdam上,夏季你会差两个小时。 - 之后: 推断类型为
DateTime { Kind: Utc or Local }和DateTimeOffset的值作为DateTime('UTC')发送。瞬间值在任何服务器时区下都能保留。 - 显式提示不受影响,如果参数类型是
{ts:DateTime},你仍然拥有时区语义。
整个客户端有许多较小的正确性改进。一些亮点:
修复了Fixed/UTC±HH:MM:SS时区: ClickHouse的合成区名不在IANA TZDB中,因此驱动程序将它们视为UTC并返回按列自身偏移量移动的值。现在解析为正确的固定偏移时区。- 复合类型序列化:
Date/DateTime/DateTime64在Array、Tuple、Map、Variant中现在通过HTTP正确引用。 - Variant NULL: 从
Variant读取NULL会抛出IndexOutOfRangeException(None判别器未处理);写入一个不会发出0xFF。两者都已修复。 - 带有转义引号、括号或
=的枚举标签现在能正确解析。还有更多在changelog中。
更少的往返,更少的GC压力
本周期有两项更改是关于性能而非能力。
插入便利性:跳过模式探测
默认情况下,每次InsertBinaryAsync调用都会通过SELECT ... WHERE 1=0往返探测表模式。这在每次插入时都代表开销。现在有两种避免方法:
1// Know the schema at compile time? Declare it and skip the probe entirely.2var options = new InsertOptions3{4 ColumnTypes = new Dictionary<string, string>5 {6 ["Id"] = "UInt64",7 ["SensorName"] = "LowCardinality(String)",8 ["Value"] = "Float64",9 },10};1112// Or probe once and reuse it for the lifetime of the client.13var cached = new InsertOptions { UseSchemaCache = true };ColumnTypes优先于UseSchemaCache,并且需要显式列列表。 UseSchemaCache在客户端生命周期内缓存每个(数据库、表),因此在涉及任何会破坏查询的ALTER TABLE语句时需要小心。如果你使用POCO,其中所有映射属性都带有[ClickHouseColumn(Type = ...)],则自动跳过探测。
ReadBufferSize默认值从512 KiB降低到8 KiB
响应读取缓冲区固定为 512 KiB。这超过了 85,000 字节的大对象堆阈值,这意味着每个查询响应都分配在大对象堆上(默认不压缩的堆,导致内存碎片)。这些分配还造成了显著的垃圾回收压力。在一个包含 1000 个小 SELECT 的基准测试中,我们看到每个查询的分配量下降了超过 80%,第 2 代回收被消除,总的 GC 暂停时间下降了超过 90%。1.3 版本使大小可通过ClickHouseClientSettings.ReadBufferSize 或 ReadBufferSize 连接字符串键进行配置,并将默认值降低到 8 KiB。如果你流式传输大型响应并希望减少缓冲区填充次数,可以调高它。
Entity Framework Core
.NET 中最常被请求的集成,现在成为官方集成:ClickHouse.EntityFrameworkCore 支持查询、插入、表引擎配置和迁移,并支持GROUP BY 聚合、字符串方法、数学函数和 JSON 列,全部转换到 ClickHouse SQL。
DDL
配置看起来像任何其他 EF Core 提供程序:
1publicclassAnalyticsContext : DbContext2{3public DbSet<PageView> PageViews { get; set; }45protectedoverridevoidOnConfiguring(DbContextOptionsBuilder optionsBuilder)6 => optionsBuilder.UseClickHouse("Host=localhost;Port=8123;Database=analytics");7}1modelBuilder.Entity<SensorReading>(b =>2{3 b.HasKey(e => e.Id);4 b.Property(e => e.Temperature).HasCodec("Delta, ZSTD");5 b.Property(e => e.Location).HasColumnComment("Installation site");6 b.HasIndex(e => e.Timestamp)7 .HasSkippingIndexType("minmax")8 .HasGranularity(4);9 b.ToTable("sensor_readings", t => t10 .HasReplacingMergeTreeEngine("Version")11 .WithOrderBy("Id", "Timestamp")12 .WithPartitionBy("toYYYYMM(Timestamp)")13 .WithPrimaryKey("Id")14 .WithTtl("Timestamp + INTERVAL 1 YEAR")15 .WithSetting("index_granularity", "4096"));16});查询构建
然后你可以使用 LINQ,所有内容都会按预期转换到 ClickHouse 语法:
1var topPages = await ctx.PageViews2 .Where(v => v.Date >= new DateOnly(2024, 1, 1))3 .GroupBy(v => v.Path)4 .Select(g => new { Path = g.Key, Views = g.Count() })5 .OrderByDescending(x => x.Views)6 .Take(10)7 .ToListAsync();批量插入
而且你不必放弃 Entity Framework 来高效插入,因为该集成提供了一个高性能的.BulkInsertAsync() 路径:
1long rowsInserted = await ctx.BulkInsertAsync(events);23// tune the batch size at configuration time4optionsBuilder.UseClickHouse("Host=localhost", o => o.MaxBatchSize(5000));该集成还提供了表引擎流式 API,支持多种表引擎、索引和列级 DDL。
不断增长的生态系统
除了 EF Core,我们还支持另外三个官方集成:
- Serilog:
WriteTo.ClickHouse()带有流式列和集群配置。对于现有 Serilog 用户来说是一行代码。查看我们之前的文章使用 Serilog 和 ClickHouse 在 .NET 中进行结构化日志记录。 - Aspire:
Aspire.Hosting.ClickHouse向应用模型添加 ClickHouse 容器资源;Aspire.ClickHouse.Driver在依赖注入中注册ClickHouseDataSource,带有健康检查、OpenTelemetry 追踪和配置绑定。我们还有一篇关于使用 ClickHouse 和 Aspire 构建 .NET API 网关 的博客文章。 - 语义内核:用于
Microsoft.Extensions.VectorData的向量存储连接器。CRUD、过滤查询、向量相似性,全部使用 ClickHouse 作为标准 SK 接口后面的向量数据库。仓库包含一个演示项目,展示在 IMDb 数据集上的语义搜索。
展望未来
1.4:Go Fast
我们的下一个版本 1.4 专注于性能改进,减少内存分配并加速。以下是对未来的预览:
- 广泛的分配削减: 消除缓冲区并池化其他缓冲区,以节省大量分配。
- 可插拔压缩:
IClickHouseCompressor,内置 GZip、Brotli、LZ4 和 Zstd(用于读取和写入)。 - 无装箱的 POCO 读写: 驱动程序将直接使用泛型接口进行读写,跳过中间的
object转换,节省大量分配。 - …还有更多!
1.5:Native/TCP 客户端
我们目前正在努力开发一个实现 TCP 协议和 Native(列式)数据格式的新客户端,这将带来更大的性能提升。从 TCP 可以获得的一些好处:列保持为列。 数据以块 传输,这些块使用数据库中存储数据的列式格式。一个好处是您将不再支付服务器端的 RowBinary 序列化成本。读取一百万个Int64 不再是百万次独立的解码步骤,而是一次复制操作。服务器告诉您它在做什么。 Native 在查询仍在运行时,将进度更新、配置文件事件和服务器端日志行交织到响应中: 已读取的行数、字节数以及服务器自己对总数的实时估计。连接 也是长寿命且有状态的,因此会话、临时表和个人连接设置不再需要驱动程序通过会话 ID 参数进行模拟。也是长期存在且有状态的,因此会话、临时表和每连接设置不再需要驱动程序通过会话ID参数来模拟。
链接
- 安装:
dotnet add package ClickHouse.Driver - 文档:https://clickhouse.com/docs/integrations/csharp
- 示例:https://github.com/ClickHouse/clickhouse-cs/tree/main/examples
- 如果您有任何反馈,请在GitHub 上联系我们,或在社区 Slack 中找到我们。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏