真到生产环境里敲CREATE DATABASE和CREATE TABLE,你会发现这两条语句能暴露一个人对 ClickHouse 的理解程度。我见过太多场景:开发环境里随手写了句ENGINE = MergeTree() ORDER BY id PARTITION BY toDate(create_time),本地几万行数据跑得飞快,上线一个月后写入开始报 "Too many parts",一查分区数上万,机器 IO 打满,最后只能重建表加导数。ClickHouse 建库建表这件事,语法本身十分钟就能学会,但库里那个引擎选不选 Atomic、表里 ORDER BY 写哪几个字段、分区键是按天还是按月、复制表路径填什么,这些决定的是这张表未来一年好不好用。下面我把建库、选引擎、写 DDL、跨库搬数据、以及踩过的坑,按真实项目的推进顺序捋一遍,涉及的参数和取值我都标出来,你可以直接抄。本文适合刚接触 ClickHouse 但已经有 SQL 基础的人,也适合已经用了一段时间、想把表设计做扎实的运维和数仓同学。
1. 建库这一步,先想清楚引擎选 Atomic 还是 Ordinary
1.1 库引擎不是摆设,它决定了 DROP 和 RENAME 的行为
很多人建库就是CREATE DATABASE mydb;一把梭,因为默认引擎能用。默认值是Atomic(20.10 版本之后替换掉了Ordinary),语法上不写ENGINE就走它。这两个引擎最核心的差别在于表的标识方式:Ordinary用「库名 + 表名」定位一张表,Atomic给每张表分配一个 UUID,表名只是它挂在库里的一个软链接。
这个差别听起来很抽象,落到实操上是三件事:
Atomic库里,RENAME TABLE和EXCHANGE TABLES是原子的,可以在线把两张表对调,做灰度切换或者数据回滚时非常有用;Ordinary库里做大表改名会有短暂的锁窗口。Atomic库里DROP TABLE是异步的,语句瞬间返回,后台慢慢删数据文件,所以删大表不会把连接卡死;Ordinary里删大表期间可能让你等很久。Atomic库里允许出现同名表(不同 UUID)在极短的重建窗口内共存,这在「先删后建」的脚本里有时反而会造成困惑,一眼看去表存在,实际是旧表还没被回收。
我个人的习惯是:新集群一律Atomic,写 DDL 时显式写出来,不依赖默认值。因为默认值会随版本走,而配置文件的迁移和版本升级经常同时发生,显式写清楚省得以后对不上。
CREATE DATABASE IF NOT EXISTS dw_ods ENGINE = Atomic COMMENT 'ODS 层,贴源数据';COMMENT是很多老版本没有、新版本才补全的能力,库和表都能加,强烈建议加。半年后你回头看一百个库名,有注释和没注释是两个世界。
1.2 另外三种库引擎:Memory、Lazy 和 Replicated
除了Atomic和Ordinary,ClickHouse 还有几种库引擎,用得少但要知道它们存在,因为选错了会丢数据。
Memory引擎的库,里面创建的表默认走Memory表引擎,数据全在内存里,服务重启就没了。适合做压测或者临时跑一批数据,绝对不要放任何需要持久化的东西。
Lazy引擎只允许放Log家族的表(Log、TinyLog、StripeLog),它会把表在内存里保留expiration_time_in_seconds这么久,超时才刷到磁盘。默认值是 300 秒,意味着这 5 分钟内如果机器掉电,数据就丢了。这个引擎现在基本没人用了,看到老集群里有Lazy库,大概率是历史遗留。
Replicated引擎是配合集群用的数据库引擎,它会自动把库里所有MergeTree家族的表都变成复制表,不用你一个个写ReplicatedMergeTree。
CREATE DATABASE IF NOT EXISTS dw_dwd ENGINE = Replicated('/clickhouse/databases/dw_dwd/{shard}', '{replica}');路径里的{shard}、{replica}两个宏来自配置文件里的<macros>段,不是随机字符串,必须提前配好。这个引擎的好处是省事,坏处是灵活性差,一旦库里有不需要复制的表就很别扭,所以我更倾向于用普通Atomic库 + 显式写ReplicatedMergeTree。
1.3 挂外部数据源的库引擎:MySQL、PostgreSQL、SQLite
还有一类库引擎,它们本身不存数据,而是把外部数据库「映射」成 ClickHouse 的库。建的时候要填连接信息:
CREATE DATABASE mysql_slave ENGINE = MySQL('10.0.0.21:3306', 'biz_db', 'readonly', 'your_password'); CREATE DATABASE pg_source ENGINE = PostgreSQL('10.0.0.22:5432', 'public', 'reader', 'your_password', 'public');建完之后SHOW TABLES FROM mysql_slave就能看到对端的表名,直接SELECT就能查。实测下来这套机制适合做一次性核对或者小表关联,不适合高频查询——每次查询都会真的去对端拉数据,没有本地缓存,延迟完全取决于网络和对端性能。如果是需要反复查的数据,老老实实同步一份到本地MergeTree里。
提示:
MaterializedMySQL和MaterializedPostgreSQL是另外两个引擎,走 binlog / WAL 做实时同步,属于实验特性的范畴,版本之间行为有差异,上生产前务必在测试环境验证 DDL 变更的同步表现。
2. MergeTree 家族选型:一张表能不能用得久,八成看这一步
2.1 为什么 ClickHouse 建表必须带引擎,而且没有默认值
和 MySQL 不一样,ClickHouse 的CREATE TABLE必须写ENGINE,没有默认引擎给你兜底。这不是设计得麻烦,而是因为不同引擎的行为差异大到无法用一个默认值覆盖:Memory不落盘、Log不支持索引和更新、MergeTree才有分区和主键索引。
绝大多数的表都该用MergeTree家族。如果你建的表是长期存的、要按时间查的、数据量超过百万行的,别犹豫,就在这个家族里选。我一般按「去重需求」和「预聚合需求」两个维度来选:
| 引擎 | 适用场景 | 关键参数 | 我的使用建议 |
|---|---|---|---|
| MergeTree | 通用明细表,不做任何去重聚合 | ORDER BY | 默认选择,80% 的表用它 |
| ReplacingMergeTree | 需要按主键去重,保留最新一条 | ver 版本列(可选) | 配合FINAL或argMax使用,注意去重只在合并时发生 |
| SummingMergeTree | 只关心指标求和,维度固定 | 求和列列表 | 只对数值列求和,非数值列取任意值,别指望它保维度一致性 |
| AggregatingMergeTree | 物化视图的物化目标表 | 配合 AggregateFunction 类型 | 建表时字段类型必须是AggregateFunction,普通 SQL 查不出结果 |
| CollapsingMergeTree | 需要按 sign 字段抵消的增量更新 | sign 列 | 写入必须成对,中间不能断,业务逻辑容易出错 |
| VersionedCollapsingMergeTree | 上面那个的加强版,乱序写入也安全 | sign, version | 多线程写入场景优先选它 |
把这张表存下来,实际项目里对着挑就行。有一个坑特别值得说:SummingMergeTree在建表时如果ORDER BY里没包含所有非求和维度列,合并之后数据会错乱,因为非维度列的值会被随机保留一个。我见过有人拿它当普通明细表用,结果报表数据对不上,排查了整整两天。
2.2 ReplacingMergeTree 的真实行为,别被「去重」两个字骗了
这是我见过误解最多的引擎。ReplacingMergeTree的去重发生在后台合并(merge)的时候,而且只在同一个分区内去重,跨分区不去重。也就是说:
- 你刚插进去的重复数据,在合并发生之前,
SELECT COUNT(*)依然能看到多条。 - 如果你按
PARTITION BY toYYYYMM(create_time)分区,同一个主键的两次更新跨月了,去重就不会发生。 - 想拿到逻辑上正确的「最新一条」,要么加
FINAL关键字,要么用argMax(字段, 版本号)聚合。
SELECT user_id, argMax(user_name, updated_at) AS user_name, argMax(level, updated_at) AS level FROM dw_dwd.user_profile_replacing GROUP BY user_id;FINAL写起来简单,SELECT * FROM t FINAL就行,但它在 22 版本之前是单线程执行,大表上慢得离谱;新版本做了并行优化,性能好了很多,但在数十亿行的表上还是要谨慎。
实测下来我的做法是:如果这张表是中间层,查询方不多,直接用FINAL;如果是对外提供的宽表,我会在写入侧用INSERT INTO ... SELECT ... GROUP BY先做一次聚合,把逻辑去重的代价前置到写入,读的时候就不用担惊受怕。
2.3 复制表与集群建表:ON CLUSTER 到底做了什么
单机建表就是CREATE TABLE ...。集群里建表,要在库表名前加ON CLUSTER:
CREATE TABLE dw_dwd.order_detail ON CLUSTER cluster_3shards_2replicas ( order_id UInt64, user_id UInt64, amount Decimal(18, 2), create_time DateTime('Asia/Shanghai') ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/order_detail', '{replica}') PARTITION BY toYYYYMM(create_time) ORDER BY (create_time, order_id);ON CLUSTER背后的机制是 Distributed DDL:ClickHouse 把这条 DDL 写到 ZooKeeper(或 ClickHouse Keeper)的一个队列节点上,集群里每个节点都监听着这个节点,谁先拿到谁执行。这意味着两件事:
第一,DDL 是异步的,语句返回成功不代表所有节点都建好了。想确认,去查每个节点上的system.tables,或者用system.distributed_ddl_queue看任务状态。
第二,如果某个节点当时离线,它上线后会补执行这条 DDL。反过来说,如果某个节点的执行失败了,整条 DDL 的状态就是失败的,你没法只让失败的那个节点重试,得整体重来一遍。所以集群里跑 DDL 之前,先确认所有节点都活着。
ZooKeeper 路径这块也有讲究。老写法是/clickhouse/tables/{shard}/表名,同一张表的两个副本共享同一个路径。新版本(21.6 之后)推荐用/clickhouse/tables/{uuid}/{shard},uuid是表的 UUID,好处是即使你把表DETACH再重新ATTACH,或者改名,只要 UUID 不变,副本关系就不会乱。我现在的模板都写{uuid}。
3. ORDER BY、PARTITION BY、TTL、CODEC 到底怎么填
3.1 ORDER BY 不是主键约束,是稀疏索引的排序键
初学者最容易把它当成 MySQL 的主键:以为ORDER BY user_id就意味着user_id唯一。不是的。ClickHouse 的ORDER BY决定的是数据在磁盘上的物理排序方式,它对应一份稀疏索引(默认每 8192 行一个索引标记,由index_granularity控制),查询时靠这份索引做范围裁剪。
这就意味着ORDER BY的顺序极其重要,规则是「基数从低到高,过滤频率从高到低,范围查询字段靠前」。举个例子,一张订单明细表,查询模式是「按日期范围 + 按商户过滤」,那ORDER BY (merchant_id, create_time)和ORDER BY (create_time, merchant_id)的性能差异可能是十倍级别。前者能把同一个商户的数据聚在一起,查单个商户时直接跳过大量数据块;后者数据按时间平铺,查商户得全表扫。
我的判断方法是:把线上最耗时的十条 SQL 抓出来,看它们的WHERE条件里哪个字段出现频次最高、选择性最好,把它放ORDER BY第一位。
再说一个约定俗成的细节:ORDER BY和PRIMARY KEY的关系。如果你只写ORDER BY,PRIMARY KEY就等于它。如果你两个都写,PRIMARY KEY必须是ORDER BY的前缀。PRIMARY KEY影响的是索引里存多少行数据(每index_granularity行存一行),写得短一点能省内存。
ORDER BY (merchant_id, create_time, order_id) PRIMARY KEY (merchant_id, create_time)这种写法在merchant_id和create_time上建索引,但物理排序还是按三个字段,属于比较常见的优化手法。
如果这张表你压根不在乎排序(比如只做全表扫描的日志表),可以写ORDER BY tuple(),表示不排序。但要清楚这不是「没有代价」的选择,所有查询都会退化成全表扫描。
3.2 PARTITION BY 与 part 命名规则,理解它才能理解太多 parts 的报错
每个分区在磁盘上是一个目录,目录名就是 part 文件名的一部分。一个 part 的完整命名格式是:
{partition_id}_{min_block_number}_{max_block_number}_{level}[_{data_version}]举几个真实的目录名:202309_1_1_0、202309_5_10_3、all_1_100_2、202310_12_12_0_7。逐段拆开看:
202309是 partition_id。如果PARTITION BY toYYYYMM(create_time),它就是年月;toDate(create_time)就是20230901。如果分区表达式是复合的或者复杂的,partition_id 会变成一串看不出规律的编码。1_1表示这个 part 里包含的 block 号范围,1到1说明是单块,5_10说明是 5 到 10 号数据块合并来的。0是 level,也就是合并层级。新插入的 part level 是 0,两个 level 0 合并成 level 1,依次往上。所以看到 level 很大的 part,说明它被反复合并过很多次,行数通常很大。- 最后的
data_version(老版本没有)在发生 mutation(ALTER TABLE ... UPDATE)之后会自增,用来区分 mutation 前后的数据。
另外,只有当PARTITION BY是tuple()或者完全不写时,partition_id 才是all,因为整张表只有一个分区。
理解这个命名,你再看system.parts就通透了:
SELECT partition, name, active, rows, bytes_on_disk, level FROM system.parts WHERE database = 'dw_dwd' AND table = 'order_detail' AND active = 1 ORDER BY rows DESC LIMIT 20;active = 1是必须加的过滤条件。合并完成后的旧 part 不会立刻消失,ClickHouse 会保留old_parts_lifetime(默认 480 秒)后才真正删除,这段时间内新旧 part 都存在于磁盘上,查询时靠active区分。所以我经常看到有人 zh 问「为什么磁盘占用翻倍了」,八成是这个原因,等几分钟就恢复了。
PARTITION BY的原则只有一条:分区数量控制在几千以内。按天分区在数据量中等的表上一年就是 365 个分区,三年上千,勉强能接受;如果每天数据量不大(比如几十万行),按天分区带来的收益远不如按月,反而会让合并压力变大。
3.3 TTL、CODEC 和跳数索引,建表时能顺手加就别拖
TTL是建表时最该顺手加的配置,因为事后加要改元数据(虽然ALTER支持,但线上操作总归有风险)。三种常用形式:
-- 90 天后删除数据 TTL create_time + INTERVAL 90 DAY DELETE -- 30 天后搬到冷盘 TTL create_time + INTERVAL 30 DAY TO DISK 'cold' -- 30 天后重新压缩成更高的压缩级别 TTL create_time + INTERVAL 30 DAY RECOMPRESS CODEC(ZSTD(17))CODEC是列级压缩算法,默认是LZ4,速度快但压缩率一般。你可以按列的数据特征换成别的:
event_time DateTime CODEC(Delta, ZSTD(1)), -- 时间戳递增,差分后压缩率高 metric_value Float64 CODEC(Gorilla, ZSTD(1)), -- 浮点序列,Gorilla 编码 user_id UInt64 CODEC(T64, LZ4), -- 整数,T64 转置编码 detail String CODEC(ZSTD(3)) -- 长文本,直接上高压缩我实测过一个日志表,把String列从默认LZ4换成ZSTD(3),磁盘占用降了大约 40%,查询耗时基本没有变化。代价是写入时的 CPU 会略高一点,但在写少读多的场景里完全值得。
跳数索引(Data Skipping Index)是另一个好东西,适合「某列基数不高但不在 ORDER BY 里,查询又经常过滤它」的场景:
INDEX idx_status status TYPE set(100) GRANULARITY 4, INDEX idx_detail_tokens detail TYPE tokenbf_v1(4096, 3, 0) GRANULARITY 1set(N)适合基数小的枚举列,bloom_filter适合等值匹配,tokenbf_v1和ngrambf_v1适合字符串模糊搜索。GRANULARITY控制索引粒度,值越大索引越小但过滤精度越低,一般从 1 到 4 之间试。
提示:跳数索引在
CREATE TABLE里定义之后,对已经存在的数据也需要ALTER TABLE ... MATERIALIZE INDEX才会生效,新写入的数据自动索引。建表时就规划好,比事后补索引省事得多。
4. 字段类型:没有自增、没有唯一约束、没有外键
4.1 Nullable 是把双刃剑,能不用就别用
从 MySQL 迁过来的人第一反应就是给字段加Nullable,因为那边字段默认就允许 NULL。在 ClickHouse 里这是个性能陷阱:Nullable(T)本质上是在原类型旁边挂了一个额外的 NULL 标记位图,所有读写都要多看一位,聚合函数处理它也要额外分支。
更麻烦的是,Nullable的列不能进ORDER BY和PARTITION BY,会直接报错:
DB::Exception: Sorting key contains nullable columns, but merge tree setting `allow_nullable_key` is disabled解决办法不是去把allow_nullable_key打开(那样索引效率会下降),而是用哨兵值代替 NULL。字符串用空串'',数值用0或-1,时间用1970-01-01 00:00:00或者toDateTime(0)。查询的时候按业务语义过滤即可。
如果确实需要一个「可能为空」的语义,我的做法是加一个has_xxx UInt8标志列,比Nullable干净得多。
4.2 数值、字符串和时区这三个类型最容易踩坑
数值类型的选择有几个固定套路:
- 业务 ID、订单号这类整数,
UInt64或Int64。注意 ClickHouse 有UInt256、Int256,精度够但计算慢,一般用不上。 - 金额一律用
Decimal(P, S),比如Decimal(18, 2)表示总共 18 位、小数 2 位。千万别用Float64存钱,浮点误差在报表汇总时会被放大得很难看。 - 比率、单价这类用
Decimal64(4)或者Float64都行,看是否需要精确计算。
字符串类型里,String是万能的,内部存字节序列,不限制编码。FixedString(N)只在长度严格固定的场景有用(比如定长编码、MD5 十六进制串),长度不匹配时会用零字节补齐,用错了反而麻烦。
重点说LowCardinality(String)。当一列的取值种类少于 1 万时,把它包成LowCardinality能显著减少存储和提升查询速度,因为它内部做了字典编码。省份、状态、渠道、枚举名这类列特别合适。但如果是用户 ID、URL 这种高基数列,加了反而增加开销,因为字典本身会膨胀。
时区这个坑我单独拎出来说。DateTime不带参数时用的是服务器时区,一旦集群里各节点时区配得不一致,同一条数据的显示结果就会不一样。我现在的写法一律显式指定:
create_time DateTime('Asia/Shanghai'), update_time DateTime64(3, 'Asia/Shanghai'),DateTime64(3)是毫秒精度,3表示小数点后 3 位。如果业务需要微秒,写DateTime64(6),但精度越高存储越大,按需选。
4.3 DEFAULT、MATERIALIZED、ALIAS 三个默认值的区别
这三个关键字看着像,行为完全不同。
DEFAULT是最常见的:插入时不给值就用默认表达式计算。它有个很实用的地方——可以引用其他列。
CREATE TABLE t ( price Decimal(18, 2), quantity UInt32, amount Decimal(18, 2) DEFAULT price * quantity ) ENGINE = MergeTree() ORDER BY tuple();MATERIALIZED也参与计算,但它不会被SELECT *返回给用户,必须显式指名才查得到。而且INSERT INTO ... SELECT *的时候不会带上它,因为它是「物化」出来的,写入后由 ClickHouse 自己算。
ALIAS则完全不存储,每次查询时实时计算,相当于给表达式起了个别名。
三个都用到的场景是这样:字段event_date Date MATERIALIZED toDate(event_time),这样你只需要插入event_time,日期列自动生成,还能拿它做分区键。
5. 建表的几种入口,以及 GUI 工具为什么最好少用
5.1 命令行客户端:最稳也最快
clickhouse-client是原生 TCP 协议,默认连 9000 端口:
# 交互式 clickhouse-client --host 127.0.0.1 --port 9000 -u default --password 'your_password' # 单条语句 clickhouse-client -q "CREATE DATABASE IF NOT EXISTS dw_ods ENGINE = Atomic" # 执行脚本文件,multiquery 允许一个文件里多条语句 clickhouse-client --multiquery < schema.sql # 只看建表语句 clickhouse-client -q "SHOW CREATE TABLE dw_dwd.order_detail"--multiquery这个参数一定要记住。默认情况下一个--query里只允许一条语句,多语句会报语法错误,很多人第一次写初始化脚本就卡在这里。
5.2 HTTP 接口:脚本化和跨语言集成最方便
8123 端口是 HTTP 接口,curl就能建库建表:
echo 'CREATE DATABASE IF NOT EXISTS dw_ods ENGINE = Atomic' \ | curl -sS 'http://127.0.0.1:8123/?user=default&password=your_password' --data-binary @-或者用查询参数直接传:
curl -sS 'http://127.0.0.1:8123/?query=SHOW%20DATABASES'HTTP 接口的好处是不依赖驱动,任何能发 HTTP 请求的语言都能用,做自动化初始化脚本的时候特别顺手。要注意的是密码直接写在 URL 里会有泄漏风险,生产环境应该走请求头或者内网白名单。
5.3 DBeaver、Navicat 这类 GUI:能查不能建
这类工具连 ClickHouse 一般是配 JDBC 驱动(ru.yandex.clickhouse或者新的com.clickhouse),连接串形如:
jdbc:clickhouse://10.0.0.31:8123/dw_dwd jdbc:ch://10.0.0.31:8123/dw_dwd它们看元数据、跑SELECT、看执行计划都挺好用,但用图形界面「新建表」这个功能我强烈建议关掉。原因是这些工具的建表向导按标准 SQL 生成 DDL,会给你自动加上PRIMARY KEY、自增列、索引之类 ClickHouse 不认或者语义完全不同的东西,生成出来直接报错,或者更糟——不报错但语义错了。
我的做法是:DDL 一律手写在.sql文件里,用版本管理管起来,通过clickhouse-client --multiquery执行;GUI 只用来查数和看表结构。这样建出来的表和代码里的定义永远一致,环境迁移时也不会有「测试环境的表结构怎么和生产不一样」这种问题。
6. 跨库搬数据:源表 tablea 到目标表 tableb 的完整链路
这个场景太常见了:源表和目标表在不同的数据库里,要把数据搬过去,而且目标表可能还得换引擎、加分区、改字段类型。
6.1 同一个实例、两个库:最省事的写法
如果两张表在同一个 ClickHouse 实例上,只是库不同,那直接跨库引用就行:
-- 方式一:复制结构(含引擎、分区、排序键) CREATE TABLE dw_dwd.tableb AS dw_ods.tablea; -- 方式二:复制结构但换引擎 CREATE TABLE dw_dwd.tableb AS dw_ods.tablea ENGINE = MergeTree() PARTITION BY toYYYYMM(create_time) ORDER BY (create_time, order_id); -- 方式三:只要结构不要数据,加 EMPTY CREATE TABLE dw_dwd.tableb AS dw_ods.tablea ENGINE = MergeTree() ORDER BY id EMPTY;方式二特别有用:源表用的是Memory或者Log引擎,目标表要换成MergeTree,语法上把ENGINE写在AS后面就能覆盖。
搬数据:
INSERT INTO dw_dwd.tableb SELECT order_id, user_id, toDecimal64(amount, 2) AS amount, parseDateTimeBestEffort(create_time_str) AS create_time FROM dw_ods.tablea WHERE create_time >= '2023-09-01';这里我顺手做了类型转换,因为跨库搬数据时字段类型对不上是家常便饭。parseDateTimeBestEffort能自动识别多种时间字符串格式,比硬写toDateTime容错性好。
搬大表的时候要注意批次。一条INSERT INTO ... SELECT会一次性把数据拉到内存里再写,几十亿行会直接 OOM 或者触发内存限制报错。正确的做法是按分区或者按时间范围分批:
INSERT INTO dw_dwd.tableb SELECT * FROM dw_ods.tablea WHERE create_time >= '2023-09-01' AND create_time < '2023-10-01';一批一个月,跑 12 次,每次都能看到进度,出错了也好定位是哪一段的问题。max_insert_block_size默认 1048576 行,一般不用动,如果单批还是太大,可以调小到 10 万行。
6.2 跨实例:remote 函数和管道迁移
两个实例之间搬数据,用remote表函数:
INSERT INTO dw_dwd.tableb SELECT * FROM remote( '10.0.0.41:9000', 'dw_ods', 'tablea', 'readonly_user', 'your_password' ) WHERE create_time >= '2023-09-01';跨公网或者需要加密的链路,把remote换成remoteSecure,端口走 9440。
当数据量特别大、网络又不稳定的时候,remote的长时间连接容易断。这时候更稳的方式是用命令行管道:
clickhouse-client --host 10.0.0.41 --query \ "SELECT * FROM dw_ods.tablea WHERE create_time >= '2023-09-01' FORMAT Native" \ | clickhouse-client --host 10.0.0.31 --query \ "INSERT INTO dw_dwd.tableb FORMAT Native"这个写法的好处是流式的,内存占用恒定,而且Native格式保留了完整的类型信息,不会出现字符串转数字的隐式转换问题。缺点是没有断点续传,断了要重跑,所以我一般会再包一层时间范围切分,一个范围一条命令,写进 shell 循环里。
6.3 建表异常排查清单
跨库建表最容易出的几类错,我整理成一个对照表,遇到问题直接查:
| 报错信息关键字 | 原因 | 处理方式 |
|---|---|---|
Table already exists | 表已存在 | 加IF NOT EXISTS,或者先DROP TABLE/ 用EXCHANGE TABLES做切换 |
Database xxx does not exist | 库没建或者库名拼错 | 先建库,注意库名大小写敏感 |
Sorting key contains nullable columns | ORDER BY 里有 Nullable 列 | 用哨兵值替换 NULL,或加has_xxx标志列 |
Too many parts (300) | 分区过细或者写入批次太小 | 改分区粒度,合并小批量写入,加大max_insert_block_size |
Replica ... already exists | ZooKeeper 里有残留路径 | 用新表名或新 UUID 路径,清理 ZK 节点后再重建 |
Memory limit exceeded | 单次 insert select 数据量过大 | 按时间或分区切批 |
Cannot parse ... | 上游数据格式和字段类型不匹配 | 用parseDateTimeBestEffort、toInt64OrZero这类容错函数 |
这张表里的每一条我都在真实项目里碰到过,尤其Too many parts和Replica already exists这两个,第一次遇到的时候真的很懵。
7. 建完表之后立刻会碰到的几个坑
7.1 小批量高频写入是 MergeTree 的天敌
ClickHouse 官方文档里有一句话值得刻在脑子里:建议每次插入至少 1000 行,或者每秒最多一次插入。原因就是每次INSERT都会在磁盘上生成一个新的 part,而后台合并线程是按「part 数量」和「part 大小」来决定合并策略的。如果每秒插一条,一天下来就是 86400 个 part,合并速度根本追不上生成速度,最终就会撞上parts_to_throw_insert(默认 300)这个阈值,写入直接被拒绝。
解决它的办法有两个层次。应用侧,把单条写入改成攒批写入,用消息队列或者内存队列缓冲,攒够 1000 到 10000 行再一次性INSERT。数据库侧,调大parts_to_delay_insert和parts_to_throw_insert,但这只是拖延问题,不是解决问题。
还有一个更隐蔽的情况:用了Distributed表做写入入口,但没开insert_distributed_sync,数据先在分布式表所在节点缓冲,异步分发。这个缓冲如果没配好(distributed_...一系列参数),也会在小批量高频写入时积压成灾。我一般直接开同步写入,牺牲一点延迟换确定性。
7.2 ALTER 的代价远高于你的想象
ALTER TABLE ... UPDATE和ALTER TABLE ... DELETE在 ClickHouse 里是 mutation,不是即时生效的。它们做的事情是「把所有历史数据重新写一遍」,异步执行,通过system.mutations看进度:
SELECT database, table, mutation_id, command, create_time, is_done, parts_to_do, latest_failed_part, latest_fail_reason FROM system.mutations WHERE is_done = 0;一条ALTER TABLE ... UPDATE在 TB 级表上可能要跑几个小时,期间磁盘 IO 和 CPU 都会被吃掉,还会产生大量临时 part。所以我的建议是:能不做 mutation 就不做。需要更新就用ReplacingMergeTree追加新版本,需要删除就靠TTL自动清理,只有在数据量小或者一次性修正的时候才用ALTER UPDATE。
ALTER TABLE ... ADD COLUMN反而是轻量的,因为默认值是懒加载的(alter_column_default相关配置),加列不会重写历史数据,秒级完成。但ALTER TABLE ... MODIFY COLUMN改类型就重了,会触发数据重写,且改类型不一定兼容,比如String改Int64需要全部数据都能解析成整数,否则整条 mutation 失败。
7.3 用 system 表把表结构管起来
建完表别急着走,先用元数据表把结果确认一遍:
-- 看库里有哪些表 SELECT name, engine, total_rows, total_bytes FROM system.tables WHERE database = 'dw_dwd'; -- 看字段定义 SELECT name, type, default_kind, default_expression, codec_expression FROM system.columns WHERE database = 'dw_dwd' AND table = 'order_detail' ORDER BY position; -- 看建表语句原文 SHOW CREATE TABLE dw_dwd.order_detail;system.columns里的default_kind会把DEFAULT、MATERIALIZED、ALIAS区分开,codec_expression能看到每列的压缩算法,这两个字段在排查「为什么这张表比预期大」的时候特别有用。我一般会把这几个查询写成一个describe_table.sql,需要的时候直接跑。
还有一个习惯值得养成:把所有 DDL 按「库 - 表 - 版本」的目录结构存在 git 里,每次变更都提交,配合ON CLUSTER的 DDL 队列做发布。ClickHouse 的表结构是没法靠「可视化比对」来管理的,只有代码化才靠得住。
我在几个项目里反复验证下来,建表阶段多花半小时想清楚 ENGINE、ORDER BY、PARTITION BY 这三件事,后面能省掉几十个小时的调优和救火时间。尤其是分区键,它一旦定下来,改分区规则意味着重建整张表,所以宁可先按粗粒度(按月)分区,观察一两个月的实际数据量和查询模式之后,再决定要不要细化。至于类型和编解码,建表时顺手加上,成本几乎为零,收益却跟着表一直走。