数组与高阶函数:arrayMap、arrayFilter 与 Lambda 表达式

数组是 ClickHouse 处理嵌套与事件数据的基础类型。本文系统讲解数组类型与构造、arrayMap/arrayFilter/arrayReduce 高阶函数、Lambda 表达式语法、arrayJoin 展开、嵌套数组处理,以及性能注意事项与一个标签/事件属性的实战案例。

前置:/clickhouse-window-functions-advanced-sql/(高级 SQL 函数)、/clickhouse-schema-modeling-best-practices/(嵌套与数组列建模)、/clickhouse-query-optimizer/(执行引擎与内存模型)。

目录

1. 数组类型与构造方式

数组是 ClickHouse 处理一对多关系(标签、属性、事件序列)的核心类型,列内可存变长数组。

数组类型:
□ Array(T):元素类型一致的变长数组
□ 嵌套:Array(Array(String));Nullable:Array(Nullable(String))

构造方式:字面量 [1,2,3] / CAST 标注 / range(n) / groupArray

注意:元素类型必须一致;高维数组内存放大
-- 字面量构造
SELECT [1, 2, 3] AS a, range(5) AS r, ['a', 'b', 'c'] AS s;

-- 显式类型标注
SELECT CAST([1, 2] AS Array(UInt64)) AS a;

-- 建表含数组列
CREATE TABLE events_tags (
    event_time DateTime, user_id UInt64, tags Array(String)
) ENGINE = MergeTree() ORDER BY (event_time, user_id);

-- 空数组与长度
SELECT [], length([]), empty([]);

工程要点:数组列用 Array(T) 表达每行携带的变长集合(标签、属性序列),构造靠字面量/range/聚合产出,元素类型必须一致;它让「行内嵌套」成为可能,是事件与画像建模的基础,但深层嵌套会放大内存,需权衡。

2. 数组的基础运算:长度、索引与切片

在使用高阶函数前,先掌握数组的常用内建函数——这些操作是列式友好的,可向量化。

基础函数:
□ length / empty:长度与空判断
□ arr[i]:第 i 个元素(1-based,越界返回默认)
□ arraySlice / arrayConcat / arrayDistinct:切片/拼接/去重
□ arraySort / arrayUniq / has / indexOf:排序/去重计数/包含/位置

语义:下标从 1 开始;越界返回默认值;按列向量化
SELECT
    length([1, 2, 3]) AS n,           -- 3
    [1, 2, 3][2] AS second,           -- 2
    arraySlice([1, 2, 3, 4], 2, 2) AS slice,   -- [2,3]
    arrayDistinct([1, 1, 2]) AS dis,           -- [1,2]
    has(['a', 'b'], 'b') AS h;                 -- 1

-- 每行标签数量分布
SELECT length(tags) AS tag_count, count()
FROM events_tags GROUP BY tag_count ORDER BY tag_count;

工程要点:数组基础函数(长度/下标/切片/拼接/去重/包含)全部按列向量化执行,是后续高阶函数的积木;牢记下标从 1 开始、越界返回默认值,先用这些函数把数组形状摸清,再进入映射与过滤。

3. arrayMap:逐元素变换

arrayMap(lambda, arr) 对数组的每个元素应用一个变换,返回等长新数组。

arrayMap 语义:
□ 输入 Lambda + 一个或多个数组
□ 对每个位置元素求值,输出等长新数组
□ 多数组版按位置配对,长度必须一致

用途:数值转换、提取字段、字符串统一处理

注意:返回类型由 Lambda 决定;过滤用 arrayFilter
-- 每个元素翻倍
SELECT arrayMap(x -> x * 2, [1, 2, 3]) AS doubled;   -- [2,4,6]

-- 多数组按位置配对
SELECT arrayMap((x, y) -> x + y, [1, 2], [10, 20]) AS s;  -- [11,22]

-- 字符串数组统一大写
SELECT arrayMap(x -> upper(x), ['a', 'b']) AS up;          -- ['A','B']

-- 实战:价格数组统一打折
SELECT arrayMap(p -> round(p * 0.9, 2), [99.0, 199.0]) AS prices;

工程要点:arrayMap 是逐元素变换器——一个 Lambda 处理每个元素,输出等长新数组;它常与多数组按位配对、字符串规范化、数值换算一起使用,是数组流水线(map → filter → reduce)的第一步。

4. arrayFilter:按条件筛选

arrayFilter(lambda, arr) 对每个元素判断条件,保留满足条件的元素,返回长度不定的子数组。

arrayFilter 语义:
□ Lambda 返回 1/0(Bool),为 1 保留、为 0 丢弃
□ 输出长度 <= 原长度,多数组版按位置配对

用途:过滤阈值元素、剔除空值异常、挑出指定类别

注意:过滤后数组可能为空 → 结合 empty() 处理
-- 保留大于 1 的元素
SELECT arrayFilter(x -> x > 1, [1, 2, 3, 0]) AS f;  -- [2,3]

-- 多数组按位置:保留金额>=100 的商品名
SELECT arrayFilter((name, price) -> price >= 100,
                   ['a', 'b', 'c'], [50, 150, 80]) AS names;  -- ['b']

-- 实战:只统计有效标签
SELECT user_id, arrayFilter(t -> t != 'spam', tags) AS clean_tags
FROM events_tags;

工程要点:arrayFilter 是条件筛选器——Lambda 对每个元素判真值,真则保留;它常与 arrayMap 构成「先变换、再筛选」的管道,也支持多数组按位置配对,让过滤条件可以引用另一数组的同位置值。

5. arrayReduce:聚合到标量

arrayReduce(func, arr) 把整个数组聚合为一个标量,是「数组 → 数值」的收口操作。

arrayReduce 语义:
□ 输入聚合函数名(字符串)+ 数组
□ 对整个数组执行聚合 → 单个值
□ 例:'sum' / 'max' / 'min' / 'avg'
□ 输出是标量列,可继续参与外层聚合

用途:
□ 数组求和/最值/均值
□ 从嵌套结构中汇总子数据
□ 与 arrayMap/arrayFilter 组成完整管道

注意:函数名是字符串;空数组返回类型默认值
-- 数组求和与最值
SELECT
    arrayReduce('sum', [1, 2, 3]) AS s,      -- 6
    arrayReduce('max', [3, 1, 2]) AS mx;     -- 3

-- 实战:每用户所有订单金额求和
SELECT user_id, arrayReduce('sum', order_amounts) AS total
FROM user_orders;

-- 数组累加(前缀和)
SELECT arrayCumSum([1, 2, 3]) AS cum;         -- [1,3,6]

工程要点:arrayReduce 把数组聚合成标量——指定聚合函数名作用于整个数组,是「数组管道」的收口;配合 arrayMap/arrayFilter,可以在行内完成「变换→筛选→汇总」,避免为每个元素单独开行。

6. Lambda 表达式语法与捕获

高阶函数的核心是 Lambda:一个匿名单表达式函数。它的语法与作用域规则直接影响正确性。

Lambda 语法:
□ 单参数 x -> expr;多参数 (x, y) -> expr
□ 类型由数组推导,参数名自由且与数组数量一致

捕获(外部列/常量):
□ 常量捕获 → 编译期代入,性能好
□ 引用列 → 该列作为数组长度对齐参与

陷阱:参数名不能与外层列冲突;多数组长度不一致报错
-- 单参数
SELECT arrayMap(x -> x + 1, [1, 2]) AS a;   -- [2,3]

-- 多参数:保留偶数位元素
SELECT arrayFilter((x, i) -> i % 2 = 0, [10, 20, 30], [1, 2, 3]) AS b;  -- [20]

-- 捕获外部列:单价列参与变换
SELECT order_items, base_price,
       arrayMap(item -> item.price * base_price, order_items) AS total_prices
FROM orders_sample;

-- 常量捕获:折扣
SELECT arrayMap(x -> x * 0.95, [100, 200]) AS discounted;  -- [95,190]

工程要点:Lambda 是匿名单表达式函数,语法为 参数 -> 表达式;它可捕获查询中的常量与列,让变换参数化;写高阶函数前先明确参数数量与数组数量一一对应,避免重名与长度错位这两个最常见的坑。

7. 数组聚合与展开:arrayJoin

arrayJoin(arr) 是数组的横向爆炸:把一行中的数组展开成多行,是「数组 ⇆ 关系表」的桥梁。

arrayJoin 语义:
□ 每行数组 → N 行(每元素一行)
□ 其他列随展开重复
□ 与 groupArray 互为逆操作

用途:
□ 把嵌套标签展开后 GROUP BY 统计
□ 事件属性行转长表(tall table)

注意:
□ arrayJoin 放大行数 → 小心爆炸
□ 多列 arrayJoin 是笛卡尔(慎用)
□ 展开后配合 count/uniq 做标签统计
-- 展开标签
SELECT user_id, tag
FROM events_tags ARRAY JOIN tags AS tag;

-- 标签频率统计(展开 → 分组)
SELECT tag, count() AS cnt
FROM events_tags ARRAY JOIN tags AS tag
GROUP BY tag ORDER BY cnt DESC;

-- groupArray 回收
SELECT user_id, groupArray(tag) AS tags_back
FROM (SELECT user_id, tag FROM events_tags ARRAY JOIN tags AS tag)
GROUP BY user_id;

工程要点:arrayJoin 把一行变多行,是数组与关系模型的桥梁——展开后用 GROUP BY 做逐元素统计,再配合 groupArray 可还原数组;但它放大行数,聚合前先想清楚是否真需要长表形态,避免不必要的数据膨胀。

8. 嵌套结构处理:数组的数组

当数据变成 Array(Array(T))(数组的数组)或数组元素是结构(Tuple/嵌套对象)时,需要递归式处理。

嵌套数组:Array(Array(UInt64)),内外层分别 map/过滤
结构元素:Tuple → arrayMap(t -> t.field, arr) 提取字段
扁平化:arrayFlatten 拍平一层后再 map/filter

注意:嵌套越深存储与索引收益越低,优先拍平
-- 二维数组:内层求和
SELECT arrayMap(inner -> arrayReduce('sum', inner),
                [[1,2],[3,4]]) AS sums;   -- [3,7]

-- 拍平
SELECT arrayFlatten([[1,2],[3,4]]) AS flat;   -- [1,2,3,4]

-- 嵌套对象(Tuple 数组):提取字段
SELECT arrayMap(prod -> prod.price,
                [('a', 10), ('b', 20)]) AS prices;   -- [10,20]

-- 实战:多订单多商品 → 全商品价合计
SELECT arrayReduce('sum',
    arrayMap(order -> arrayReduce('sum',
        arrayMap(prod -> prod.price, order.products)),
    orders)) AS total_amount
FROM user_baskets;

工程要点:嵌套数组处理的关键是逐层分解——外层 arrayMap 进入内层、内层再 arrayMap/arrayReduce,必要时用 arrayFlatten 拍平、用 Tuple 提取字段;嵌套越深存储与索引收益越低,建模上尽量「拍平 + 偏移量」表达多维结构。

9. 性能注意事项与实战案例

数组函数大多向量化,但写法不当仍会拖慢查询。这里给出性能纪律与一个完整实战。

性能纪律:
□ 一次遍历:map+filter 合并为一个 lambda
□ 避免超大数组 → 内存放大
□ arrayJoin 爆炸行数 → 先过滤再展开
□ 不在 Lambda 里做重计算

检查:EXPLAIN 看额外 Transform、read_rows vs 结果行数、query_log
-- 低效:先 map 再 filter(两遍扫描)
SELECT arrayFilter(x -> x > 10, arrayMap(x -> x * 2, arr)) FROM t;

-- 高效:合并为一次遍历
SELECT arrayFilter(x -> x * 2 > 10, arr) FROM t;

-- 完整实战:事件标签统计
SELECT tag, count() AS cnt, uniq(user_id) AS users
FROM events_tags ARRAY JOIN tags AS tag
WHERE length(tags) BETWEEN 1 AND 20
GROUP BY tag ORDER BY cnt DESC LIMIT 20;

-- 体检:arrayJoin 放大倍数
SELECT read_rows, result_rows FROM system.query_log
WHERE query ILIKE '%ARRAY JOIN%' ORDER BY query_start_time DESC LIMIT 10;

工程要点:数组优化的核心纪律是**「一次遍历」**——map 与 filter 尽量合成单个 Lambda,避免对数组反复扫描;arrayJoin 展开前先过滤、数组元素上限受控,防止行数与内存爆炸;每个含数组的查询都用 query_log 盯住放大倍数。

10. 速查表与一句话记忆

把数组函数族压成速查表。

数组函数速查:
□ arrayMap(x -> f(x), arr):逐元素变换
□ arrayFilter(x -> cond(x), arr):条件筛选
□ arrayReduce('sum', arr):聚合为标量
□ arrayJoin(arr):一行展开为多行
□ arrayFlatten:拍平嵌套
□ 基础:length/slice/concat/distinct/has

管道思维:变换 → 筛选 → 汇总
□ arrayMap → arrayFilter → arrayReduce
□ 需要行级统计 → ARRAY JOIN + GROUP BY

一句记忆:行内数组用高阶函数,行间数组用 arrayJoin,一次遍历
-- 一个通用的数组管道模板
SELECT user_id,
       arrayReduce('sum',
           arrayFilter(x -> x > 0,
               arrayMap(x -> x - cost, amounts))) AS net
FROM transactions;

工程要点:数组与高阶函数把「行内的集合运算」变成向量化管道——map 变换、filter 筛选、reduce 汇总、arrayJoin 展开;记忆口诀「行内用高阶、行间用展开、一次遍历」,就能在保持列式性能的同时优雅处理一对多数据。

延伸阅读

  • /clickhouse-window-functions-advanced-sql/ — 窗口函数与更多高级 SQL 能力
  • /clickhouse-schema-modeling-best-practices/ — 数组列与嵌套结构的建模实践
  • /clickhouse-query-optimizer/ — 执行引擎与向量化内存模型
  • /clickhouse-table-engines/ — 表引擎体系与数组列存储的底层载体
  • /clickhouse-columnar-compression/ — 列压缩对数组列与嵌套数据的影响

数据库专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. MergeTree 调优:part 生命周期、merge 策略与 granularity
  2. 联邦查询与外部数据源:MySQL、PostgreSQL 与 URL 表引擎
  3. JOIN 高级技巧与优化:哈希连接、全局表与关联陷阱