1. 为什么说 split、explode、lateral view 天生是一套
1.1 从一条写不出来的SQL说起
做数仓的同学应该都有这种经历:业务方抛来一个需求——"统计一下每个标签覆盖多少用户",你打开表一看,用户标签全挤在一个字符串字段里,逗号分隔,一个人十几个标签。第一反应可能是写一堆CASE WHEN逐个匹配,或者把这一列拆成好几列再UNION ALL。但这些做法要么SQL膨胀得没法维护,要么性能一言难尽。
其实Hive早就给了标准答案:split负责切分字符串,explode负责把多值展开成多行,lateral view负责把展开后的行和原表字段拼回去。三者配合,一条SQL就能拿下这类"字段内多值"的需求。这套组合我几乎每周都会用到,今天把原理、写法、坑一次性讲透。
1.2 先解决"行转列/列转行"的叫法问题
说实话,"行转列函数(explode)"这个叫法在Hive社区里并不算统一。网上搜"行转列",你会看到两个阵营:有人把explode叫做"列转行"或"一行变多行",有人把collect_list那类聚合叫做"行转列",两边经常吵起来。
严格按SQL语义,explode是把"一个字段里的多个值"展开成"多行",更接近"列转行"(相当于UNPIVOT)的方向。但既然标题和很多教程里都写"行转列"搭配explode,理解成"把一行的内容展开到列方向"也能说通。我自己的习惯是别纠结叫法,看动作:看到"行转列/列转行"带着explode出现,就知道是"拆多行"这个操作;看到collect_list/collect_set,就知道是反过来"并多行"。本文按标题的说法叫,但到5.3节我会把反向操作一起带上。
1.3 它们解决的核心问题:字段内多值
这套组合要解决的核心问题,可以概括成一个词:字段内多值(multi-valued field)。一列里存了多个逻辑值,不管是逗号分隔的字符串、数组字段还是Map字段,只要你想让每个值独立参与统计、过滤、关联,就绕不开这三个函数。
比如找出一周内活跃渠道超过3个的用户,或者统计每个品类被多少订单命中,本质上都是同一个套路:先把多值拆开,再按拆开后的单值做聚合。理解了这个需求模型,你就明白这套组合为什么会这么常用——数仓里这种字段实在太多了。
2. split函数:字符串切分的底层逻辑与正则陷阱
2.1 基本用法:返回的一定是数组
split(str, regex)返回array<string>,语法很简单:
SELECT split('hello,world,hive', ','); -- ["hello","world","hive"]注意第二个参数叫regex(正则表达式),不是普通字符串。这一点是后面几乎所有坑的根源。返回值是数组,所以split往往是和explode连用的起点:split负责"切",explode负责"摊"。
2.2 正则转义:点号、竖线都在等着你
很多第一次用split的人,会在网页域名或路径上翻车:
-- 错误示范:点号在正则里表示"任意字符" SELECT split('www.baidu.com', '.'); -- 切完会得到一堆空串,因为每个字符都被匹配掉了 -- 正确写法:转义 SELECT split('www.baidu.com', '\\.'); -- ["www","baidu","com"] -- 竖线分隔符同理,\\| 才能表示一个普通竖线 SELECT split('a|b|c', '\\|'); -- ["a","b","c"]为什么是两个反斜杠?Hive的字符串字面量本身要先处理一层转义,正则引擎再处理一层。你最终要传给正则的是一个\.,在Hive SQL字符串里就要写成'\\.'。如果分隔符本身是反斜杠,那就要写四个,看着吓人,但规律记牢就没事。
还有一个实用技巧:split支持正则做多分隔符拆分。比如某字段用减号和下划线混着分隔,可以写:
SELECT split('a-b_c', '[-_]'); -- ["a","b","c"]2.3 空串与尾部分隔符:Java String.split带来的老毛病
Hive的split底层走的就是Java的String.split(),所以它把Java的两个"老毛病"也原样带过来了。
第一,末尾的空串会被丢掉:
SELECT split('a,b,', ','); -- ["a","b"],注意末尾逗号后的空串没了第二,开头的空串和中间连续分隔符产生的空串会保留:
SELECT split(',a,b', ','); -- ["","a","b"] SELECT split('a,,b', ','); -- ["a","","b"]这个差异在数据清洗时非常坑。你辛辛苦苦拆出来的数组,可能开头或中间混着空串,后面count的时候多出一些"脏标签"。解决办法有两个方向:一是在split的正则上做文章,比如split('a,,b', ',+')会把连续逗号当成一个分隔符,空串直接消失;二是在explode之后用where过滤掉空串,5.1节的实战会演示。
2.4 注意:Hive的split没有limit参数
用过Spark SQL的人要格外注意这里的差异。Spark的split支持第三个参数,比如split('a,b,c', ',', 2)只切一刀,得到["a","b,c"]。但Hive原生的split函数只有两个参数,不存在limit。
这个区别我之前没留意,把Spark的SQL直接迁到Hive时,发现结果不对,排查了半天才定位到是split语义差异。如果你也在做引擎之间的SQL迁移,建议提前把这类函数差异列成清单逐个过一遍。
3. explode函数:一行变多行的核心引擎
3.1 数组展开:每个元素单独成行
explode是Hive里最典型的UDTF(User Defined Table Generating Function,表生成函数)。它接收一个数组或Map,输出多行。数组展开是最常见的用法:
SELECT explode(array('游戏', '购物', '数码')); -- 游戏 -- 购物 -- 数码这里有几个细节要注意:数组里的null元素,explode会保留,输出一行null;但整个数组如果是null,则输出0行。这两个行为差别很大。后面讲lateral view时你会看到它的实际影响——尤其是当你用left join得到带null数组的结果再explode时,整行会悄无声息地消失。
3.2 Map展开:一次输出key和value两列
explode也能处理Map,展开后输出两列,分别是key和value:
SELECT explode(map('游戏', 10, '购物', 5)); -- 游戏 10 -- 购物 5在lateral view里,Map展开要同时给两个列别名:
SELECT k, v FROM score_table LATERAL VIEW explode(score_map) mv AS k, v;Map拆出来的两列,不指定别名时默认叫key和value,但实际使用中几乎总是要自己指定别名,否则多张表联查时字段名容易撞车。
3.3 直接SELECT explode的三个硬性限制
explode单独用没问题:
SELECT explode(array('a','b')); -- a -- b但一旦想同时select其他字段,Hive立刻报错:
SELECT user_id, explode(tags) FROM user_table; -- FAILED: SemanticException UDTF functions are not supported in SELECT clause with other expressions除了"不能和其他表达式混用",还有几个限制:
- 同一个SELECT里不能出现两个UDTF;
- UDTF不能嵌套,比如explode(explode(...))就行不通;
- 同一个查询里不能直接使用GROUP BY / SORT BY / DISTRIBUTE BY / CLUSTER BY。
这些限制不是Hive故意刁难,而是UDTF的语义决定的:它的输出本身就是一张"新的表",系统没法直接知道新表怎么和原表的其他列配套。解决办法就是lateral view,所以下一节才是重头戏。
4. lateral view:把炸开后的结果拼回原表
4.1 它本质上是一个隐式关联
Hive的LATERAL VIEW语法看起来有点怪,其实逻辑很简单:对原表的每一行,调用一次UDTF,把输出结果和这一行拼接成多行。它本质上就是一个"隐式的关联"——UDTF输出没有显式的关联键,而是按行直接拼,类似每次生成一行就展开成n行,其他列在这n行里复制一遍。
为什么非要这么绕?因为explode这类UDTF一次处理一行、输出多行,这个行为在SQL标准里没有直接的等价表达,所以Hive专门造了一个语法。理解了这个本质,后面所有lateral view的写法你都能自己推出来。
4.2 标准写法:split + explode + lateral view三件套
SELECT 原表字段..., 视图列 FROM 原表 LATERAL VIEW [OUTER] udtf(表达式) 视图别名 AS 视图列名[, 视图列名...];最经典的写法,就是三件套一起上:
SELECT user_id, tag FROM user_profile LATERAL VIEW explode(split(tag_list, ',')) tag_view AS tag;拆开看每一步:
- split把"游戏,购物,数码"切成长度为3的数组;
- explode把数组炸成3行,每行一个标签;
- lateral view把炸出来的3行和原表行拼接,user_id跟着复制3份。
所以SELECT里同时取user_id和tag,完全没有问题。注意视图别名tag_view和列别名tag都不能省略,这是语法要求。
4.3 空数组与NULL:记住lateral view outer
默认的lateral view是"inner"语义。当UDTF返回0行(数组为空或整个是null)时,原表的这一行会被丢弃。这在很多场景下不是你要的行为,甚至会造成数据静默丢失。
举个例子:你left join一张子表,子表没匹配上的记录,join结果里数组字段是null,然后你explode这个数组,主表记录直接没了,join的左连接语义就白做了。解决办法是加OUTER关键字:
SELECT id, x FROM demo LATERAL VIEW OUTER explode(arr) t AS x;行为对比如下:
- 普通LATERAL VIEW:arr为空或null的行不输出;
- LATERAL VIEW OUTER:arr为空或null的行保留,x列补NULL。
这个坑我刚开始接触时栽过跟头,排查了好久才发现不是数据问题,是explode把空数组对应的行吃掉了。后来凡是遇到"先关联再explode"的场景,我默认先想清楚要不要加OUTER。
提示:LATERAL VIEW OUTER在Hive 0.12之后才支持,如果还在维护很老的集群,记得先确认版本。
4.4 多个lateral view叠加:行数会膨胀成笛卡尔积
一张表里如果有两个数组字段都需要展开,直接叠两个lateral view:
SELECT user_id, tag, channel FROM user_info LATERAL VIEW explode(user_tags) t AS tag LATERAL VIEW explode(user_channels) c AS channel;多个lateral view之间按顺序逐个处理,结果等价于两个展开结果做笛卡尔积。假设某个用户的tags有3个元素,channels有4个元素,这个用户最终会生成12行。写这种SQL之前,最好先估算一下行数膨胀量级,否则一个用户生成几百行,几个大客户就能把下游join的任务拖垮。
5. 实战:用户标签展开、多数组交叉与反向聚合
5.1 标签覆盖用户数统计
回到最开头的场景。有一张user_profile表:
| user_id | user_name | tag_list |
|---|---|---|
| u001 | 张三 | 游戏,购物,数码 |
| u002 | 李四 | 购物,运动 |
| u003 | 王五 | 游戏,游戏,数码 |
注意u003的tag_list里有重复的"游戏"。需求是统计每个标签覆盖多少用户,完整SQL:
SELECT tag, COUNT(DISTINCT user_id) AS user_cnt FROM ( SELECT user_id, tag FROM user_profile LATERAL VIEW explode(split(tag_list, ',')) tag_view AS tag ) t WHERE tag <> '' GROUP BY tag ORDER BY user_cnt DESC;执行过程分两层理解:
- 内层子查询负责"拆":split切数组、explode炸多行、lateral view带出user_id;
- 外层负责"聚合":按tag分组,COUNT(DISTINCT user_id)去重计数。
有几个细节值得展开说。为什么用COUNT(DISTINCT user_id)而不是COUNT()?因为u003的标签列表里有重复"游戏",直接COUNT()会把"游戏"这个标签多算一次。只有当需求明确是"标签被累加了多少次"时才应该用COUNT(*)。
为什么在外层包一层子查询再做WHERE和GROUP BY?一是让"拆"和"聚合"两件事逻辑边界清晰,二是规避不同Hive版本里直接在带LATERAL VIEW的查询中写GROUP BY可能出现的兼容性问题。这个写法看着多一层,但可读性和稳定性都更好,实战中我一直这么写。
5.2 多数组字段的组合分析
再举一个复杂一点的场景。假设有一张用户行为表,channels是用户活跃渠道数组,active_days是对应渠道的活跃天数:
SELECT user_id, channel, active_day FROM ( SELECT user_id, channel, active_day FROM user_behavior LATERAL VIEW explode(channels) c AS channel LATERAL VIEW explode(active_days) d AS active_day ) t WHERE channel IS NOT NULL;这种写法做交叉分析很顺手,但要注意:多个explode之间是笛卡尔积,如果channels和active_days在业务上的对应关系是按下标一一对应的,而不是想取所有组合,那这里就应该用posexplode先拿到下标,再按下标分别取值。下标配对和笛卡尔积,这两个结果差别极大,业务语义一定要先和需求方确认清楚。
5.3 反向操作:collect_list / collect_set 把多行并回一行
拆完之后,实际工作中经常还要"拼回去"。比如做用户画像时,原始数据是多行标签,你想聚合成一行数组字段:
SELECT user_id, collect_set(tag) AS tag_set, concat_ws(',', collect_list(tag)) AS tag_str FROM ( SELECT user_id, tag FROM user_profile LATERAL VIEW explode(split(tag_list, ',')) tag_view AS tag ) t GROUP BY user_id;三个函数的用途各不相同:
- collect_set:去重收集,适合标签这类业务上本就不该有重复值的场景;
- collect_list:保留重复值,适合事件序列这类有序且允许重复的数据;
- concat_ws(',', collect_list(...)):把数组再拼回逗号分隔字符串,方便落库或导出给其他系统。
有了"拆"和"拼"两套函数,遇到"上游一个字段塞多值、下游要逐值统计",或者"多行明细要并成一行宽表"的需求,都能从容应对。
6. 性能、报错与兄弟函数
6.1 explode引发数据倾斜的隐蔽路径
先给结论:explode本身不是性能瓶颈,瓶颈在炸开之后的下游算子。
当一个用户的数组特别大,比如某个用户挂了1万个标签,这个用户会被炸成1万行。后续GROUP BY tag时,这个tag对应的数据量可能远大于其他tag,全部压到同一个reducer上,其他reducer闲死,它累死。这是Hive经典的group by数据倾斜,只不过explode让倾斜的来源变得很隐蔽,执行计划里不容易一眼看出来。
实战中的应对思路:
- 先用size(tags)这类手段把大数组行识别出来,单独处理,别让极端行拖垮整体;
- 如果倾斜集中在少数key上,可以在聚合前给key加随机盐做两阶段聚合,最后再去盐合并;
- 如果只是做统计,尽量把过滤条件下推,减少explode的爆炸量,别把全表炸开再过滤;
- 必要时调整hive.groupby.skewindata=true,让Hive自动把聚合拆成两轮。
另外,explode之后接Join时要特别小心。两张大表如果key分布都倾斜,爆炸后的记录数会被成倍放大,join产生的中间数据可能直接把磁盘写满。我的习惯是:凡是带explode的SQL,先在小样本上跑通,再全量跑;跑之前看一眼执行计划里每个算子的输入输出行数,对行数膨胀做到心里有数。
6.2 高频报错速查
把常见的报错整理成一张表,遇到问题直接对号入座:
| 现象 | 根因 | 解法 |
|---|---|---|
| UDTF functions are not supported in SELECT clause with other expressions | SELECT里混用了explode和其他字段,没加lateral view | 用LATERAL VIEW,SELECT里只取视图列 |
| Only a single UDTF is supported in the SELECT clause | SELECT里放了两个explode | 拆成多个LATERAL VIEW |
| UDTF functions are not supported in GROUP BY | 想在GROUP BY里直接使用explode的产物 | 先用LATERAL VIEW生成列,再对视图列GROUP BY |
| 结果行数无故变少 | explode对空数组/null是inner语义,行被丢弃 | 换LATERAL VIEW OUTER |
| split按"."切分失败或结果全是空串 | 点号是正则元字符,未转义 | 写成split(str, '\.') |
| 数组拆分后尾部元素丢失 | Java String.split默认丢弃末尾空串 | 换,+这类正则,或接受该行为并补过滤 |
注意:不同Hive版本的报错措辞略有差异,但只要看到"UDTF""SemanticException"这类关键字,优先往上面几个方向排查,命中率很高。
6.3 兄弟函数:posexplode、inline、stack
explode不是UDTF的全部,实际工作中这几个函数会一起出现:
- posexplode:比explode多输出一列下标(从0开始)。需要保留数组元素原始顺序,或者两个数组按下标配对时,它比explode可靠得多,这正是5.2节提到的场景。
- inline:接收一个struct数组,每个字段展开成一列,适合处理复杂嵌套数据,比如日志解析出来的对象数组。
- stack(n, 值1, 值2...):把传入的多个值按n行重新排列,常用于把"行方向的多列"重排成多行。
这些函数在语法上都能配合lateral view使用,只要理解了explode的"表生成"语义,剩下的看一遍官方文档基本就能上手。我在实际项目里的体会是,这三个函数加上本文的split、explode、lateral view,已经能覆盖日常90%以上的多值字段处理需求,真正需要写自定义UDTF的场景其实很少。