前言
Long time no see(鞠躬
最近終于開始嘗試推廣Hudi在部門內部的應用,作為流批一體計劃的最后一塊拼圖,順便復活許久未更的博客,希望今后至少能保持周更的節奏吧。
在Hudi官方文檔的開頭列舉了四大核心概念,分別是:
- Timeline
- File Layout
- Table Types
- Query Types
本文就來簡要地談談Timeline。
Timeline作用與結構
官網關于Timeline的頁面洋洋灑灑介紹了很多,但是少了筆者認為最關鍵的、本質的概念:
Timeline就是Hudi的事務日志。
讀者可以回想一下MySQL中的Redo/Undo Log、Kudu中的Redo/Undo File(可參見很久之前寫的解析)。Timeline在Hudi中扮演的角色和它們基本相同(盡管Hudi并不是一個數據庫系統),也就是說,Hudi依靠Timeline提供快照隔離(SI)的事務語義,并使得增量查詢、Time-travel等特性成為可能。
每張Hudi表都有一條Timeline,由許多Instant組成,其中維護了各個時間點在該表上進行的操作。每個Instant又包含以下3個主要field。
- Time:操作進行的時間戳,單調遞增,格式為
yyyyMMddHHmmssSSS
; - Action:該時間戳進行的具體操作,如
commit
、compaction
等,所有操作都是原子的; - State:這個Instant的狀態,包含
requested
、inflight
和completed
三種。
Timeline和Instant的詳細圖示如下。
關于各個Action和State值的含義,可直接參考文檔,這里不再贅述。
Timeline以文件序列的形式存儲,其路徑位于/path/to/table/.hoodie
目錄,每個文件的命名方式是[time].[action].[state]
(處于completed狀態的Instant沒有state
后綴),例如:20220822181448272.deltacommit.inflight
。不同類型的Action對應的文件格式由不同的Avro Schema定義,以一個已經完成的deltacommit操作為例,它對應的Instant數據節選如下:
{
"fileId" : "6e0ef835-2474-4182-b085-e64994788729",
"path" : "2022-08-22/.6e0ef835-2474-4182-b085-e64994788729_20220822181218028.log.1_3-4-0",
"prevCommit" : "20220822181218028",
"numWrites" : 179,
"numDeletes" : 0,
"numUpdateWrites" : 179,
"numInserts" : 0,
"totalWriteBytes" : 60666,
"totalWriteErrors" : 0,
"tempPath" : null,
"partitionPath" : "2022-08-22",
"totalLogRecords" : 0,
"totalLogFilesCompacted" : 0,
"totalLogSizeCompacted" : 0,
"totalUpdatedRecordsCompacted" : 0,
"totalLogBlocks" : 0,
"totalCorruptLogBlock" : 0,
"totalRollbackBlocks" : 0,
"fileSizeInBytes" : 199309,
"minEventTime" : null,
"maxEventTime" : null,
"logVersion" : 1,
"logOffset" : 0,
"baseFile" : "6e0ef835-2474-4182-b085-e64994788729_0-4-0_20220822181218028.parquet",
"logFiles" : [ ".6e0ef835-2474-4182-b085-e64994788729_20220822181218028.log.1_3-4-0" ],
"recordsStats" : {
"val" : null,
"present" : false
},
"columnStats" : {
"val" : null,
"present" : false
}
}
Timeline實現
Timeline的類層次體系如下圖所示。
HoodieTimeline
接口定義了所有合法的Action和State的組合(也就是Instant文件的擴展名組合),以及Instant的獲取、過濾和文件名拼接等規范,主要的實現則位于HoodieDefaultTimeline
類。所有的Instant維護在List<HoodieInstant>
容器中。
舉個例子,Flink-Hudi Sink配備了生成Inline Compaction計劃的算子CompactionPlanOperator
,在每個Checkpoint完畢時負責調度。它需要在Timeline中尋找第一個pending的Compaction操作,就會用到HoodieDefaultTimeline
提供的對應方法:
// CompactionPlanOperator
private void scheduleCompaction(HoodieFlinkTable<?> table, long checkpointId) throws IOException {
// the first instant takes the highest priority.
Option<HoodieInstant> firstRequested = table.getActiveTimeline().filterPendingCompactionTimeline()
.filter(instant -> instant.getState() == HoodieInstant.State.REQUESTED).firstInstant();
if (!firstRequested.isPresent()) {
// do nothing.
LOG.info("No compaction plan for checkpoint " + checkpointId);
return;
}
// ......
}
// HoodieDefaultTimeline
@Override
public HoodieTimeline filterPendingCompactionTimeline() {
return new HoodieDefaultTimeline(
instants.stream().filter(s -> s.getAction().equals(HoodieTimeline.COMPACTION_ACTION) && !s.isCompleted()), details);
}
下面再來看看HoodieDefaultTimeline
的兩個實現。
HoodieActiveTimeline
顧名思義,HoodieActiveTimeline
維護當前活動的Timeline,它的主要作用是讀寫不同Action、不同State對應的Instant文件,所以大部分操作都是直接對文件操作。以requested
狀態到inflight
狀態的轉換為例,代碼比較易懂,其他操作都類似:
public void transitionRequestedToInflight(HoodieInstant requested, Option<byte[]> content,
boolean allowRedundantTransitions) {
HoodieInstant inflight = new HoodieInstant(State.INFLIGHT, requested.getAction(), requested.getTimestamp());
ValidationUtils.checkArgument(requested.isRequested(), "Instant " + requested + " in wrong state");
transitionState(requested, inflight, content, allowRedundantTransitions);
}
private void transitionState(HoodieInstant fromInstant, HoodieInstant toInstant, Option<byte[]> data,
boolean allowRedundantTransitions) {
ValidationUtils.checkArgument(fromInstant.getTimestamp().equals(toInstant.getTimestamp()));
try {
if (metaClient.getTimelineLayoutVersion().isNullVersion()) {
// Re-create the .inflight file by opening a new file and write the commit metadata in
createFileInMetaPath(fromInstant.getFileName(), data, allowRedundantTransitions);
Path fromInstantPath = getInstantFileNamePath(fromInstant.getFileName());
Path toInstantPath = getInstantFileNamePath(toInstant.getFileName());
boolean success = metaClient.getFs().rename(fromInstantPath, toInstantPath);
if (!success) {
throw new HoodieIOException("Could not rename " + fromInstantPath + " to " + toInstantPath);
}
} else {
// Ensures old state exists in timeline
LOG.info("Checking for file exists ?" + getInstantFileNamePath(fromInstant.getFileName()));
ValidationUtils.checkArgument(metaClient.getFs().exists(getInstantFileNamePath(fromInstant.getFileName())));
// Use Write Once to create Target File
if (allowRedundantTransitions) {
FileIOUtils.createFileInPath(metaClient.getFs(), getInstantFileNamePath(toInstant.getFileName()), data);
} else {
createImmutableFileInPath(getInstantFileNamePath(toInstant.getFileName()), data);
}
LOG.info("Create new file for toInstant ?" + getInstantFileNamePath(toInstant.getFileName()));
}
} catch (IOException e) {
throw new HoodieIOException("Could not complete " + fromInstant, e);
}
}
除此之外,HoodieActiveTimeline
還有一個非常重要的功能是生成新的Instant時間戳:
public static String createNewInstantTime(long milliseconds) {
return lastInstantTime.updateAndGet((oldVal) -> {
String newCommitTime;
do {
if (commitTimeZone.equals(HoodieTimelineTimeZone.UTC)) {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
newCommitTime = now.format(MILLIS_INSTANT_TIME_FORMATTER);
} else {
Date d = new Date(System.currentTimeMillis() + milliseconds);
newCommitTime = MILLIS_INSTANT_TIME_FORMATTER.format(convertDateToTemporalAccessor(d));
}
} while (HoodieTimeline.compareTimestamps(newCommitTime, HoodieActiveTimeline.LESSER_THAN_OR_EQUALS, oldVal));
return newCommitTime;
});
}
注意最近一個Instant的時間以AtomicReference<String>
來維護,這樣就可以通過CAS操作(updateAndGet()
)來保證Instant的時間戳單調遞增。
活動Timeline中可維護的Commit數目的上下界可由參數hoodie.keep.max.commits
和hoodie.keep.min.commits
來指定,默認值分別為30和20。
HoodieArchivedTimeline
隨著Hudi表不斷寫入,Instant會逐漸增多,為了降低活動Timeline上的文件壓力,需要對比較久遠的Instant進行歸檔,并將這些Instant從活動Timeline移除。這個操作一般是默認執行的(hoodie.archive.automatic
默認為true
),歸檔后的Instant就會維護在HoodieArchivedTimeline
中,位于/path/to/table/.hoodie/archived
目錄下。觸發自動歸檔的Commit數上下界則由參數archive.max_commits
和archive.min_commits
指定,默認值分別為50和40。
向HoodieArchivedTimeline
進行歸檔的邏輯并不在它內部,而位于HoodieTimelineArchiver
中,看官可自行參考其源碼。為了進一步減少小文件的影響,在歸檔的同時還可以進行小文件合并,與合并操作相關的參數有:
-
hoodie.archive.merge.enable
:是否啟用歸檔合并,默認false; -
hoodie.archive.merge.small.file.limit.bytes
:小文件閾值,默認20971520字節; -
hoodie.archive.merge.files.batch.size
:合并小文件的批次大小,默認為10。
The End
晚安晚安。