上个月我接了一个需求数据团队给了一张季度指标宽表每个业务线一行后面跟了2023年四个季度、2024年两个季度的六列指标。需求方说想看这六个季度之间的环比趋势说得很轻松——可我一打开表就明白宽表根本没法直接画趋势线。像这种把多列摊成键值对长表的操作在Spark里就叫列转行英文社区叫unpivot也有人直接说wide to long。这正是本篇要解决的问题在Spark Dataframe上做列转行把一行业务线、多列指标变成一行业务线、一列季度、一列数值的标准长表。如果你也经常和宽表、透视表打交道或者刚接手一堆需要聚合分析的项目数据这篇文章应该能帮你省下不少折腾时间。我第一次接触这个需求的时候第一反应是这不就是pivot反过来吗但真上手才发现Spark的Dataframe API里并没有一个直观的unpivot方法老版本里甚至只能用stack表达式硬拼新版本3.4倒是有了原生unpivot但很多人项目还在用老版本。这篇博文就把我实际用过的三种方案、踩过的坑、最后封装的通用函数完整写出来配合代码和踩坑记录你可以直接抄到项目里用。1. 宽表转长表的需求从哪来1.1 一个具体的宽表困局先还原一下我遇到的那张表简化之后结构是这样的业务线q1_2023q2_2023q3_2023q4_2023q1_2024q2_2024华东区100120140160180200华南区8090110130150170看着挺整齐可真要做趋势分析就麻烦大了。想算某个业务线所有季度的平均值得在SQL里把六列加起来除以6写一大堆(q1_2023 q2_2023 ...) / 6想按季度分组对比各业务线更是无从下手。数据可视化工具通常只认维度列 指标列这种宽表喂进去折线图都画不出来。我当时的需求是把六列季度数值转成两列一列叫quarter存季度名称一列叫revenue存对应数值。转换后每一行就是一个业务线在某个季度的记录这样无论是groupBy聚合还是直接扔给报表工具都顺理成章。这里要特别说明一下术语列转行unpivot和行转列pivot是互为镜像的操作。行转列是把长表里的键值对摊开成多列比如把quarter列的各个值变成表头的列列转行则是反过来把多列折叠成一个键列加一个值列。Spark自带的groupBy.pivot做行转列很熟练但列转行一直比较绕所以才有了这篇文章。1.2 为什么宽表在项目里无处不在可能你会问既然宽表这么不方便当初为啥要生成宽表道理也简单——宽表是从业务库或报表系统同步过来的原始形态很多上游系统为了展示方便就是按一个指标一列来存储的。数据仓库的同学有时候也会为了查询性能把多个维度的度量字段冗余到一张大宽表里。这就导致下游分析师拿到手的数据十有八九需要先做一步列转行才能进入正常的数据处理流程。所以在项目里列转行不是一个花活而是数据清洗环节的基础操作。掌握它的意义在于遇到任何多列同质指标的脏数据或宽表数据你都能快速把它整理成适合进一步分析的长表结构。2. 三种列转行实现stack、unpivot、explode选哪个2.1 stack一句话SQL几乎全版本通用stack是Spark SQL内置的生成器函数语法是stack(n, expr1, expr2, ..., exprn)它会把后面的表达式分成n行输出。核心用法是配合selectExpr使用。比如针对前面那张表可以这样写df.selectExpr( 业务线, stack(6, q1_2023, q1_2023, q2_2023, q2_2023, q3_2023, q3_2023, q4_2023, q4_2023, q1_2024, q1_2024, q2_2024, q2_2024) as (quarter, revenue) )运行完的结果就是长表业务线quarterrevenue华东区q1_2023100华东区q2_2023120.........stack函数第一个参数6表示要生成6行后面是成对出现的列名常量和列值引用。行数必须和参数对的个数对得上这个是硬性约束写错了直接报错。这个方案的优点是很直白Spark 2.x和3.x全版本都能跑缺点是表达式要手写列一多就特别啰嗦。2.2 unpivotSpark 3.4的新写法如果你的项目已经升级到Spark 3.4及以上那就有福了。Dataframe API直接提供了unpivot方法不需要拼SQL只要指定保留哪些列作为id列、哪些列作为待转换列、新键列名和新值列名df.unpivot( ids Array(业务线), values Array(q1_2023, q2_2023, q3_2023, q4_2023, q1_2024, q2_2024), variableColumnName quarter, valueColumnName revenue )这个写法和SQL标准里的UNPIVOT思想是一致的代码可读性高很多。但它有两个限制一是版本要求高很多生产集群还停在Spark 3.1或3.3用不了二是values必须显式列出来遇到几十上百列的大宽表依然要生成一个超长数组。所以我会说unpivot适合新项目、列数量可控的场景老项目还是得靠stack或者下面第三种方案。2.3 explode array当你的场景需要更多控制第三种方案是用array配合struct把多个列包装成数组再用explode拆开。思路是先把每个待转换列做成一个struct(列名, 列值)放进一个数组里然后炸开。import org.apache.spark.sql.functions._ df.withColumn( tmp, explode(array( struct(lit(q1_2023), col(q1_2023)), struct(lit(q2_2023), col(q2_2023)), struct(lit(q3_2023), col(q3_2023)), struct(lit(q4_2023), col(q4_2023)), struct(lit(q1_2024), col(q1_2024)), struct(lit(q2_2024), col(q2_2024)) )) ).select( col(业务线), col(tmp.col1).as(quarter), col(tmp.col2).as(revenue) )相比stack这种方式的优势在于每个列名/列值对本身就是一个结构体你可以在放进数组之前做条件过滤、类型转换甚至调整输出顺序。缺点也很明显语法比stack复杂想象一下六列就这样六十列会写到手软。所以它适合那些需要在列转行过程中嵌入更多业务逻辑的复杂场景而不是简单的批量转换。2.4 三种方案怎么选我用一个简表总结一下这几种方案的实际区别方便你按项目情况对号入座方案版本要求代码复杂度适用场景stack表达式Spark 2.x低但列多时噪音大老版本主力方案字段数在20个以内很推荐原生unpivotSpark 3.4最低新集群、列名固定、追求可读性explode arraySpark 2.x中等偏高需要在列转行时嵌入类型转换、条件逻辑等我在实际项目里默认选stack因为兼容性好逻辑直观出了问题也好排查。除非确认生产环境版本够新我才会考虑unpivot。3. 实战封装写一个通用列转行组件3.1 动态读取Schema从列名生成stack表达式手写stack表达式终究不是长久之计尤其是当你的宽表有几十个季度列、或者列名是程序自动生成的后缀时手动维护一个超长字符串就是灾难。我在项目里做了一件事读取Dataframe的schema自动过滤出需要转换的列然后动态拼接stack表达式。假设我们的宽表里id列叫业务线其他以q_开头的列都是待转换指标列那么动态生成的逻辑可以这样写import org.apache.spark.sql.DataFrame import org.apache.spark.sql.functions._ def unpivotWithStack( df: DataFrame, idCols: Seq[String], valueColPattern: String, keyColName: String, valueColName: String ): DataFrame { // 1. 根据列名前缀过滤出待转换列 val valueCols df.columns.filter(c c.startsWith(valueColPattern)).toSeq // 2. 生成stack表达式的列名常量, 列引用对 val stackPairs valueCols.flatMap { c Seq(s${c}, s${c}) }.mkString(, ) // 3. 拼接selectExpr val stackExpr sstack(${valueCols.size}, ${stackPairs}) as ($keyColName, $valueColName) df.selectExpr(idCols.map(c s${c}) : stackExpr: _*) }用的时候就是一行val longDf unpivotWithStack(df, Seq(业务线), q_, quarter, revenue)这里有两个细节值得注意。第一列名引用我用的是反引号包裹因为批量处理的宽表里很可能会出现中文列名、特殊字符甚至数字开头列名反引号能保证它们在SQL表达式里被正确解析。第二stack的第一参数是行数我直接用valueCols.size这样不管列数怎么变都不会出现数量不匹配的报错。3.2 类型统一与空值处理别让脏数据拆家如果你只是简单拼接stack表达式往往会遇到一个很隐蔽的坑stack要求所有列值表达式的类型一致。假设q1_2023是int类型q1_2024因为上游数据异常被推断成了string类型那selectExpr执行栈会直接抛类似cannot resolve ... due to data type mismatch的异常。解决办法很简单在动态生成表达式之前先把所有待转换列统一cast成同一种类型。因为指标列一般都要参与后续聚合计算我通常会统一cast成doubleval castedDf valueCols.foldLeft(df) { (tmpDf, colName) tmpDf.withColumn(colName, col(colName).cast(double)) }然后才是调用上面那个unpivotWithStack。这样处理之后类型不一致的坑一次解决。另一个关键问题是空值。stack函数会把null值也照常炸出一行也就是说某个业务线某个季度没有数据长表里就会多出一行quarternull或某季度、revenuenull的记录。如果后续聚合用的是avgnull会被忽略结果没问题但如果用的聚合函数是countnull就会变成计数的一部分导致统计口径错误。我习惯在列转行之后加一个过滤条件把值列为null的行去掉longDf.filter(col(revenue).isNotNull)这样下游无论怎么聚合都不会被null干扰。如果你希望保留null值做特殊业务标记那也可以反过来单独把null行挑出来打标签——这属于业务逻辑的取舍但数据清洗阶段我强烈建议至少把它显式处理掉不要让脏数据静默流到结果里。3.3 完整示例与输出效果把上面的片段拼起来一个完整的调用就是import org.apache.spark.sql.SparkSession val spark SparkSession.builder() .appName(UnpivotDemo) .master(local[*]) .getOrCreate() import spark.implicits._ val df Seq( (华东区, 100, 120, 140, 160, 180, 200), (华南区, 80, 90, 110, 130, 150, 170) ).toDF(业务线, q1_2023, q2_2023, q3_2023, q4_2023, q1_2024, q2_2024) val result unpivotWithStack(df, Seq(业务线), q_, quarter, revenue) .filter(col(revenue).isNotNull) result.show()输出----------------------- |业务线 |quarter |revenue| ----------------------- |华东区 |q1_2023 |100 | |华东区 |q2_2023 |120 | |华东区 |q3_2023 |140 | |华东区 |q4_2023 |160 | |华东区 |q1_2024 |180 | |华东区 |q2_2024 |200 | |华南区 |q1_2023 |80 | |华南区 |q2_2023 |90 | |华南区 |q3_2023 |110 | |华南区 |q4_2023 |130 | |华南区 |q1_2024 |150 | |华南区 |q2_2024 |170 | -----------------------到这一步宽表转长表的核心目标就达成了。后续无论是groupBy(quarter).avg(revenue)还是直接输出给报表工具都是水到渠成的事。4. 几种必须小心的列名和脏数据场景4.1 特殊字符列名、中文列名怎么处理真实项目的宽表列名往往长得不那么规整。我就遇到过列名叫2023Q1收入万元这种带括号、带中文单位、甚至数字开头的字段。这种列名如果直接拼到stack表达式里很容易让Spark的SQL解析器犯迷糊。前面动态方案里我用反引号包裹列名就是专门应对这种情况的。你可以把反引号理解成SQL世界的转义符——凡是列名里有特殊字符、空格、中文、数字开头用反引号包一层就对了。但还有一个容易忽略的点生成的键列值。stack表达式里每个列名常量也会成为长表里keyCol列的值如果这个常量里带了括号、单位、逗号后续查询同样麻烦。建议在生成${c}这个常量时顺手做一个清洗比如用正则把非字母数字的字符替换掉再把原始列名存成另一列作为溯源字段。这样既保证键值列干净又不丢失原始信息。这里给个参考实现把清洗逻辑加到动态生成里def cleanKeyName(colName: String): String { colName.replaceAll([^0-9a-zA-Z\\u4e00-\\u9fa5], _) }然后在拼接常量的时候用cleanKeyName(c)替换原始的c。实测下来下游无论是做报表还是做分析都能少踩很多字符串匹配的坑。4.2 空值语义保留还是剔除前面说了stack会把null一起炸出来但null的语义其实要分情况看。如果一张表里某个业务线某季度指标确实没有产生那null代表无数据剔除它会让长表更干净但如果null代表这个界面应该显示0那直接剔除就会丢掉信息导致同比环比算出来是空值。我遇到过的一个典型案例是接入了某外部渠道的数据该渠道在早期季度不产生数据但业务方希望长表里保留这些行只是标记一个channel_id方便之后找数据产品单独处理。这种时候null行就不能简单丢而是应该加一个标记列longDf.withColumn( is_missing, when(col(revenue).isNull, lit(1)).otherwise(lit(0)) )所以我的建议是先明确业务口径再决定null的去留。如果你只是做通用清洗组件默认剔除null但把开关做成参数暴露出来让下游调用方自己决定。这样做最灵活也最符合数据工程里通用组件少做隐式假设的实践。5. 性能真相与典型坑位5.1 为什么不要用多个union堆叠可能有人会想列转行嘛我用union一条条拼不行吗先select 业务线, q1_2023 as quarter, q1_2023 as revenue from df再union第二个季度的最后把所有union结果合并。技术上当然能跑但性能非常糟糕。原因在于union会把每个分支都当成一个独立的逻辑计划执行时会产生多份全量数据的复制和合并尤其是当宽表本身有几百GB甚至上TB时这种一条SQL挂几十个union的计划会让Spark的优化器非常难受执行时间可能膨胀到stack方案的十倍以上。stack方案本质上是在单个selectExpr里生成多行执行计划精简得多数据只需要扫描一遍就能完成行展开。用一句话总结列转行是纯行内数据重排根本不需要union这种重量级操作。5.2 大宽表场景的注意事项如果你要处理的宽表有上百个指标列stack表达式会很长这里有三个实战注意点。第一确保driver端的内存够用。动态拼接一个几百列的长表达式生成的字符串虽然不大但Spark会将整个表达式序列化到executor端表达式越长解析开销越大。我建议如果列数超过200可以在应用层分批次转或者直接改用explode array方式因为array方式在物理计划里更模块化。第二stack生成的行数会显著放大数据量。假如原来有1亿行、100个指标列转成长期末可能是100亿行。这不是计算上的瓶颈而是存储和下游读取的瓶颈一定要提前和下游确认好产出表的分区策略避免一次性写出超大文件导致任务OOM或下游查询卡死。第三如果源表本身有分区列列转行之前一定要检查待转换的列里是否混入了分区列。分区列的值在不同分区里范围不同一旦被误当成指标列展开会产生大量重复键后续聚合结果会完全失真。正确做法是将分区列放进idCols只把真正的度量列放进转换范围。5.3 踩坑记录表我把这段时间踩过的坑汇总成一张列表方便你对照自查现象根因解决办法selectExpr报data type mismatch多列类型不一致stack要求类型相同转列前统一cast成double长表出现大量null行stack把null值也炸出来了根据业务口径选择过滤或加标记中文列名导致解析失败列名有特殊字符生成表达式时用反引号包裹结果键列值含括号单位列名常量未清洗用正则清洗键列常量数据量被异常放大把分区列当成了指标列检查schema分区列加入idCols用了union导致任务极慢union多分支全量数据复制改用stack或explodearray这些坑单独看都不大但组合起来真的能让人在调试上耗掉一整天。提前规避比事后排查划算得多。6. 最后的实操体会写这篇文章的时候我又把老项目里那个列转行函数翻出来看了一眼。最初版本是用几十行union拼出来的后来重构成stack动态生成再后来新集群支持了unpivot我也在测试环境体验了一把原生API的爽快但生产代码还是不打算整体迁移——原因是依赖的Spark版本还没统一升级完stack方案虽然在写法上老派却胜在稳、兼容、好维护。如果你现在就打算在项目里落地列转行我给一个具体的行动顺序先在测试环境确认Spark版本如果等于或高于3.4且列数不多直接用unpivot代码最干净如果还在老版本把本文第三节的unpivotWithStack函数复制过去配合cast和null过滤基本就能覆盖大多数需求遇到特殊字符列名或超大宽表再参考第四节和第五节的注意事项做调整。最后再分享一个小技巧列转行的产出表写出去之后最好顺手做一个数据质量校验比如assert(长表行数 原表行数 * 指标列数)。别小看这一步它能在数据链路出问题时帮你瞬间定位是转换逻辑的问题还是上游数据的问题省下来的排查时间绝对物超所值。