1. 从 MySQL 思维切到 ClickHouse建表前先把脑子换一遍clickhouse创建数据库以及表这两步是每个新项目落地时躲不开的第一道关。我见过太多团队的做法是把 MySQL 的建表语句复制过来删掉AUTO_INCREMENT把ENGINEInnoDB改成ENGINEMergeTree然后发现要么跑不起来要么能建起来但查询慢得让人怀疑人生。问题的根子不在语法差异而在于 ClickHouse 的表结构和它的存储模型是深度绑定的——你在ORDER BY里写什么数据在磁盘上就按什么顺序落你按什么粒度分区后台合并线程就按什么颗粒度搬运文件目录。MySQL 建表本质上是在定义字段和约束ClickHouse 建表本质上是在给数据设计物理布局。这个认知差如果不先补上后面所有的调优都是白费力气。1.1 为什么同样的字段ClickHouse 建出来快这么多很多人第一次看到 ClickHouse 的建表语句会觉得简陋没有外键、没有唯一约束、没有自增主键连VARCHAR(255)都不给你写长度。这不是它偷工减料而是列式存储在存储层做了完全不同的取舍。数据按列单独成文件存放同一列的值类型一致、连续排列压缩比可以做到非常夸张——比如一个重复度极高的status字段用 LowCardinality 包装之后几百亿行数据可能只占几个 GB。代价就是写入路径变重了。每一批数据写入都会在分区目录下生成一个新的 part 目录后台再异步把这些 part 合并成大 part。你频繁小批量插入part 数量会迅速堆积最终撞上parts_to_delay_insert和parts_to_throw_insert这两个阈值写入直接被拒绝。所以建表时你就得预判写入模式是批量导入还是流式写入单批大概多少行决定了分区键该怎么选、要不要开 TTL 自动清理。我一般的判断顺序是先看数据量级和写入频率再定分区键最后才定排序键和字段类型。反过来做十有八九要返工。1.2 建库建表之前必须先回答的几个问题动手敲 DDL 之前我习惯先在纸上回答四个问题这四个问题答不上来建出来的表基本是要推倒重来的。第一查询模式是什么。是点查按 ID 精确找一条还是范围扫描按时间查一段还是多维聚合按维度分组统计。ClickHouse 的主键是稀疏索引它对点查的支持远不如 B 树那么精准但对大范围扫描加聚合极强。如果你的场景里 90% 是一行一行的点查那这张表可能压根不该放在 ClickHouse 里。第二数据保留多久。这直接决定 TTL 怎么配。日志类数据通常只留 30 到 180 天配上TTL之后过期数据会由后台线程自动从分区级别整体删除几乎零成本。第三分区粒度多大合适。经验值是单个分区几十万到几千万行全表分区总数控制在几百到一千以内。按天分区适合中小规模按月分区适合超大规模。分区太细元数据和合并开销会吃掉性能分区太粗删除和归档又不灵活。第四字段要不要用 Nullable。ClickHouse 里Nullable会额外维护一个空值标记文件读写都有开销而且很多函数和键定义不接受 Nullable。能用默认值代替的就别用 Nullable比如用0或空字符串表达缺失。1.3 版本差异为什么别人的建表语句在你这跑不通ClickHouse 迭代速度非常快每两周就出一个稳定版。这带来一个很现实的问题网上抄来的建表语句在你本地可能直接报语法错误。典型的分水岭就是数据库引擎。20.10 版本之后新建数据库的默认引擎从Ordinary换成了Atomic两者在元数据存储、重命名支持、表 UUID 机制上都不同。再比如一些系统表字段、跳数索引类型、PROJECTION的语法细节版本之间都有差异。所以我强烈建议动手前第一件事是确认版本SELECT version();拿到版本号之后再去对官方文档而不是看某篇三年前的文章。生产环境升级前也务必要在测试集群上跑一遍建表脚本确认没踩到语法变更。我吃过一次亏升级后发现某个物化视图引用的表因为引擎行为变化导致数据对不上排查了一整天才定位到是版本差异。提示生产集群不要盲目追新。选一个已经发布几个月、社区反馈稳定的 LTS 版本比用最新版踩坑要划算得多。建表脚本里用到的语法特性先确认目标版本支持再上线。2. 创建数据库语法、引擎与目录真相很多人建库就是一句CREATE DATABASE xxx跑完也不关心它在磁盘上到底长什么样。等哪天需要迁移、恢复或者排查问题时才发现自己对元数据一无所知。数据库这一层在 ClickHouse 里轻量但不简单尤其是选用哪种引擎直接影响后面的重命名、原子性替换、副本同步这些操作能不能做。2.1 CREATE DATABASE 的完整写法与引擎选择最基础的写法就一行CREATE DATABASE IF NOT EXISTS analytics;加上IF NOT EXISTS是个好习惯脚本重复执行不会报错方便放进自动化部署流程。如果要指定引擎和参数CREATE DATABASE IF NOT EXISTS analytics ENGINE Atomic;ClickHouse 支持的数据库引擎主要有这么几类用途差别很大引擎用途关键特性Atomic默认推荐支持原子 RENAME、DROP、EXCHANGE表有 UUIDOrdinary老版本默认目录即表名重命名受限Lazy临时表场景表在内存保留一段时间后自动清理Replicated集群元数据同步库级 DDL 自动复制到所有副本MySQL挂载外部 MySQL 库直接查询 MySQL 里的表MaterializedMySQLMySQL 实时同步实验性功能谨慎用于生产绝大部分场景直接用Atomic就够了。它的原子性体现在RENAME TABLE和EXCHANGE TABLES是瞬时完成的元数据操作不会出现表删了一半的中间状态。做数据回刷的时候这个特性非常关键——先用新表跑完数据再一句EXCHANGE TABLES换过去业务侧几乎无感。Replicated引擎适合多副本集群建库语句带上集群标识后各节点自动创建同名库。不过要注意它和表级别的ReplicatedMergeTree是两回事前者管库的元数据后者管表的副本。2.2 库名、路径与元数据文件到底存在哪ClickHouse 默认数据根目录是/var/lib/clickhouse/。库名大小写在 Linux 上是敏感的Analytics和analytics是两个不同的库这个和 MySQL 在 Windows 下默认不区分大小写的行为完全不同从 MySQL 迁过来的同学特别容易在这里翻车。我的建议是库名表名统一小写下划线风格避免任何大小写歧义。用Atomic引擎建出来的库元数据文件放在/var/lib/clickhouse/metadata/db_name/table_name.sql里面存的就是建表语句本身第一行通常是ATTACH TABLE ...这是 ClickHouse 重启后加载表结构用的。而真实数据文件放在/var/lib/clickhouse/store/uuid前三位/uuid/注意这里的目录名是表的 UUID不是表名。这是 Atomic 引擎最重要的设计表名到 UUID 的映射存在元数据文件里UUID 才是数据的真实身份。所以如果你手动删了元数据.sql文件数据目录就变成无人认领的孤儿数据再想恢复就很麻烦了。想查看表在磁盘上的实际位置可以用系统表SELECT database, name, uuid, engine, data_paths FROM system.tables WHERE database analytics;data_paths字段会直接给你磁盘路径排查磁盘占用时很好用。2.3 日常库管理操作清单日常工作中高频用到就这么几条我把它们整理在一起方便直接抄-- 查看所有库 SHOW DATABASES; -- 切换当前库 USE analytics; -- 重命名Atomic 引擎支持 RENAME DATABASE analytics TO analytics_v2; -- 删除库危险操作会连带删除所有表 DROP DATABASE IF EXISTS analytics; -- 查看建库语句 SHOW CREATE DATABASE analytics;RENAME DATABASE在Atomic下是原子的在Ordinary下则会报错。这点在做库迁移时要注意如果老库是Ordinary得先把表逐个迁移到新库或者干脆导出重建。注意DROP DATABASE默认不会立刻物理删除数据而是把整个库目录移动到 detached 目录下同时标记删除。这样设计是防止误删后无法挽回。但千万别因此就掉以轻心很快就会有过期清理机制把 detached 目录清掉。真要删库先备份。另外提醒一句在集群环境下所有 DDL 都要考虑加ON CLUSTER子句否则只在当前节点生效各节点结构不一致后面会出大问题。这个在第四部分会详细说。3. 创建表真正决定性能的是引擎和键建表是 ClickHouse 里最需要功力的一步。表结构定下来之后再改排序键基本等于重建表成本极高。所以这一节我会把建表语句拆开逐块说清楚每个部分的取舍逻辑。3.1 一条建表语句拆开看哪部分绝不能照抄完整的建表语法骨架长这样CREATE TABLE [IF NOT EXISTS] [db.]table_name ( column1 type1 [DEFAULT|MATERIALIZED|ALIAS expr] [CODEC(...)] [TTL expr], column2 type2 ..., INDEX index_name expr TYPE type(...) GRANULARITY n, PROJECTION proj_name (SELECT ...), CONSTRAINT c_name CHECK expr ) ENGINE MergeTree PARTITION BY expr ORDER BY expr PRIMARY KEY expr SAMPLE BY expr TTL expr SETTINGS index_granularity 8192;这里面有三个部分绝对不能照抄 MySQL一是ENGINE二是PARTITION BY和ORDER BY三是字段类型。MySQL 里VARCHAR(255)是有意义的长度限制ClickHouse 里String是变长的写长度反而可能报错。MySQL 里主键是唯一性约束ClickHouse 里主键是稀疏索引不保证唯一。这些概念上的错位是新手最容易踩的坑。字段定义里还有几个实用修饰符值得说。DEFAULT expr是写入时未提供值就填默认值读的时候也能看到MATERIALIZED expr是插入时自动计算并落盘查询时不能显式写这一列ALIAS expr完全不落盘查询时才计算用于定义衍生的表达式列。CODEC用来指定压缩算法低基数字段用T64时间序列用DoubleDelta通用文本用ZSTD(1)压缩收益很明显。created_at DateTime CODEC(DoubleDelta, ZSTD(1)), status LowCardinality(String) CODEC(ZSTD(1)),3.2 MergeTree 家族引擎怎么选这是建表时最核心的一个决策。表面上都是 MergeTree实际行为差别巨大引擎去重/聚合行为典型场景MergeTree不去重原样存日志、事件流、明细数据ReplacingMergeTree按 ORDER BY 键去重保留版本最高需要最终一致的维度表SummingMergeTree合并时对指定数值列求和预聚合的指标表AggregatingMergeTree合并时按聚合函数的状态合并物化视图目标表CollapsingMergeTree按 sign 列折叠正负行需要更新删除语义的场景VersionedCollapsingMergeTree带版本号折叠支持乱序乱序严重的更新场景ReplacingMergeTree有一个必须记住的坑它只在后台合并时去重查询的那一刻可能还没合并完所以结果里仍然可能看到重复行。想强制去重要加FINAL关键字但那会显著拖慢查询。正确做法是在查询里按 ORDER BY 键GROUP BY加max()取值或者接受最终一致这个语义。SummingMergeTree也不是实时求和它只对合并过程中 ORDER BY 键相同的行做数值列累加未合并的数据仍然是多行。所以查询时依然要sum()一遍。这个设计很多人一开始理解不了觉得我用了 SummingMergeTree 怎么还要 sum本质上是把合并当成一种后台优化而不是即时计算。对于日志、埋点这类只追加不改写的场景老老实实用MergeTree就好别为了高级去选复杂引擎反而引入不必要的维护成本。3.3 主键、排序键、分区键的取舍与参数计算这三个键的关系必须先理清排序键决定数据在 part 内部的物理顺序主键是建立在排序键之上的稀疏索引分区键决定数据按什么规则拆到不同目录。如果不显式指定主键它默认等于排序键。主键必须是排序键的前缀。也就是说ORDER BY (a, b, c)时PRIMARY KEY (a, b)合法PRIMARY KEY (b, c)直接报错。这个约束的原因很简单稀疏索引要沿物理顺序二分查找前缀之外的列没法保证有序。排序键的字段顺序遵循一条原则低基数字段在前高基数字段在后。比如设备日志表(device_type, toDate(created_at), device_id)这种排列前两列能快速收窄扫描范围最后一列才用于精确定位。反过来把device_id放第一位基数几千万索引前缀区分度虽然高但后两列就失去了过滤能力聚合查询会明显变慢。分区键的选择有个经验公式。假设日增数据 2000 万行、单行 500 字节那么单日数据约 10GB。按天分区的话每个分区 10GB一年的分区数 365 个这个量级很健康。如果日增只有 20 万行按天分区就太细了单个分区才 100MB元数据开销占比过高这种情况建议按周或按月分区。分区键不能是 Nullable 类型也不能是返回空的表达式。PARTITION BY toYYYYMM(created_at)返回 UInt32是标准写法。绝对不要用高基数列做分区键比如PARTITION BY user_id那会让分区数瞬间爆炸到百万级元数据直接把内存吃光。ENGINE MergeTree PARTITION BY toYYYYMM(created_at) -- 按月分区 ORDER BY (tenant_id, toDate(created_at), user_id) -- 低基数在前还有几个建表 SETTINGS 值得关注。index_granularity默认 8192表示每 8192 行在稀疏索引里存一个标记这个值调小索引更精确但占空间更大。index_granularity_bytes默认 10MB配合前者工作宽表场景下按字节数自适应更合理。如果不是特别清楚自己在做什么这两个保持默认就行。至于SAMPLE BY只有当你要用SAMPLE子句做抽样查询时才需要表达式必须是整数类型且必须是主键的子集。做 A/B 实验数据分析时挺有用普通业务表用不上。3.4 跳数索引与数据类型映射跳数索引skip index是次级索引用来加速非主键列的过滤。它不是精确索引而是在 granule 级别记录一些统计信息查询时快速跳过不可能命中的 granule。常见的几类INDEX idx_url url TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1, INDEX idx_age age TYPE minmax GRANULARITY 4, INDEX idx_tags tags TYPE bloom_filter(0.01) GRANULARITY 2,minmax记录每个 granule 的最小最大值适合有范围特征的数值列bloom_filter适合等值过滤tokenbf_v1和ngrambf_v1用于字符串包含匹配。索引不是越多越好每个索引都会拖慢写入并占用空间只给真正高频过滤的列建。从 MySQL 迁过来的数据类型映射可以参考这张表基本能覆盖八成场景MySQL 类型ClickHouse 类型备注TINYINT / SMALLINTInt8 / Int16无符号对应 UInt8 / UInt16INTInt32无符号用 UInt32BIGINTInt64无符号用 UInt64FLOAT / DOUBLEFloat32 / Float64精度语义基本一致DECIMAL(18,2)Decimal(18,2)金额场景强烈建议保留CHAR / VARCHAR / TEXTString不写长度DATEDate / Date32Date32 支持 1900 年前DATETIMEDateTime秒级精度TIMESTAMP(3)DateTime64(3)毫秒精度ENUMEnum8 / Enum16或用 LowCardinality(String)有一个细节值得单独提ClickHouse 的DateTime默认精度是秒如果你从 MySQL 迁过来的字段是毫秒时间戳直接用DateTime会丢精度。这种情况用DateTime64(3)括号里是小数位数。我见过因为这个问题导致同秒内的事件顺序错乱的案例排查了很久才定位到。另外低基数的字符串列比如状态、类型、渠道这类枚举值建议用LowCardinality(String)包装。它在存储上用字典编码查询时还能走字典优化收益非常明显。4. 完整实操从零建一套日志分析库表前面讲的都是原理这一节我们把它们串起来走一遍完整流程。场景设定为一个多租户的接口访问日志分析系统日增约 3000 万行保留 90 天主要查询是按租户、按时间范围聚合接口耗时和错误率。4.1 环境确认与连接方式第一步永远是确认环境。命令行客户端连上之后先跑几条基础查询clickhouse-client --host 127.0.0.1 --port 9000 --user default --password your_passwordSELECT version(), hostName(), uptime(); SELECT * FROM system.clusters;system.clusters能告诉你当前集群配置里有哪些节点如果是单机部署可能只有默认的default集群。集群信息在建分布式表时会用到。配置文件主要在/etc/clickhouse-server/config.xml用户和权限在users.xml或者users.d/目录下的独立配置文件。修改配置后需要重启或者SYSTEM RELOAD CONFIG生效。建表之前确认一下磁盘策略和存储路径特别是要挂冷热分层的话得先在配置里定义好 storage policySELECT * FROM system.storage_policies;4.2 建库建表完整脚本与逐行说明库先建出来CREATE DATABASE IF NOT EXISTS log_analytics ENGINE Atomic;表分本地表和分布式表两步走先看单机版本CREATE TABLE IF NOT EXISTS log_analytics.access_log ( tenant_id UInt32 COMMENT 租户ID, log_date Date COMMENT 日志日期, created_at DateTime CODEC(DoubleDelta, ZSTD(1)), api_path String CODEC(ZSTD(1)) COMMENT 接口路径, method LowCardinality(String) COMMENT 请求方法, status_code UInt16 COMMENT HTTP状态码, cost_ms UInt32 COMMENT 耗时毫秒, client_ip String CODEC(ZSTD(1)), user_agent String CODEC(ZSTD(1)), INDEX idx_path api_path TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1, INDEX idx_cost cost_ms TYPE minmax GRANULARITY 4 ) ENGINE MergeTree PARTITION BY toYYYYMM(log_date) ORDER BY (tenant_id, log_date, status_code, created_at) TTL log_date INTERVAL 90 DAY DELETE SETTINGS index_granularity 8192;逐块解释一下我的取舍。tenant_id放排序键首位因为所有查询都带租户过滤它基数不高几百到几万前缀过滤效率极高。第二列log_date是日期配合分区键做双重收窄。status_code基数很低放第三位能快速过滤错误请求统计。最后created_at精度最高用于最终定位和排序。分区按月而不是按天因为日增 3000 万行、单行约 400 字节算下来日增约 12GB按月就是 360GB 左右一个分区。这个大小对合并和删除都合适按月分区后 90 天数据大约涉及 4 个分区数量非常健康。如果按天分区分区数会到 90 个虽然也能接受但元数据开销略高。TTL 用log_date INTERVAL 90 DAY DELETE到期的整个分区直接整体删除不产生逐行删除开销。这里注意 TTL 列必须是 Date 或 DateTime用log_date比用created_at更合适因为日期粒度对齐分区删除更彻底。字段类型上method和status_code都是低基数一个用 LowCardinality 一个用整数各自都是最省的表达。字符串列统一加ZSTD(1)压缩比通常在 5 到 10 倍之间。4.3 集群场景下的本地表与分布式表如果在集群上跑本地表要改成ReplicatedMergeTree并且加上ON CLUSTERCREATE TABLE IF NOT EXISTS log_analytics.access_log_local ON CLUSTER my_cluster ( ... -- 字段定义同上 ) ENGINE ReplicatedMergeTree(/clickhouse/tables/{shard}/access_log, {replica}) PARTITION BY toYYYYMM(log_date) ORDER BY (tenant_id, log_date, status_code, created_at) TTL log_date INTERVAL 90 DAY DELETE;路径里的{shard}和{replica}是宏变量需要在每台机器的配置文件里分别定义这样同一份建表语句能在所有节点复用。这个设计非常实用避免了为每个节点写不同的路径。然后建分布式表它本身不存数据只做查询路由和写入分发CREATE TABLE IF NOT EXISTS log_analytics.access_log ON CLUSTER my_cluster AS log_analytics.access_log_local ENGINE Distributed(my_cluster, log_analytics, access_log_local, rand());用AS语法建分布式表时字段定义会从本地表自动继承不用重写一遍减少出错概率。最后一个参数是分片键rand()表示随机均匀写入也可以用tenant_id做分片键实现同租户数据集中具体看查询模式。一般来说如果查询大部分带租户条件用租户做分片键能减少跨分片查询如果是全局统计为主rand()更均衡。注意写入一定要走分布式表查询也尽量走分布式表。本地表只在集群运维、手动补数据、排查单节点问题时才直接操作。这两张表的职责分工要提前跟团队讲清楚否则很容易出现一部分人写本地表导致数据分布不均的事故。4.4 写数据、验证数据、观察 part 变化建完表先做一次小批量验证写入INSERT INTO log_analytics.access_log (tenant_id, log_date, created_at, api_path, method, status_code, cost_ms, client_ip, user_agent) VALUES (1001, 2024-06-01, 2024-06-01 10:00:01, /api/v1/order/list, GET, 200, 45, 10.0.0.1, Mozilla/5.0), (1001, 2024-06-01, 2024-06-01 10:00:02, /api/v1/order/detail, GET, 500, 320, 10.0.0.2, Mozilla/5.0);写完之后立刻验证SELECT count() FROM log_analytics.access_log; SELECT * FROM log_analytics.access_log LIMIT 5;接着看 part 的落盘情况这是观察写入健康度的关键SELECT partition, name, active, rows, formatReadableSize(bytes_on_disk) AS size, level FROM system.parts WHERE database log_analytics AND table access_log AND active ORDER BY partition, name;你会看到name字段形如202406_1_1_0。这个命名规则值得解释一下202406是分区 ID1_1是这批数据的块号范围最后的0是合并层级 level。level 为 0 表示还没被合并过每次后台合并后 level 加一块号范围也会合并成一个更大的区间。连续插入几批数据你会看到多个202406_*的 part 并存等几十秒后再查它们会被后台线程合并成少数几个大 part。这个观察过程非常重要它能让你直观感受到为什么不能高频小批量写入。如果每秒钟插一次、每次几百行你会很快看到几十上百个活跃 part超过 150 左右就开始触发写入延迟超过 300 左右直接拒绝写入。解决方案有两个一是客户端攒批单批建议 10 万到 100 万行二是用 Buffer 表或者异步插入机制做缓冲。5. 建表异常排查与踩坑实录哪怕流程都对实操中还是会遇到各种报错。这一节把高频问题和处理办法整理出来都是我实际遇到并解决过的。5.1 常见报错速查表报错信息关键词原因处理方式Table ... already exists同名表已存在加IF NOT EXISTS或先 DROPPrimary key must be a prefix of the sorting key主键不是排序键前缀调整主键字段顺序PARTITION BY expression is not allowed引擎不支持分区换成 MergeTree 家族引擎Cannot create table, because it has a different structure同名表结构不一致检查是否有残留元数据先 DETACHToo many parts (300)小批量写入过频客户端攒批或调大阈值Cannot parse input: expected ...数据类型和值不匹配检查 INSERT 的值格式DB::Exception: Unknown data type family版本不支持该类型确认版本降级为兼容类型Replica ... already exists复制表路径冲突清理 zookeeper 节点或换路径慢查询和错误的定位我习惯先用system.query_log查最近的执行记录SELECT query, query_duration_ms, read_rows, result_rows, exception FROM system.query_log WHERE type QueryFinish AND event_time now() - 3600 ORDER BY query_duration_ms DESC LIMIT 20;这条查询基本能帮你找到最近一小时最慢或报错的语句比翻日志文件快得多。5.2 元数据异常后的处理思路ClickHouse 的元数据文件就是建表语句的副本。如果遇到服务启动失败、提示某张表无法加载通常是元数据文件损坏或和数据目录对不上。第一步是找到对应的文件ls -l /var/lib/clickhouse/metadata/log_analytics/正常情况下每张表有一个同名的.sql文件。如果看到.sql.tmp或者文件名带随机后缀说明上次写入元数据时被中断了这种残留文件会导致启动时解析失败。正确的处理姿势是用 DETACH 和 ATTACH而不是直接删文件DETACH TABLE log_analytics.access_log; ATTACH TABLE log_analytics.access_log;DETACH会把表从内存中卸载但保留元数据文件在metadata目录下其实会移入metadata下的 detached 子目录记录ATTACH再重新加载。如果表确实不需要了用DROP TABLE它会走正常清理流程。注意千万不要直接rm掉 metadata 目录里的.sql文件。Atomic 引擎下这个文件里存着表的 UUID删掉之后数据目录会变成孤立状态重新建表会产生新的 UUID老数据就挂不上了。真要抢救先把文件备份出来再走 DETACH 流程。如果数据实在对不上还有一招是用ALTER TABLE ... ATTACH PARTITION把数据目录里的分区重新挂载回新表但这个操作需要清楚目录结构风险较高建议在测试环境演练过再上生产。5.3 我踩过的几个坑和一点个人心得第一个坑是关于日期类型的。我早期做一张按天分区的表字段用DateTime分区键写toYYYYMMDD(created_at)。结果发现跨时区的数据边界总是对不上——不同客户端上报的时间戳时区不一致导致同一天的日志被分到相邻两天的分区里。后来改成单独存一个log_date Date字段由写入方按统一时区生成分区键用这个字段问题彻底解决。结论是凡是涉及分区和 TTL 的日期单独建列别依赖时间戳函数现场算。第二个坑是排序键顺序。有张表我图省事把自增的event_id放在排序键第一位查询确实很快但压缩率惨不忍睹——相邻两行的event_id完全不同公共前缀几乎为零。把event_id挪到最后一位、前面加上低基数的app_id和日期之后同样的数据磁盘占用降了将近 40%。排序键的前几位决定了数据聚集程度这个顺序值钱。第三个坑是分区数量。有次接手一张别人建的表分区键用了PARTITION BY toStartOfHour(created_at)一天 24 个分区一个月 720 个。刚开始数据量小看不出问题跑了半年后system.parts查询慢得离谱元数据占用内存明显上升。重建表改成按月分区之后一切恢复正常。分区粒度这件事一定要按数据量算不能凭感觉。最后分享一个实用小技巧建表后先用SHOW CREATE TABLE log_analytics.access_log把最终生效的语句打印出来和你的预期对比一遍。有时 ClickHouse 会对语句做规范化处理比如补充默认设置、调整表达式书写方式看一眼能发现不少笔误。另外养成把所有 DDL 脚本纳入版本管理的习惯配合ON CLUSTER实现可重复部署比手工在客户端敲命令靠谱得多。我在实际项目里是把建库建表的 SQL 按序号编号放在代码仓库里每次变更都走审查出问题时能快速定位到是哪次改动引入的。