本文接上篇(http://www.lxweimin.com/p/8e2f2f0d4b6c)繼續講解Hive/HiveQL常用優化方法,按照目錄,會從“優化SQL處理join數據傾斜”說起。
優化SQL處理join數據傾斜
上篇已經多次提到了數據傾斜,包括已經寫過的sort by代替order by,以及group by代替distinct方法,本質上也是為了解決它。join操作更是數據傾斜的重災區,需要多加注意。
空值或無意義值
這種情況很常見,比如當事實表是日志類數據時,往往會有一些項沒有記錄到,我們視情況會將它置為null,或者空字符串、-1等。如果缺失的項很多,在做join時這些空值就會非常集中,拖累進度。
因此,若不需要空值數據,就提前寫where語句過濾掉。需要保留的話,將空值key用隨機方式打散,例如將用戶ID為null的記錄隨機改為負值:
select a.uid,a.event_type,b.nickname,b.age
from (
select
(case when uid is null then cast(rand()*-10240 as int) else uid end) as uid,
event_type from calendar_record_log
where pt_date >= 20190201
) a left outer join (
select uid,nickname,age from user_info where status = 4
) b on a.uid = b.uid;
單獨處理傾斜key
這其實是上面處理空值方法的拓展,不過傾斜的key變成了有意義的。一般來講傾斜的key都很少,我們可以將它們抽樣出來,對應的行單獨存入臨時表中,然后打上一個較小的隨機數前綴(比如0~9),最后再進行聚合。SQL語句與上面的相仿,不再贅述。
不同數據類型
這種情況不太常見,主要出現在相同業務含義的列發生過邏輯上的變化時。
舉個例子,假如我們有一舊一新兩張日歷記錄表,舊表的記錄類型字段是(event_type int),新表的是(event_type string)。為了兼容舊版記錄,新表的event_type也會以字符串形式存儲舊版的值,比如'17'。當這兩張表join時,經常要耗費很長時間。其原因就是如果不轉換類型,計算key的hash值時默認是以int型做的,這就導致所有“真正的”string型key都分配到一個reducer上。所以要注意類型轉換:
select a.uid,a.event_type,b.record_data
from calendar_record_log a
left outer join (
select uid,event_type from calendar_record_log_2
where pt_date = 20190228
) b on a.uid = b.uid and b.event_type = cast(a.event_type as string)
where a.pt_date = 20190228;
build table過大
有時,build table會大到無法直接使用map join的地步,比如全量用戶維度表,而使用普通join又有數據分布不均的問題。這時就要充分利用probe table的限制條件,削減build table的數據量,再使用map join解決。代價就是需要進行兩次join。舉個例子:
select /*+mapjoin(b)*/ a.uid,a.event_type,b.status,b.extra_info
from calendar_record_log a
left outer join (
select /*+mapjoin(s)*/ t.uid,t.status,t.extra_info
from (select distinct uid from calendar_record_log where pt_date = 20190228) s
inner join user_info t on s.uid = t.uid
) b on a.uid = b.uid
where a.pt_date = 20190228;
MapReduce優化
調整mapper數
mapper數量與輸入文件的split數息息相關,在Hadoop源碼org.apache.hadoop.mapreduce.lib.input.FileInputFormat
類中可以看到split劃分的具體邏輯。這里不貼代碼,直接敘述mapper數是如何確定的。
- 可以直接通過參數
mapred.map.tasks
(默認值2)來設定mapper數的期望值,但它不一定會生效,下面會提到。 - 設輸入文件的總大小為
total_input_size
。HDFS中,一個塊的大小由參數dfs.block.size
指定,默認值64MB或128MB。在默認情況下,mapper數就是:
default_mapper_num = total_input_size / dfs.block.size
。 - 參數
mapred.min.split.size
(默認值1B)和mapred.max.split.size
(默認值64MB)分別用來指定split的最小和最大大小。split大小和split數計算規則是:
split_size = MAX(mapred.min.split.size, MIN(mapred.max.split.size, dfs.block.size))
;
split_num = total_input_size / split_size
。 - 得出mapper數:
mapper_num = MIN(split_num, MAX(default_num, mapred.map.tasks))
。
可見,如果想減少mapper數,就適當調高mapred.min.split.size
,split數就減少了。如果想增大mapper數,除了降低mapred.min.split.size
之外,也可以調高mapred.map.tasks
。
一般來講,如果輸入文件是少量大文件,就減少mapper數;如果輸入文件是大量非小文件,就增大mapper數;至于大量小文件的情況,得參考下面“合并小文件”一節的方法處理。
調整reducer數
reducer數量的確定方法比mapper簡單得多。使用參數mapred.reduce.tasks
可以直接設定reducer數量,不像mapper一樣是期望值。但如果不設這個參數的話,Hive就會自行推測,邏輯如下:
- 參數
hive.exec.reducers.bytes.per.reducer
用來設定每個reducer能夠處理的最大數據量,默認值1G(1.2版本之前)或256M(1.2版本之后)。 - 參數
hive.exec.reducers.max
用來設定每個job的最大reducer數量,默認值999(1.2版本之前)或1009(1.2版本之后)。 - 得出reducer數:
reducer_num = MIN(total_input_size / reducers.bytes.per.reducer, reducers.max)
。
reducer數量與輸出文件的數量相關。如果reducer數太多,會產生大量小文件,對HDFS造成壓力。如果reducer數太少,每個reducer要處理很多數據,容易拖慢運行時間或者造成OOM。
合并小文件
- 輸入階段合并
需要更改Hive的輸入文件格式,即參數hive.input.format
,默認值是org.apache.hadoop.hive.ql.io.HiveInputFormat
,我們改成org.apache.hadoop.hive.ql.io.CombineHiveInputFormat
。
這樣比起上面調整mapper數時,又會多出兩個參數,分別是mapred.min.split.size.per.node
和mapred.min.split.size.per.rack
,含義是單節點和單機架上的最小split大小。如果發現有split大小小于這兩個值(默認都是100MB),則會進行合并。具體邏輯可以參看Hive源碼中的對應類。 - 輸出階段合并
直接將hive.merge.mapfiles
和hive.merge.mapredfiles
都設為true即可,前者表示將map-only任務的輸出合并,后者表示將map-reduce任務的輸出合并。
另外,hive.merge.size.per.task
可以指定每個task輸出后合并文件大小的期望值,hive.merge.size.smallfiles.avgsize
可以指定所有輸出文件大小的均值閾值,默認值都是1GB。如果平均大小不足的話,就會另外啟動一個任務來進行合并。
啟用壓縮
壓縮job的中間結果數據和輸出數據,可以用少量CPU時間節省很多空間。壓縮方式一般選擇Snappy,效率最高。
要啟用中間壓縮,需要設定hive.exec.compress.intermediate
為true,同時指定壓縮方式hive.intermediate.compression.codec
為org.apache.hadoop.io.compress.SnappyCodec
。另外,參數hive.intermediate.compression.type
可以選擇對塊(BLOCK)還是記錄(RECORD)壓縮,BLOCK的壓縮率比較高。
輸出壓縮的配置基本相同,打開hive.exec.compress.output
即可。
JVM重用
在MR job中,默認是每執行一個task就啟動一個JVM。如果task非常小而碎,那么JVM啟動和關閉的耗時就會很長。可以通過調節參數mapred.job.reuse.jvm.num.tasks
來重用。例如將這個參數設成5,那么就代表同一個MR job中順序執行的5個task可以重復使用一個JVM,減少啟動和關閉的開銷。但它對不同MR job中的task無效。
并行執行與本地模式
- 并行執行
Hive中互相沒有依賴關系的job間是可以并行執行的,最典型的就是多個子查詢union all。在集群資源相對充足的情況下,可以開啟并行執行,即將參數hive.exec.parallel
設為true。另外hive.exec.parallel.thread.number
可以設定并行執行的線程數,默認為8,一般都夠用。 - 本地模式
Hive也可以不將任務提交到集群進行運算,而是直接在一臺節點上處理。因為消除了提交到集群的overhead,所以比較適合數據量很小,且邏輯不復雜的任務。
設置hive.exec.mode.local.auto
為true可以開啟本地模式。但任務的輸入數據總量必須小于hive.exec.mode.local.auto.inputbytes.max
(默認值128MB),且mapper數必須小于hive.exec.mode.local.auto.tasks.max
(默認值4),reducer數必須為0或1,才會真正用本地模式執行。
嚴格模式
所謂嚴格模式,就是強制不允許用戶執行3種有風險的HiveQL語句,一旦執行會直接失敗。這3種語句是:
- 查詢分區表時不限定分區列的語句;
- 兩表join產生了笛卡爾積的語句;
- 用order by來排序但沒有指定limit的語句。
要開啟嚴格模式,需要將參數hive.mapred.mode
設為strict。
采用合適的存儲格式
在HiveQL的create table語句中,可以使用stored as ...
指定表的存儲格式。Hive表支持的存儲格式有TextFile、SequenceFile、RCFile、Avro、ORC、Parquet等。
存儲格式一般需要根據業務進行選擇,在我們的實操中,絕大多數表都采用TextFile與Parquet兩種存儲格式之一。
TextFile是最簡單的存儲格式,它是純文本記錄,也是Hive的默認格式。雖然它的磁盤開銷比較大,查詢效率也低,但它更多地是作為跳板來使用。RCFile、ORC、Parquet等格式的表都不能由文件直接導入數據,必須由TextFile來做中轉。
Parquet和ORC都是Apache旗下的開源列式存儲格式。列式存儲比起傳統的行式存儲更適合批量OLAP查詢,并且也支持更好的壓縮和編碼。我們選擇Parquet的原因主要是它支持Impala查詢引擎,并且我們對update、delete和事務性操作需求很低。
這里就不展開講它們的細節,可以參考各自的官網:
https://parquet.apache.org/
https://orc.apache.org/
結束
寫了這么多,肯定有遺漏或錯誤之處,歡迎各位大佬批評指正。