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)返回arraystring语法很简单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里最典型的UDTFUser 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和valueSELECT 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里不能出现两个UDTFUDTF不能嵌套比如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 VIEWarr为空或null的行不输出LATERAL VIEW OUTERarr为空或null的行保留x列补NULL。这个坑我刚开始接触时栽过跟头排查了好久才发现不是数据问题是explode把空数组对应的行吃掉了。后来凡是遇到先关联再explode的场景我默认先想清楚要不要加OUTER。提示LATERAL VIEW OUTER在Hive 0.12之后才支持如果还在维护很老的集群记得先确认版本。4.4 多个lateral view叠加行数会膨胀成笛卡尔积一张表里如果有两个数组字段都需要展开直接叠两个lateral viewSELECT 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_iduser_nametag_listu001张三游戏,购物,数码u002李四购物,运动u003王五游戏,游戏,数码注意u003的tag_list里有重复的游戏。需求是统计每个标签覆盖多少用户完整SQLSELECT 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.skewindatatrue让Hive自动把聚合拆成两轮。另外explode之后接Join时要特别小心。两张大表如果key分布都倾斜爆炸后的记录数会被成倍放大join产生的中间数据可能直接把磁盘写满。我的习惯是凡是带explode的SQL先在小样本上跑通再全量跑跑之前看一眼执行计划里每个算子的输入输出行数对行数膨胀做到心里有数。6.2 高频报错速查把常见的报错整理成一张表遇到问题直接对号入座现象根因解法UDTF functions are not supported in SELECT clause with other expressionsSELECT里混用了explode和其他字段没加lateral view用LATERAL VIEWSELECT里只取视图列Only a single UDTF is supported in the SELECT clauseSELECT里放了两个explode拆成多个LATERAL VIEWUDTF functions are not supported in GROUP BY想在GROUP BY里直接使用explode的产物先用LATERAL VIEW生成列再对视图列GROUP BY结果行数无故变少explode对空数组/null是inner语义行被丢弃换LATERAL VIEW OUTERsplit按.切分失败或结果全是空串点号是正则元字符未转义写成split(str, \.)数组拆分后尾部元素丢失Java String.split默认丢弃末尾空串换,这类正则或接受该行为并补过滤注意不同Hive版本的报错措辞略有差异但只要看到UDTFSemanticException这类关键字优先往上面几个方向排查命中率很高。6.3 兄弟函数posexplode、inline、stackexplode不是UDTF的全部实际工作中这几个函数会一起出现posexplode比explode多输出一列下标从0开始。需要保留数组元素原始顺序或者两个数组按下标配对时它比explode可靠得多这正是5.2节提到的场景。inline接收一个struct数组每个字段展开成一列适合处理复杂嵌套数据比如日志解析出来的对象数组。stack(n, 值1, 值2...)把传入的多个值按n行重新排列常用于把行方向的多列重排成多行。这些函数在语法上都能配合lateral view使用只要理解了explode的表生成语义剩下的看一遍官方文档基本就能上手。我在实际项目里的体会是这三个函数加上本文的split、explode、lateral view已经能覆盖日常90%以上的多值字段处理需求真正需要写自定义UDTF的场景其实很少。