前言
最近正在準備關於Flink 1.13 / 1.14版本新特性的內部分享,順便做點記錄。
又見網絡緩存
很久沒有聊過Flink的網絡棧了,但相信大家對網絡緩存(Network Buffer)這個概念不會陌生。它是Flink網絡層數據交換的最小單元,承載序列化後的數據,以直接內存的形式分配,並且一個Buffer的大小就等於一個MemorySegment的大小,即taskmanager.memory.segment-size
,默認爲32kB。
上圖示出了Buffer(實心黑框)在兩個TaskManager之間的傳輸過程。其中:
- RP = ResultPartition / RS = ResultSubpartition
- IG = InputGate / IC = InputChannel
- LocalBufferPool / NetworkBufferPool未示出
Buffer數量的推算規則是:
- 發送端ResultPartition分配的Buffer總數爲ResultSubpartition的數量+1,且爲了防止傾斜,每個ResultSubpartition可獲得的Buffer數不能多於
taskmanager.network.memory.max-buffers-per-channel
(默認值10)。 - 接收端每個InputChannel獨享(exclusive)的Buffer數爲
taskmanager.network.memory.buffers-per-channel
(默認值2),InputGate可額外提供的浮動(floating)Buffer數爲taskmanager.network.memory.floating-buffers-per-gate
(默認值8)。
也就是說,如果一個作業的ExecutionGraph確定,那麼我們可以用上述規則配合Tasks之間的DistributionPattern(POINTWISE / ALL_TO_ALL)估計網絡緩存的需求量。
有什麼問題?
大多數情況下,除了調整taskmanager.memory.network.fraction
之外,我們都不需要擔心網絡緩存的其他方面。
但是,網絡緩存的配置都是靜態的,如果緩存了大量的數據(特別是並行度比較大的任務),不僅浪費內存空間,而且不利於Checkpointing過程——如果是傳統的對齊檢查點,Barrier需要經過更長的時間(即經過更多的in-flight數據)才能對齊;如果啓用了非對齊檢查點,需要做快照的in-flight數據也會變多。網絡緩存消脹(Network Buffer Debloating)就是爲了儘量減少in-flight數據而產生的。
如FLIP-183中的下圖所示,接收端的Buffer大小不再是固定的,而是動態地根據一個預設的消費時間閾值和一定時間段內的吞吐量確定。
當然,Buffer的數量仍然是固定的。經過Debloat之後,Buffer的大小最小可以達到taskmanager.memory.min-segment-size
(默認值爲1kB)。
Buffer Debloating相關設置
taskmanager.network.memory.buffer-debloat.enabled
是否啓用網絡緩存消脹,默認爲false。taskmanager.network.memory.buffer-debloat.period
調度緩存消脹操作的時間週期,默認爲200ms。taskmanager.network.memory.buffer-debloat.samples
計算新Buffer大小時的採樣數,默認爲20,表示考慮之前20箇舊的Buffer的大小。taskmanager.network.memory.buffer-debloat.target
In-flight數據被接收方消費的期望時間閾值,默認爲1s。taskmanager.network.memory.buffer-debloat.threshold-percentages
緩存消脹過程中的新舊Buffer相對變化率的閾值,默認爲25(即25%)。若變化率小於此值,則不執行Debloat操作,可以避免頻繁調整產生性能抖動。
Buffer Debloating的實現
一個StreamTask啓動後,在其invoke()
方法中就會藉助Timer以上述的週期開始Buffer Debloating的調度。
private void scheduleBufferDebloater() {
// See https://issues.apache.org/jira/browse/FLINK-23560
// If there are no input gates, there is no point of calculating the throughput and running
// the debloater. At the same time, for SourceStreamTask using legacy sources and checkpoint
// lock, enqueuing even a single mailbox action can cause performance regression. This is
// especially visible in batch, with disabled checkpointing and no processing time timers.
if (getEnvironment().getAllInputGates().length == 0
|| !environment
.getTaskManagerInfo()
.getConfiguration()
.getBoolean(TaskManagerOptions.BUFFER_DEBLOAT_ENABLED)) {
return;
}
systemTimerService.registerTimer(
systemTimerService.getCurrentProcessingTime() + bufferDebloatPeriod,
timestamp ->
mainMailboxExecutor.execute(
() -> {
debloat();
scheduleBufferDebloater();
},
"Buffer size recalculation"));
}
debloat()
方法會調用該StreamTask內所有InputGate的triggerDebloating()
方法。
void debloat() {
for (IndexedInputGate inputGate : environment.getAllInputGates()) {
inputGate.triggerDebloating();
}
}
來到SingleInputGate#triggerDebloating()
方法。該方法分三步執行:
- 計算吞吐量;
- 重新計算Buffer大小;
- 如果Buffer大小有變,將新的Buffer大小傳播到底層。
public void triggerDebloating() {
if (isFinished() || closeFuture.isDone()) {
return;
}
checkState(bufferDebloater != null, "Buffer debloater should not be null");
final long currentThroughput = throughputCalculator.calculateThroughput();
bufferDebloater
.recalculateBufferSize(currentThroughput, getBuffersInUseCount())
.ifPresent(this::announceBufferSize);
}
計算吞吐量的邏輯比較簡單,位於ThroughputCalculator#calculateThroughput()
方法中,即單位時間內累計的數據量。
/** @return Calculated throughput based on the collected data for the last period. */
public long calculateThroughput() {
if (measurementStartTime != NOT_TRACKED) {
long absoluteTimeMillis = clock.absoluteTimeMillis();
currentMeasurementTime += absoluteTimeMillis - measurementStartTime;
measurementStartTime = absoluteTimeMillis;
}
long throughput = calculateThroughput(currentAccumulatedDataSize, currentMeasurementTime);
currentAccumulatedDataSize = currentMeasurementTime = 0;
return throughput;
}
public long calculateThroughput(long dataSize, long time) {
checkArgument(dataSize >= 0, "Size of data should be non negative");
checkArgument(time >= 0, "Time should be non negative");
if (time == 0) {
return currentThroughput;
}
return currentThroughput = instantThroughput(dataSize, time);
}
static long instantThroughput(long dataSize, long time) {
return (long) ((double) dataSize / time * MILLIS_IN_SECOND);
}
在第二步則先根據吞吐量和期望消費時間計算出總的Buffer大小的初始目標值,然後利用滑動指數平均(Exponential Moving Average, EMA)算法求出每個Buffer的大小。該算法可以有效地抹平流量毛刺帶來的影響。
BufferSizeEMA類中也用到了之前提到的採樣數,代碼略去。最後檢查變化率,並更新Buffer大小。
public OptionalInt recalculateBufferSize(long currentThroughput, int buffersInUse) {
int actualBuffersInUse = Math.max(1, buffersInUse);
long desiredTotalBufferSizeInBytes =
(currentThroughput * targetTotalBufferSize) / MILLIS_IN_SECOND;
int newSize =
bufferSizeEMA.calculateBufferSize(
desiredTotalBufferSizeInBytes, actualBuffersInUse);
lastEstimatedTimeToConsumeBuffers =
Duration.ofMillis(
newSize
* actualBuffersInUse
* MILLIS_IN_SECOND
/ Math.max(1, currentThroughput));
boolean skipUpdate = skipUpdate(newSize);
// Skip update if the new value pretty close to the old one.
if (skipUpdate) {
return OptionalInt.empty();
}
lastBufferSize = newSize;
return OptionalInt.of(newSize);
}
最後,更新的Buffer大小會由InputGate傳播到其包含的每個InputChannel。根據其種類,又有兩種處理方式:
- 若爲LocalInputChannel(即連接本地輸出),則直接更新其對應的ResultSubpartition的Buffer大小;
- 若爲RemoteInputChannel(即通過Netty連接其他TaskManager的遠端輸出),則將
NewBufferSizeMessage
通過此Channel發送出去。
The End
民那晚安好夢。