写入两百万行,看看列文件里有什么
目标
向 MergeTree 表写入 200 万行,并从 system 表中读出数据片段、颗粒和各列的压缩率。用 rows_read、bytes_read 确认,即使读取相同的行数,成本也会因触碰的列和排序键而不同。
为什么重要
在列式数据库中,查询成本不取决于“选了多少行”,而取决于“读了哪些列、多少个颗粒”。没有这种感觉,就会选错排序键,习惯性地使用 SELECT *,并按原始大小来估算磁盘容量。本实验的评分器不会相信你写的数字——它会直接读取服务器的 system.parts、system.columns、system.query_log,并把你保存的 SELECT 以只读方式重新运行,测量读取的行数和字节数进行核对。评分器自己的查询不会留在 query_log 中。
步骤
- 创建数据库
col和表col.events——列为ts DateTime, site LowCardinality(String), user_id UInt64, url String, dur_ms UInt32, country FixedString(2)(按此顺序),引擎为MergeTree,排序键为ORDER BY (site, ts)。 - 运行一次
/opt/lab/fixtures/columnar/events.sql,写入 200 万行。 - 用
OPTIMIZE TABLE col.events FINAL把数据片段合并成一个,然后把该数据片段的part(名称)、rows、marks、part_type写入 /root/ch/columnar/parts.json。 - 从 system.columns 中找出未压缩/压缩比值最大的列(
most_compressed)、最小的列(least_compressed)以及压缩后的字节合计(total_compressed_bytes),写入 /root/ch/columnar/columns.json。 - 创建只求
dur_ms之和的 /root/ch/columnar/q_one.sql,以及读取url字符串内容的 /root/ch/columnar/q_url.sql(例如max(url)),并把两个查询的bytes_read以one_col_bytes、url_bytes写入 /root/ch/columnar/bytes.json。 - 创建求
site = 'docs.example'的行的dur_ms之和的 /root/ch/columnar/q_key.sql,以及求country = 'KR'的行的dur_ms之和的 /root/ch/columnar/q_nokey.sql,并把两个查询的rows_read以key_rows_read、nokey_rows_read写入 /root/ch/columnar/rows.json。 - 加上
SETTINGS log_comment = 'chs-col-07'运行SELECT uniqExact(user_id) FROM col.events WHERE site = 'shop.example',然后在 system.query_log 中找到这个查询,把query_id、read_rows、read_bytes、result_rows写入 /root/ch/columnar/qlog.json。 - 创建与
col.events列相同的表col.small,用一次 INSERT 写入col.events的 1000 行,并把两张表的数据片段格式以events、small写入 /root/ch/columnar/part_types.json。
参考
- 服务器在 Pod 启动时就已经运行(127.0.0.1:9000)。只需输入
clickhouse-client即可连接。如果停了,就运行ch-up。 - 用文件运行查询:
clickhouse-client --queries-file 파일.sql(占位符为文件名)。用--format JSON可以在statistics中拿到结果末尾的统计信息(jq .statistics)。 - 想让数字在 JSON 中不带引号,可以加上
--output_format_json_quote_64bit_integers 0。 OPTIMIZE ... FINAL在实验中是为了固定数据片段数量而使用的。在生产环境中,原则是把合并交给服务器。- query_log 大约每 1 秒清空一次。如果看不到刚运行的查询,请执行
SYSTEM FLUSH LOGS。 - 常见错误:第 2 步运行了两次,变成 400 万行——MergeTree 不会阻止重复。这时要先
TRUNCATE TABLE col.events再重新写入。 - 官方文档:Table parts · Primary indexes · MergeTree · system.parts · system.columns · system.query_log
创建带排序键的 MergeTree 表
创建数据库 col 和表 col.events。列依次为 ts DateTime, site LowCardinality(String), user_id UInt64, url String, dur_ms UInt32, country FixedString(2),引擎为 MergeTree,排序键为 ORDER BY (site, ts)。
就是 CREATE DATABASE 和 CREATE TABLE 两条语句。没有写排序键的 MergeTree 是建不出来的——因为键就是行在磁盘上的顺序。列名和类型哪怕有一个字符不同,后面步骤中的源脚本就写不进去。
写入 200 万行源数据
运行一次 /opt/lab/fixtures/columnar/events.sql,向 col.events 写入 2,000,000 行。
用 --queries-file 传给 clickhouse-client 即可。这个脚本用 numbers() 生成行号,并用哈希取值,所以不管运行多少次,得到的行都相同。如果行数是 400 万,说明写了两次。
合并成一个数据片段并读出名称
用 OPTIMIZE TABLE col.events FINAL 把数据片段合并成一个,然后从 system.parts 中读出该活动数据片段的 part(名称)、rows、marks、part_type,写入 /root/ch/columnar/parts.json。
一次 INSERT 会根据块大小产生多个数据片段。合并之后名称会变(合并级别提高)。只看 active = 1 的行——被合并的旧数据片段也会在列表里留一小会儿。把 system.parts 的 name 列用别名命名为 part,以 JSONEachRow 取出,就可以直接保存。
各列的压缩率相差多大
从 system.columns 中为 col.events 的每一列计算 data_uncompressed_bytes / data_compressed_bytes,把最大的列作为 most_compressed,最小的列作为 least_compressed,压缩后的字节合计作为 total_compressed_bytes,写入 /root/ch/columnar/columns.json。
用 argMax(name, 比率) 和 argMin(name, 比率),一条查询就能完成。压缩得最好的列通常位于排序键的前部,或者是只有几种取值的列;压缩得最差的列,是不断增大的值或接近随机的值。
同样的行,不同的列——读取的字节数
创建只求 dur_ms 之和的 /root/ch/columnar/q_one.sql,以及读取 url 字符串内容的 /root/ch/columnar/q_url.sql,并用 --format JSON 运行这两个查询,把 statistics.bytes_read 以 one_col_bytes、url_bytes 写入 /root/ch/columnar/bytes.json。
两个查询都会读取全部 200 万行。不同的是所触碰的列的大小。一个 UInt32 列,每行 4 字节。url 那边不能用 length(url)——这个函数会被改为只读取仅含长度的子列,而不读取内容。请使用 max(url) 这样会比较内容的函数。
只有按排序键过滤才会跳过
创建求 site = 'docs.example' 的行的 dur_ms 之和的 /root/ch/columnar/q_key.sql,以及求 country = 'KR' 的行的 dur_ms 之和的 /root/ch/columnar/q_nokey.sql,并把两个查询的 statistics.rows_read 以 key_rows_read、nokey_rows_read 写入 /root/ch/columnar/rows.json。
site 是排序键的第一列,所以索引会跳过不可能含有该值的颗粒。country 不在键中,没有可以跳过的依据。想一想,为什么读取的行数会接近 8192 的倍数——跳过的单位不是行,而是颗粒。
在 query_log 中找到自己的查询
运行 SELECT uniqExact(user_id) FROM col.events WHERE site = 'shop.example' SETTINGS log_comment = 'chs-col-07',然后在 system.query_log 中找到该查询的结束(type = 'QueryFinish')记录,把 query_id、read_rows、read_bytes、result_rows 写入 /root/ch/columnar/qlog.json。
query_log 大约每 1 秒写入磁盘一次。想立刻找到,就先执行 SYSTEM FLUSH LOGS。一个查询会留下开始(QueryStart)和结束(QueryFinish)两行,读取的行数只在结束那一行。用 log_comment 过滤,就能在众多查询中只留下自己的。
小数据片段会成为 Compact
创建与 col.events 列和引擎相同的表 col.small,用一次 INSERT 写入 col.events 的 1000 行,并把两张表的活动数据片段格式(part_type)以 events、small 写入 /root/ch/columnar/part_types.json。
CREATE TABLE ... AS 另一张表 会原样复制列和引擎。数据片段格式取决于:数据片段的字节数、行数小于表设置 min_bytes_for_wide_part、min_rows_for_wide_part 就是 Compact,大于则是 Wide。请不要修改表设置。