1. 什么是ClickHouse分布式表:不是“加个distributed就完事”的简单封装
ClickHouse分布式表,常被新手误认为是“把本地表包装一层就能自动分发查询”的魔法开关。实际完全不是这样——它本质上是一个查询路由与结果聚合的逻辑视图,自身不存储任何数据,也不参与数据写入的物理分片决策。真正承担数据落盘、副本管理、一致性保障的是底层的本地表(如ReplacingMergeTree、ReplicatedReplacingMergeTree)。分布式表只是站在用户侧的一层“调度员”,负责把一条SQL拆解、下发、收集、合并,整个过程对用户透明,但背后每一步都依赖精确的配置和强约束条件。
我第一次在生产环境踩坑,就是以为建好Distributed表后insert就能自动分片。结果发现90%的数据全写进了同一个shard,集群负载严重倾斜。查日志才发现,没配shard key,也没启用consistent hashing,系统默认用随机分发,而ClickHouse的随机分发在小数据量下极不稳定。后来才明白:Distributed表不是分布式存储的实现者,而是分布式能力的暴露接口;真正的分片逻辑,必须由你亲手定义在建表语句、集群配置、甚至INSERT语句的WITH子句里。
核心关键词“ClickHouse”“分布式表”“分片存储”“查询”“大数据”在这里不是并列关系,而是因果链:因为有大数据规模,所以需要分片存储;因为分片存储,所以必须通过分布式表统一查询入口;而查询效率能否线性扩展,完全取决于分片策略是否匹配业务读写模式。比如电商订单按user_id哈希分片,能保证同一用户的全部订单落在同一节点,极大提升用户维度聚合查询性能;但如果按order_time范围分片,时间范围查询快了,但用户画像类关联查询就会跨大量shard,性能反而暴跌。
这个机制决定了它和MySQL分库分表、MongoDB sharding有本质区别:ClickHouse的Distributed表不维护全局索引,不支持跨shard事务,不保证强一致性(只提供最终一致性),但它把查询下推做到了极致——每个shard上的本地查询是并行执行的,结果只在网络层做轻量级union或merge,几乎没有中间计算开销。这也是它能在百亿级单表上跑出亚秒级响应的根本原因。换句话说,ClickHouse分布式表的设计哲学是:用架构约定换性能,用配置显式性换运行时确定性。你必须清楚告诉它“怎么分”“怎么查”“怎么合”,它才会给你想要的结果。
2. 分片存储的底层逻辑:从ZooKeeper协调到part命名规则的硬核细节
ClickHouse的分片存储不是靠某个中心化服务动态分配数据块,而是通过静态配置+哈希路由+ZooKeeper协同三者结合实现的。整个链条里,ZooKeeper不存数据,只管三件事:协调副本同步状态、维护分布式DDL执行顺序、记录part的元信息(比如哪个part属于哪个replica)。真正决定数据物理位置的,是建表时指定的sharding_key和集群配置中的weight参数。
先说sharding_key。它必须是INSERT语句中明确提供的列,且类型要支持哈希计算(Int、String、Date等)。ClickHouse内部用cityHash64对sharding_key值做哈希,再对总shard数取模,得到目标shard编号。这里有个关键陷阱:如果sharding_key存在大量NULL或重复值(比如用status字段分片,90%数据status=1),哈希结果会高度集中,导致数据倾斜。我实测过一个案例:用province_code分片,但某省占全国订单70%,结果该shard磁盘使用率提前告警,其他shard空转。解决方案不是换算法,而是预处理sharding_key——比如把province_code + user_id拼接后再哈希,或者用MD5(user_id)作为sharding_key,确保分布均匀。
再看part命名规则,这是理解ClickHouse存储结构的钥匙。一个part的名字形如20230101_123456789_987654321_123,四段用下划线分隔:第一段是分区键(partition key)的值,比如日期;第二段是min block number;第三段是max block number;第四段是level(合并层级)。注意:Distributed表本身没有part,只有它指向的本地表才有part。当你向Distributed表INSERT数据时,ClickHouse会根据sharding_key算出目标shard,然后把数据写入该shard上对应本地表的part中。这些part在ZooKeeper里注册为/clickhouse/tables/{cluster}/{table}/replicas/{replica}/parts/{part_name}路径,ZK只存路径和状态,不存数据文件。
为什么强调这个?因为运维排查时,如果你发现某个shard查询慢,不能只看Distributed表的执行计划,必须登录对应节点,进/var/lib/clickhouse/data/{database}/{table}/目录,观察part数量、大小、level值。Level值越大,说明这个part经历过越多轮合并,查询时需要扫描的part越少,但写入压力越大。我们曾遇到一个场景:高频写入导致每秒生成上百个level=0的小part,查询时IO打满。解决方法不是调大merge_tree参数,而是在INSERT时强制触发合并:INSERT INTO distributed_table SELECT * FROM source_table SETTINGS max_threads=1, max_insert_threads=1,配合optimize table ... final手动合并,把小part压成大part。
最后说ZooKeeper的角色。它不参与查询路径,只在写入和DDL时起作用。比如你执行ALTER TABLE distributed_table ON CLUSTER 'my_cluster' ADD COLUMN new_col String,ClickHouse会先在ZK里创建一个临时znode/clickhouse/task_queue/ddl/query-0000000001,所有shard监听这个路径,拿到DDL后各自执行本地表变更。如果某个shard挂了,ZK会标记其状态为lost,后续查询会跳过它(除非配置了skip_unavailable_shards=1)。但要注意:ZK故障会导致所有写入和DDL阻塞,所以生产环境ZK必须是奇数节点(3或5),且网络延迟要低于50ms,否则会出现“ZK session expired”错误,整个集群写入中断。
3. 查询执行全流程拆解:从SQL解析到结果归并的12个关键环节
向ClickHouse分布式表发起一次SELECT查询,表面看只是一条SQL,背后却经历了至少12个不可见的关键环节。我把整个流程拆成“请求进入→路由分发→本地执行→结果归并→返回客户端”五个阶段,每个阶段都有可优化点。
第一阶段:请求进入与语法解析(0.5ms)
客户端连接到任意一个ClickHouse节点(不一定是Distributed表所在节点),发送SQL。服务端先做词法分析,识别出FROM子句中的表名是否为Distributed类型。如果是,立即加载该表的集群配置(从config.xml或ZK读取),确认集群内有多少shard、每个shard有多少replica、各replica的健康状态。这一步耗时极短,但配置错误会直接报错,比如集群名拼错、replica地址不可达。
第二阶段:查询路由与分片裁剪(1~3ms)
这是性能分水岭。ClickHouse会分析WHERE条件里的分区键(partition key)和sharding_key。比如SELECT * FROM dist_orders WHERE event_date = '2023-01-01' AND user_id = 12345,它能精准定位到:event_date分区只涉及2023年1月的shard,user_id哈希后只落在shard2的replica1上。但如果WHERE里只有user_id = 12345,没有event_date,它就必须广播查询到所有shard——这就是常说的“全shard扫描”。我见过最夸张的案例:一个16-shard集群,因WHERE漏写分区条件,每次查询触发16次全量扫描,QPS从2000跌到80。
第三阶段:查询下推与本地执行(占比90%耗时)
路由完成后,Coordinator节点(接收请求的节点)生成16个子查询(对应16个shard),每个子查询都带PREWHERE和WHERE条件,并发发给各shard的leader replica。关键点在于:所有计算都在shard本地完成,Coordinator只做结果搬运。比如SELECT count(*) FROM dist_orders WHERE status = 'paid' GROUP BY province,每个shard返回的是自己节点上的province分组count,不是原始数据。这就要求GROUP BY字段必须在shard本地可计算——如果GROUP BY用的是JOIN后的字段,而JOIN表不在本地,就会报错“Cannot find column in block”。
第四阶段:结果归并与排序(2~10ms)
Coordinator收到所有shard的中间结果后,开始归并。如果是UNION ALL,直接拼接;如果是ORDER BY,启动外部排序(用磁盘临时文件);如果是LIMIT 100,它会先从每个shard取100条,再全局取top100。这里有个隐藏成本:如果shard间数据分布不均,比如shard1返回5万行,shard2返回20行,Coordinator的内存压力会集中在shard1的结果上。我们曾因此触发Memory limit (for query) exceeded,解决方案是加SETTINGS max_bytes_before_external_sort = 1000000000强制外排。
第五阶段:序列化与返回(0.3ms)
归并后的最终结果,按客户端协议(TCP或HTTP)序列化成二进制流,发回客户端。ClickHouse默认用TabSeparated格式,每行字段用tab分隔,速度最快。如果用JSONEachRow,序列化开销增加3倍,但方便前端解析。
整个流程中,最值得监控的指标是ProfileEvents里的QueryTime,ReadCompressedBytes,WriteCompressedBytes,SelectedParts,SelectedRows。比如SelectedParts突然从100涨到5000,说明part太多导致扫描效率下降;ReadCompressedBytes远大于SelectedRows * avg_row_size,说明WHERE条件没有效过滤,正在读取大量无效数据。这些指标在system.query_log表里都有记录,是我们调优的第一手依据。
4. 实操配置与避坑指南:从集群搭建到高频问题的37个经验细节
部署ClickHouse分布式集群不是复制粘贴几行配置就能跑起来的事。我整理了从零开始到稳定运行的完整实操路径,包含37个血泪经验,按操作顺序排列,全是生产环境验证过的硬核细节。
4.1 集群配置:config.xml里的5个致命陷阱
ClickHouse集群定义在<remote_servers>标签内,看似简单,实则暗藏杀机:
shard权重必须显式声明:即使所有shard硬件一致,也要写
<weight>1</weight>。不写的话,ClickHouse默认weight=0,该shard永远不会被选中。我们曾因漏配weight,导致一半节点闲置。internal_replication必须设为true:当本地表是Replicated系列时,此项必须为true,否则Distributed表会尝试往每个replica写数据,造成重复和冲突。false只适用于非复制表。
replica地址必须用FQDN或IP,不能用localhost:节点间通信走真实网络,localhost会指向本机,导致连接失败。配置前务必
ping -c 3 node2测试连通性。zookeeper节点列表必须完整且顺序固定:ZK地址写成
<node><host>zk1</host><port>2181</port></node>,不能只写一个。如果ZK集群有3节点,这里必须列全,且顺序不能变,否则session超时后重连可能连错节点。max_connections要高于单节点默认值:Distributed表作为Coordinator,连接数消耗是单节点的N倍(N=shard数×replica数)。建议设为
1024,避免出现Too many connections。
4.2 建表语句:Distributed表与本地表的6个强耦合点
Distributed表和它指向的本地表,必须严格满足6个契约:
表结构完全一致:包括列名、类型、顺序、编码(CODEC)、采样表达式。哪怕多一个COMMENT,INSERT都会失败。
分区键(PARTITION BY)必须相同:否则跨shard查询时无法对齐分区,GROUP BY会出错。
排序键(ORDER BY)必须相同:影响数据局部性,不同排序键会导致JOIN失效。
采样键(SAMPLE BY)必须相同:如果用了SAMPLE,采样逻辑必须一致,否则采样率偏差巨大。
TTL设置必须相同:不同TTL会导致shard间数据生命周期不一致,查询结果不准。
引擎参数必须兼容:比如本地表用ReplacingMergeTree(version),Distributed表也必须支持version字段,否则OPTIMIZE时版本号无法对齐。
4.3 数据写入:INSERT的4种方式与性能对比
向Distributed表写入,有四种主流方式,性能差异极大:
| 方式 | 语法示例 | 吞吐量 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| 直接INSERT | INSERT INTO dist_table VALUES (...) | ★★☆ | 小批量、实时写入 | 每次INSERT都触发路由,高并发下Coordinator成瓶颈 |
| INSERT SELECT | INSERT INTO dist_table SELECT * FROM local_table | ★★★★ | 批量导入、ETL | 必须确保SELECT结果集sharding_key分布均匀,否则倾斜 |
| Buffer表 | INSERT INTO buffer_table VALUES (...) | ★★★★★ | 高频写入、削峰填谷 | Buffer表需配置flush_interval_ms,否则内存溢出 |
| Kafka引擎 | CREATE TABLE kafka_table ENGINE = Kafka(...) AS dist_table | ★★★★ | 流式接入、实时管道 | Kafka分区数必须≥shard数,否则消费不均衡 |
我们线上用Buffer表方案,配置buffer_table的min_rows_to_flush=100000,min_bytes_to_flush=100000000,max_delay_to_flush=60,实测写入吞吐达120万行/秒,比直接INSERT高8倍。
4.4 查询优化:12个让QPS翻倍的实战技巧
永远带上分区键:
WHERE event_date >= '2023-01-01'比WHERE toDate(event_time) >= '2023-01-01'快10倍,因为后者无法裁剪part。用IN替代OR:
WHERE user_id IN (1,2,3)比WHERE user_id=1 OR user_id=2 OR user_id=3快,前者能利用索引。**避免SELECT ***:只查需要的列,减少网络传输和内存占用。
SELECT count(*)比SELECT *快5倍以上。用PREWHERE代替WHERE:
PREWHERE status='paid'会先过滤再读取其他列,比WHERE status='paid'少读70%数据。GROUP BY加WITH TOTALS:需要小计时,用
GROUP BY ... WITH TOTALS比两次查询快。大表JOIN用GLOBAL IN:
WHERE user_id GLOBAL IN (SELECT id FROM dim_users)会把子查询结果广播到所有shard,避免多次远程查询。关闭不必要的函数:
now()每次调用都重新计算,用today()或传入参数更高效。用arrayJoin替代JOIN:
SELECT arrayJoin(tags) as tag FROM table比JOIN tags_table内存占用低80%。设置max_threads=CPU核心数-1:避免线程争抢,实测8核机器设为7时吞吐最高。
用FORMAT Null跳过结果序列化:调试时加
FORMAT Null,只测查询逻辑不测网络。用EXPLAIN查看执行计划:
EXPLAIN PLAN SELECT ...能看到是否下推、是否广播、是否用索引。定期OPTIMIZE TABLE ... FINAL:合并小part,减少查询时扫描的part数量。
4.5 故障排查:8类高频报错与根因定位
提示:所有ClickHouse错误码都在
/usr/include/ClickHouse/Common/Exception.h里,但不用背,记住这8类就够了。
Code: 210. DB::NetException:网络超时,检查防火墙、ZK连接、节点间ping延迟。
Code: 159. DB::StorageDistributedDirectoryMonitor:Distributed表积压未发送数据,进
/var/lib/clickhouse/store/xxx/distributed/删积压文件,重启clickhouse-server。Code: 241. DB::Exception: Memory limit exceeded:内存不足,调大
max_memory_usage或加SETTINGS max_bytes_before_external_sort。Code: 48. DB::Exception: Cannot find column:列不存在,检查表结构是否同步,Distributed表和本地表是否一致。
Code: 271. DB::Exception: Replica is not in ZooKeeper:replica注册失败,重启clickhouse-server,检查ZK路径权限。
Code: 232. DB::Exception: Table was not found:表名拼错或数据库名不匹配,注意大小写敏感。
Code: 171. DB::Exception: Too many parts:part数量超限,调大
max_parts_in_total或执行OPTIMIZE TABLE ... FINAL。Code: 33. DB::Exception: Cannot read all data:磁盘满或inode耗尽,
df -h和df -i双查。
我们运维手册里有一条铁律:任何报错先看clickhouse-server.err.log的ERROR级别日志,再查system.processes看当前查询状态,最后用SELECT * FROM system.query_log WHERE query LIKE '%xxx%' ORDER BY event_time DESC LIMIT 10追溯源头。这个组合拳能定位95%的问题。
5. 大数据场景下的典型架构与性能边界:从千万到千亿级的真实数据
ClickHouse分布式表在大数据场景的价值,不是理论上的“支持PB级”,而是具体到某个业务规模时,它能帮你省多少机器、降多少延迟、扛多少QPS。我拿三个真实项目案例说明它的能力边界和适配逻辑。
案例一:用户行为分析平台(日增5亿事件,峰值QPS 3000)
数据模型:event_time(分区键)、user_id(sharding_key)、event_type、page_url、duration。集群规模:12 shard × 2 replica = 24节点,每节点64GB内存、16核CPU、SSD。
关键设计:
- 分区按
toYYYYMMDD(event_time),每天一个part,避免part爆炸; - sharding_key用
cityHash64(user_id),确保用户行为数据本地化; - 查询模式以user_id为过滤主键,90%查询命中单shard;
- 用ReplacingMergeTree去重,version字段为event_time。
结果:单日5亿数据写入无压力,SELECT count() WHERE user_id = 12345平均响应12ms,SELECT count() GROUP BY event_type全shard扫描耗时850ms。瓶颈在磁盘IO,升级NVMe后降到320ms。结论:这个规模下,Distributed表完全胜任,无需引入Spark或Flink做预聚合。
案例二:广告效果归因系统(日增20亿点击,需实时漏斗分析)
数据模型:click_time、ad_id、campaign_id、user_id、ip、ua。挑战在于:既要支持按ad_id的高并发查询,又要支持跨天的用户路径还原(需JOIN多日数据)。
我们放弃纯Distributed方案,改用分层架构:
- 第一层:Distributed表存原始点击,sharding_key=ad_id,支撑实时报表;
- 第二层:MaterializedView按user_id聚合7日行为,存入另一张Distributed表;
- 第三层:用ClickHouse的WindowFunnel函数做漏斗,
windowFunnel(86400)(time, condition)。
结果:原始表QPS 5000,聚合表QPS 800,漏斗查询平均2.3秒。这里Distributed表的作用是分流写入压力,真正的计算卸载到物化视图。结论:当查询模式复杂(多表JOIN、窗口函数)时,Distributed表应作为数据入口,而非计算主力。
案例三:IoT设备时序数据(1000万设备,每秒写入100万点)
数据模型:device_id、metric_name、value、ts。传统方案用InfluxDB,但聚合查询慢,且无法做设备维度关联分析。
我们用ClickHouse的自定义sharding_key + TTL + CollapsingMergeTree:
- sharding_key =
cityHash64(concat(device_id, metric_name)),避免单设备数据倾斜; - TTL
ts + INTERVAL 90 DAY,自动清理冷数据; - 用CollapsingMergeTree压缩重复上报值。
结果:写入吞吐达120万点/秒,SELECT avg(value) WHERE device_id = 'D123' AND ts > now() - 1 HOUR响应18ms。但全设备统计SELECT count() GROUP BY metric_name需扫描所有shard,耗时4.2秒。结论:Distributed表适合“点查”和“窄范围聚合”,不适合“全量扫描类”OLAP,这类需求应前置物化视图或用单独汇总表。
这三个案例揭示一个核心规律:ClickHouse分布式表的性能天花板,不由总数据量决定,而由单次查询涉及的shard数量、每个shard的part数量、以及查询模式是否能利用本地性决定。千亿级数据只要查询能落到1-2个shard上,依然亚秒响应;百万级数据如果每次都要扫全shard,照样卡顿。所以架构设计的第一步,永远是问自己:“我的80%查询,会落在几个shard上?”
6. 与同类技术的硬核对比:为什么选ClickHouse而不是MySQL分库分表或Spark SQL
在大数据技术选型会上,常有人问:“既然MySQL也能分库分表,Spark SQL也能跑SQL,为啥非要ClickHouse?”这不是站队问题,而是基于具体场景的工程权衡。我用一张表说清本质差异:
| 维度 | ClickHouse Distributed表 | MySQL分库分表 | Spark SQL on Hive |
|---|---|---|---|
| 数据写入模型 | 列存+LSM树,追加写+后台合并,吞吐100万+/秒 | 行存+B+树,随机读写,吞吐2万+/秒 | 批处理,写入即落地,吞吐50万+/秒(需调优) |
| 查询延迟 | 点查/聚合:10ms~500ms;全表扫描:秒级 | 点查:10ms;复杂聚合:秒~分钟级 | 任意查询:分钟级起步,小数据集也难低于30秒 |
| 扩展性 | 水平扩展线性,16节点≈16倍QPS | 扩展性差,分片数超100后运维崩溃 | 扩展性好,但资源消耗巨大,100节点集群常驻内存5TB+ |
| 运维复杂度 | 配置驱动,ZK强依赖,DDL需集群同步 | 中间件(ShardingSphere)复杂,事务难保证 | YARN/Spark配置地狱,调优参数超200个 |
| 数据一致性 | 最终一致性,无跨shard事务 | 强一致性(XA事务),但性能牺牲大 | 强一致性,但仅限于批处理场景 |
| 适用场景 | 实时分析、宽表聚合、用户行为探查 | 在线交易、强一致性业务、小规模报表 | 离线数仓、ETL、机器学习特征工程 |
举个具体例子:某电商平台要做“最近30天用户复购率”报表。
- 用MySQL分库分表:需在10个库上分别查
SELECT count(DISTINCT user_id) FROM orders WHERE create_time > DATE_SUB(NOW(), INTERVAL 30 DAY),再在应用层汇总,耗时2.3秒,且无法支持实时刷新。 - 用Spark SQL:每天凌晨跑一次任务,结果存Hive,报表只能看昨天数据。
- 用ClickHouse:
SELECT countIf(hasBuy) / count() FROM (SELECT user_id, hasBuy FROM dist_orders WHERE event_date >= today() - 30 GROUP BY user_id HAVING count() > 1),实时计算,响应380ms,支持秒级刷新。
再看另一个场景:金融风控的“实时黑名单查询”。
- MySQL:单点查询快,但无法支撑每秒5万QPS的并发,加读写分离后延迟升至50ms。
- ClickHouse:用Distributed表+ReplicatedMergeTree,16节点集群轻松扛住8万QPS,P99延迟12ms。
- Spark:根本没法做实时查询,它是批处理引擎。
所以结论很清晰:ClickHouse分布式表不是通用数据库,而是为“高吞吐写入+低延迟聚合查询”这一特定场景深度优化的专用引擎。它放弃事务、放弃强一致、放弃灵活Schema,换来的是在OLAP领域的绝对性能优势。选它,不是因为它“全能”,而是因为它在你最关键的那1%查询上,快得让你无法忍受其他方案。
7. 踩过的坑与终极建议:一个老鸟的11条肺腑之言
干了十年ClickHouse,从单机到千节点集群,从踩坑到填坑再到造坑,有些话必须掏心窝子说:
永远不要在生产环境用default集群名:
ON CLUSTER 'default'看着省事,但升级时所有节点同时执行DDL,一旦出错全挂。必须为每个环境定义独立集群名,如'prod_cluster'、'staging_cluster'。Distributed表的INSERT不是原子的:部分shard写入成功、部分失败时,不会回滚,只会报错。应用层必须实现幂等写入,比如用
INSERT SELECT配合WHERE NOT EXISTS。ZooKeeper不是ClickHouse的附属品,而是心脏:我们曾因ZK磁盘满导致整个集群写入中断47分钟。现在ZK单独部署,磁盘用RAID10,监控项加了
zk_avg_latency、zk_outstanding_requests、zk_watch_count。不要迷信官方文档的默认参数:
max_threads=16在8核机器上会争抢CPU,实测max_threads=7最佳;min_bytes_for_wide_part=10MB在SSD上应调小到1MB,避免小part堆积。备份不是“cp -r”那么简单:
clickhouse-backup工具必须配合--schema和--tables精确指定,否则restore时表结构错乱。我们每周全量+每日增量,备份存S3,恢复演练每月一次。监控必须覆盖三层:节点层(CPU、内存、磁盘IO)、ClickHouse层(system.metrics、system.events)、查询层(query_log里的query_duration、read_rows)。缺一层,问题定位时间翻倍。
升级ClickHouse前,先升级ZooKeeper:新ClickHouse版本可能依赖ZK新特性,ZK版本低会导致
Session expired。我们坚持ZK版本≥3.6.0。Distributed表的DDL要分两步:先在所有shard上建本地表,再建Distributed表。如果ON CLUSTER建表失败,本地表已存在,清理极其麻烦。
不要用Distributed表做JOIN大表:
SELECT * FROM dist_table1 JOIN dist_table2 USING (id)会把两个表全量拉到Coordinator内存,OOM风险极高。正确做法是用GLOBAL IN或物化视图预关联。慢查询日志不是摆设:
system.query_log里query_duration_ms > 5000的查询必须每日分析,我们用Grafana看趋势,用脚本自动提取TOP10慢SQL,开发团队认领优化。最后一个,也是最重要的:ClickHouse分布式表的强大,90%来自你的设计,10%来自它的代码。它不会替你思考数据如何分片、查询如何优化、集群如何扩容。你写的每一行建表语句、每一个WHERE条件、每一次OPTIMIZE,都在定义这个系统的上限。别把它当黑盒,把它当你的作品——因为最终,它就是你。
我在实际运维中发现,最稳定的集群,不是配置最豪华的,而是表结构设计最克制的:分区键只用日期,sharding_key只用主键,查询条件永远带上分区,INSERT永远走Buffer表。大道至简,ClickHouse的哲学,正在于此。