数组与嵌套结构
MySQL 里想存一个「标签列表」,你要么建关联表,要么塞进逗号分隔的字符串。ClickHouse 有原生数组类型,而且配套了一整套函数——这是它做半结构化分析的核心能力。
1. Array 基础
SELECT
[1, 2, 3] AS a,
length(a) AS len,
a[1] AS first, -- 下标从 1 开始!
a[-1] AS last, -- 负下标从末尾数
arrayConcat(a, [4, 5]) AS concat,
arraySlice(a, 2, 2) AS slice, -- [2,3]
has(a, 2) AS contains, -- 1
indexOf(a, 3) AS idx, -- 3
arrayReverse(a) AS rev,
arraySort([3, 1, 2]) AS sorted,
arrayReverseSort([3, 1, 2]) AS desc_sorted,
arrayDistinct([1, 1, 2, 2]) AS distinct_,
arrayFlatten([[1,2],[3]]) AS flat,
arrayEnumerate([10, 20, 30]) AS positions; -- [1,2,3]┌─a───────┬─len─┬─first─┬─last─┬─concat────┬─slice─┬─contains─┬─idx─┐
│ [1,2,3] │ 3 │ 1 │ 3 │ [1,2,3,4,5]│ [2,3] │ 1 │ 3 │
└─────────┴─────┴───────┴──────┴───────────┴───────┴──────────┴─────┘和大多数编程语言不同,ClickHouse 数组下标从 1 开始(跟 SQL、Lua、R 一致)。a[0] 返回该类型的默认值而不是报错,很容易埋雷。
集合运算:
SELECT
arrayIntersect([1,2,3], [2,3,4]) AS inter, -- [2,3]
arrayDifference([1, 3, 7]) AS diff, -- [0,2,4] 相邻差值
arrayCumSum([1, 2, 3]) AS cumsum, -- [1,3,6]
arrayCompact([1,1,2,2,1]) AS compact, -- [1,2,1] 去掉连续重复
arrayZip([1,2], ['a','b']) AS zipped, -- [(1,'a'),(2,'b')]
arrayJoin([1,2,3]) AS unnested; -- 展开成 3 行,见下节聚合数组:
SELECT
arraySum([1,2,3]) AS s, -- 6
arrayAvg([1,2,3]) AS a, -- 2
arrayMax([1,2,3]) AS mx,
arrayMin([1,2,3]) AS mn,
arrayProduct([2,3,4]) AS p; -- 242. 高阶函数:arrayMap / arrayFilter
这是 ClickHouse 数组能力的精华。用 lambda 表达式操作数组:
SELECT
arrayMap(x -> x * 2, [1, 2, 3]) AS doubled, -- [2,4,6]
arrayFilter(x -> x > 1, [1, 2, 3]) AS filtered, -- [2,3]
arrayCount(x -> x > 1, [1, 2, 3]) AS cnt, -- 2
arrayExists(x -> x > 2, [1, 2, 3]) AS any_gt2, -- 1
arrayAll(x -> x > 0, [1, 2, 3]) AS all_pos, -- 1
arraySum(x -> x * x, [1, 2, 3]) AS sum_squares, -- 14
arraySort(x -> -x, [1, 3, 2]) AS sort_desc, -- [3,2,1]
arrayFirst(x -> x > 1, [1, 2, 3]) AS first_gt1, -- 2
arrayFirstIndex(x -> x > 1, [1, 2, 3]) AS first_idx; -- 2多数组并行操作(lambda 接多个参数):
SELECT
arrayMap((x, y) -> x + y, [1, 2, 3], [10, 20, 30]) AS summed, -- [11,22,33]
arrayFilter((x, y) -> y > 15, [1,2,3], [10,20,30]) AS filtered; -- [2,3]第二个例子很有用:用一个数组的条件过滤另一个数组。
arrayReduce 让你对数组应用任意聚合函数:
SELECT
arrayReduce('max', [1, 5, 3]) AS mx, -- 5
arrayReduce('uniq', ['a','b','a']) AS u, -- 2
arrayReduce('quantile(0.9)', [1,2,3,4,5,6,7,8,9,10]) AS p90; -- 9.13. ARRAY JOIN:数组展开成行
这是数组最重要的用法。把一行的数组"炸开"成多行,等价于 SQL 标准的 UNNEST 或 Spark 的 explode。
SELECT
user_id,
tag
FROM (
SELECT 1001 AS user_id, ['vip', 'active', 'mobile'] AS tags
UNION ALL
SELECT 1002 AS user_id, ['new'] AS tags
)
ARRAY JOIN tags AS tag;┌─user_id─┬─tag────┐
│ 1001 │ vip │
│ 1001 │ active │
│ 1001 │ mobile │
│ 1002 │ new │
└─────────┴────────┘3.1 LEFT ARRAY JOIN
空数组的行默认会被丢弃,用 LEFT ARRAY JOIN 保留:
SELECT user_id, tag
FROM (
SELECT 1001 AS user_id, ['vip'] AS tags
UNION ALL
SELECT 1002 AS user_id, [] AS tags
)
LEFT ARRAY JOIN tags AS tag;┌─user_id─┬─tag─┐
│ 1001 │ vip │
│ 1002 │ │ ← 保留了,tag 为空串
└─────────┴─────┘3.2 多数组同时展开
-- 两个等长数组按位置对齐展开
SELECT user_id, tag, score
FROM (
SELECT 1001 AS user_id, ['a','b','c'] AS tags, [10, 20, 30] AS scores
)
ARRAY JOIN tags AS tag, scores AS score;┌─user_id─┬─tag─┬─score─┐
│ 1001 │ a │ 10 │
│ 1001 │ b │ 20 │
│ 1001 │ c │ 30 │
└─────────┴─────┴───────┘注意:写成 ARRAY JOIN tags, scores(逗号分隔)是按位置对齐;如果要笛卡尔积,需要写两个 ARRAY JOIN 子句。
3.3 实战:会话路径分析
-- 把每个用户的访问序列折叠成数组,再展开成相邻页面对
WITH user_paths AS (
SELECT
user_id,
groupArray(page) AS pages
FROM (SELECT user_id, page, event_time FROM events ORDER BY user_id, event_time)
GROUP BY user_id
)
SELECT
from_page,
to_page,
count() AS transitions
FROM user_paths
ARRAY JOIN
arraySlice(pages, 1, length(pages) - 1) AS from_page,
arraySlice(pages, 2) AS to_page
GROUP BY from_page, to_page
ORDER BY transitions DESC
LIMIT 5;┌─from_page─┬─to_page───┬─transitions─┐
│ /p/183 │ /checkout │ 1204 │
│ /p/427 │ /p/183 │ 1188 │
│ / │ /p/183 │ 1155 │
│ /p/91 │ /checkout │ 1142 │
│ /p/183 │ / │ 1098 │
└───────────┴───────────┴─────────────┘arraySlice(pages, 1, n-1) 和 arraySlice(pages, 2) 错位一格,展开后就是相邻页面对。这是做用户路径分析的标准套路。
4. Nested:结构化的数组组
Nested 是一组等长数组的语法糖,适合存「一对多」的嵌套数据:
CREATE TABLE events_nested
(
event_time DateTime,
user_id UInt64,
page String,
products Nested
(
id UInt32,
name String,
price Decimal(10, 2),
quantity UInt8
)
)
ENGINE = MergeTree
ORDER BY (user_id, event_time);
INSERT INTO events_nested VALUES
(
'2024-06-15 10:00:00', 1001, '/checkout',
[101, 205, 388], -- products.id
['键盘', '鼠标', '显示器'], -- products.name
[299.00, 89.00, 1299.00], -- products.price
[1, 2, 1] -- products.quantity
);物理上 products.id、products.name 等就是四个独立的数组列。查询时:
-- 直接查是数组
SELECT user_id, products.id, products.name FROM events_nested;┌─user_id─┬─products.id───┬─products.name──────────────────┐
│ 1001 │ [101,205,388] │ ['键盘','鼠标','显示器'] │
└─────────┴───────────────┴────────────────────────────────┘-- ARRAY JOIN 整个 Nested,所有子列同时展开
SELECT
user_id,
products.id AS pid,
products.name AS pname,
products.price AS price,
products.quantity AS qty,
products.price * products.quantity AS subtotal
FROM events_nested
ARRAY JOIN products;┌─user_id─┬─pid─┬─pname────┬───price─┬─qty─┬─subtotal─┐
│ 1001 │ 101 │ 键盘 │ 299.00 │ 1 │ 299.00 │
│ 1001 │ 205 │ 鼠标 │ 89.00 │ 2 │ 178.00 │
│ 1001 │ 388 │ 显示器 │ 1299.00 │ 1 │ 1299.00 │
└─────────┴─────┴──────────┴─────────┴─────┴──────────┘不展开也能算:
SELECT
user_id,
arraySum(arrayMap((p, q) -> p * q, products.price, products.quantity)) AS order_total,
length(products.id) AS item_count
FROM events_nested;┌─user_id─┬─order_total─┬─item_count─┐
│ 1001 │ 1776.00 │ 3 │
└─────────┴─────────────┴────────────┘插入时四个数组长度不一致会报错。ClickHouse 不会帮你补齐。
另外 Nested 只支持一层,不能嵌套 Nested。多层结构用 Array(Tuple(...)) 或 JSON。
5. Map 类型
存动态的键值对,比如埋点的自定义属性:
CREATE TABLE events_map
(
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
props Map(String, String)
)
ENGINE = MergeTree
ORDER BY (user_id, event_time);
INSERT INTO events_map VALUES
('2024-06-15 10:00:00', 1001, 'purchase',
{'order_id': '9527', 'coupon': 'SUMMER20', 'channel': 'wechat'}),
('2024-06-15 10:05:00', 1002, 'view',
{'ref': 'google', 'ab_test': 'B'});SELECT
user_id,
props['coupon'] AS coupon, -- 不存在返回默认值(空串)
mapContains(props, 'coupon') AS has_coupon,
mapKeys(props) AS keys,
mapValues(props) AS vals,
length(props) AS prop_count
FROM events_map;┌─user_id─┬─coupon───┬─has_coupon─┬─keys──────────────────────────────┬─prop_count─┐
│ 1001 │ SUMMER20 │ 1 │ ['channel','coupon','order_id'] │ 3 │
│ 1002 │ │ 0 │ ['ab_test','ref'] │ 2 │
└─────────┴──────────┴────────────┴───────────────────────────────────┴────────────┘Map 也能展开:
SELECT user_id, k, v
FROM events_map
ARRAY JOIN mapKeys(props) AS k, mapValues(props) AS v;props['coupon'] 需要读取整个 Map 列(所有 key 和 value),再在内存里查找。如果 Map 有 50 个 key 而你只用 1 个,就浪费了 98% 的 I/O。
高频访问的属性应该提升为独立列:
ALTER TABLE events_map ADD COLUMN coupon String
MATERIALIZED props['coupon'];MATERIALIZED 列在写入时计算并单独存储,查询它时不需要读 Map。这是 Map 类型的标准优化手段。
6. Tuple
匿名的定长结构,常用于函数返回多值:
SELECT
(1, 'a', now()) AS t,
t.1 AS first, -- 1
t.2 AS second, -- 'a'
tupleElement(t, 3) AS third,
untuple(t) AS unpacked; -- 展开成多列配合 argMax 一次取多列(第 12 章讲过):
SELECT
user_id,
argMax((page, country, duration_ms), event_time) AS last,
last.1 AS last_page,
last.2 AS last_country
FROM events
GROUP BY user_id LIMIT 3;Tuple 还能做多列比较:
SELECT count() FROM events WHERE (country, event_type) IN (('CN','purchase'), ('US','signup'));7. JSON 处理
ClickHouse 24.x 有多种处理 JSON 的方式。
7.1 存为 String + JSON 函数(最通用)
CREATE TABLE events_json
(
event_time DateTime,
user_id UInt64,
payload String -- 原始 JSON 字符串
)
ENGINE = MergeTree ORDER BY (user_id, event_time);
INSERT INTO events_json VALUES
('2024-06-15 10:00:00', 1001,
'{"order":{"id":9527,"amount":299.5},"tags":["vip","new"],"active":true}');SELECT
JSONExtractString(payload, 'order', 'id') AS order_id_str,
JSONExtractUInt(payload, 'order', 'id') AS order_id,
JSONExtractFloat(payload, 'order', 'amount') AS amount,
JSONExtractBool(payload, 'active') AS active,
JSONExtractArrayRaw(payload, 'tags') AS tags_raw,
JSONExtract(payload, 'tags', 'Array(String)') AS tags,
JSONHas(payload, 'order', 'amount') AS has_amount,
JSONLength(payload, 'tags') AS tag_count,
JSONType(payload, 'order') AS order_type
FROM events_json;┌─order_id_str─┬─order_id─┬─amount─┬─active─┬─tags─────────────┬─has_amount─┬─tag_count─┬─order_type─┐
│ 9527 │ 9527 │ 299.5 │ 1 │ ['vip','new'] │ 1 │ 2 │ Object │
└──────────────┴──────────┴────────┴────────┴──────────────────┴────────────┴───────────┴────────────┘还有更快的简化版(牺牲严谨性换速度):
SELECT
simpleJSONExtractString(payload, 'name') AS name,
simpleJSONExtractUInt(payload, 'id') AS id
FROM events_json;simpleJSON* 不做完整解析,只做字符串扫描,对扁平 JSON 快 3~5 倍,但不支持嵌套路径。
7.2 用 MATERIALIZED 列提取热字段
和 Map 一样,高频字段应该提升:
ALTER TABLE events_json
ADD COLUMN order_id UInt64 MATERIALIZED JSONExtractUInt(payload, 'order', 'id'),
ADD COLUMN amount Float64 MATERIALIZED JSONExtractFloat(payload, 'order', 'amount');
-- 新写入的行会自动计算,历史数据需要 MATERIALIZE
ALTER TABLE events_json MATERIALIZE COLUMN order_id;
ALTER TABLE events_json MATERIALIZE COLUMN amount;之后 WHERE order_id = 9527 就是普通列查询,还能建索引。
7.3 直接用 JSONEachRow 导入并自动展开
-- 让 ClickHouse 自动把 JSON 字段映射到表的列
CREATE TABLE events_typed
(
event_time DateTime,
user_id UInt64,
order_id UInt64,
amount Float64,
tags Array(String)
) ENGINE = MergeTree ORDER BY (user_id, event_time);
SET input_format_import_nested_json = 1;
SET input_format_skip_unknown_fields = 1;
-- 然后 INSERT ... FORMAT JSONEachRow把所有字段塞进一个 JSON String 列,等于放弃了列存的全部优势:
- 查一个字段要读整个 JSON(可能几 KB)
- 无法用类型专用压缩(LowCardinality、Delta)
- 无法建索引和跳数索引
- 每次查询都要解析 JSON,CPU 开销大
正确做法是「schema-on-write」:把已知的、高频的字段建成正式列,只把真正动态、低频的部分留在 JSON 里。这比「什么都塞 JSON」的性能好一个数量级。
8. 综合实战:用户行为序列分析
-- 每个用户 6 月的完整行为序列与关键指标
SELECT
user_id,
length(seq) AS event_count,
seq AS event_sequence,
arrayCount(x -> x = 'purchase', seq) AS purchases,
arrayExists(x -> x = 'signup', seq) AS did_signup,
arrayFirstIndex(x -> x = 'purchase', seq) AS first_purchase_step,
arrayStringConcat(arrayDistinct(seq), ' > ') AS uniq_path,
arraySum(durations) AS total_ms,
arrayMax(durations) AS max_ms,
-- 相邻事件的时间间隔
arrayDifference(times) AS gaps
FROM (
SELECT
user_id,
groupArray(event_type) AS seq,
groupArray(duration_ms) AS durations,
groupArray(toUnixTimestamp(event_time)) AS times
FROM (
SELECT user_id, event_type, duration_ms, event_time
FROM events
WHERE event_time >= '2024-06-01' AND event_time < '2024-06-02'
ORDER BY user_id, event_time
)
GROUP BY user_id
)
WHERE event_count >= 3
ORDER BY purchases DESC, event_count DESC
LIMIT 3;┌─user_id─┬─event_count─┬─event_sequence──────────────────────────┬─purchases─┬─did_signup─┬─first_purchase_step─┬─uniq_path──────────────────────┬─total_ms─┬─max_ms─┐
│ 18234 │ 6 │ ['view','click','purchase','view',...] │ 3 │ 0 │ 3 │ view > click > purchase │ 15238 │ 4912 │
│ 9077 │ 5 │ ['view','view','click','purchase',...] │ 2 │ 1 │ 4 │ view > click > purchase > signup│ 12044 │ 4788 │
└─────────┴─────────────┴─────────────────────────────────────────┴───────────┴────────────┴─────────────────────┴────────────────────────────────┴──────────┴────────┘一条 SQL 完成了「按用户聚合行为序列 + 序列内分析」,在 MySQL 里这需要应用层代码。
- 用
groupArray把每个用户的页面访问序列折叠成数组,再用ARRAY JOIN+arraySlice错位技巧统计最常见的页面跳转对(Top 10)。 - 建一张带
Nested的订单商品表,插入 3 笔订单(每笔 2~4 个商品),用ARRAY JOIN展开后统计每个商品的总销量,再用arraySum(arrayMap(...))不展开直接算每笔订单总额,验证两种方式结果一致。 - 建一张带
Map(String, String)的表存埋点自定义属性,用MATERIALIZED列把其中一个高频 key 提升为独立列,对比两种查询方式的 Elapsed 和Processed bytes。 - 用
arrayFilter+arrayMap组合,从用户行为序列里筛出所有duration_ms > 3000的事件类型。 - 存一段嵌套 JSON,分别用
JSONExtract*和MATERIALIZED列两种方式查询同一个字段,对比性能。
小结
- 数组下标从 1 开始,负下标从末尾数
- 高阶函数
arrayMap/arrayFilter/arrayCount/arraySum用 lambda 操作数组,支持多数组并行 ARRAY JOIN把数组炸开成行,LEFT ARRAY JOIN保留空数组的行- 双
arraySlice错位展开是路径分析的标准套路 Nested是一组等长数组的语法糖,ARRAY JOIN nested_name同时展开所有子列Map灵活但查一个 key 要读整列,高频 key 用MATERIALIZED列提升Tuple用于函数返回多值和多列比较- JSON 优先「schema-on-write」:已知字段建成正式列,只把动态部分留在 JSON
simpleJSON*比JSONExtract*快但只支持扁平结构- 下一章讲窗口函数、漏斗与留存 →