行:stack與explode實(shí)戰(zhàn)詳解)
做數(shù)據(jù)的人大概都遇到過(guò)這種場(chǎng)景業(yè)務(wù)方丟過(guò)來(lái)一張表字段是按月份展開(kāi)的什么202401_sales、202402_sales、202403_sales一路排下去又或者是一行記錄里同時(shí)塞了十幾個(gè)指標(biāo)列。你要做統(tǒng)計(jì)分析、要出報(bào)表第一步就是把這些“橫著”的列變成“豎著”的行。這個(gè)動(dòng)作在行業(yè)里叫列轉(zhuǎn)行也叫寬表轉(zhuǎn)長(zhǎng)表、unpivot。在 Spark DataFrame 里實(shí)現(xiàn)列轉(zhuǎn)行常規(guī)的做法有stack函數(shù)、explode函數(shù)還有一堆人繞遠(yuǎn)路的union方案。這篇文章我就結(jié)合自己在某項(xiàng)目里的真實(shí)處理過(guò)程把 Spark DataFrame 列轉(zhuǎn)行的幾種寫法、選型邏輯、參數(shù)坑、性能調(diào)優(yōu)一次說(shuō)清楚。先說(shuō)結(jié)論如果只是普通的多列轉(zhuǎn)多行stack是首選如果你處理的是 Array 或 Map 類型的嵌套數(shù)據(jù)explode才派得上用場(chǎng)其他場(chǎng)景下用union硬懟基本都是自找麻煩。為什么這么說(shuō)下面拆開(kāi)講。1. 項(xiàng)目場(chǎng)景與需求拆解1.1 接到一個(gè)什么需求事情是這樣的。某個(gè)數(shù)據(jù)平臺(tái)項(xiàng)目里上游給我們同步了一張渠道推廣效果表表結(jié)構(gòu)大致是這樣每一行是一個(gè)渠道渠道名和渠道 ID 是固定的維度字段然后后面跟著jan_amount、feb_amount、mar_amount這種十二個(gè)月份的轉(zhuǎn)化金額列再加若干分組字段。表本身不算大幾千萬(wàn)行的規(guī)模但如果直接拿這張寬表去做同比環(huán)比、趨勢(shì)分析、前端圖表渲染會(huì)非常別扭——因?yàn)榘丛路治霰举|(zhì)上需要一個(gè)“月份”維度而寬表里月份是散在列名里的。當(dāng)時(shí)數(shù)據(jù)組拿到的需求原文很明確把這張寬表里每個(gè)月的數(shù)據(jù)拆成獨(dú)立一行輸出channel_id、channel_name、month、amount四個(gè)核心字段方便下游直接掛 BI 出趨勢(shì)圖。這種“列名即值”的表結(jié)構(gòu)就是列轉(zhuǎn)行最典型的適用場(chǎng)景。1.2 列轉(zhuǎn)行的本質(zhì)是什么列轉(zhuǎn)行聽(tīng)起來(lái)是個(gè)高大上的數(shù)據(jù)操作其實(shí)底層邏輯一句話就能講透把多個(gè)列的值連同它們對(duì)應(yīng)的列名重新組織成“行記錄”中的字段。你可以理解成把一張 A4 紙橫著折一下原本橫向排列的信息變成縱向羅列。在 Spark 的 DataFrame / SQL 模型里列轉(zhuǎn)行本質(zhì)上是對(duì)“列名到值”這一組映射關(guān)系做展開(kāi)。最常見(jiàn)的實(shí)現(xiàn)手段有兩個(gè)方向使用stack系列函數(shù)直接展開(kāi)多列成多行使用explode系列函數(shù)拆解行內(nèi)的集合類型字段前者解決的是“結(jié)構(gòu)規(guī)整的寬表”后者解決的是“字段本身是數(shù)組或字典”的復(fù)雜結(jié)構(gòu)。兩者適用場(chǎng)景不同但很多人一搜列轉(zhuǎn)行就看見(jiàn)explode順手就用了結(jié)果發(fā)現(xiàn)根本不靈——因?yàn)槠胀懈緵](méi)法直接 explode。這也是我在實(shí)際項(xiàng)目中反復(fù)跟同事強(qiáng)調(diào)的一點(diǎn)先看數(shù)據(jù)類型再選工具。1.3 技術(shù)選型前的幾個(gè)判斷維度在動(dòng)手寫代碼之前我一般會(huì)先問(wèn)自己三個(gè)問(wèn)題。這看起來(lái)有點(diǎn)多余但實(shí)踐證明想清楚再寫比瞎寫半天再返工高效得多。第一列的數(shù)量多不多、是否固定。如果列是動(dòng)態(tài)生成的比如這個(gè)月新增了一個(gè)字段那代碼里就不能硬編碼列名得考慮動(dòng)態(tài)拼接。第二每個(gè)要轉(zhuǎn)置的列的數(shù)據(jù)類型是否一致。有的是 String有的是 Long有的是 Double如果不統(tǒng)一stack會(huì)直接報(bào)類型不匹配或者運(yùn)行時(shí)拋異常。第三有沒(méi)有需要保留的維度列。列轉(zhuǎn)行之后原來(lái)一行里的維度字段比如渠道 ID需要復(fù)制到每一行里這個(gè)行為stack天然支持但很多人寫的時(shí)候容易漏。這三個(gè)問(wèn)題想清楚之后代碼其實(shí)就是一個(gè)模板的事。但是模板背后的參數(shù)和坑才是真正值得寫下來(lái)的部分。2. 核心方案解析為什么 stack 是第一選擇2.1 stack 函數(shù)的工作原理stack是 Hive / Spark SQL 里的一個(gè) UDTF用戶定義表生成函數(shù)作用是把 N 列數(shù)據(jù)展開(kāi)成 M 行其中 N 是參與展開(kāi)的列數(shù)M 由你指定的行數(shù)決定。它的標(biāo)準(zhǔn)語(yǔ)法長(zhǎng)這樣stack(n, col1, col2, ..., colk)這里的n表示一共要拆成多少行拆完之后每一行有幾列取決于你后續(xù)AS子句里定義的字段數(shù)量。舉個(gè)最直白的例子如果你寫stack(2, col_a, col_b, col_c, col_d)意思就是把這四個(gè)列的值拆成 2 行每行包含 2 列值。第 1 行的兩列取自col_a和col_b第 2 行的兩列取自col_c和col_d。初次接觸的人很容易繞暈的點(diǎn)就在這里stack的第一個(gè)參數(shù)是“輸出行數(shù)”而不是“輸入列數(shù)”。輸入的列數(shù)必須是n的整數(shù)倍多出來(lái)的列會(huì)被按順序分到下一行。如果列數(shù)對(duì)不上加載階段報(bào)錯(cuò)如果類型對(duì)不上運(yùn)行階段報(bào)錯(cuò)。在 DataFrame 里使用stack通常不會(huì)直接寫.select(col1, ...)這種普通選擇而是要用selectExpr或者把整個(gè)表達(dá)式當(dāng)作一個(gè)字符串丟進(jìn)去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) )執(zhí)行完這個(gè)之后原來(lái)的 12 個(gè)金額列就會(huì)變成兩列輸出一列是月份名字段名month_name一列是金額字段名amount。整張 DataFrame 的行數(shù)會(huì)從原來(lái)的 1 行變成 12 行如果原來(lái)有 N 行就是 12N 行。2.2 參數(shù)細(xì)節(jié)和列名映射規(guī)則寫 stack 的時(shí)候有四個(gè)細(xì)節(jié)值得單獨(dú)提出來(lái)說(shuō)全是踩過(guò)的坑換來(lái)的經(jīng)驗(yàn)。第一個(gè)細(xì)節(jié)stack里列的順序決定了行的順序。上例中(month_name, amount)是兩列一組所以偶數(shù)位列名會(huì)落在month_name奇數(shù)位值會(huì)落在amount。如果你把列順序?qū)懛戳溯敵龅?value 和 key 就全錯(cuò)位了。當(dāng)時(shí)我們項(xiàng)目里就出現(xiàn)過(guò)一版數(shù)據(jù)月份和金額完全對(duì)不上排查了半天才發(fā)現(xiàn)是列順序?qū)懛?。第二個(gè)細(xì)節(jié)AS子句里的字段數(shù)量必須和stack展開(kāi)后每一行的字段數(shù)量一致。如果你寫stack(6, col1, col2)每個(gè)輸出行只有 1 個(gè)字段那么AS (value)里就只能寫一個(gè)字段名寫兩個(gè)就報(bào)錯(cuò)。這是一個(gè)非常容易犯的低級(jí)錯(cuò)誤。第三個(gè)細(xì)節(jié)stack展開(kāi)過(guò)程中如果某個(gè)原始值為null它不會(huì)自動(dòng)幫你過(guò)濾掉而是會(huì)把 null 值照常輸出到目標(biāo)字段。如果你的業(yè)務(wù)需要“只有有效數(shù)據(jù)的行”那你得在 stack 之后手動(dòng)加過(guò)濾條件。這個(gè)坑當(dāng)初讓我們多跑了一遍全量任務(wù)。第四個(gè)細(xì)節(jié)參與 stack 的列必須顯式寫出完整列表。不能寫stack(12, *)這種語(yǔ)法也不支持直接放一個(gè)列集合變量進(jìn)去。對(duì)于幾十上百個(gè)字段的寬表手寫不太現(xiàn)實(shí)通常用代碼動(dòng)態(tài)生成表達(dá)式字符串這個(gè)在下面第 3 部分會(huì)給出完整例子。2.3 為什么不用 union 方案我見(jiàn)過(guò)不少同事在沒(méi)接觸過(guò) stack 的時(shí)候會(huì)用union加多次select去拼。思路大概是先 select 維度列 第一個(gè)月字段union 上 select 維度列 第二個(gè)月字段再 union 第三個(gè)月……這個(gè)方案不是不能跑但是它在代碼層面有非常明顯的毛病第一代碼量爆炸12 個(gè)月就要寫 12 段 select如果字段有幾十個(gè)腳本能寫到上千行第二每寫一個(gè) union 子句Spark 都會(huì)多一層邏輯計(jì)劃節(jié)點(diǎn)解析、優(yōu)化、執(zhí)行的開(kāi)銷都會(huì)增加第三可維護(hù)性極差哪天業(yè)務(wù)加了一個(gè)新字段你要從第 1 段改到最后一段漏改一個(gè)就很難發(fā)現(xiàn)。相比之下stack 用一個(gè)表達(dá)式的線性復(fù)雜度就搞定了邏輯清晰、代碼量小、性能也更好。除非你是在做某些極端復(fù)雜的轉(zhuǎn)換或者你的目標(biāo)是把不同來(lái)源的數(shù)據(jù)按行拼接這種應(yīng)該用 union 而不是 stack否則在“寬表轉(zhuǎn)長(zhǎng)表”這個(gè)場(chǎng)景下stack 的優(yōu)先級(jí)是最高的。3. 實(shí)操過(guò)程從數(shù)據(jù)模擬到完整實(shí)現(xiàn)3.1 構(gòu)造模擬數(shù)據(jù)為了把整個(gè)過(guò)程講透我模擬一份脫敏后的數(shù)據(jù)。假設(shè)我們有 3 個(gè)渠道每個(gè)渠道對(duì)應(yīng) 3 個(gè)月的銷售額為了演示方便只取 3 個(gè)月而不是 12 個(gè)月邏輯完全一樣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()輸出結(jié)果---------------------------------------------------- |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| ----------------------------------------------------這就是一個(gè)標(biāo)準(zhǔn)的待轉(zhuǎn)置寬表維度列是channel_id和channel_name需要展開(kāi)的列是jan_amount、feb_amount、mar_amount。3.2 靜態(tài)寫法固定三列轉(zhuǎn)行最直接的方法是寫死三個(gè)列名用selectExpr完成轉(zhuǎn)換result_df df.selectExpr( channel_id, channel_name, stack(3, jan_amount, feb_amount, mar_amount) as (month, amount) ) result_df.show()執(zhí)行結(jié)果--------------------------------- |channel_id|channel_name|month|amount| --------------------------------- | C001| 渠道A| 100| ? ---------------------------------等一下這里就有個(gè)問(wèn)題。stack(3, jan_amount, feb_amount, mar_amount)是把 3 個(gè)列拆成 3 行每行只有 1 列。所以AS (month, amount)的寫法是錯(cuò)的——month拿到的其實(shí)是第一個(gè)輸入列的值也就是 100amount根本沒(méi)有對(duì)應(yīng)的分配系統(tǒng)會(huì)報(bào)錯(cuò)。正確的寫法是讓每個(gè)輸出行有 2 個(gè)字段所以需要傳入 6 個(gè)值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()這次的結(jié)果才是對(duì)的--------------------------------- |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 列轉(zhuǎn)行的核心套路你要輸出的“行標(biāo)識(shí)字段”這里就是月份名其實(shí)就是列名本身但列名是靜態(tài)的文本不會(huì)跟著值走。所以你需要手動(dòng)把這些常量對(duì)應(yīng)地放進(jìn)參數(shù)序列里讓它們和真實(shí)列值一一配對(duì)。如果你希望輸出的月份是數(shù)字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) )原理完全一樣只是常量從字符串變成了整數(shù)。3.3 動(dòng)態(tài)寫法字段特別多怎么辦如果表里有幾十甚至上百個(gè)要轉(zhuǎn)置的列手動(dòng)寫col1, col1, col2, col2, ...當(dāng)然是不現(xiàn)實(shí)的。這時(shí)候就需要 Python / Scala 代碼來(lái)拼接表達(dá)式。以 Python PySpark 為例假設(shè)你的寬表字段名遵循xxx_amount這樣的規(guī)則可以先篩選出所有目標(biāo)列名然后動(dòng)態(tài)構(gòu)造 stack 表達(dá)式的參數(shù)列表amount_cols [c for c in df.columns if c.endswith(_amount)] # 按列名字典序排序保證結(jié)果穩(wěn)定 amount_cols.sort() # 構(gòu)造 stack 參數(shù)序列每個(gè)列前面加上它自己的名字文本 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 )這樣寫完之后即使下個(gè)月上游多了一個(gè)apr_amount字段你只要重新從df.columns里篩選代碼一行都不用改。動(dòng)態(tài)拼接表達(dá)式算是這類需求里比較推薦的進(jìn)階寫法。但這里有個(gè)前提你的列名必須有規(guī)律。如果列名亂得毫無(wú)章法比如a1、b2、c3那就得老老實(shí)實(shí)維護(hù)一個(gè)字段映射表手工指定哪些列要展開(kāi)、對(duì)應(yīng)的輸出標(biāo)識(shí)文本是什么。映射表的方式雖然要維護(hù)但勝在可控、不容易出錯(cuò)。3.4 explode 的適用場(chǎng)景處理 Array 和 Map上面說(shuō)了 stack 是列轉(zhuǎn)行的主力但還有一個(gè)高頻場(chǎng)景是會(huì)用到 explode 的當(dāng)數(shù)據(jù)本身以 Array 或 Map 存儲(chǔ)時(shí)。比如上游給了你一行數(shù)據(jù)里面一個(gè)字段是score_list存的是這個(gè)渠道每一天的分?jǐn)?shù)是一個(gè) Array[Double]你要把它拆成一行行記錄。這種場(chǎng)景用 stack 就得先把 Array 展開(kāi)到多個(gè)列如果 Array 長(zhǎng)度不定根本沒(méi)法展開(kāi)而 explode 就是專門干這個(gè)的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| ---------------------------如果還需要知道當(dāng)前拆出來(lái)的是這個(gè)數(shù)組里的第幾個(gè)元素用posexplode它會(huì)在輸出里額外帶一個(gè)索引列from pyspark.sql.functions import posexplode result_df df.select( channel_id, channel_name, posexplode(score_list).alias(pos, score) )posexplode在同時(shí)需要“序號(hào)”和“值”的場(chǎng)景里很實(shí)用比如你要把數(shù)組里的第一個(gè)值當(dāng)作基準(zhǔn)值那pos就派上用場(chǎng)了。至于是不是應(yīng)該先把多列合并成struct再用explode拆這種事情純粹看個(gè)人習(xí)慣。如果有人喜歡把 3 個(gè)月的金額先構(gòu)造成一個(gè)array再explode那也能達(dá)到列轉(zhuǎn)行的效果但多了一步構(gòu)造數(shù)組的開(kāi)銷性能上不如直接用 stack 來(lái)得干脆。我的建議是普通的多列轉(zhuǎn)多行別繞路stack 直接上數(shù)組/字典里的嵌套數(shù)據(jù)才輪到 explode 發(fā)揮。3.5 類型不一致時(shí)的處理辦法stack 特別容易踩的坑之一是參與展開(kāi)的列類型不統(tǒng)一。比如某個(gè)月的金額字段是 Double另外某個(gè)月由于上游數(shù)據(jù)問(wèn)題被定義成了 String此時(shí) Spark 在生成邏輯計(jì)劃時(shí)會(huì)發(fā)現(xiàn) stack 的輸入類型不一致直接拋 AnalysisException。解決辦法是在傳入 stack 之前先對(duì)所有列做一次類型統(tǒng)一from pyspark.sql.functions import col # 假設(shè) df 里有多個(gè)列需要轉(zhuǎn)置先統(tǒng)一轉(zhuǎn)成 Double for c in amount_cols: df df.withColumn(c, col(c).cast(double))這種操作在數(shù)據(jù)治理不嚴(yán)格的業(yè)務(wù)場(chǎng)景里非常常見(jiàn)。同一個(gè)業(yè)務(wù)字段因?yàn)樯嫌螆?bào)表口徑調(diào)整從 Decimal 變成 String再到 Double你根本不知道哪一天會(huì)變。所以在 stack 前統(tǒng)一 cast 是一個(gè)成本很低但收益很大的好習(xí)慣。4. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄4.1 報(bào)錯(cuò)“The number of columns doesnt match”這個(gè)錯(cuò)誤信息通常長(zhǎng)這樣org.apache.spark.sql.AnalysisException: The number of columns produced by STACK should not be greater than number of columns in the query問(wèn)題根源基本是stack里傳的列數(shù)和AS子句定義的字段數(shù)量對(duì)不上。舉個(gè)例子你寫了stack(3, jan, jan_amount, feb, feb_amount, mar, mar_amount)這 6 個(gè)值會(huì)被分成 3 行、每行 2 個(gè)字段所以AS 子句必須寫 2 個(gè)字段名。如果你寫成了as (month)就會(huì)報(bào)錯(cuò)。排查思路很簡(jiǎn)單數(shù)一下 stack 參數(shù)列表里一共有幾個(gè)值除以n得到的結(jié)果必須和 AS 里的字段數(shù)一致。4.2 類型不匹配的坑另一個(gè)高頻報(bào)錯(cuò)是cannot resolve ... due to data type mismatch。參與 stack 的列類型五花八門有 String、有 Double、有 IntegerSpark 為了讓這些類型能放進(jìn)同一個(gè)輸出字段需要找到一個(gè)公共類型找不到就直接報(bào)錯(cuò)。解決方法前面已經(jīng)提過(guò)在 stack 前統(tǒng)一 cast。如果因?yàn)槟銘胁幌雽?cast也可以用coalesce或者字符串拼接把它們統(tǒng)一成 String但這樣后續(xù)用起來(lái)還得再轉(zhuǎn)一次不太劃算。我個(gè)人建議是統(tǒng)一成業(yè)務(wù)最終使用的類型比如金額就統(tǒng)一成 Decimal(18, 2) 或 Double。4.3 合并 null 值的影響stack 不會(huì)主動(dòng)過(guò)濾 null。如果你有 12 個(gè)月的金額列但某些月份確實(shí)沒(méi)有數(shù)據(jù)轉(zhuǎn)出來(lái)的 12 行里那些月份對(duì)應(yīng)的amount字段就是 null。下游聚合的時(shí)候 null 會(huì)被 sum 忽略但如果業(yè)務(wù)要的是“只保留有實(shí)際值的月份”你必須在 stack 之后加一個(gè)過(guò)濾條件result_df.filter(col(amount).isNotNull())這里有個(gè)容易忽略的細(xì)節(jié)如果你用coalesce把 null 替換成 0再去過(guò)濾就徹底失效了因?yàn)槿兂?0 之后你根本分辨不出哪些是真實(shí) 0、哪些是缺失值。所以除非業(yè)務(wù)明確要求缺失按 0 計(jì)否則別輕易把 null 填成 0寧可多留一步讓下游自己決定怎么處理。4.4 執(zhí)行慢重點(diǎn)看這兩處列轉(zhuǎn)行本身不會(huì)引起數(shù)據(jù)膨脹因?yàn)樵疽恍欣锏臄?shù)據(jù)拆成多行后值還是那些值只是形態(tài)變了。所以正常情況下這個(gè)操作不會(huì)比普通 select 慢很多。如果你發(fā)現(xiàn)任務(wù)運(yùn)行特別慢排查重點(diǎn)應(yīng)該放在以下兩處第一檢查是不是有大字段或者數(shù)量巨大的列全部參與 stack。比如一張表有 200 個(gè)字段、100 個(gè)都要轉(zhuǎn)那么單條記錄的寬度極大序列化和網(wǎng)絡(luò)傳輸?shù)某杀径紩?huì)上一個(gè)臺(tái)階。建議先只 select 需要的字段再轉(zhuǎn)不要一上來(lái)就select *。第二檢查是不是在轉(zhuǎn)完之后立刻做了高代價(jià)的 join 或 groupBy。列轉(zhuǎn)行之后數(shù)據(jù)量不變但如果你轉(zhuǎn)完之后又和一張大表做 join那慢的根源其實(shí)是 join而不是列轉(zhuǎn)行本身。把執(zhí)行計(jì)劃拿過(guò)來(lái)看一下哪個(gè) stage 耗時(shí)最長(zhǎng)通常問(wèn)題一目了然。4.5 一個(gè)實(shí)用小技巧動(dòng)態(tài)生成 stack 時(shí)給字段加序號(hào)批量生成的字段比如多個(gè)月份列它們的順序在動(dòng)態(tài)篩選后不一定可控。你可以主動(dòng)給參數(shù)序列排序而不是依賴 DataFrame 的列順序。具體做法是把篩選出來(lái)的列名先排序再拼接表達(dá)式。這樣不管上游表字段怎么亂排你的輸出順序始終穩(wěn)定下游做對(duì)比測(cè)試也方便。另外一個(gè)容易被忽略的地方stack表達(dá)式里的字符串常量比如jan如果是變量拼 SQL 的時(shí)候記得轉(zhuǎn)義和加引號(hào)防止空格、特殊字符把表達(dá)式搞壞。我見(jiàn)過(guò)有人動(dòng)態(tài)拼出的表達(dá)式里字符串寫成了month_2024-01結(jié)果日期里的連字符導(dǎo)致表達(dá)式被截?cái)嗯挪榇_實(shí)費(fèi)了些時(shí)間。穩(wěn)妥的做法是構(gòu)造表達(dá)式后用spark.sql跑一個(gè)select快速驗(yàn)證或者打印出來(lái)看一遍再執(zhí)行。5. 項(xiàng)目落地中的一點(diǎn)個(gè)人經(jīng)驗(yàn)列轉(zhuǎn)行這個(gè)操作看著簡(jiǎn)單但在真實(shí)項(xiàng)目里它往往不是一次性跑完就結(jié)束的。數(shù)據(jù)入倉(cāng)、數(shù)據(jù)清洗、ETL 調(diào)度、下游 BI 報(bào)表每一步都可能對(duì)輸出的字段命名、類型、null 值策略有不同要求。我在這個(gè)項(xiàng)目里做了一版標(biāo)準(zhǔn)化方案之后基本把它固化成了一個(gè)公共函數(shù)團(tuán)隊(duì)里其他人遇到“寬表轉(zhuǎn)長(zhǎng)表”就直接復(fù)用不再重復(fù)踩坑。公共函數(shù)的核心邏輯不復(fù)雜輸入 DataFrame 和一組“列名 → 輸出標(biāo)識(shí)”的映射自動(dòng)統(tǒng)一類型、動(dòng)態(tài)拼 stack 表達(dá)式、輸出標(biāo)準(zhǔn)化字段。如果你們團(tuán)隊(duì)經(jīng)常處理類似的報(bào)表數(shù)據(jù)我強(qiáng)烈建議一步到位做成通用工具省得每次手工改腳本。還有一點(diǎn)列轉(zhuǎn)行的結(jié)果最好直接落成 Parquet 格式的中間表字段命名要跟下游約定清楚。因?yàn)檗D(zhuǎn)完之后原先藏在列名里的月份信息現(xiàn)在變成了一個(gè)字段的值這個(gè)值的數(shù)據(jù)質(zhì)量直接決定了下游過(guò)濾條件怎么寫。如果month字段有時(shí)間類型、字符串類型混著來(lái)下游查詢腳本就得寫兩套邏輯維護(hù)成本翻倍。最后說(shuō)一個(gè)從實(shí)際操作里沉淀出來(lái)的心得column-to-row 的思路在很多 SQL 引擎里是通用的不只是 Spark。你如果熟悉了 Spark 的 stack后面接觸其他引擎里類似的 unpivot 函數(shù)會(huì)發(fā)現(xiàn)核心思想都是一樣的——把“元數(shù)據(jù)”從列名里解放出來(lái)變成真正的數(shù)據(jù)。這個(gè)理念在數(shù)據(jù)建模里也值得留意凡是長(zhǎng)期要沿用的寬表多花一點(diǎn)時(shí)間把它轉(zhuǎn)成規(guī)整的長(zhǎng)表后面的分析工作會(huì)輕松非常多。