Skip to content

第 18 章 生态与扩展

学习目标:跳出「ClickHouse 只是一个 SQL 数据库」的盒子,理解它在数据世界里的「外延」 —— 单进程版(clickhouse-local)、嵌入式版(chdb)、UDF 扩展点、与 BI / 数据湖 / 流计算的集成、各语言客户端、周边运维工具。学完之后你会发现:把 ClickHouse 当一种「可被到处嵌入的列式 SQL 引擎」来用,比当数据库用更香。


0. 开场白:ClickHouse 不是一个产品,是一个「形态家族」

┌────────────── ClickHouse 形态家族 ───────────────┐
│                                                   │
│  clickhouse-local      ←  单进程 / 即跑即走        │
│  chdb (Python/Go/...)  ←  库形态 / 嵌入应用进程    │
│  clickhouse-server     ←  服务形态 / 真正的「数据库」│
│  Distributed cluster   ←  分布式 / 跨机器          │
│  云托管 (Altinity / Tinybird / ClickHouse Cloud)  │
│                                                   │
│  共享同一份「列式 SQL 引擎 + MergeTree」核心       │
└───────────────────────────────────────────────────┘

外延工具与生态围绕这个核心生长:

        BI 看板 (Superset/Metabase/Redash/Grafana/DataLens)
             ▲ JDBC/ODBC/HTTP

       客户端 (Python/Go/JS/Java) ←─── chdb 同核心引擎

     ──── ClickHouse 引擎 (server / local / chdb) ────

   流计算 (Flink CDC) ─→ Kafka 引擎 ─→ MV ─→ MergeTree
   数据湖 (S3/Parquet) ←─→ S3 表函数 / Delta / Iceberg
   维度库 (MySQL/PG)   ←─→ Dictionary / 表函数
   ETL / 调度 (DBT-clickhouse, Airbyte, Debezium)
   UDF (SQL UDF / Executable UDF / 自定义聚合函数)

本章把这些外延一件一件讲清楚。


18.1 clickhouse-local:「SQL 版 awk」

18.1.1 它是什么

clickhouse-local 是一个独立可执行文件,把 ClickHouse 的 SQL 引擎完整内嵌进来,不需要任何服务端。它把磁盘 / 网络 / 标准输入流上的文件直接当作表来查。

bash
# 安装(如果没装服务端,单独拿)
curl https://clickhouse.com/ | sh
ls clickhouse           # 单文件,二选一即可:./clickhouse local 或 ./clickhouse-local

# 体验:把 csv 当表查
$ cat sales.csv
date,country,amount
2025-04-15,CN,120
2025-04-15,US,80
2025-04-16,CN,200

$ clickhouse local --query "
SELECT country, sum(amount) AS total
FROM file('sales.csv', CSVWithNames,
          'date Date, country String, amount UInt32')
GROUP BY country ORDER BY total DESC
"
CN  320
US  80

📌 首次术语解释 · 表函数(Table Function):与「表」可互换的 SQL 构造,最典型就是 file('xxx.csv', Format)url('https://...', Format)s3('s3://...', Format),让你临时把外部资源当表查,无需 CREATE TABLE。

18.1.2 7 个最常用姿势

bash
# 1) 标准输入 → 一行 SQL
$ cat access.log | clickhouse local --query "
  SELECT count() FROM table" --input-format LineAsString

# 2) Parquet 直接查(无 schema 也行,CH 自己推断)
$ clickhouse local -q "SELECT count(), sum(price) FROM file('orders.parquet')"

# 3) 多文件 + 通配符
$ clickhouse local -q "
  SELECT toStartOfDay(ts), count() FROM file('logs/2025-04-*.json.gz', JSONEachRow)
  GROUP BY 1 ORDER BY 1"

# 4) 输出格式自由切换
$ clickhouse local -q "SELECT * FROM file('a.csv') FORMAT JSONEachRow" > a.json
$ clickhouse local -q "SELECT * FROM file('a.csv') FORMAT PrettyCompact"

# 5) 文件互转(CSV → Parquet)
$ clickhouse local -q "
  SELECT * FROM file('big.csv') FORMAT Parquet" > big.parquet

# 6) 读 S3 / HTTP
$ clickhouse local -q "
  SELECT count() FROM s3(
    'https://bkt.s3.amazonaws.com/2025/*.parquet','AKID','SK','Parquet')"

# 7) 直接连远端 ClickHouse 查(变成一个轻量客户端)
$ clickhouse local -q "
  SELECT * FROM remote('ck-prod:9000','db.t','user','pwd') LIMIT 10"

18.1.3 用 clickhouse-local 一行命令分析 nginx access log

下面这段是真实生产场景。access.log 一行长这样:

192.168.1.1 - - [17/Apr/2025:10:23:01 +0800] "GET /api/order HTTP/1.1" 200 1287 "-" "curl/7.68"

目标:找出过去 1 小时内 5xx 状态码 Top 10 的接口路径,按出现次数倒序。

bash
clickhouse local --query "
SELECT
    extract(line, '\"(?:GET|POST|PUT|DELETE) ([^ ?]+)') AS path,
    extract(line, ' (\\d{3}) ')                          AS status,
    count() AS errors
FROM file('/var/log/nginx/access.log', LineAsString, 'line String')
WHERE toUInt16OrZero(status) >= 500
  AND parseDateTimeBestEffort(extract(line, '\\[([^\\]]+)\\]')) > now() - INTERVAL 1 HOUR
GROUP BY path, status
ORDER BY errors DESC
LIMIT 10
FORMAT PrettyCompact
"

grep + awk + sort + uniq -c 几十行 shell 脚本能干的事,被 1 条 SQL 替代了,而且自动多线程自动列存压缩中间状态。这就是为什么社区把 clickhouse-local 叫「SQL 版的 awk / sed」。

18.1.4 与 DuckDB 的对比

📌 横向对比小框

维度clickhouse-localDuckDB
形态单二进制 / Python (chdb)单二进制 / Python pip 安装
引擎ClickHouse 列式 + MergeTree自研列式 + 向量化
SQL 方言ClickHouse 方言(函数极多,2000+)PostgreSQL 方言
持久化临时;可写本地 MergeTree单文件 .duckdb
与 Pandas / Polars 集成chdb / pyarrow一等公民,零拷贝
分布式同核心可扩展到 server / cluster偏单机
典型场景「平时用 CH server,临时分析掏出 local」「一直就是单机/嵌入式」

一句话:DuckDB 是「设计就是嵌入式的」,clickhouse-local 是「server 引擎的小弟分身」;二者都很优秀,用熟一个即可,但选 ClickHouse 系的好处是:写过的 SQL、用过的函数、调过的优化经验,从 local 到 server 到分布式集群完全无缝


18.2 chdb:嵌入式 ClickHouse

18.2.1 它是什么

chdb 把 ClickHouse 引擎打成 进程内库,不起 server,直接在 Python / Go / Rust 应用里调用。可以理解为「Python 里也有一个 SQLite,但它是 OLAP 列式 + 向量化的」。

18.2.2 安装与第一段 SQL

bash
pip install chdb
python
import chdb

# 一行 SQL
res = chdb.query("SELECT 1+1, version()")
print(res)            # b"2\t24.x.x.x\n"
print(res.bytes())    # 同上 raw bytes

18.2.3 直接对 Pandas DataFrame 跑 SQL

chdb 会把 df 通过零拷贝(基于 Arrow)注册成 ClickHouse 引擎里的「文件表」,然后你就能 SQL 它。

python
import pandas as pd
import chdb

df = pd.DataFrame({
    "country": ["CN", "US", "CN", "JP", "US"],
    "amount":  [120,  80, 200,  90,  60],
})

# 老式:每次都把 df 序列化成 parquet 临时文件
import io, pyarrow as pa, pyarrow.parquet as pq
buf = io.BytesIO()
pq.write_table(pa.Table.from_pandas(df), buf)
open("/tmp/d.parquet","wb").write(buf.getvalue())

print(chdb.query(
    "SELECT country, sum(amount) AS s FROM file('/tmp/d.parquet') GROUP BY country ORDER BY s DESC",
    "PrettyCompact"
).bytes().decode())

# 新式:用 chdb.dataframe 模块(24.x 起)
from chdb.dataframe import query as df_query
out = df_query("SELECT country, sum(amount) AS s FROM df GROUP BY country", df=df)
print(out)

18.2.4 持久化:给 chdb 一个 MergeTree 数据库目录

chdb 也支持把数据写到本地路径,下次进程启动数据还在:

python
sess = chdb.session.Session("/tmp/my_chdb_db")
sess.query("CREATE DATABASE IF NOT EXISTS app")
sess.query("""
CREATE TABLE IF NOT EXISTS app.events (
    ts DateTime, user_id UInt64, value Float32
) ENGINE = MergeTree ORDER BY (user_id, ts)
""")
sess.query("INSERT INTO app.events VALUES (now(), 1, 3.14)")
print(sess.query("SELECT * FROM app.events").bytes().decode())

18.2.5 适用场景

  • 数据分析脚本 / Notebook:DataFrame 太大、Pandas 慢,又不想起 server。
  • 机器学习预处理:把 Spark / Hive 抽出来的 Parquet 在本地跑 ETL。
  • Edge / Lambda:函数计算容器内只跑几秒,不可能起 server。
  • 测试 / CI:CI 里跑 SQL 单测,不想拉 docker。

📌 三种形态对比server 是「数据库」、local 是「命令行工具」、chdb 是「进程内库」。三者引擎完全相同,所以学的 SQL、踩的坑、调的参数都通用。


18.3 UDF(用户自定义函数)

ClickHouse 的 UDF 分两类:SQL UDFExecutable UDF

18.3.1 SQL UDF:纯 SQL 表达式

最轻量,等价于「带名字的表达式宏」,写完即用。

sql
CREATE FUNCTION fee_after_tax AS (price, tax_rate) -> price * (1 + tax_rate);

SELECT fee_after_tax(100, 0.13);
-- → 113

CREATE FUNCTION user_age_from_id AS (uid) ->
    floor((toUInt64(now()) - bitAnd(uid, 0xFFFFFFFFFF)) / (3600 * 24 * 365));

DROP FUNCTION IF EXISTS fee_after_tax;

特点

  • 只能是单条表达式,不能多语句、不能 IF;
  • 定义存在 system.functions 里;
  • 在 SQL 解析阶段直接展开,零开销

适合「业务里反复出现的复杂表达式」,比如脱敏、特殊业务计算。

18.3.2 Executable UDF:调用任意外部脚本

更强大但也更危险:让 ClickHouse 把每行(或每批)数据通过 STDIN 传给外部进程,再读 STDOUT 拿结果。

第一步:写脚本(Python / Bash / 任何能读写 stdin/stdout 的语言)

/var/lib/clickhouse/user_scripts/upper.py

python
#!/usr/bin/env python3
import sys
for line in sys.stdin:
    sys.stdout.write(line.strip().upper() + "\n")
    sys.stdout.flush()
bash
chmod +x /var/lib/clickhouse/user_scripts/upper.py

第二步:在 <user_defined_executable_functions_config> 注册

config.d/udf.xml

xml
<clickhouse>
  <user_defined_executable_functions_config>/etc/clickhouse-server/udf_*.xml</user_defined_executable_functions_config>
</clickhouse>

/etc/clickhouse-server/udf_upper.xml

xml
<functions>
  <function>
    <type>executable</type>
    <name>my_upper</name>
    <return_type>String</return_type>
    <argument><type>String</type></argument>
    <format>TabSeparated</format>
    <command>upper.py</command>
  </function>
</functions>

第三步:用

sql
SYSTEM RELOAD FUNCTIONS;

SELECT my_upper('hello clickhouse');
-- → HELLO CLICKHOUSE

SELECT user_id, my_upper(country) FROM events LIMIT 5;

进阶:把 <type> 改成 executable_pool 可以复用进程(避免每次 fork),适合频繁调用的场景。

18.3.3 限制与安全

  • 脚本必须放在 user_scripts 目录里,且 ClickHouse 进程对其有执行权限;
  • 每行启动一个进程开销很大,必须用 executable_pool 或一次处理一批;
  • 不要在 UDF 里做网络调用 / 写数据库:每行都做一次会拖死整个查询;
  • 多租户场景下要开 <execute_direct>false</execute_direct> 等限制;UDF 是 「绝对信任脚本」 的,能跑任何代码。

18.3.4 自定义聚合函数 / 窗口函数?

ClickHouse 不支持像 PG 那样自己写 C 注册聚合函数(社区有 clickhouse-cpp-udf 实验项目,未成主流)。需要自定义聚合时,常用做法是:

  • 组合内置 quantile* / topK / argMax / sumMap 等已经很丰富的函数;
  • groupArray + arrayMap lambda 自己组装;
  • 实在不行,导出到 chdb 在 Python 里算。

18.4 BI 集成

ClickHouse 几乎是所有主流 BI 工具的「一等公民数据源」。下面是最常用的连接姿势:

18.4.1 Apache Superset

text
Database URL:
clickhouse+native://default:@127.0.0.1:9000/learn_ck

Driver: clickhouse-connect / clickhouse-sqlalchemy

或者 HTTP:

text
clickhousedb+connect://default:@127.0.0.1:8123/learn_ck

实操注意:Superset 默认用 LIMIT 1000 探查表 schema,对超大表要在 Database 设置里开「Allow CREATE TABLE AS」、「Async Query」等,并把 query timeout 调大。

18.4.2 Metabase

社区版自 0.45 起内置 ClickHouse driver。Connection 字符串:

Host: 127.0.0.1   Port: 8123
DB: learn_ck      User: default
SSL: 关

18.4.3 Redash

text
DSN: clickhouse://default@127.0.0.1:8123/learn_ck

18.4.4 Grafana

Grafana 9+ 官方插件 grafana-clickhouse-datasource

yaml
# datasource.yml
apiVersion: 1
datasources:
- name: ClickHouse
  type: grafana-clickhouse-datasource
  jsonData:
    host: 127.0.0.1
    port: 9000
    protocol: native
    username: default

适合做:监控面板、时序大屏、即席探索。

18.4.5 DataLens(Yandex 自家)

ClickHouse 是 Yandex 出品,DataLens 是同门 BI,集成度最深,可视化体验在 OLAP 时序分析上很顺手。SaaS 形态可直连云上 ClickHouse。

18.4.6 选型建议

场景推荐
业务自助分析 / 公司报表Superset / Metabase
监控大屏 / 时序面板Grafana
Notebook 式探索Redash / chdb + Jupyter
全套 Yandex 生态DataLens

18.5 数据湖与流计算集成

18.5.1 Spark:clickhouse-spark 与 JDBC

社区维护的 clickhouse-spark-connector 支持 DataSource V2,分区读写、谓词下推一应俱全。

scala
spark.read
  .format("clickhouse")
  .option("host", "ck-prod")
  .option("database", "learn_ck")
  .option("table", "events_v2")
  .load()
  .filter($"country" === "CN")
  .groupBy($"event_date")
  .agg(count("*"))

或简单走 JDBC:

scala
spark.read.format("jdbc")
  .option("url", "jdbc:clickhouse://ck-prod:8123/learn_ck")
  .option("dbtable", "events_v2")
  .load()

📌 常见模式:Spark 跑长批 ETL(Hive / S3 → Parquet),最后一段 INSERT OVERWRITE 把结果写入 ClickHouse 当查询层。Spark 干「重型搬运」,CH 干「即席查询」,分工明确。

MySQL/PG (源) ─┐                    ┌─→ ClickHouse Kafka 引擎
                │  Flink CDC         │   ↓ Materialized View
                ▼                    ▼
              捕获 binlog/wal ─→ Kafka ─→ MergeTree

两段式架构最稳:CDC 进 Kafka,CH 用 Kafka 引擎 + 物化视图消费。一旦下游 CH 抖动,Kafka 兜底。

也可以直接 flink-connector-clickhouse 一步到位写入。

18.5.3 DBT-ClickHouse

dbt-clickhouse 是官方 dbt 适配器。把 SQL 变更纳入 git + CI / CD:

yaml
# profiles.yml
my_project:
  outputs:
    prod:
      type: clickhouse
      host: ck-prod
      port: 9000
      schema: learn_ck
      user: default
  target: prod
sql
-- models/daily_uv.sql
{{ config(materialized='materialized_view',
          engine='AggregatingMergeTree() ORDER BY day',
          to_table='daily_uv_target') }}

SELECT toDate(event_time) AS day,
       uniqState(user_id) AS uv_state
FROM {{ ref('events_v2') }}
GROUP BY day;

dbt run 会自动建/重建物化视图,并把版本和血缘记录在 dbt-docs 里。

18.5.4 Airbyte / Debezium

Airbyte 有 ClickHouse 的 source 与 destination connector,很适合做「Postgres 全表同步到 CH」「业务数据库 → 数仓」一类标准搬运任务。Debezium 直出 Kafka,配合 18.5.2 的两段式架构。


18.6 客户端生态(按语言)

语言推荐库协议备注
Pythonclickhouse-connect(官方)HTTP / 8123全教程统一用它
Pythonclickhouse-driverTCP / 9000历史悠久;性能更接近 native
Pythonchdb进程内嵌入式,无需 server
Java / JVMclickhouse-jdbc(官方)HTTP/TCPSpring Boot 直接接
Goclickhouse-go(官方 v2)TCP高性能,支持 batch insert
Node.js@clickhouse/client(官方)HTTP流式读写都很自然
C++clickhouse-cppTCP嵌入式应用
.NETClickHouse.ClientHTTPNuGet 装
Rustclickhouse.rs(官方)HTTP异步
ODBCclickhouse-odbcHTTPExcel / Tableau / Power BI 走它

Python 写入示例(统一全教程风格)

python
import clickhouse_connect

client = clickhouse_connect.get_client(
    host="127.0.0.1", port=8123, username="default", database="learn_ck"
)

# 批量写入
rows = [(1, "view"), (2, "click")]
client.insert("events", rows, column_names=["user_id", "event_type"])

# 查询
rs = client.query("SELECT count() FROM events").result_rows
print(rs[0][0])

18.7 周边运维 / 数据工具

工具一句话典型场景
clickhouse-backup备份恢复工程化封装第 17 章
clickhouse-benchmark内置压测客户端灰度上线 / 容量评估
clickhouse-copier跨集群表数据搬运(已退出主推,新版用 INSERT FROM remote老集群 → 新集群迁移
clickhouse-obfuscator数据脱敏:保留分布、随机替换值给开发拿生产样本练 SQL
clickhouse-diagnostics一键收集运维诊断包提工单
clickhouse-keeperZK 替代品,CH 自研协调器副本协调,更轻量
clickhouse-formatSQL 格式化 / 美化CI 校验 SQL 风格

18.7.1 clickhouse-benchmark 速用

bash
echo "SELECT count() FROM learn_ck.events_v2 WHERE country='CN'" |
clickhouse benchmark --host=127.0.0.1 --port=9000 \
  --concurrency=8 --iterations=100

# 输出会给 P50 / P90 / P99 延迟、QPS、行/秒、字节/秒

18.7.2 clickhouse-obfuscator 做生产样本

bash
clickhouse-client --query="SELECT * FROM events FORMAT Native" |
clickhouse obfuscator \
  --structure="user_id UInt64, country String, event_type String" \
  --input-format=Native --output-format=CSV --seed=42 > sample.csv

随机化但保留分布特征(高基数列不变成 1 个值),开发拿到这份 CSV 在 chdb 或 local 里练 SQL,绝对不泄漏隐私。


18.8 真实案例串讲:用 clickhouse-local + chdb 做「一次性数据盘点」

背景:产品同学发了一份「过去 30 天用户事件 csv(200 GB,gzip)」,让你回答 5 个问题。 你既不想把它灌到 server(污染生产),也不想搬到 Python 一行一行处理(慢)。

bash
# Step 1: clickhouse-local 看 schema
clickhouse local -q "
DESCRIBE file('events_*.csv.gz', CSVWithNames)
SETTINGS schema_inference_make_columns_nullable=0"

# Step 2: 算一个全局 PV / UV
clickhouse local -q "
SELECT count(), uniqExact(user_id)
FROM file('events_*.csv.gz', CSVWithNames)"

# Step 3: 把粗算后的中间结果写成 parquet 落盘 (压缩后只有 200 MB)
clickhouse local -q "
SELECT toDate(ts) AS d, country, count() AS pv, uniqExact(user_id) AS uv
FROM file('events_*.csv.gz', CSVWithNames)
GROUP BY d, country
FORMAT Parquet" > daily.parquet
python
# Step 4: 在 Jupyter 里用 chdb + Pandas 继续探索
import chdb, pandas as pd
df = pd.read_parquet("daily.parquet")
print(chdb.dataframe.query("""
    SELECT country, avg(uv) AS avg_uv, max(pv) AS peak_pv
    FROM df GROUP BY country ORDER BY avg_uv DESC LIMIT 10
""", df=df))

整个流程没有起服务端没有动生产SQL 体验完全一致。这就是 ClickHouse 生态的杀手级体验:SQL 写一遍,从命令行到嵌入式到分布式都能跑


18.9 本章小结

┌──────────────────────────────────────────────────────────┐
│                  本章核心要点                             │
├──────────────────────────────────────────────────────────┤
│  ① 三形态:                                                │
│     server  - 真正的数据库                                │
│     local   - SQL 版 awk, 单进程即跑即走                  │
│     chdb    - 嵌入式库, 在 Python/Go 进程里跑 SQL          │
│     三者引擎完全相同, SQL/调优经验通用                    │
│                                                            │
│  ② UDF 两类:                                              │
│     SQL UDF        - 表达式宏, 零开销                     │
│     Executable UDF - 外挂脚本, 强大但要 pool & 防滥用      │
│                                                            │
│  ③ BI 全家桶: Superset / Metabase / Redash / Grafana /   │
│     DataLens, 都有现成 driver                             │
│                                                            │
│  ④ 数据湖 / 流计算:                                       │
│     Spark + clickhouse-spark / JDBC                       │
│     Flink CDC + Kafka 两段式                              │
│     dbt-clickhouse 把 SQL 改动纳入 CI/CD                  │
│     Airbyte / Debezium 兜底                               │
│                                                            │
│  ⑤ 客户端: Python(connect/driver/chdb) / JDBC / Go /     │
│     Node / C++ / Rust / ODBC, 全语言通行                  │
│                                                            │
│  ⑥ 运维工具: clickhouse-backup / -benchmark /            │
│     -obfuscator / -diagnostics / -keeper / -format       │
└──────────────────────────────────────────────────────────┘

18.10 面试高频题

Q1:clickhouse-serverclickhouse-localchdb 三者的关系与区别?

考察点:对 ClickHouse 形态全景的理解。

标准答案

  1. 同一个引擎:三者都使用相同的 ClickHouse 列式 + 向量化执行 + MergeTree 存储核心,SQL 方言完全一致。
  2. 形态不同
    • clickhouse-server 是「服务端」:常驻进程,通过 TCP/9000、HTTP/8123 提供网络服务,数据持久化在 /var/lib/clickhouse,是真正的「数据库」。
    • clickhouse-local 是「命令行工具」:单二进制,不监听端口,把 STDIN / 本地文件 / S3 / HTTP / 远端表当成临时表查;进程退出数据消失(除非显式写到 MergeTree 路径)。
    • chdb 是「进程内库」:把同一引擎打成 .so / .py 包,在 Python / Go 应用进程里直接调用,零网络开销,可读写 Pandas DataFrame。
  3. 取舍:生产数据库用 server;命令行临时分析 / CSV 处理用 local;嵌入数据脚本 / Notebook / Edge 函数用 chdb。

加分项:能补一句「DuckDB 的形态对应是 duckdb CLI 与 duckdb python 包,但 DuckDB 没有 server 形态;ClickHouse 系的优势是 server / local / chdb / Distributed cluster 一以贯之」。

易错点:以为 clickhouse-local 是「轻量版 server」,可以多人连接 —— 它根本不监听网络。


Q2:SQL UDF 和 Executable UDF 的区别?分别什么时候用?

考察点:UDF 体系。

标准答案

  • SQL UDFCREATE FUNCTION fn AS (a, b) -> a + b * 2。本质是「带名字的表达式宏」,在 SQL 解析阶段直接展开,零开销;只能写单条表达式,不能多语句、不能 IF。适合「业务里反复出现的复杂计算式」。
  • Executable UDF:通过 XML 注册一个外部脚本(Python / Bash / Go),ClickHouse 把行通过 STDIN 给它,从 STDOUT 拿结果;功能强(任意逻辑),但代价高(每次 fork 进程)。必须用 executable_pool 复用进程,绝对不要在 UDF 里做网络调用

加分项:能提「ClickHouse 不支持 PG 那种自定义聚合 / 窗口函数;需要时一般组合内置 topK / quantile / argMax,或 export 到 chdb 在 Python 里完成」。

易错点:在 SQL UDF 里写多语句逻辑(语法错误);在 Executable UDF 里逐行 fork 进程(性能塌方)。


Q3:ClickHouse 适合用什么 BI 工具?接入要注意什么?

考察点:BI 集成实战。

标准答案

  • 主流 BI(Superset / Metabase / Redash / Grafana / DataLens)都有官方或社区 driver,连接串通常是 clickhouse://default:@host:8123/db(HTTP)或 clickhouse+native://default:@host:9000/db(TCP)。
  • 接入注意点:
    1. Schema 探查:很多 BI 默认 LIMIT 1000 拉样本,对超大表 OK;但 SELECT * 探 schema 时记得加 FORMAT NullLIMIT 0,避免拉数据。
    2. Query timeout / async:BI 自身有超时,对长聚合查询要在 BI 与 CH user profile 双侧调大 max_execution_time
    3. 并发限制:用 quotas 给 BI 用户限并发,防止「一个看板拖死全集群」。
    4. 缓存:能在 BI 层缓存就在 BI 层缓存(Superset cache、Grafana panel 缓存),CH 端再开 query result cache 进一步优化。
    5. 权限:建专用 readonly 用户给 BI 用,加行级 / 列级权限。

加分项:能提「时序大屏首选 Grafana + grafana-clickhouse-datasource,自助分析首选 Superset」。

易错点:用 default 账号 + 无 quota,结果 BI 误发一个全表扫的查询打挂集群。


Q4:你怎么把 MySQL / PostgreSQL 的实时变更同步到 ClickHouse?

考察点:流批集成架构。

标准答案:两段式 CDC 是行业标准:

源 MySQL/PG
   ↓  (Flink CDC / Debezium / 阿里 Canal)
Kafka topic (按 binlog/wal 顺序投递)
   ↓  (ClickHouse Kafka 引擎表)
   ↓  (Materialized View 把消息变换/过滤后)
ReplicatedMergeTree (业务最终表)

为什么两段式

  1. Kafka 起到「削峰 + 兜底」作用,下游 CH 抖动不丢数据;
  2. 多个下游消费者可以并行消费(CH、ES、HDFS、监控),不耦合;
  3. 调试和回放方便。

也可一步到位:用 flink-connector-clickhouse 直写 CH,或 ClickHouse 自带的 MaterializedPostgreSQL 引擎做 PG 整库 CDC(实验性)。但生产推荐 Kafka 中介。

注意点

  • CDC 投到 Kafka 时通常有「Insert / Update / Delete」三种事件,CH 落地用 ReplacingMergeTree(version) + 软删,或 CollapsingMergeTree(sign) 做行折叠。
  • 一定要给 Kafka topic 设合理 retention,避免重启时 offset 丢失要从源库重头来一次。

加分项:能提「dbt-clickhouse 在 CDC 之后做汇总层 / 维度层,把数据建模纳入 git」。

易错点:直接 Flink → CH,CH 一抖整条链路停摆;或者把 CDC 当成「INSERT only」忽略 update 事件,结果 CH 里数据永不更新。


Q5:clickhouse-local / chdb 与 DuckDB 怎么选?

考察点:行业视野。

标准答案:「取决于你的整体技术栈」。

  • 如果你的生产环境本来就跑着 ClickHouse server,那么用 clickhouse-local / chdb 让本地 SQL、CI 单测、Notebook 与生产完全一致:函数库一致、SQL 方言一致、调优经验通用,零迁移成本。
  • 如果你只是单机 / 嵌入式分析,没有 OLAP 集群,DuckDB 在「与 Pandas/Polars/Arrow 的零拷贝集成」「单文件 .duckdb 形式持久化」上更顺手,且 SQL 方言贴近 PG 标准。
  • 性能方面:在「单机 + 中等数据量(< 100 GB)」上两者各有胜负,差距不大;超过 1 TB 或需要分布式 / 高并发服务,ClickHouse 系优势明显(因为它有 server 形态)。
  • 趋势:两者都在快速演进;选熟悉的一个深用就好,不必互斥(chdb 与 DuckDB 都可以同时装在 Python 里)。

加分项:能提「DuckDB 主导『嵌入式 OLAP』范式,ClickHouse 反过来用 clickhouse-local / chdb 拥抱了这个范式 —— 行业共识:OLAP 不再只是大集群,而是从嵌入式到分布式连续光谱」。

易错点:把它们说成「替代品」 —— 在很多团队里它们并存,分别服务不同人群。


📌 本章总结:到这里,我们已经从「ClickHouse 是什么」「怎么用」「怎么调」「怎么运维」,走到了「它在整个数据世界中的位置」。下一章(第 19 章)会用一个完整项目把前 18 章串起来 —— 从建模 → 摄入 → 物化视图 → 查询 → 看板,让你真正具备「拿 ClickHouse 做一套实时数据系统」的能力。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
#!/usr/bin/env python3
"""
第 18 章 · chdb 嵌入式 ClickHouse 演示
=========================================

干什么:
    1) 用 chdb 直接在 Python 进程里跑 ClickHouse SQL, 无需 server.
    2) 演示三种典型形态:
         (a) 纯 SQL 计算 (numbers/range)
         (b) 直接读取本地 CSV / Parquet 文件
         (c) Pandas DataFrame ↔ chdb 互通 (DataFrame as table)
    3) 演示 Session 模式: 在内存里建表, 多次查询复用 + 持久化到本地目录.

依赖:
    pip install chdb pandas pyarrow

运行:
    python chdb_demo.py
"""
from __future__ import annotations

import os
import sys
import tempfile
import textwrap
from pathlib import Path

try:
    import chdb
    from chdb import session as chs
except ImportError:
    print("[ERROR] 请先 pip install chdb", file=sys.stderr)
    sys.exit(1)

try:
    import pandas as pd
except ImportError:
    print("[ERROR] 请先 pip install pandas pyarrow", file=sys.stderr)
    sys.exit(1)


def banner(title: str) -> None:
    print()
    print("=" * 60)
    print(f"  {title}")
    print("=" * 60)


# ------------------------------------------------------------
# Demo 1: 一行 SQL 算 1 ~ 1e6 的和 (纯 stateless)
# ------------------------------------------------------------
def demo_stateless() -> None:
    banner("Demo 1 · stateless: chdb.query 直接出结果")
    res = chdb.query(
        "SELECT count() AS cnt, sum(number) AS total FROM numbers(1000000)",
        "PrettyCompact",
    )
    print(res)


# ------------------------------------------------------------
# Demo 2: 用 chdb 读 CSV / 写 Parquet
# ------------------------------------------------------------
def demo_files(workdir: Path) -> Path:
    banner("Demo 2 · file(): 把本地 CSV 当成表")

    csv_path = workdir / "sales.csv"
    csv_path.write_text(textwrap.dedent("""\
        order_id,country,amount,ts
        1,CN,120.50,2026-04-01 10:00:00
        2,US, 88.00,2026-04-01 10:05:00
        3,CN,330.10,2026-04-01 10:30:00
        4,JP, 50.00,2026-04-01 11:00:00
        5,CN,210.00,2026-04-01 11:30:00
        6,US,400.00,2026-04-01 12:00:00
        7,JP,160.00,2026-04-01 12:10:00
        8,DE, 75.00,2026-04-01 12:30:00
    """), encoding="utf-8")

    sql = f"""
        SELECT country,
               count()           AS orders,
               round(sum(amount), 2) AS gmv
        FROM file('{csv_path}', CSVWithNames)
        GROUP BY country
        ORDER BY gmv DESC
        FORMAT PrettyCompact
    """
    print(chdb.query(sql))

    parquet_path = workdir / "sales.parquet"
    chdb.query(f"""
        SELECT * FROM file('{csv_path}', CSVWithNames)
        INTO OUTFILE '{parquet_path}'
        FORMAT Parquet
    """)
    print(f"[OK] 已写出 Parquet: {parquet_path} "
          f"({parquet_path.stat().st_size} bytes)")
    return parquet_path


# ------------------------------------------------------------
# Demo 3: Pandas DataFrame 互通
#   chdb 0.x 起支持: 把 DataFrame 当成虚拟表, 用 SQL 直接查
# ------------------------------------------------------------
def demo_dataframe() -> None:
    banner("Demo 3 · DataFrame 互通: SQL on Pandas")

    df = pd.DataFrame({
        "user_id": [1, 1, 2, 2, 3, 3, 3],
        "event":   ["view", "buy", "view", "view", "view", "buy", "buy"],
        "amount":  [0, 99.0, 0, 0, 0, 12.5, 8.0],
    })
    print("[原始 DataFrame]")
    print(df)
    print()

    sql = """
        SELECT user_id,
               countIf(event = 'view') AS views,
               countIf(event = 'buy')  AS buys,
               round(sumIf(amount, event = 'buy'), 2) AS spend
        FROM Python(df)
        GROUP BY user_id
        ORDER BY user_id
        FORMAT PrettyCompact
    """
    try:
        print(chdb.query(sql))
    except Exception as e:  # 老版本 chdb 可能不支持 Python() 表函数
        print(f"[WARN] 当前 chdb 版本不支持 Python() 表函数: {e}")
        print("[FALLBACK] 用 csv 中转")
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".csv", delete=False
        ) as f:
            df.to_csv(f.name, index=False)
            tmp = f.name
        sql2 = f"""
            SELECT user_id,
                   countIf(event = 'view') AS views,
                   countIf(event = 'buy')  AS buys,
                   round(sumIf(amount, event = 'buy'), 2) AS spend
            FROM file('{tmp}', CSVWithNames)
            GROUP BY user_id
            ORDER BY user_id
            FORMAT PrettyCompact
        """
        print(chdb.query(sql2))
        os.unlink(tmp)


# ------------------------------------------------------------
# Demo 4: Session 模式 + 持久化 (本地目录就是数据库)
# ------------------------------------------------------------
def demo_session(workdir: Path, parquet: Path) -> None:
    banner("Demo 4 · Session 模式: 本地目录 = 数据库, 多次查询复用")

    db_dir = workdir / "chdb_data"
    db_dir.mkdir(exist_ok=True)

    sess = chs.Session(str(db_dir))
    sess.query("CREATE DATABASE IF NOT EXISTS demo")
    sess.query("""
        CREATE TABLE IF NOT EXISTS demo.sales
        ENGINE = MergeTree
        ORDER BY ts
        AS SELECT * FROM file('{path}', Parquet)
    """.format(path=parquet))

    print("[demo.sales 行数]")
    print(sess.query("SELECT count() FROM demo.sales", "PrettyCompact"))

    print("[按国家汇总]")
    print(sess.query("""
        SELECT country, count() AS orders, round(sum(amount), 2) AS gmv
        FROM demo.sales
        GROUP BY country
        ORDER BY gmv DESC
        FORMAT PrettyCompact
    """))

    sess.close()
    print(f"[OK] 关闭 session. 数据落地于 {db_dir} (下次直接 reopen 即可)")


def main() -> None:
    print("chdb version:", getattr(chdb, "__version__", "unknown"))

    with tempfile.TemporaryDirectory(prefix="chdb_demo_") as tmp:
        workdir = Path(tmp)
        demo_stateless()
        parquet = demo_files(workdir)
        demo_dataframe()
        demo_session(workdir, parquet)

    banner("DONE")
    print("以上 4 个 demo 均不需要 ClickHouse server, 全部跑在 Python 进程内.")


if __name__ == "__main__":
    main()
bash
#!/usr/bin/env bash
# ============================================================
# 第 18 章 · clickhouse-local 演示脚本
# ------------------------------------------------------------
# 干什么:
#   1) 生成一份「nginx access log 样例」(100 行)
#   2) 用 clickhouse-local 跑 6 段不同形态的 SQL 演示, 完全无需 server
#   3) 输出转 Parquet, 演示「文件即表 + 一行命令转格式」
#
# 前置:
#   - 已安装 clickhouse-local 或 clickhouse 多功能二进制
#     curl https://clickhouse.com/ | sh           # 在当前目录得到 ./clickhouse
#     export CHL="./clickhouse local"   或   alias CHL="clickhouse local"
#
# 用法:
#   bash local_demo.sh
# ============================================================
set -euo pipefail

# 优先用 PATH 上的 clickhouse-local; 没有就用 ./clickhouse local
if command -v clickhouse-local >/dev/null 2>&1; then
  CHL="clickhouse-local"
elif command -v clickhouse >/dev/null 2>&1; then
  CHL="clickhouse local"
elif [[ -x "./clickhouse" ]]; then
  CHL="./clickhouse local"
else
  echo "[ERROR] 找不到 clickhouse-local. 请先 'curl https://clickhouse.com/ | sh'" >&2
  exit 1
fi

WORKDIR="$(mktemp -d -t ch_local_demo_XXXX)"
LOG="${WORKDIR}/access.log"
PARQUET="${WORKDIR}/access.parquet"

echo "==================================================="
echo "工作目录: ${WORKDIR}"
echo "==================================================="

# ------------------------------------------------------------
# 1) 生成 100 行 nginx 风格的 access.log
# ------------------------------------------------------------
echo
echo "[Step 1] 生成 100 行 nginx access.log → ${LOG}"
cat > "${LOG}" <<'EOF_HEAD'
EOF_HEAD

PATHS=("/api/order" "/api/order" "/api/order" "/api/user" "/api/cart" "/api/pay" "/static/js/app.js" "/health")
STATUSES=(200 200 200 200 200 200 304 404 500 502 503)
METHODS=(GET GET GET POST POST DELETE)

for i in $(seq 1 100); do
  ip="$((RANDOM % 200 + 1)).$((RANDOM % 255)).$((RANDOM % 255)).$((RANDOM % 255))"
  ts_offset=$((i * 30))
  ts="$(date -u -d "${ts_offset} seconds ago" +'%d/%b/%Y:%H:%M:%S +0000' 2>/dev/null \
        || date -u -v "-${ts_offset}S" +'%d/%b/%Y:%H:%M:%S +0000')"
  m="${METHODS[$((RANDOM % ${#METHODS[@]}))]}"
  p="${PATHS[$((RANDOM % ${#PATHS[@]}))]}"
  s="${STATUSES[$((RANDOM % ${#STATUSES[@]}))]}"
  size=$((RANDOM % 8000 + 100))
  echo "${ip} - - [${ts}] \"${m} ${p} HTTP/1.1\" ${s} ${size} \"-\" \"curl/7.68\"" >> "${LOG}"
done
wc -l "${LOG}"
head -3 "${LOG}"

# ------------------------------------------------------------
# 2) 演示 SQL ① 总行数
# ------------------------------------------------------------
echo
echo "[Step 2] 总行数"
$CHL --query "
SELECT count() AS total
FROM file('${LOG}', LineAsString, 'line String')
"

# ------------------------------------------------------------
# 3) 演示 SQL ② 状态码分布 Top
# ------------------------------------------------------------
echo
echo "[Step 3] HTTP 状态码分布 Top"
$CHL --query "
WITH file('${LOG}', LineAsString, 'line String') AS T
SELECT
    extract(line, ' (\\d{3}) ') AS status,
    count() AS cnt
FROM T
GROUP BY status
ORDER BY cnt DESC
FORMAT PrettyCompact
"

# ------------------------------------------------------------
# 4) 演示 SQL ③ 5xx 路径 Top 10 (生产场景)
# ------------------------------------------------------------
echo
echo "[Step 4] 5xx 失败接口 Top 10"
$CHL --query "
WITH parsed AS (
    SELECT
        extract(line, ' \"(?:GET|POST|PUT|DELETE) ([^ ?]+)') AS path,
        toUInt16OrZero(extract(line, ' (\\d{3}) ')) AS status
    FROM file('${LOG}', LineAsString, 'line String')
)
SELECT path, count() AS errors
FROM parsed
WHERE status >= 500
GROUP BY path
ORDER BY errors DESC
LIMIT 10
FORMAT PrettyCompact
"

# ------------------------------------------------------------
# 5) 演示 SQL ④ 按方法统计平均响应字节
# ------------------------------------------------------------
echo
echo "[Step 5] 按 HTTP 方法的平均响应字节"
$CHL --query "
SELECT
    extract(line, ' \"([A-Z]+) ') AS method,
    round(avg(toUInt32OrZero(extract(line, ' (\\d{3}) (\\d+) ')))) AS avg_bytes,
    count() AS cnt
FROM file('${LOG}', LineAsString, 'line String')
GROUP BY method
ORDER BY cnt DESC
FORMAT PrettyCompact
"

# ------------------------------------------------------------
# 6) 演示 SQL ⑤ 转 Parquet 落盘 (一行命令)
# ------------------------------------------------------------
echo
echo "[Step 6] 把 log 解析成结构化 Parquet → ${PARQUET}"
$CHL --query "
SELECT
    extract(line, '^([^ ]+) ') AS ip,
    parseDateTimeBestEffortOrNull(extract(line, '\\[([^\\]]+)\\]')) AS ts,
    extract(line, ' \"([A-Z]+) ') AS method,
    extract(line, ' \"(?:GET|POST|PUT|DELETE) ([^ ?]+)') AS path,
    toUInt16OrZero(extract(line, ' (\\d{3}) ')) AS status,
    toUInt32OrZero(extract(line, ' \\d{3} (\\d+) ')) AS bytes
FROM file('${LOG}', LineAsString, 'line String')
FORMAT Parquet
" > "${PARQUET}"
ls -lh "${PARQUET}"

# ------------------------------------------------------------
# 7) 演示 SQL ⑥ 把 Parquet 当表反查
# ------------------------------------------------------------
echo
echo "[Step 7] 反查 Parquet, 验证落盘后还能跑同样的 SQL"
$CHL --query "
SELECT toStartOfMinute(ts) AS minute, count() AS cnt
FROM file('${PARQUET}', Parquet)
WHERE ts IS NOT NULL
GROUP BY minute
ORDER BY minute DESC
LIMIT 5
FORMAT PrettyCompact
"

echo
echo "==================================================="
echo "全部演示完成. 中间文件保留在: ${WORKDIR}"
echo "  rm -rf ${WORKDIR}   # 清理"
echo "==================================================="

chdb_demo.py ↗ · local_demo.sh ↗