数据部分与合并 — INSERT 产生部分,合并减少部分
一句话总结
在 MergeTree 中,一次 INSERT 至少会创建一个新的数据片段,后台合并再把小的数据片段合并成大的。如果数据片段堆积得比合并更快,服务器就会拒绝 INSERT(TOO_MANY_PARTS)。所以向 ClickHouse 写入数据的关键不是“写入多少行”,而是“创建多少个数据片段”。
为什么需要它
上一个模块说过,数据片段一旦写出就不会改变。多亏这一点,写入无需加锁,创建一个新目录就完成了;读取也只需要扫描已经排序、压缩好的文件。但这种设计带着一张账单。一行一行地写入,数据片段也会一个接一个地产生,每个只有一行。查询要分别查看每个数据片段的索引、分别打开文件,所以数据片段越多就越慢;而合并则要把这些小数据片段重新读出再重新写入。
熟悉行式数据库的人,很容易写出每来一个事件就发一条 INSERT 的代码。在 ClickHouse 中,这样的代码会变成生产故障。本模块通过 system.part_log 中的事件,追踪数据片段的产生与合并过程,并用数字观察越过阈值时什么会被挡住,以及由服务器代为攒批的异步 INSERT 改变了什么。
工作原理
数据片段的名称就是履历。 all_3_7_2 表示分区为 all,覆盖块编号 3 到 7,合并级别为 2。INSERT 创建的数据片段拿到一个块编号,成为 all_N_N_0,正如官方文档(Part merges)所述,每合并一次,级别就加一。在实验 Pod 中,对停止合并的表发送 10 条 INSERT,all_1_1_0 …… all_10_10_0 就会原样保留。
一次 INSERT ≠ 一个数据片段。 服务器会把进来的行攒成块,每个块写一个数据片段。INSERT ... SELECT 会把块拼在一起,直到攒够 min_insert_block_size_rows(默认 1,048,449)。用 min_insert_block_size_rows = 250000 写入 100 万行,产生了 4 个数据片段(261,636 × 3 + 215,092)。如果是有分区的表,块还会按分区再拆分——这是下一个模块的主题。
事件会留在 part_log 中。 这个 Pod 开启了 system.part_log。数据片段产生时会记录 NewPart(同时记录是哪个 INSERT 的 query_id 创建的),合并时会记录 MergeParts(合并了什么在 merged_from 中),各占一行。system.parts 只显示“现在”,而 part_log 显示“是怎么走到这一步的”。如果把表删除后以同样的名称重新创建,就会与旧记录混在一起,所以要用 table_uuid 过滤。
合并可以停止,但 OPTIMIZE 也会一起停。 SYSTEM STOP MERGES 표(占位符为表名)会停止该表的合并(重新启动服务器后恢复)。实测发现,对已停止的表执行 OPTIMIZE ... FINAL,会因 Cancelled merging parts(ABORTED)被拒绝。用 SYSTEM START MERGES 恢复之后,后台合并先把 10 个合并成了 all_1_10_1,FINAL 又把这一个数据片段重写,变成 all_1_10_2。正如文档所说,FINAL 即使只有一个数据片段也会合并。究竟走的是哪条路径,每次都可能不同,所以要用 part_log 确认。
阈值。 一个分区的活动数据片段数超过 parts_to_delay_insert 时,会有意放慢 INSERT;达到 parts_to_throw_insert 时,会以 Too many parts ... Merges are processing significantly slower than inserts 拒绝(错误 252)。这个值是表设置。降到 5 并停止合并,逐行写入,前 5 个成功,第六个被拒绝。解决的办法是减少数据片段——开启合并并合并之后,再写入就进去了。
异步 INSERT 由服务器代为攒批。 26.8 的默认值是 async_insert = 1、wait_for_async_insert = 1。在实验 Pod 上发送 INSERT ... VALUES,query_log 中会留下 Insert 和 AsyncInsertFlush 两条查询。但在等待模式下,如果一个客户端一条一条地发,每次缓冲区都会被清空,数据片段也就一个一个地产生(5 次 → 5 个)。要让多个 INSERT 成为一个数据片段,它们必须同时位于缓冲区中。用不等待模式(wait_for_async_insert = 0)发送 5 次,再用 SYSTEM FLUSH ASYNC INSERT QUEUE 清空,5 次就变成了一个数据片段。文档不推荐不等待模式——因为错误不会返回给客户端。实验中使用它,是为了看到与时间无关的汇集。另外,INSERT ... SELECT 不适用异步 INSERT。
在现场相遇的样子
最常见的故障是“Too many parts”。原因几乎总是这两者之一——一行一行写入的采集器,或者分得过细的分区。文档也指出,调高阈值只是推迟症状。先在 part_log 中按 query_id 统计 NewPart,立刻就能看出是哪个 INSERT 在大量产生数据片段。解决办法是在客户端攒批后再发送(文档(Selecting an insert strategy)的建议是一次至少 1,000 行,最好 10,000–100,000 行),做不到的话,就交给异步 INSERT。
第二种是用 cron 定时运行 OPTIMIZE ... FINAL 的运维方式。文档(Avoid OPTIMIZE FINAL)要求把它当作管理操作,而不是日常操作——即使已经只有一个数据片段也会重写,所以对大表来说很昂贵。本实验使用 FINAL,是为了固定数据片段数量,方便肉眼观察。
下一项实验要做什么
向停止了合并的 parts.events 发送 10 条 INSERT,产生 10 个数据片段,并通过 part_log 确认一次 100 万行的 INSERT 会按块大小变成多个数据片段。读出开启合并并用 OPTIMIZE 合并的过程,在把阈值降到 5 的表中触发 TOO_MANY_PARTS,再把它解开。最后把 5 次异步 INSERT 汇集成一个数据片段,并用一张表整理每张表的新数据片段数。