建一张 50 列的宽表,用 system.query_log 里的 read_bytes 数字,看列存到底是怎么让“只查几个字段”变得比“查整行”便宜很多倍的。
建一张只有 id + 50 个 String 列的表,每个字符串约 45 字节,插入 2 万行,再用不同的查询去读它。
CREATE TABLE demo.wide (id UInt64, c1 String, ..., c50 String) ENGINE=MergeTree ORDER BY id插入 20000 行,每行 50 个字符串列 SELECT count() FROM demo.wide → 20000
行式数据库(比如 MySQL 的 InnoDB)会把每一行的 51 个字段紧挨着存在一起;ClickHouse 这种列式数据库,是把同一列的 2 万个值单独存成一段连续数据,51 个列对应 51 段独立的物理存储。这个差异平时看不出来,一旦"只查一部分列"就会立刻体现在读取的数据量上。
用 system.query_log 里记录的 read_bytes,看两条查询到底读了多少数据。
SELECT sum(length(c1)) FROM demo.wide → read_bytes = 156.25 KiB SELECT * FROM demo.wide FORMAT Null → read_bytes = 49.04 MiB
同一张表,只查 1 列只读了 156.25 KiB,查全部 50 列读了 49.04 MiB——差了 约 320 倍。行式存储做不到这一点:因为一行的 51 个字段物理上挨在一起,哪怕你只要其中 1 个字段,也得把这一行完整读出来再从里面抠出那 1 个字段;列存则可以直接跳过其它 50 列对应的磁盘文件,压根不去碰它们。
从 1 列加到 50 列,逐步测 read_bytes,看是不是线性增长。
SELECT sum(length(c1)+...+length(cN)) FROM demo.wide, read_bytes:N=1 列 → 156.25 KiB N=3 列 → 468.75 KiB (≈3.0倍) N=10 列 → 1.53 MiB (≈9.8倍) N=25 列 → 3.81 MiB (≈24.9倍) N=50 列 → 7.63 MiB (≈48.8倍)
数字几乎是完美的线性关系:查 3 列约等于查 1 列的 3 倍,查 50 列约等于 49 倍。列存把"读取量"和"查询涉及的列数"绑定在一起,而不是绑定在"表有多少列"或"表有多少行的原始大小"上。
同样是摸全部 50 列,为什么 sum(length(...)) 只读 7.63 MiB,SELECT * 却要读 49.04 MiB?
原因在 String 列的存储结构:ClickHouse 把一列 String 拆成两段——一段是每个字符串的结束位置偏移量(offsets,定长、很小),另一段才是所有字符串真正的字节内容(chars,不定长、很大)。length() 只需要相邻两个 offset 相减就能算出字符串长度,完全不需要碰 chars 那一大段内容;而 SELECT * 要把每个字符串原样吐出来,必须把 chars 完整读出来。
每一步都是本文上面查询 system.query_log 得到的原始 read_bytes。
system.query_log:记录每条查询的 read_rows / read_bytesSYSTEM FLUSH LOGS:强制把 query_log 缓冲区落盘再查询