跳转到主要内容
clickhouse-c 是一个面向 ClickHouse 原生协议的仅头文件 C 客户端。 其源代码以及各头文件的参考文档位于 GitHub 仓库 与更高层的客户端不同,它是有意只提供尽可能少的封装。核心头文件会通过你提供的 I/O 回调,对 Native 格式的块进行解码和编码。套接字、TLS 上下文、内存分配器、重试机制以及连接池都由你自行管理。因此它足够小,便于嵌入:仅包含 clickhouse.h 即可,除 libc 外不会引入任何链接时依赖项。
该库仍在积极开发中。v1 可解码 ClickHouse 的核心类型。 如有局限或缺失的功能,请通过 issue tracker 反馈。 但也请理解,该库有意在设计上省略了部分功能。

该库不做什么

以下是刻意不纳入该库范围的内容。请在你的应用程序中处理,或借助配套库实现:
  • HTTP 协议。直接封装 libcurl 以使用 HTTP interface
  • DNS 解析、端点故障转移、连接池、重试和退避。
  • TLS 上下文生命周期。OpenSSL 后端使用的是你已建立连接的 SSL
  • 线程。每个 chc_client 按设计都是单线程的。
  • 库内部的异步 I/O。阻塞式 client 会同步调用 chc_io.read。如果需要一个 自身不执行任何 I/O 的事件循环 client,请使用 ioless client

库的组织方式

clickhouse-c 以一组扁平的头文件形式发布。每个头文件同时包含声明和实现, 并由哨兵宏保护。请按构建需要选择相应的头文件。
HeaderPurposeLink flags
clickhouse.hCore:类型、错误、内存分配器、I/O vtable、类型名解析器、块读取器和写入器
clickhouse-client.hTCP 数据包循环:Hello、Query、Data、EndOfStream、Exception、Progress、Pong
clickhouse-async.h无 I/O 客户端:由调用方提交字节驱动的相同数据包循环,不使用套接字
clickhouse-compression.h压缩帧布局、CityHash128、编解码器分发,以及 LZ4/ZSTD 适配器-llz4 -lzstd
clickhouse-posix-io.h基于阻塞 read(2)/write(2) 的 I/O 后端
clickhouse-openssl.h基于 SSL_read/SSL_write 的 I/O 后端-lssl -lcrypto

必需的服务器设置

解码器会从 wire 中读取可打印的类型名称,因此它们必须以文本形式编码。ClickHouse 默认会以文本形式写入它们,但请在查询中显式固定此设置,以免服务器或 session profile 将其设为二进制后导致解码失败:
output_format_native_encode_types_in_binary_format = 0

将其添加到你的项目中

无需安装任何软件包,因此你应通过 Git 子模块或直接拷贝的方式,将这些头文件纳入代码树中。 必须且只能有一个编译单元定义 CHC_IMPLEMENTATION 并包含实现; 其他所有编译单元都只包含相同的头文件,以获得声明。
/* clickhouse_impl.c */
#define CHC_IMPLEMENTATION
#include "clickhouse.h"
#include "clickhouse-posix-io.h"
#include "clickhouse-client.h"
#include "clickhouse-compression.h"
/* every other TU */
#include "clickhouse.h"
#include "clickhouse-client.h"
在包含 clickhouse.h 之前定义 CHC_PROVIDE_STDLIB_ALLOC,即可使用 chc_alloc_stdlib。 为 clickhouse-compression.h 定义 CHC_NO_LZ4CHC_NO_ZSTD,即可去除对 lz4/zstd 的依赖项。

通过 TCP 连接

要与 ClickHouse server 通信,需要自行建立套接字,将其封装为 chc_io,然后将其传给 chc_client_init,它会同步执行 Hello 握手。该库不负责 DNS、 故障转移、重连或连接池化——这些都需要由调用方处理。
int fd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);

struct sockaddr_in sa = {};
sa.sin_family      = AF_INET;
sa.sin_port        = htons(9000);
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
connect(fd, (struct sockaddr *) &sa, sizeof sa);

chc_alloc al = chc_alloc_stdlib();
chc_posix_io state;
chc_io io;
chc_posix_io_init(&state, &io, fd, NULL, NULL);

chc_client *client = NULL;
chc_client_opts opts = {
    .user     = "default",
    .password = "",
    .database = "default",
};
chc_err err = {};
if (chc_client_init(&client, &opts, &al, &io, &err) != CHC_OK) {
    fprintf(stderr, "connect: %s\n", err.msg);
    chc_client_close(client);   /* safe to call on the NULL-on-failure handle */
    return 1;
}

const chc_server_info *info = chc_client_server_info(client);
printf("connected to %s %llu.%llu.%llu\n", info->display_name,
       (unsigned long long) info->version_major,
       (unsigned long long) info->version_minor,
       (unsigned long long) info->version_patch);
每个 chc_client 都是单线程的,并封装一个连接。库会同步调用 chc_io 回调;至于这些回调在底层具体使用什么机制 (epollio_uringWaitLatchOrSocket) ,则由你决定。

运行查询

发送查询后,持续接收数据包,直到 CHC_PKT_END_OF_STREAM。使用 chc_client_send_query_ex 可 附带所需的服务器级设置;而基础版 chc_client_send_query 会发送一个 空的设置列表,并继承服务器的默认设置。
chc_query_setting settings[] = {
    { .name = "output_format_native_encode_types_in_binary_format", .value = "0" },
};
chc_query_opts qopts = { .settings = settings, .n_settings = 1 };

const char *sql = "SELECT number, toString(number * number) FROM numbers(5)";
if (chc_client_send_query_ex(client, sql, strlen(sql), &qopts, &err) != CHC_OK) {
    fprintf(stderr, "query: %s\n", err.msg);
    return 1;
}

for (;;) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        break;
    }

    if (pkt.kind == CHC_PKT_DATA) {
        for (size_t r = 0; r < chc_block_n_rows(pkt.block); r++)
            for (size_t c = 0; c < chc_block_n_columns(pkt.block); c++)
                print_value(chc_block_column_type(pkt.block, c),
                            chc_block_column(pkt.block, c), r);
    } else if (pkt.kind == CHC_PKT_EXCEPTION) {
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    }

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}
服务器异常会以 CHC_PKT_EXCEPTION 数据包的形式到达,而不是作为 chc_client_recv_packet 的非 OK 返回值。只有传输层故障才会返回非 OK。结果中的第一个 CHC_PKT_DATA 数据包是一个描述 schema 且包含零行的头部块;后面跟着的是数据块。 chc_packet_clear 会释放该数据包的块或异常——如果要改为自行接管其所有权,请先将数据包中的这些字段设为 NULL。

读取列数据

块采用列式布局。每一列都有一个由 chc_column_layout 返回的物理布局, 你需要根据它进行分派处理;其声明类型来自 chc_block_column_type。复合布局可以嵌套,因此 读取 Nullable(Array(String)) 意味着先拆开 Nullable,遍历数组偏移量,然后 切分字符串数据。
布局访问器
CHC_COL_FIXEDchc_column_fixed_data(c, &elem_size)n_rows * elem_size 个小端字节序字节
CHC_COL_STRINGchc_column_string_data(c), chc_column_string_offsets(c)offsets[i] 是第 i 行在主机字节序中的结束位置 (不含) ;第 0 行从 0 开始
CHC_COL_NULLABLEchc_column_null_map(c) (每行一个字节,1 = NULL) ,chc_column_nullable_inner(c)
CHC_COL_ARRAYchc_column_array_offsets(c) (累计结束位置) ,chc_column_array_values(c)Map 解码为 Array(Tuple(K, V))
CHC_COL_TUPLEchc_column_tuple_arity(c), chc_column_tuple_child(c, i) — 每个子列都具有相同的行数
CHC_COL_LOW_CARDINALITYchc_column_lc_key_size(c) (1/2/4/8) ,chc_column_lc_keys(c)chc_column_lc_dict(c);字典槽位 0 是默认值
下面是一个用于读取普通数值列、字符串列和 Nullable 列的读取器:
void print_value(const chc_type *t, const chc_column *c, size_t row)
{
    if (chc_column_layout(c) == CHC_COL_NULLABLE) {
        if (chc_column_null_map(c)[row]) { fputs("\\N", stdout); return; }
        print_value(chc_type_child(t, 0), chc_column_nullable_inner(c), row);
        return;
    }

    switch (chc_column_layout(c)) {
    case CHC_COL_FIXED: {
        /* fixed_data is a raw little-endian byte slab. memcpy into a typed
           local to avoid unaligned loads and strict-aliasing UB, then
           byte-swap on big-endian hosts. */
        size_t es;
        const uint8_t *p = chc_column_fixed_data(c, &es) + row * es;
        switch (chc_type_kind(t)) {
        case CHC_UINT64: { uint64_t v; memcpy(&v, p, sizeof v); printf("%" PRIu64, v); break; }
        case CHC_INT32:  { int32_t  v; memcpy(&v, p, sizeof v); printf("%" PRId32, v); break; }
        case CHC_FLOAT64: { double  v; memcpy(&v, p, sizeof v); printf("%g", v); break; }
        /* ... remaining numeric kinds ... */
        default: break;
        }
        break;
    }
    case CHC_COL_STRING: {
        const uint8_t  *bytes   = chc_column_string_data(c);
        const uint64_t *offsets = chc_column_string_offsets(c);
        uint64_t start = row == 0 ? 0 : offsets[row - 1];
        fwrite(bytes + start, 1, (size_t) (offsets[row] - start), stdout);
        break;
    }
    default: break;
    }
}
CHC_COL_FIXED 数据在线上传输时采用 小端字节序;在 big-endian 主机上,你需要自行对多字节整数进行字节交换。 偏移量和 LowCardinality 键在解码时就已经转换为主机字节序。 UUID 由两个 小端字节序 的 UInt64 半部分组成,IPv4 是一个 4 字节的 小端字节序 整数,而 IPv6 采用 network byte order。 DateTime64 tick 以 UTC 为准——类型中的 timezone 仅是元数据。 从不受信任的对端摄取数据时,在遍历每一列之前都要调用 chc_column_validate 进行校验。 chc_block_read 不会验证跨字段约束,例如数组偏移量和 LowCardinality 键,因此伪造的块可能会导致读取越过内部列的边界。

插入数据

使用 chc_block_builder 构建一个块,然后将其交给 chc_client_send_data。该构建器记录的是 指针而不是复制数据,因此各列的 slab 必须在发送完成前一直有效。一次 INSERT 会先发送查询, 等待服务器的头部块,再发送一个或多个数据块,最后发送一个空块来 终止 stream。
const char *sql = "INSERT INTO greetings (id, message) VALUES";
chc_client_send_query(client, sql, strlen(sql), "", 0, &err);

/* Wait for the server's header block (schema, 0 rows). */
bool got_header = false;
while (!got_header) {
    chc_packet pkt = {};
    if (chc_client_recv_packet(client, &pkt, &err) != CHC_OK) {
        fprintf(stderr, "recv: %s\n", err.msg);
        return 1;
    }
    chc_packet_kind kind = pkt.kind;
    if (kind == CHC_PKT_DATA) got_header = true;
    else if (kind == CHC_PKT_EXCEPTION && pkt.exception)
        fprintf(stderr, "server: %s\n", pkt.exception->display_text);
    chc_packet_clear(client, &pkt);
    if (kind == CHC_PKT_EXCEPTION || kind == CHC_PKT_END_OF_STREAM) return 1;  /* no header coming */
}

chc_block_builder *bb = NULL;
chc_block_builder_init(&bb, &al, &err);

uint64_t ids[3] = { 1, 2, 3 };
chc_type *u64 = NULL;
chc_type_parse("UInt64", 6, &al, &u64, &err);
chc_block_builder_append_fixed(bb, "id", 2, u64, ids, 3, &err);

/* String columns: cumulative exclusive end offsets + a packed byte slab. */
uint64_t offsets[3] = { 5, 11, 20 };   /* "hello", "buenas", "goedendag" */
const uint8_t bytes[] = "hellobuenasgoedendag";
chc_block_builder_append_string(bb, "message", 7, offsets, bytes, 3, &err);

chc_client_send_data(client, bb, &err);   /* the populated block */
chc_client_send_data(client, NULL, &err); /* empty block ends the INSERT */

/* Drain to EndOfStream. */
for (;;) {
    chc_packet pkt = {};
    chc_client_recv_packet(client, &pkt, &err);
    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    chc_packet_clear(client, &pkt);
    if (done) break;
}

chc_block_builder_destroy(bb);
chc_type_destroy(u64, &al);
chc_block_builder_append_fixed 接受 n_rows * elem_size 个小端字节序字节; chc_block_builder_append_string 接受基于主机字节序、作用于紧凑 slab 的累计结束偏移量 (不包含终点) 。让 builder 经由 chc_client_send_data 而不是更底层的 chc_block_write 发送,可使 client 根据协商后的 revision 设置块选项并应用 压缩。

压缩

chc_client_opts 中传入压缩模式和一个已填充的 编解码器。客户端会解压传入的 Data 包,并压缩传出的数据包。压缩请求头内置了 LZ4ZSTD 适配器; 每个 init 只会填充各自的槽位,因此若要同时支持两者,请把它们都调用一次。
#include "clickhouse-compression.h"

chc_codec codec = {};
chc_lz4_codec_init(&codec);
chc_zstd_codec_init(&codec);

chc_client_opts opts = {
    .user        = "default",
    .compression = CHC_COMP_LZ4,   /* or CHC_COMP_ZSTD */
    .codec       = &codec,
};
如果要使用项目未提供绑定的压缩库,请自行定义一个 chc_codec; 其 vtable 在 clickhouse-compression.h 中声明。

TLS

clickhouse-openssl.h 基于 SSL_read/SSL_write 提供了一个 chc_io backend。OpenSSL 需要由你自行驱动: 该库不会创建 SSL_CTX、验证证书、设置 SNI,或调用 SSL_connect / SSL_shutdown。在 chc_io.read 触发之前,握手必须已经完成。
#include "clickhouse-openssl.h"

SSL *ssl = /* connected, handshake complete */;
chc_openssl_io state;
chc_io io;
chc_openssl_io_init(&state, &io, ssl, NULL, NULL);
/* hand &io to chc_client_init, same as the POSIX backend */
ClickHouse Cloud 和其他启用 TLS 的部署使用 9440 端口上的原生协议。两个后端都接受可选的 check_cancel 回调,该回调会在两次读取之间轮询;并且可通过 chc_openssl_io_set_deadline / chc_posix_io_set_deadline 设置读取截止时间。

Ioless (异步) 客户端

clickhouse-async.h 是面向事件循环的 TCP 客户端 ioless 变体。它完全不会操作 套接字:你提交已接收的字节,取出它需要发送的字节,并自行驱动 epollio_uringWaitLatchOrSocket。其选项、数据包类型和块构建器与阻塞式客户端 相同。 chc_async_client_init 不执行 I/O,也不会阻塞。之后的握手会以可恢复的 状态机方式运行,每次发送和接收也都是如此。当解析超出你已提交的字节时, 调用会返回 CHC_WOULD_BLOCK,而不是发生阻塞——提交更多入站字节后再次调用, parser 会从块中间继续解析。
#include "clickhouse-async.h"

chc_async_client *c = NULL;
chc_client_opts opts = { .user = "default" };
chc_async_client_init(&c, &opts, &al, &err);

for (;;) {
    int rc = chc_async_handshake(c, &err);
    if (rc == CHC_OK) break;
    if (rc != CHC_WOULD_BLOCK) break;   /* hard error */
    pump(c);   /* drain pending_out to the socket; feed received bytes to chc_async_submit */
}

chc_async_send_query(c, sql, strlen(sql), "", 0, &err);

for (;;) {
    chc_packet pkt = {};
    int rc = chc_async_recv_packet(c, &pkt, &err);
    if (rc == CHC_WOULD_BLOCK) { pump(c); continue; }
    if (rc != CHC_OK) break;

    bool done = pkt.kind == CHC_PKT_END_OF_STREAM;
    if (pkt.kind == CHC_PKT_DATA && pkt.block) { /* read columns as above */ }
    chc_async_packet_clear(c, &pkt);
    if (done) break;
}
你的 pump 会在两个方向上传输字节。对于 Outbound,chc_async_pending_out 会返回一个指向已排队字节的指针及其长度;当套接字接收了其中一部分后,用该数量调用 chc_async_consume_out,部分写入也是可以的。对于 Inbound,将从套接字读取的数据传给 chc_async_submit。发送操作永远不会阻塞,也不会施加背压,因此要留意待发送数据的长度,并在其增长过大时停止继续发送。 一个可用的 liburing driver 位于 test/test_async_uring.c

内存与分配器

每个入口点都接受一个 chc_alloc vtable,因此内存分配会遵循宿主所采用的机制。
typedef struct chc_alloc {
    void *ud;
    void *(*alloc)  (void *ud, size_t bytes);
    void *(*realloc)(void *ud, void *p, size_t old_bytes, size_t new_bytes);
    void  (*free)   (void *ud, void *p, size_t bytes);
} chc_alloc;
在包含 clickhouse.h 之前定义 CHC_PROVIDE_STDLIB_ALLOC,并调用 chc_alloc_stdlib() 以使用 基于标准 malloc 的分配器。

错误和服务器异常

函数返回 CHC_OK (0) 或非零的 CHC_ERR_* 代码。该代码即返回值;由 调用方在栈上分配的 chc_err 保存便于人类阅读的错误消息。该库绝不会在堆上分配 错误对象。
typedef struct chc_err {
    int  server_code;           /* set when the return code is CHC_ERR_SERVER */
    char msg[CHC_ERR_MSG_LEN];  /* NUL-terminated, default 256 bytes */
    char server_name[64];       /* ClickHouse exception class, if SERVER */
} chc_err;
服务端查询错误并不是 chc_err 失败。它们会以 CHC_PKT_EXCEPTION 的形式在数据包 stream 中传输,并携带 server 的 codedisplay_textstack_trace。请仅将 chc_err 检查用于传输、协议和解码失败。

支持的数据类型

块读取器可解码:
  • Int8Int256, UInt8UInt256
  • Float32, Float64, BFloat16
  • Bool
  • Decimal32, Decimal64, Decimal128, Decimal256
  • Date, Date32, DateTime, DateTime64, Time, Time64
  • String, FixedString(N)
  • UUID, IPv4, IPv6
  • Enum8, Enum16
  • Nullable(T), Array(T), Tuple(...), Map(K, V), Nested(...)
  • LowCardinality(T)
  • Interval
  • QBit(...)
  • Point, Ring, Polygon, MultiPolygon
  • SimpleAggregateFunction(f, T),解码后为其内部的 T
  • JSONObject('json'),在字符串序列化下会被解码为 String 列 (见下文)
只有在查询设置了 output_format_native_write_json_as_string=1 时,JSONObject('json') 才会被解码。 每一行都会以 CHC_COL_STRING 列中的一个 JSON 文档形式传入,因此可由字符串访问器读取; 构建器则使用 chc_block_builder_append_json_string 写入相同的形态。任何其他 JSON 序列化版本都会返回 CHC_ERR_TYPE,并注明该设置。 VariantDynamicAggregateFunction 目前尚不能解码,返回 CHC_ERR_TYPE; 可作为回退方案,在服务端将其 CAST 为 String
最后修改于 2026年6月11日