這周我們會(huì)接著上周的話題,繼續(xù)聊一聊Hudi的實(shí)現(xiàn)原理,主要關(guān)注Hudi的核心讀寫邏輯,數(shù)據(jù)的存儲(chǔ)和處理邏輯,以及一些附屬的功能。正在使用或是考慮使用Hudi的朋友,請(qǐng)不要錯(cuò)過,因?yàn)槔斫饬藢?shí)現(xiàn)原理以后可以避免很多使用上的坑,也能更好地發(fā)揮出Hudi的優(yōu)勢(shì)。
今天我們會(huì)講一講Hudi這些功能的實(shí)現(xiàn)原理:
由于這篇文章會(huì)用到上一篇文章中講到的知識(shí),還沒有讀過的朋友,推薦先讀完上一篇文章。Merge on Read(簡(jiǎn)稱MOR表),是Hudi最初開源時(shí)尚處于“實(shí)驗(yàn)階段”的新功能,在開源后的0.3.5版本開始才告完成。現(xiàn)在則是Hudi最常用的表類型。
之所以在COW表之后又增加了一種新的表類型,原因在上一篇文章中也有提到Merge on Read則是對(duì)Copy on Write的優(yōu)化。優(yōu)化了什么呢?主要是寫入性能。
導(dǎo)致COW表寫入慢的原因,是因?yàn)?strong>COW表每次在寫入時(shí),會(huì)把新寫入的數(shù)據(jù)和老數(shù)據(jù)合并以后,再寫成新的文件。單單是寫入的過程(不包含前期的repartition和tagging過程),就包含至少三個(gè)步驟:- 讀取老數(shù)據(jù)的parquet文件(涉及對(duì)parquet文件解碼,不輕松)
- 將老數(shù)據(jù)和新數(shù)據(jù)合并
- 將合并后的數(shù)據(jù)重新寫成parquet文件(又涉及parquet文件編碼,也不輕松)

種種原因?qū)е翪OW表的寫入速度始終快不起來,限制了其在時(shí)效性要求高,寫入量巨大的場(chǎng)景下的應(yīng)用。(關(guān)于parquet文件不輕松的原因,可以看這篇文章《詳解Parquet文件格式》)為了解決COW表寫入速度上的瓶頸,Hudi采用了另一種寫入方式:upsert時(shí)把變更內(nèi)容寫入log文件,然后定期合并log文件和base文件。這樣的好處是避免了寫入時(shí)讀取老數(shù)據(jù),也就避免了parquet文件不輕松的編解碼過程,只需要把變更記錄寫入一個(gè)文件即可(而且是順序?qū)懭耄?。顯然是輕松了不少。
warehouse├── .hoodie├── 20220101│ ├── fileId1_001.parquet│ ├── .fileId1_20220312163419285.log│ └── .fileId1_20220312172212361.log└── 20220102 ├── fileId2_001.parquet????└──?.fileId2_20220312163512913.log
典型的MOR表的目錄,注意log文件包含寫入的時(shí)間戳有些朋友這時(shí)或許會(huì)有疑問,“這樣寫入固然是輕松了,但怎么讀到最新的數(shù)據(jù)呢?”是個(gè)好問題。為了解決讀取最新數(shù)據(jù)的問題,Hudi提供了好幾種機(jī)制,但從原理上來說只有兩種:- 讀取數(shù)據(jù)時(shí),同時(shí)從base文件和log文件讀取,并把兩邊的數(shù)據(jù)合并
- 定期地、異步地把log文件的數(shù)據(jù)合并到base文件(這個(gè)過程被稱為compaction)
第一種機(jī)制也是Merge on Read這個(gè)名字的由來,因?yàn)?strong>Hudi的讀取過程是實(shí)時(shí)地把base數(shù)據(jù)和log數(shù)據(jù)合并起來,并返回給用戶。注意這兩種機(jī)制不是非此即彼的,而是互為補(bǔ)充。Hudi的默認(rèn)配置就是同時(shí)使用這兩種機(jī)制,即:讀取時(shí)merge,同時(shí)定期地compact。在讀取時(shí)合并數(shù)據(jù),聽起來很影響效率。事實(shí)也是如此,因?yàn)閷?shí)時(shí)合并的實(shí)現(xiàn)方式是把所有l(wèi)og文件讀入內(nèi)存,放在一個(gè)HashMap里,然后遍歷base文件,把base數(shù)據(jù)和緩存在內(nèi)存里的log數(shù)據(jù)進(jìn)行join,最后才得到合并后的結(jié)果。難免會(huì)影響到讀取效率。COW影響寫入,MOR影響讀取,那有沒有什么辦法可以兼顧讀寫,魚與熊掌能不能得兼?目前來說不能,好在Hudi把選擇權(quán)留給了用戶,讓用戶可以根據(jù)自身的業(yè)務(wù)需求,選擇不同的query類型。對(duì)于MOR表,Hudi支持3種query類型,分別是
其中1和3就是為了平衡讀和寫之間的取舍。這兩者的區(qū)別是:Snapshot Query和上文所說的一樣,讀取時(shí)進(jìn)行“實(shí)時(shí)合并”;Read Optimized Query則不同,只讀取base文件,不讀取log文件,因此讀取效率和COW表相同,但讀到的數(shù)據(jù)可能不是最新的。以上講完了Hudi和upsert相關(guān)的主要功能,接下來講講Hudi另一大特色功能:Transactional,也就是事務(wù)功能。
Hudi的事務(wù)功能被稱為Timeline,因?yàn)镠udi把所有對(duì)一張表的操作都保存在一個(gè)時(shí)間線對(duì)象里面。Hudi官方文檔中對(duì)于Timeline功能的介紹稍微有點(diǎn)復(fù)雜,不是很清晰。其實(shí)從用戶角度來看的話,Hudi提供的事務(wù)相關(guān)能力主要是這些:| 特性 | 功能
|
|---|
原子性
| 寫入即使失敗,也不會(huì)造成數(shù)據(jù)損壞 |
隔離性
| 讀寫分離,寫入不影響讀取,不會(huì)讀到寫入中途的數(shù)據(jù) |
回滾
| 可以回滾變更,把數(shù)據(jù)恢復(fù)到舊版本 |
| 時(shí)間旅行 | 可以讀取舊版本的數(shù)據(jù)(但太老的版本會(huì)被清理掉) |
存檔
| 可以長(zhǎng)期保存舊版本數(shù)據(jù)(存檔的版本不會(huì)被自動(dòng)清理) |
| 增量讀取 | 可以讀取任意兩個(gè)版本之間的差分?jǐn)?shù)據(jù) |
講完了功能清單,接下來就講一講事務(wù)的實(shí)現(xiàn)原理。內(nèi)容以COW表為主,但MOR表也可以由此類推,因?yàn)镸OR表本質(zhì)上是對(duì)COW表的優(yōu)化。
這里沿用上一篇文章中的例子,假設(shè)初始我們有5條數(shù)據(jù),內(nèi)容如下| txn_id | user_id
| item_id
| amount
| date
|
|---|
| 1 | 1
| 1
| 2 | 20220101
|
| 2 | 2 | 1 | 1 | 20220101 |
| 3 | 1
| 2
| 3
| 20220101 |
4
| 1 | 3 | 1 | 20220102 |
5
| 2
| 3 | 2 | 20220102 |
實(shí)際存儲(chǔ)的目錄結(jié)構(gòu)是這樣的(文件名做了簡(jiǎn)化)
warehouse├──?.hoodie├── 20220101│?? ├── fileId1_001.parquet│?? └── fileId1_002.parquet├── 20220102│?? └── fileId2_001.parquet└── 20220103 └── fileId3_001.parquet
它的數(shù)據(jù)保存在fileId1_001和fileId2_001兩個(gè)文件里。我們稱呼這個(gè)版本為v1。接下來我們寫入3條新的數(shù)據(jù),其中1條是更新,2條是新增。
| txn_id | user_id
| item_id | amount | date |
|---|
3
| 1 | 2 | 5
| 20220101
|
6
| 1 | 4
| 1 | 20220103 |
7
| 2 | 3 | 2
| 20220103 |
寫入后的目錄結(jié)構(gòu)如下
warehouse├──?.hoodie├── 20220101│?? ├── fileId1_001.parquet│?? └── fileId1_002.parquet├── 20220102│?? └── fileId2_001.parquet└── 20220103 └── fileId3_001.parquet
更新的1條數(shù)據(jù)(txn_id=3)保存在fileId1_002這個(gè)文件里,而新增的2條數(shù)據(jù)(txn_id=6和txn_id=7)則被保存在fileId3_001。
Hudi在這張表的timeline里(實(shí)際存放在.hoodie目錄下)會(huì)記錄下v1和v2對(duì)應(yīng)的文件列表。當(dāng)client讀取數(shù)據(jù)時(shí),首先會(huì)查看timeline里最新的commit是哪個(gè),從最新的commit里獲得對(duì)應(yīng)的文件列表,再去這些文件讀取真正的數(shù)據(jù)。
v1和v2對(duì)應(yīng)的文件
Hudi通過這種方式實(shí)現(xiàn)了多版本隔離的能力。當(dāng)一個(gè)client正在讀取v1的數(shù)據(jù)時(shí),另一個(gè)client可以同時(shí)寫入新的數(shù)據(jù),新的數(shù)據(jù)會(huì)被寫入新的文件里,不影響v1用到的數(shù)據(jù)文件。只有當(dāng)數(shù)據(jù)全部寫完以后,v2才會(huì)被commit到timeline里面。后續(xù)的client再讀取時(shí),讀到的就是v2的數(shù)據(jù)。順帶一提的是,盡管Hudi具備多版本數(shù)據(jù)管理的能力,但舊版本的數(shù)據(jù)不會(huì)無限制地保留下去。Hudi會(huì)在新的commit完成時(shí)開始清理舊的數(shù)據(jù),默認(rèn)的策略是“清理早于10個(gè)commit前的數(shù)據(jù)”。最后再講講Hudi的另一個(gè)特色功能:Incremental Query(增量查詢)。這個(gè)功能提供給用戶“讀取任意兩個(gè)commit之間差分?jǐn)?shù)據(jù)”的能力。這個(gè)功能也是基于上述的“多版本數(shù)據(jù)管理”實(shí)現(xiàn)的,下面就來講講。還是以上文的例子,假設(shè)我們想要讀取v1 → v2之間的差分?jǐn)?shù)據(jù)Hudi會(huì)計(jì)算出v2到v1之間的差異是兩個(gè)文件:fileId01_002和fileId03_001,然后client從這兩個(gè)文件中讀到的就是增量數(shù)據(jù)。有些朋友或許會(huì)發(fā)現(xiàn),fileId01_002里面包含了兩條老數(shù)據(jù)txn_id=1和txn_id=2,不屬于v2到v1的差分?jǐn)?shù)據(jù),不應(yīng)該被讀取。確實(shí)如此。其實(shí)Hudi對(duì)每一條數(shù)據(jù),都有一個(gè)隱藏字段_hoodie_commit_time用于記錄commit時(shí)間,這個(gè)字段會(huì)和其他數(shù)據(jù)字段一起保存在parquet文件里。Hudi在讀取parquet文件時(shí),會(huì)同時(shí)用這個(gè)字段對(duì)結(jié)果進(jìn)行過濾,把不屬于時(shí)間范圍內(nèi)的記錄都過濾掉。關(guān)于Hudi原理的講解,寫到這里就差不多告一段落了。接下來可能會(huì)繼續(xù)介紹”數(shù)據(jù)湖三劍客“的其他兩個(gè)——Iceberg和Delta,以及它們之間的對(duì)比。如果朋友們對(duì)接下來想看到什么內(nèi)容有好的建議,歡迎在公眾號(hào)后臺(tái)留言。