只改变类型和编解码器存储相同的值并测量大小
目标
创建默认类型、默认编解码器的表,以及只改变类型或编解码器来存放同样值的对照表,用 system.columns 的压缩后字节数确认哪种选择获胜。最后看看,修改运行中的表的编解码器,大小会在何时改变。
为什么重要
分析型数据库的磁盘、内存、读取成本,归根结底是字节。即使是同样的数据,大小也会因类型和编解码器而相差好几倍,而哪个编解码器获胜,取决于数据的形态——本实验中,也有一个专用编解码器反而吃亏。所以比较必须是在相同的数据、相同的排序下,只改变一个因素。本实验的对照表就是这种方法。评分器不会相信你写的数字,而是直接读取 system.columns 进行核对,判定依据是字节,而不是速度。
步骤
- 创建数据库
codecs和表codecs.plain——列ts DateTime, host String, status String, cpu Float64, bytes_total UInt64, latency_ms UInt32, err_code Nullable(UInt16)(按此顺序,不加编解码器),引擎MergeTree,ORDER BY (host, ts)。 - 运行一次
/opt/lab/fixtures/codecs/metrics.sql,写入 100 万行,并用OPTIMIZE TABLE codecs.plain FINAL把数据片段变成一个。 - 用
ORDER BY (host, ts)创建表codecs.status_bench (host String, ts DateTime, s_str String, s_lc LowCardinality(String), s_enum Enum8('ok' = 1, 'warn' = 2, 'error' = 3, 'timeout' = 4)),在三列中都放入 plain 的status,合并成一个数据片段之后,把三列的压缩后字节数和最小的列名(smallest)写入 /root/ch/codecs/types.json。 - 用
ORDER BY (host, ts)创建表codecs.ts_bench (host String, ts DateTime, ts_zstd DateTime CODEC(ZSTD(1)), ts_delta DateTime CODEC(Delta, ZSTD(1)), ts_dd DateTime CODEC(DoubleDelta, ZSTD(1))),在四个时间列中都放入 plain 的ts,合并成一个数据片段之后,把四列的压缩后字节数和最小的列(best)写入 /root/ch/codecs/time.json。 - 用
ORDER BY (host, ts)创建表codecs.num_bench (host String, ts DateTime, cnt UInt64 CODEC(ZSTD(1)), cnt_delta UInt64 CODEC(Delta, ZSTD(1)), lat UInt32 CODEC(ZSTD(1)), lat_t64 UInt32 CODEC(T64, ZSTD(1)), cpu Float64 CODEC(ZSTD(1)), cpu_gorilla Float64 CODEC(Gorilla, ZSTD(1))),为每一对放入 plain 的bytes_total、latency_ms、cpu,合并成一个数据片段之后,把六列的压缩后字节数,以及比配对的另一列反而更大的专用编解码器列名的数组(codec_lost)写入 /root/ch/codecs/numbers.json。 - 用
ORDER BY (host, ts)创建表codecs.null_bench (host String, ts DateTime, err_null Nullable(UInt16), err_zero UInt16),在err_null中放入 plain 的err_code,在err_zero中放入把 NULL 填成 0 的值,并合并成一个数据片段。创建统计 NULL 行数的 /root/ch/codecs/q_isnull.sql,把null_uncompressed(err_null 的压缩前字节数)、isnull_bytes_read(q_isnull.sql 的 bytes_read)、avg_null、avg_zero(两列的 avg)写入 /root/ch/codecs/nullable.json。 - 用
ORDER BY (host, ts)创建表codecs.tuned (ts DateTime CODEC(Delta, ZSTD(1)), host LowCardinality(String), status LowCardinality(String), cpu Float64 CODEC(ZSTD(1)), bytes_total UInt64 CODEC(Delta, ZSTD(1)), latency_ms UInt16 CODEC(T64, ZSTD(1)), err_code UInt16),迁移 plain 的行(NULL 按 0 处理)并合并成一个数据片段之后,把两张表的压缩后字节数之和以plain_total、tuned_total写入 /root/ch/codecs/tuned.json。 - 用与 plain 相同的列、引擎创建
codecs.legacy,放入 plain 的行并合并成一个数据片段后,测量 ts 的压缩后字节数(before)。在执行ALTER TABLE codecs.legacy MODIFY COLUMN ts CODEC(Delta, ZSTD(1))之后立刻再测一次(after_alter),在OPTIMIZE TABLE codecs.legacy FINAL之后再测一次(after_optimize),把三个值写入 /root/ch/codecs/alter.json。
参考
- 服务器在 Pod 启动时就已经运行。只需输入
clickhouse-client即可连接。如果停了,就运行ch-up。 - 各列大小:
SELECT name, data_compressed_bytes, data_uncompressed_bytes FROM system.columns WHERE database = 'codecs' AND table = '...'。编解码器可以在同一张表的compression_codec列中看到(默认编解码器是空字符串)。 - 想让数字在 JSON 中不带引号,用
--output_format_json_quote_64bit_integers 0。也可以把 JSONEachRow 的一行原样保存为文件。 - 测量大小之前,始终先执行
OPTIMIZE TABLE ... FINAL。数据片段有多个时,压缩块的边界不同,数字会波动,评分器只接受数据片段为一个时的结果(这是实验用的步骤——在生产环境中,合并交给服务器)。 - 常见错误:向对照表中放入两次 plain(200 万行)。这时先
TRUNCATE TABLE再重新写入。如果只写专用编解码器而不写通用编解码器(CODEC(Delta)),服务器会拒绝,或者大小压不下去。 - 官方文档:CODEC · Compression in ClickHouse · LowCardinality · system.columns
不假思索创建的表
创建数据库 codecs 和表 codecs.plain。列依次为 ts DateTime, host String, status String, cpu Float64, bytes_total UInt64, latency_ms UInt32, err_code Nullable(UInt16)(不加编解码器),引擎为 MergeTree,排序键为 ORDER BY (host, ts)。
这是有意做成把源数据库 schema 原样搬过来的样子——字符串用 String,空值用 Nullable,不写编解码器,所以是默认的(LZ4)。这张表将作为后面各步骤比较的基准。
写入 100 万行,并合并成一个数据片段
运行一次 /opt/lab/fixtures/codecs/metrics.sql,向 codecs.plain 写入 1,000,000 行,并用 OPTIMIZE TABLE codecs.plain FINAL 把活动数据片段变成一个。
用 --queries-file 传给 clickhouse-client 即可。脚本把 50 台服务器每 10 秒上报的指标用哈希生成,所以不管运行多少次,得到的行都相同。要比较大小,数据片段必须是一个,数字才不会波动。
String · LowCardinality · Enum8
用 ORDER BY (host, ts) 创建 codecs.status_bench (host String, ts DateTime, s_str String, s_lc LowCardinality(String), s_enum Enum8('ok' = 1, 'warn' = 2, 'error' = 3, 'timeout' = 4)),在三列中都放入 plain 的 status,合并成一个数据片段之后,把三列的压缩后字节数(s_str、s_lc、s_enum)和最小的列名(smallest)写入 /root/ch/codecs/types.json。
像 INSERT ... SELECT host, ts, status, status, status 这样把同一个值放三次,差别就只来自类型。String 按行存字符,LowCardinality 存字典和编号,Enum8 存 1 字节的编号。请使用 system.columns 的 data_compressed_bytes——不是 data_uncompressed_bytes。
时间列——LZ4 · ZSTD · Delta · DoubleDelta
用 ORDER BY (host, ts) 创建 codecs.ts_bench (host String, ts DateTime, ts_zstd DateTime CODEC(ZSTD(1)), ts_delta DateTime CODEC(Delta, ZSTD(1)), ts_dd DateTime CODEC(DoubleDelta, ZSTD(1))),在四个时间列中都放入 plain 的 ts,合并成一个数据片段之后,把四列的压缩后字节数(ts、ts_zstd、ts_delta、ts_dd)和最小的列名(best)写入 /root/ch/codecs/time.json。
排序键是 (host, ts),所以在一台服务器内,时间恒定地每 10 秒增加。通用编解码器在不断增大的整数中找不到重复,但如果预处理编解码器先把值改写成与相邻值的差,情况就不同了。编解码器链从左到右应用,所以预处理写在前面,通用压缩写在后面。
专用编解码器并不总是赢
用 ORDER BY (host, ts) 创建 codecs.num_bench (host String, ts DateTime, cnt UInt64 CODEC(ZSTD(1)), cnt_delta UInt64 CODEC(Delta, ZSTD(1)), lat UInt32 CODEC(ZSTD(1)), lat_t64 UInt32 CODEC(T64, ZSTD(1)), cpu Float64 CODEC(ZSTD(1)), cpu_gorilla Float64 CODEC(Gorilla, ZSTD(1))),在 cnt 这一对中放入 bytes_total,在 lat 这一对中放入 latency_ms,在 cpu 这一对中放入 cpu,合并成一个数据片段之后,把六列的压缩后字节数,以及比配对的另一列(只用 ZSTD 的列)反而更大的专用编解码器列名的数组 codec_lost 写入 /root/ch/codecs/numbers.json。
每一对中,一列只用 ZSTD,另一列是专用编解码器 + ZSTD,所以差别就是专用编解码器所增加的部分。累计计数器总是在增大,延迟时间范围很窄,CPU 是精确到小数点后第二位的浮点数。先猜测并记下哪一个会赢,再与数字对照。如果没有变大的列,就是空数组。
Nullable 会多放一个文件
用 ORDER BY (host, ts) 创建 codecs.null_bench (host String, ts DateTime, err_null Nullable(UInt16), err_zero UInt16),在 err_null 中放入 plain 的 err_code,在 err_zero 中放入把 NULL 填成 0 的值,并合并成一个数据片段。创建统计 err_null 为 NULL 的行数的 /root/ch/codecs/q_isnull.sql,把 null_uncompressed(err_null 的 data_uncompressed_bytes)、isnull_bytes_read(q_isnull.sql 的 statistics.bytes_read)、avg_null、avg_zero(两列的 avg)写入 /root/ch/codecs/nullable.json。
Nullable 列会另外放一个值文件和一个每行 1 字节的 null 映射。只询问是否为 NULL 的条件只读 null 映射,所以读取的字节数应该等于行数。如果混入比较值的条件,就连值文件也要读。平均值会去掉 NULL,但会计入 0——你应该能解释两个 avg 为什么不同。
汇集获胜选择的表
用 ORDER BY (host, ts) 创建 codecs.tuned (ts DateTime CODEC(Delta, ZSTD(1)), host LowCardinality(String), status LowCardinality(String), cpu Float64 CODEC(ZSTD(1)), bytes_total UInt64 CODEC(Delta, ZSTD(1)), latency_ms UInt16 CODEC(T64, ZSTD(1)), err_code UInt16),迁移 plain 的行(err_code 的 NULL 按 0 处理)并合并成一个数据片段之后,把 plain 和 tuned 的压缩后字节数之和以 plain_total、tuned_total 写入 /root/ch/codecs/tuned.json。
这是把前面步骤的结果汇集起来——先选类型(LowCardinality、符合范围的 UInt16、用 0 代替 NULL),然后再加编解码器。Gorilla 在前面的步骤里输了,所以 cpu 只用 ZSTD。两张表的总和,把 system.columns 按 table 分组后相加即可。
修改运行中的表的编解码器,什么时候会变小
用与 plain 相同的列、引擎创建 codecs.legacy,放入 plain 的行并合并成一个数据片段之后,测量 ts 的压缩后字节数(before)。执行 ALTER TABLE codecs.legacy MODIFY COLUMN ts CODEC(Delta, ZSTD(1)) 之后立刻再测一次(after_alter),在 OPTIMIZE TABLE codecs.legacy FINAL 之后再测一次(after_optimize),把三个值写入 /root/ch/codecs/alter.json。
CREATE TABLE ... AS 另一张表 会原样复制列和引擎。数据片段一旦写出就不会改变,所以 ALTER 只改变今后要写出的数据片段的规则。想一想,已有的数据片段在什么时候会用新的编解码器被重写。三次都用同一个 system.columns 查询来测量。