This is part 4 of the Kafka series.
- Part 1: Introduction to Kafka TCP Fragmentation
- Part 2: Introduction to Reactor Pattern and Kafka’s Reactor Implementation
- Part 3: An introduction to Kafka’s High‑Performance Binary RPC Protocol (Part 3)
The source code referenced in this article uses Kafka’s trunk branch. I’ve pushed it to my personal repo for version alignment with this article:
https://github.com/cyrilwongmy/kafka-src-reading
In the previous article, we explored the path of a Kafka request from the network layer to the KafkaApis
class. However, we didn’t cover much about how Kafka processes and returns a response. This article focuses specifically on how Kafka handles a Fetch request, constructs the response, and sends log data using zero-copy techniques.
If you’re not familiar zero-copy, there is an introductory video you can have a look.
1. From Fetch Request to FileRecords
Let’s jump straight into the implementation of KafkaApis.handleFetchRequest
, which eventually calls ReplicaManager.fetchMessages
. While the low-level file lookup logic can be skipped, it’s important to understand that what fetchMessages
returns is primarily:
- A
FileRecords
object (a wrapper over JDK’sFileChannel
) - Some metadata
Notably, the log data is not immediately loaded into memory. Instead, Kafka records the file offset range (start and end) that needs to be read later.
The key question now is: how does Kafka serialize and send this data over the network efficiently?
2. Response Construction and Data Wrapping
Inside handleFetchRequest
, Kafka defines a callback processResponseCallback
which handles the response construction after the fetch data is ready:
replicaManager.fetchMessages(
params = params,
fetchInfos = interesting,
quota = replicationQuota(fetchRequest),
responseCallback = processResponseCallback,
)
Response Callback Logic
The callback processResponseCallback
performs the following:
- Constructs a map from
TopicIdPartition
toFetchResponseData.PartitionData
- For each partition, Kafka builds a
PartitionData
object and sets its fields (e.g., high watermark, log start offset, records) - The
records
field here is theFileRecords
object — a file reference, not in-memory data
.setRecords(data.records)
Final Response
Kafka then calls:
fetchContext.updateAndGenerateResponseData(partitions, nodeEndpoints.values.toSeq.asJava)
This creates the final FetchResponse
and sends it using:
requestChannel.sendResponse(request, fetchResponse, None)
3. Serialization and Zero-Copy Packaging
Kafka serializes the response using this method chain:
Request.buildResponseSend()
→ RequestContext.buildResponseSend()
→ AbstractResponse.toSend()
→ SendBuilder.buildSend()
Within SendBuilder.buildSend()
, Kafka performs:
- Header and payload size calculation
- Writes header and
FetchResponseData
into a buffer - Writes the actual
records
using:
_writable.writeInt(records.sizeInBytes());
_writable.writeRecords(records); // FileRecords path
Zero-Copy Optimization
When writing records
, Kafka distinguishes between:
MemoryRecords
: in-memory buffersFileRecords
: backed by disk files
For FileRecords
, Kafka flushes any pending in-memory data to a Send Queue item and then calls: addSend(records.toSend())
wraps the FileRecords
into a Send
implementation (e.g., DefaultRecordsSend
) and append it to the Send queue in SendBuilder
.
Summary: Buffer and Send Queue
- Metadata is serialized into a
ByteBuffer
, converted to a SendQueue item when a - Log data (
FileRecords
) is added as aSend
- Both are added into the
SendBuilder
queue - Finally, Kafka returns a
MultiRecordsSend
object representing the full response
4. Network Sending with Zero-Copy
Once the MultiRecordsSend
is ready, it is enqueued into the Processor’s response queue:
processor.enqueueResponse(response)
The processor then sends the response using the selector’s send()
method. Kafka wraps the data in a NetworkSend
, which sets the send object (MultiRecordsSend
) on the corresponding KafkaChannel
.
Kafka uses non-blocking I/O (NIO) and epoll
to handle write-read events. Each client request is processed sequentially — one must complete before the next begins. This is managed through mute/unmute logic.
Writing to the Socket
When the channel becomes writable, Kafka calls:
send.writeTo(transportLayer)
This in turn invokes:
MultiRecordsSend.writeTo(channel)
Which iterates through its internal queue of Send
objects and writes each one to the socket channel.
5. Final Step: FileRecords Zero-Copy Transfer
When the current Send
is a DefaultRecordsSend
, it delegates to the FileRecords.writeTo()
method:
return (int) destChannel.transferFrom(channel, position, count);
Here, destChannel
is Kafka’s PlaintextTransportLayer
, and it ultimately calls JDK’s:
fileChannel.transferTo(position, count, socketChannel);
This leverages the zero-copy feature of the OS, enabling direct file-to-socket data transfer without copying to user-space memory.
Conclusion
Kafka’s fetch response pipeline — from data preparation to network transmission — is a masterclass in efficient I/O:
- Uses
FileRecords
to avoid premature loading into memory - Defers serialization until needed
- Constructs responses with minimal data duplication
- Leverages JDK’s
transferTo
for zero-copy network sends
The entire process showcases how to combine Java NIO, efficient design patterns, and clean abstractions to build a high-performance distributed system.
Kafka is not just a messaging system — it’s also an exemplar of elegant and performant I/O engineering.