大資料處理之如何確保斷電不丟資料
今年7、8月份杭州實行拉閘限電時,導致阿里餘杭機房的機器意外斷電,造成HDFS叢集上的部分資料丟失。
在Hadoop 2.0.2-alpha之前,HDFS在機器斷電或意外崩潰的情況下,有可能出現正在寫的資料丟失的問題。而最近剛釋出的CDH4中HDFS在Client端提供了hsync()的方法呼叫(HDFS-744),從而保證在機器崩潰或意外斷電的情況下,資料不會丟失。這篇檔案將圍繞這個新的介面對其實現細節進行簡單的分析,從而希望找出一種合理使用hsync()的策略,避免重要資料丟失。
HDFS中sync(),hflush()和hsync()的差別
在hsync()之前,HDFS就已經提供了sync()和hflush()的呼叫,單從方法的名稱上看,很難分辨這三個方法之間的區別。咱們先從這幾個方法之間的差別介紹起。
hsync()則是除了確保會將Client端buffer中的存放資料更新到Datanode端外,還會確保Datanode端的資料更新到物理磁碟上,這樣在hsync()呼叫結束後,即使Datanode所在的機器意外斷電,資料並不會因此丟失。而hflush()在機器意外斷電的情況下卻有可能丟失資料,因為Client端傳給Datanode的資料可能存在於Datanode的cache中,並未持久化到磁碟上。下圖描述了從Client發起一次寫請求後,在HDFS中的資料包傳遞的流程。在HDFS中,呼叫hflush()會將Client端buffer中的存放資料更新到Datanode端,直到收到所有Datanode的ack響應時結束呼叫。這樣可保證在hflush()呼叫結束時,所有的Client端都可以讀到一致的資料。HDFS中的sync()本質也是呼叫hflush()。
hsync()的實現本質
hsync()執行時,實際上會在對應Datanode的機器上產生一個fsync的系統呼叫,從而將記憶體中的相關檔案的資料更新到磁碟。
Client端執行hsync時,Datanode端會識別到Client傳送過來的資料包中的syncBlock_欄位為true,從而判定需要將記憶體中的資料更新到磁碟。此時會在BlockReceiver.java的flushOrSync()中執行如下語句:
((FileOutputStream)cout).getChannel().force(true);
而FileChannel的force(boolean metadata)方法在JDK中,底層為於
Java_sun_nio_ch_FileDispatcherImpl_force0(JNIEnv *env, jobject this, jobject fdo, jboolean md) { jint fd = fdval(env, fdo); int result = 0; if (md == JNI_FALSE) { result = fdatasync(fd); } else { result = fsync(fd); } return handle(env, result, "Force failed"); }
當Datanode將資料持久化到磁碟上後,會發ack響應給Client端。當收到所有Datanode的ack響應時,hsync()的呼叫結束。
值得注意的是,fsync或fdatasync本身是一個非常耗時的呼叫,因為磁碟的讀寫速度遠低於記憶體的讀寫速度。在不呼叫fsync或fdatasync的情況下,資料可能儲存在各級cache中。
最開始筆者在測hsync()的讀寫效能時,發現不同機器上測試結果hsync()耗時差別巨大,有的叢集平均呼叫耗時為4ms,而有的叢集平均呼叫耗時則需25ms。後來在公司各位大神的點撥下才意識到是跟Linux檔案系統的機制有關。在這種情況下,只有一探Linux相關部分的原始碼才能解開心中的疑惑,下面這節就將從更底層的角度來解析與hsync()密切相關的系統呼叫fsync及fdatasync方法。
fsync和fdatasync的大致實現過程
對ext4格式的檔案系統來說,fsync和fdatasync方法的實現程式碼位於fs/ext4/fsync.c這個檔案中。在追加寫檔案的情況下,fsync和fdatasync的流程幾乎一致,因為對HDFS的寫操作基本都是追加寫,下面我們只討論追加寫檔案下的情景。ext4格式的檔案系統中佈局大致如下:
Group 0 Padding |
Super Block |
Group Descriptors |
Reserved GDT Blocks Data |
Data Block Bitmap |
inode Bitmap |
inode Table |
Data Blocks |
1024 bytes |
1 block |
many blocks |
many blocks |
1 block |
1 block |
many block |
many more blocks |
在我們追加寫檔案時,涉及到修改的有DataBlock BitMap、inode BitMap、inode Table、Data Blocks。但從程式碼中來看,實際上對檔案的追加會被合併成兩次寫(這裡是指邏輯意義上的兩次寫,實際在從系統Cache重新整理到磁碟時,讀寫操作會被再次合併),第一次為寫DataBlock和DataBlock Bitmap,第二次為寫inode BitMap和更新inode BitMap中的inode。ext4為了支援更大的容量,使用了extend tree來實現塊對映。在追加檔案的情況下,fsync和fdatasync除了更新inode中的extend tree外,還會更新inode中檔案大小,塊計數這些metadata。對fsync來說,還會修改inode中的檔案修改時間、檔案訪問時間(在mount選項不含noatime的情況下)和inode修改時間。
寫障礙和Disk Cache的影響
在瞭解了fsync()和fdatasync()方法會對檔案系統進行的改動後,離找出之前為什麼在不同叢集上hsync()的呼叫平均耗時的原因仍還有一段距離。這時我發現了不同的磁碟掛載選項會影響到fsync()和fdatasync()的執行時間,進而確定是寫障礙和Disk Cache在搞怪。下面這節就將分析寫障礙和Disk Cache對hsync()方法呼叫耗時的影響。
由於市面上大部分的磁碟都是帶Disk Cache的,這導致在不開啟寫障礙的情況下,機器意外斷電可能會對其造成metadata的不一致。對ext4這種journal檔案系統來說,journal寫入一個事務後,會對metadata進行更新,更新完成後會將該事務標記從未執行修改為完成。舉個例子,加入我們要建立並寫一個檔案,那麼在journal中可能會產生三個事務。那麼建立並寫一個檔案的執行流程如下:
在磁碟沒有Disk Cache的情況下,即時機器意外斷電,那麼重啟自檢時,可通過journal中最後事務的狀態來對metadata進行重新執行修復或者廢棄該事務。從而保證了metadata的一致性。但在磁碟有Disk Cache的情況下,IO事件會當資料寫到Disk Cache中就響應完成。雖然journal按上圖的流程進行執行,但是執行完成後這些資料仍可能有部分並未持久化到磁碟上。假如在執行第6個步驟的時候機器意外斷電,同時第4個步驟中的資料暫未更新到磁碟,而第1,2,3,5個步驟的資料已經同步到磁碟的話。這時機器重啟自檢時,由於第5個步驟中journal的執行狀態為未完成,會重新執行第6個步驟一次。但第6個步驟對metadata的修改是建立在第4個步驟已經完成的基礎之上的,由於第4個步驟並未持久化到磁碟,所以重新執行第6個步驟時會發生異常,造成metadata的錯誤。
Linux中為了避免這一情況,可以在ext4的mount選項中加barrier=1,data=ordered開啟寫障礙,來確保資料持久化到磁碟的順序。在寫障礙前的資料會先於寫障礙後的資料重新整理到磁碟,Linux會在journal的事務寫到Disk Cache中後放置一個寫障礙。這樣journal的事務位於寫障礙之前,而對應的metadata的修改資料位於寫障礙之後。避免了Disk Cache中合併IO時,對讀寫操作進行重排序後,由於讀寫操作執行順序的改變而造成意外斷電後metadata無法修復的情況。
關閉寫障礙,即ext4的mount選項為barrier=0時,除了有可能造成在機器斷電或異常崩潰重啟後metadata錯誤外,fsync和fdatasync的呼叫還會在資料更新到Disk Cache時就返回,而非等到資料重新整理到磁碟上後才結束呼叫。因為在不開寫障礙的情況下,Linux會將此時的磁碟當做沒有Disk Cache的磁碟來處理,當資料只是更新到Disk Cache,就會認為該IO操作已完成,這也正是前文中提到的不同叢集上hsync()的平均呼叫時長差別巨大的原因。所以關閉寫障礙的情況下,呼叫fsync或fdatasync並不能確保資料在機器斷電或異常崩潰時不丟失。
Disk Cache的存在可以提高磁碟每秒的吞吐量,通過重排序IO,儘量將IO讀寫變成順序讀寫提高速率,同時減少檔案系統碎片。而通過開啟寫障礙,可避免意外斷電情形下metadata異常,同時確保呼叫fsync或fdatasync時Disk Cache中的資料持久到磁碟。
開啟journal的影響
除了寫障礙和Disk Cache會影響到hsync()的呼叫時長外,Datanode上檔案系統有沒有開啟journal也是影響因素之一。關閉journal的情況下可以減少hsync()的呼叫時長。
在不開啟journal的情況下,呼叫fsync或fdatasync主要是由generic_file_fsync這個方法來實現將資料重新整理到磁碟。在追加寫檔案的情況下,不論是fsync還是fdatasync,在generic_file_fsync這個方法中都會先更新Data Block資料,再更新inode資料。如果執行fsync或fdatasync的檔案為新建立的檔案,在不開啟journal的情況下,還會在更新完檔案的inode後,更新該檔案的父結點的Data Block和inode。
而開啟journal的情況下,呼叫fsync或fdatasync會先寫Data Block,然後提交journal的事務。雖然呼叫fsync或fdatasync是指定對某個檔案進行操作,但在ext4中,整個檔案系統只有一個journal檔案,提交journal的修改事務時會將整個檔案系統的metadata的修改事務一併提交。在檔案系統寫入操作頻繁時,這一步操作會比較耗時。
fsync及fdatasync耗時測試
測試使用的程式碼如下:
程式碼中以追加的方式向一個已存在的檔案寫入4k資料,4k剛好為記憶體頁和磁碟塊的大小。下面分別以幾種模式來測試fsync和fdatasync的耗時。
#define BLOCK_LEN 1024 static long long microseconds(void) { struct timeval tv; long long mst; gettimeofday(&tv, NULL); mst = ((long long)tv.tv_sec) * 1000000; mst += tv.tv_usec; return mst; } int main(void) { int block = open("./block", O_WRONLY|O_APPEND, 0644); long long block_start, block_end, fdatasync_time, fsync_time; char block_buf[BLOCK_LEN]; int i = 0; for(i = 0; i < BLOCK_LEN; i++){ block_buf[i] = i % 50; } if (write(block, block_buf, BLOCK_LEN) == -1) { perror("write"); exit(1); } block_start = microseconds(); fdatasync(block); block_end = microseconds(); fdatasync_time = block_end - block_start; if (write(block, block_buf, BLOCK_LEN) == -1) { perror("write"); exit(1); } block_start = microseconds(); fsync(block); block_end = microseconds(); fsync_time = block_end - block_start; printf("fdatasync spent: %lld, fsync spent: %lld\n", fdatasync_time, fsync_time); close(block); exit(0); }
測試準備
- 檔案系統:ext4
- 作業系統核心:Linux 2.6.18-164.el5
- 硬碟型號:WDC WD1003FBYX-1 1V02,SCSI介面
- 通過sdparm--set=WCE /dev/sdx開啟Disk Write Cache,sdparm--clear=WCE /dev/sdx關閉Disk Write Cache
- 通過barrier=1,data=ordered開啟寫障礙,barrier=0關閉寫障礙
- 通過tune4fs-O has_journal /dev/sdxx開啟Journal,tune4fs-O ^has_journal /dev/sdxx關閉Journal
關閉Disk Cache,關閉Journal
型別 |
耗時(微秒) |
fdatasync |
8368 |
fsync |
8320 |
Device |
wrqm/s |
w/s |
wkB/s |
avgrq-sz |
avgqu-sz |
await |
svctm |
%util |
sdi |
0.00 |
120.00 |
480.00 |
8.00 |
1.00 |
8.33 |
8.33 |
100.00 |
可以看到,iostat為8ms,對inode、Data Block、inode Bitmap、DataBlock Bitmap的資料更新合併為了一次寫操作。
關閉Disk Cache,開啟Journal
型別 |
耗時(微秒) |
fdatasync |
33534 |
fsync |
33408 |
Device |
wrqm/s |
w/s |
wkB/s |
avgrq-sz |
avgqu-sz |
await |
svctm |
%util |
sdi |
37.00 |
74.00 |
444.00 |
11.95 |
1.22 |
16.15 |
13.32 |
99.90 |
通過使用blktrace跟蹤對磁碟塊的讀寫,發現此處寫journal會比較耗時,下面的記錄為fsync過程中對磁碟傳送的寫操作,已預處理掉了大部分不重要的資訊,可以看到,後面三條記錄都是journal的寫操作(通過此處kjournald的程序id為3001來識別)。
0,0 |
13 |
1 |
0.000000000 |
8835 |
A |
W |
2855185 + 8 <- (8,129) 2855184 |
0,0 |
4 |
5 |
0.000313001 |
3001 |
A |
W |
973352281 + 8 <- (8,129) 973352280 |
0,0 |
4 |
1 |
0.000305325 |
3001 |
A |
W |
973352273 + 8 <- (8,129) 973352272 |
0,0 |
4 |
12 |
0.014780357 |
3001 |
A |
WS |
973352289 + 8 <- (8,129) 973352288 |
開啟Disk Cache,開啟寫障礙,開啟Journal
型別 |
耗時(微秒) |
fdatasync |
23759 |
fsync |
25006 |
從結果可以看到,Disk Cache的開啟可以合併更多IO,從而減少耗時。
值得注意的是,在開啟Disk Cache時,iostat的await是按照從記憶體寫完到Disk Cache中來統計耗時,並非是按照寫到磁碟上來計時,所以此種情況下iostat的await引數會比較小,並無參考意義。
小結
從這次測試結果可以看到,雖然CDH4提供了hsync()方法,但是若我們對每次寫操作都執行hsync(),會嚴重加劇磁碟的寫延遲。通過一些策略,比方說定期執行hsync()或當存在於Cache中的資料達到一定數目時,執行hsync()會是更可行的方案,從而儘量減少機器意外斷電所帶來的影響。
附:術語解釋
- Hadoop: Apache基金會的開源專案,用於海量資料儲存與計算。
- CDH4: Cloudera公司在Apache社群發行版基礎之上進行改進後的發行版,更穩定更適用於生產環境。
- Namenode: Hadoop的HDFS模組中管理所有檔案元資料的元件。
- Datanode: Hadoop的HDFS模組中儲存檔案實際資料的元件。
- HDFS Client: 這裡指連線HDFS對其中檔案進行讀寫操作的客戶端。