Optimizing Backpressure in Flink’s Network Stack

Liao Jiayi Liao Jiayi #Flink#Apache Flink

Flink’s backpressure mechanism is often praised, but a production problem led me to discover that it is not perfect.

Translated from Chinese with AI · Read the original

I often heard praise for Flink’s backpressure mechanism. Only after encountering a problem in an actual application and investigating it closely did I realize it was not perfect.

Network Stack

Several posts explain Apache Flink’s network stack:

On the receiving side, a RemoteChannel has m+n buffers: floating plus exclusive buffers. Because each RemoteInputChannel has its own exclusive buffers, different channels in the same TaskManager do not block each other when receiving data.

From the receiving side, this looks sound: slow input channels do not block faster ones. From the sending side, however, things differ. For clarity, the following diagram omits the receiver’s credit-based mechanism:

LocalBufferPool

Different subpartitions of the same task share a LocalBufferPool without a comparable credit-based mechanism. When sending data, they request a MemorySegment from that pool, wrap the data, and send it. Could a slowly draining subpartition occupy all pool resources, blocking a faster one that cannot acquire a buffer?

Yes. Try the demo below. I deliberately slow one task’s consumption. Since MapTask consumes more slowly than SourceTask produces, and distribution is round-robin, the subpartition destined for the slow MapTask occupies all LocalBufferPool resources. The subpartition destined for the fast MapTask cannot acquire resources and blocks.

@Test
def testRebalance(): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(2)
env.getConfig.setMaxParallelism(2)
val sourceStream = env.addSource(new SourceFunction[(Int, Int)] {
override def run(ctx: SourceContext[(Int, Int)]): Unit = {
while (true) {
0 until 10 foreach {
// keys '1' and '2' hash to different buckets
i => ctx.collect((1 + MathUtils.murmurHash(i) % 5, i))
}
}
}
override def cancel(): Unit = {}
})
sourceStream.rebalance.map(new RichMapFunction[(Int, Int), String] {
var block = false
override def open(parameters: Configuration): Unit = {
super.open(parameters)
if (getRuntimeContext.getIndexOfThisSubtask == 0) {
block = true
}
}
override def map(value: (Int, Int)): String = {
if (block) {
Thread.sleep(10000)
}
s"Subtask Index: ${getRuntimeContext.getIndexOfThisSubtask}"
}
}).print()
println(env.getExecutionPlan)
env.execute()
}

Optimization

The demo uses round-robin distribution, so I do not care which subpartition receives a record, only that later processing avoids skew and backpressure. This case can be optimized. Credit-based flow control tracks a sender backlog: data waiting to be sent. Receivers use backlog size to decide whether to request floating buffers. We can also use it to determine how many LocalBufferPool resources each subpartition occupies. Here is the key code:

Introduce a BacklogBasedSelector trait and implement it in RebalancePartitioner. RecordWriter checks whether its channelSelector implements the trait and, if so, whether targetChannel is blocked.

if (targetPartition instanceof ResultPartition && channelSelector instanceof BacklogBasedSelector) {
targetChannel = ((ResultPartition) targetPartition).changIfBlockingChannel(targetChannel);
}

Add changeIfBlockingChannel to ResultPartition.

public int changIfBlockingChannel(int subpartitionIndex) {
int bufferSize = bufferPool.getNumBuffers();
int backlogSize = subpartitions[subpartitionIndex].getBuffersInBacklog();
// 根据 bufferPool 的大小和 subpartition 的 backlog 大小来选择是否需要更换 targetChannel
}

This only works when receiving RemoteInputChannels are interchangeable. For operations such as keyBy, there is currently no better alternative here.