ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spark DataFrame列转行:stack与explode实战详解

Spark DataFrame列转行:stack与explode实战详解 做数据的人大概都遇到过这种场景业务方丢过来一张表字段是按月份展开的什么202401_sales、202402_sales、202403_sales一路排下去又或者是一行记录里同时塞了十几个指标列。你要做统计分析、要出报表第一步就是把这些“横着”的列变成“竖着”的行。这个动作在行业里叫列转行也叫宽表转长表、unpivot。在 Spark DataFrame 里实现列转行常规的做法有stack函数、explode函数还有一堆人绕远路的union方案。这篇文章我就结合自己在某项目里的真实处理过程把 Spark DataFrame 列转行的几种写法、选型逻辑、参数坑、性能调优一次说清楚。先说结论如果只是普通的多列转多行stack是首选如果你处理的是 Array 或 Map 类型的嵌套数据explode才派得上用场其他场景下用union硬怼基本都是自找麻烦。为什么这么说下面拆开讲。1. 项目场景与需求拆解1.1 接到一个什么需求事情是这样的。某个数据平台项目里上游给我们同步了一张渠道推广效果表表结构大致是这样每一行是一个渠道渠道名和渠道 ID 是固定的维度字段然后后面跟着jan_amount、feb_amount、mar_amount这种十二个月份的转化金额列再加若干分组字段。表本身不算大几千万行的规模但如果直接拿这张宽表去做同比环比、趋势分析、前端图表渲染会非常别扭——因为按月分析本质上需要一个“月份”维度而宽表里月份是散在列名里的。当时数据组拿到的需求原文很明确把这张宽表里每个月的数据拆成独立一行输出channel_id、channel_name、month、amount四个核心字段方便下游直接挂 BI 出趋势图。这种“列名即值”的表结构就是列转行最典型的适用场景。1.2 列转行的本质是什么列转行听起来是个高大上的数据操作其实底层逻辑一句话就能讲透把多个列的值连同它们对应的列名重新组织成“行记录”中的字段。你可以理解成把一张 A4 纸横着折一下原本横向排列的信息变成纵向罗列。在 Spark 的 DataFrame / SQL 模型里列转行本质上是对“列名到值”这一组映射关系做展开。最常见的实现手段有两个方向使用stack系列函数直接展开多列成多行使用explode系列函数拆解行内的集合类型字段前者解决的是“结构规整的宽表”后者解决的是“字段本身是数组或字典”的复杂结构。两者适用场景不同但很多人一搜列转行就看见explode顺手就用了结果发现根本不灵——因为普通列根本没法直接 explode。这也是我在实际项目中反复跟同事强调的一点先看数据类型再选工具。1.3 技术选型前的几个判断维度在动手写代码之前我一般会先问自己三个问题。这看起来有点多余但实践证明想清楚再写比瞎写半天再返工高效得多。第一列的数量多不多、是否固定。如果列是动态生成的比如这个月新增了一个字段那代码里就不能硬编码列名得考虑动态拼接。第二每个要转置的列的数据类型是否一致。有的是 String有的是 Long有的是 Double如果不统一stack会直接报类型不匹配或者运行时抛异常。第三有没有需要保留的维度列。列转行之后原来一行里的维度字段比如渠道 ID需要复制到每一行里这个行为stack天然支持但很多人写的时候容易漏。这三个问题想清楚之后代码其实就是一个模板的事。但是模板背后的参数和坑才是真正值得写下来的部分。2. 核心方案解析为什么 stack 是第一选择2.1 stack 函数的工作原理stack是 Hive / Spark SQL 里的一个 UDTF用户定义表生成函数作用是把 N 列数据展开成 M 行其中 N 是参与展开的列数M 由你指定的行数决定。它的标准语法长这样stack(n, col1, col2, ..., colk)这里的n表示一共要拆成多少行拆完之后每一行有几列取决于你后续AS子句里定义的字段数量。举个最直白的例子如果你写stack(2, col_a, col_b, col_c, col_d)意思就是把这四个列的值拆成 2 行每行包含 2 列值。第 1 行的两列取自col_a和col_b第 2 行的两列取自col_c和col_d。初次接触的人很容易绕晕的点就在这里stack的第一个参数是“输出行数”而不是“输入列数”。输入的列数必须是n的整数倍多出来的列会被按顺序分到下一行。如果列数对不上加载阶段报错如果类型对不上运行阶段报错。在 DataFrame 里使用stack通常不会直接写.select(col1, ...)这种普通选择而是要用selectExpr或者把整个表达式当作一个字符串丢进去df.selectExpr( channel_id, channel_name, stack(12, jan_amount, feb_amount, mar_amount, apr_amount, may_amount, jun_amount, jul_amount, aug_amount, sep_amount, oct_amount, nov_amount, dec_amount) as (month_name, amount) )执行完这个之后原来的 12 个金额列就会变成两列输出一列是月份名字段名month_name一列是金额字段名amount。整张 DataFrame 的行数会从原来的 1 行变成 12 行如果原来有 N 行就是 12N 行。2.2 参数细节和列名映射规则写 stack 的时候有四个细节值得单独提出来说全是踩过的坑换来的经验。第一个细节stack里列的顺序决定了行的顺序。上例中(month_name, amount)是两列一组所以偶数位列名会落在month_name奇数位值会落在amount。如果你把列顺序写反了输出的 value 和 key 就全错位了。当时我们项目里就出现过一版数据月份和金额完全对不上排查了半天才发现是列顺序写反。第二个细节AS子句里的字段数量必须和stack展开后每一行的字段数量一致。如果你写stack(6, col1, col2)每个输出行只有 1 个字段那么AS (value)里就只能写一个字段名写两个就报错。这是一个非常容易犯的低级错误。第三个细节stack展开过程中如果某个原始值为null它不会自动帮你过滤掉而是会把 null 值照常输出到目标字段。如果你的业务需要“只有有效数据的行”那你得在 stack 之后手动加过滤条件。这个坑当初让我们多跑了一遍全量任务。第四个细节参与 stack 的列必须显式写出完整列表。不能写stack(12, *)这种语法也不支持直接放一个列集合变量进去。对于几十上百个字段的宽表手写不太现实通常用代码动态生成表达式字符串这个在下面第 3 部分会给出完整例子。2.3 为什么不用 union 方案我见过不少同事在没接触过 stack 的时候会用union加多次select去拼。思路大概是先 select 维度列 第一个月字段union 上 select 维度列 第二个月字段再 union 第三个月……这个方案不是不能跑但是它在代码层面有非常明显的毛病第一代码量爆炸12 个月就要写 12 段 select如果字段有几十个脚本能写到上千行第二每写一个 union 子句Spark 都会多一层逻辑计划节点解析、优化、执行的开销都会增加第三可维护性极差哪天业务加了一个新字段你要从第 1 段改到最后一段漏改一个就很难发现。相比之下stack 用一个表达式的线性复杂度就搞定了逻辑清晰、代码量小、性能也更好。除非你是在做某些极端复杂的转换或者你的目标是把不同来源的数据按行拼接这种应该用 union 而不是 stack否则在“宽表转长表”这个场景下stack 的优先级是最高的。3. 实操过程从数据模拟到完整实现3.1 构造模拟数据为了把整个过程讲透我模拟一份脱敏后的数据。假设我们有 3 个渠道每个渠道对应 3 个月的销售额为了演示方便只取 3 个月而不是 12 个月逻辑完全一样from pyspark.sql import SparkSession spark SparkSession.builder.master(local).appName(column_to_row).getOrCreate() data [ (C001, 渠道A, 100, 150, 200), (C002, 渠道B, 300, 250, 280), (C003, 渠道C, 400, 420, 380), ] df spark.createDataFrame(data, [channel_id, channel_name, jan_amount, feb_amount, mar_amount]) df.show()输出结果---------------------------------------------------- |channel_id|channel_name|jan_amount|feb_amount|mar_amount| ---------------------------------------------------- | C001| 渠道A| 100| 150| 200| | C002| 渠道B| 300| 250| 280| | C003| 渠道C| 400| 420| 380| ----------------------------------------------------这就是一个标准的待转置宽表维度列是channel_id和channel_name需要展开的列是jan_amount、feb_amount、mar_amount。3.2 静态写法固定三列转行最直接的方法是写死三个列名用selectExpr完成转换result_df df.selectExpr( channel_id, channel_name, stack(3, jan_amount, feb_amount, mar_amount) as (month, amount) ) result_df.show()执行结果--------------------------------- |channel_id|channel_name|month|amount| --------------------------------- | C001| 渠道A| 100| ? ---------------------------------等一下这里就有个问题。stack(3, jan_amount, feb_amount, mar_amount)是把 3 个列拆成 3 行每行只有 1 列。所以AS (month, amount)的写法是错的——month拿到的其实是第一个输入列的值也就是 100amount根本没有对应的分配系统会报错。正确的写法是让每个输出行有 2 个字段所以需要传入 6 个值result_df df.selectExpr( channel_id, channel_name, stack(3, jan, jan_amount, feb, feb_amount, mar, mar_amount) as (month, amount) ) result_df.show()这次的结果才是对的--------------------------------- |channel_id|channel_name|month|amount| --------------------------------- | C001| 渠道A| jan| 100| | C001| 渠道A| feb| 150| | C001| 渠道A| mar| 200| | C002| 渠道B| jan| 300| | C002| 渠道B| feb| 250| | C002| 渠道B| mar| 280| | C003| 渠道C| jan| 400| | C003| 渠道C| feb| 420| | C003| 渠道C| mar| 380| ---------------------------------注意我在传入序列里加了字符串常量jan、feb、mar。这是 stack 列转行的核心套路你要输出的“行标识字段”这里就是月份名其实就是列名本身但列名是静态的文本不会跟着值走。所以你需要手动把这些常量对应地放进参数序列里让它们和真实列值一一配对。如果你希望输出的月份是数字1、2、3就写成result_df df.selectExpr( channel_id, channel_name, stack(3, 1, jan_amount, 2, feb_amount, 3, mar_amount) as (month, amount) )原理完全一样只是常量从字符串变成了整数。3.3 动态写法字段特别多怎么办如果表里有几十甚至上百个要转置的列手动写col1, col1, col2, col2, ...当然是不现实的。这时候就需要 Python / Scala 代码来拼接表达式。以 Python PySpark 为例假设你的宽表字段名遵循xxx_amount这样的规则可以先筛选出所有目标列名然后动态构造 stack 表达式的参数列表amount_cols [c for c in df.columns if c.endswith(_amount)] # 按列名字典序排序保证结果稳定 amount_cols.sort() # 构造 stack 参数序列每个列前面加上它自己的名字文本 stack_params [] for c in amount_cols: month_name c.replace(_amount, ) stack_params.append(f{month_name}) # 输出行的月份字段 stack_params.append(c) # 输出行的金额字段 stack_expr fstack({len(amount_cols)}, {, .join(stack_params)}) as (month, amount) result_df df.selectExpr( channel_id, channel_name, stack_expr )这样写完之后即使下个月上游多了一个apr_amount字段你只要重新从df.columns里筛选代码一行都不用改。动态拼接表达式算是这类需求里比较推荐的进阶写法。但这里有个前提你的列名必须有规律。如果列名乱得毫无章法比如a1、b2、c3那就得老老实实维护一个字段映射表手工指定哪些列要展开、对应的输出标识文本是什么。映射表的方式虽然要维护但胜在可控、不容易出错。3.4 explode 的适用场景处理 Array 和 Map上面说了 stack 是列转行的主力但还有一个高频场景是会用到 explode 的当数据本身以 Array 或 Map 存储时。比如上游给了你一行数据里面一个字段是score_list存的是这个渠道每一天的分数是一个 Array[Double]你要把它拆成一行行记录。这种场景用 stack 就得先把 Array 展开到多个列如果 Array 长度不定根本没法展开而 explode 就是专门干这个的from pyspark.sql.functions import explode data [ (C001, 渠道A, [100, 150, 200]), (C002, 渠道B, [300, 250, 280]), ] df spark.createDataFrame(data, [channel_id, channel_name, score_list]) result_df df.select( channel_id, channel_name, explode(score_list).alias(score) ) result_df.show()输出--------------------------- |channel_id|channel_name|score| --------------------------- | C001| 渠道A| 100| | C001| 渠道A| 150| | C001| 渠道A| 200| | C002| 渠道B| 300| | C002| 渠道B| 250| | C002| 渠道B| 280| ---------------------------如果还需要知道当前拆出来的是这个数组里的第几个元素用posexplode它会在输出里额外带一个索引列from pyspark.sql.functions import posexplode result_df df.select( channel_id, channel_name, posexplode(score_list).alias(pos, score) )posexplode在同时需要“序号”和“值”的场景里很实用比如你要把数组里的第一个值当作基准值那pos就派上用场了。至于是不是应该先把多列合并成struct再用explode拆这种事情纯粹看个人习惯。如果有人喜欢把 3 个月的金额先构造成一个array再explode那也能达到列转行的效果但多了一步构造数组的开销性能上不如直接用 stack 来得干脆。我的建议是普通的多列转多行别绕路stack 直接上数组/字典里的嵌套数据才轮到 explode 发挥。3.5 类型不一致时的处理办法stack 特别容易踩的坑之一是参与展开的列类型不统一。比如某个月的金额字段是 Double另外某个月由于上游数据问题被定义成了 String此时 Spark 在生成逻辑计划时会发现 stack 的输入类型不一致直接抛 AnalysisException。解决办法是在传入 stack 之前先对所有列做一次类型统一from pyspark.sql.functions import col # 假设 df 里有多个列需要转置先统一转成 Double for c in amount_cols: df df.withColumn(c, col(c).cast(double))这种操作在数据治理不严格的业务场景里非常常见。同一个业务字段因为上游报表口径调整从 Decimal 变成 String再到 Double你根本不知道哪一天会变。所以在 stack 前统一 cast 是一个成本很低但收益很大的好习惯。4. 常见问题与排查技巧实录4.1 报错“The number of columns doesnt match”这个错误信息通常长这样org.apache.spark.sql.AnalysisException: The number of columns produced by STACK should not be greater than number of columns in the query问题根源基本是stack里传的列数和AS子句定义的字段数量对不上。举个例子你写了stack(3, jan, jan_amount, feb, feb_amount, mar, mar_amount)这 6 个值会被分成 3 行、每行 2 个字段所以AS 子句必须写 2 个字段名。如果你写成了as (month)就会报错。排查思路很简单数一下 stack 参数列表里一共有几个值除以n得到的结果必须和 AS 里的字段数一致。4.2 类型不匹配的坑另一个高频报错是cannot resolve ... due to data type mismatch。参与 stack 的列类型五花八门有 String、有 Double、有 IntegerSpark 为了让这些类型能放进同一个输出字段需要找到一个公共类型找不到就直接报错。解决方法前面已经提过在 stack 前统一 cast。如果因为你懒不想写 cast也可以用coalesce或者字符串拼接把它们统一成 String但这样后续用起来还得再转一次不太划算。我个人建议是统一成业务最终使用的类型比如金额就统一成 Decimal(18, 2) 或 Double。4.3 合并 null 值的影响stack 不会主动过滤 null。如果你有 12 个月的金额列但某些月份确实没有数据转出来的 12 行里那些月份对应的amount字段就是 null。下游聚合的时候 null 会被 sum 忽略但如果业务要的是“只保留有实际值的月份”你必须在 stack 之后加一个过滤条件result_df.filter(col(amount).isNotNull())这里有个容易忽略的细节如果你用coalesce把 null 替换成 0再去过滤就彻底失效了因为全变成 0 之后你根本分辨不出哪些是真实 0、哪些是缺失值。所以除非业务明确要求缺失按 0 计否则别轻易把 null 填成 0宁可多留一步让下游自己决定怎么处理。4.4 执行慢重点看这两处列转行本身不会引起数据膨胀因为原本一行里的数据拆成多行后值还是那些值只是形态变了。所以正常情况下这个操作不会比普通 select 慢很多。如果你发现任务运行特别慢排查重点应该放在以下两处第一检查是不是有大字段或者数量巨大的列全部参与 stack。比如一张表有 200 个字段、100 个都要转那么单条记录的宽度极大序列化和网络传输的成本都会上一个台阶。建议先只 select 需要的字段再转不要一上来就select *。第二检查是不是在转完之后立刻做了高代价的 join 或 groupBy。列转行之后数据量不变但如果你转完之后又和一张大表做 join那慢的根源其实是 join而不是列转行本身。把执行计划拿过来看一下哪个 stage 耗时最长通常问题一目了然。4.5 一个实用小技巧动态生成 stack 时给字段加序号批量生成的字段比如多个月份列它们的顺序在动态筛选后不一定可控。你可以主动给参数序列排序而不是依赖 DataFrame 的列顺序。具体做法是把筛选出来的列名先排序再拼接表达式。这样不管上游表字段怎么乱排你的输出顺序始终稳定下游做对比测试也方便。另外一个容易被忽略的地方stack表达式里的字符串常量比如jan如果是变量拼 SQL 的时候记得转义和加引号防止空格、特殊字符把表达式搞坏。我见过有人动态拼出的表达式里字符串写成了month_2024-01结果日期里的连字符导致表达式被截断排查确实费了些时间。稳妥的做法是构造表达式后用spark.sql跑一个select快速验证或者打印出来看一遍再执行。5. 项目落地中的一点个人经验列转行这个操作看着简单但在真实项目里它往往不是一次性跑完就结束的。数据入仓、数据清洗、ETL 调度、下游 BI 报表每一步都可能对输出的字段命名、类型、null 值策略有不同要求。我在这个项目里做了一版标准化方案之后基本把它固化成了一个公共函数团队里其他人遇到“宽表转长表”就直接复用不再重复踩坑。公共函数的核心逻辑不复杂输入 DataFrame 和一组“列名 → 输出标识”的映射自动统一类型、动态拼 stack 表达式、输出标准化字段。如果你们团队经常处理类似的报表数据我强烈建议一步到位做成通用工具省得每次手工改脚本。还有一点列转行的结果最好直接落成 Parquet 格式的中间表字段命名要跟下游约定清楚。因为转完之后原先藏在列名里的月份信息现在变成了一个字段的值这个值的数据质量直接决定了下游过滤条件怎么写。如果month字段有时间类型、字符串类型混着来下游查询脚本就得写两套逻辑维护成本翻倍。最后说一个从实际操作里沉淀出来的心得column-to-row 的思路在很多 SQL 引擎里是通用的不只是 Spark。你如果熟悉了 Spark 的 stack后面接触其他引擎里类似的 unpivot 函数会发现核心思想都是一样的——把“元数据”从列名里解放出来变成真正的数据。这个理念在数据建模里也值得留意凡是长期要沿用的宽表多花一点时间把它转成规整的长表后面的分析工作会轻松非常多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表