Distributed Commit Log #

In the world of modern real-time data processing, we’re often confronted with a classic architectural dilemma: how do we reliably store millions of events per second without collapsing the server under extreme disk I/O load? Traditional relational databases answer the storage challenge with complex index structures, but those architectures aren’t designed to handle giant-scale sequential throughput loads simultaneously. This is where the Distributed Commit Log comes in as the strongest defense pillar and the heart of Apache Kafka’s architecture. Kafka isn’t a traditional relational database with dynamic indexes; Kafka is a very simple distributed transaction log with extraordinary performance. By treating data as a stream of records that can only be appended at the end (append-only) and can never be modified (immutable), Kafka flips the distributed data storage paradigm on its head. Understanding the commit log’s anatomy, how the operating system optimizes this binary stream through Page Cache and Zero-Copy, and how it differs from traditional database structures is the most crucial first step to mastering Kafka’s extreme performance in our application ecosystem.


Anatomy and Philosophy of the Append-Only Log #

Logically, a commit log is one of the simplest data structures in computer science: a chronologically ordered list of records that only allows writing at the very end (append-only). We can’t insert data in the middle of the log, and we can’t update data that’s already written. Once data enters the log, its status becomes absolute and eternal (immutable).

In Kafka, every partition of a topic is represented by one of these distributed commit log units. Every event sent by a producer is written to the end of this physical log and immediately given a unique identity number as a 64-bit integer called an offset. This offset acts as the message’s absolute position pointer within the partition. Consumers read this log sequentially from left to right (from low offsets to high offsets) while maintaining their own read coordinates. Because consumers don’t modify the log while reading, many consumers can read the same log simultaneously without interfering with or blocking each other.

At the physical file system level, the commit log is split into several smaller physical files called Segments. This segmentation exists because writing all data into one single terabyte-sized file would make space management and data cleanup difficult for the operating system. Each segment consists of three main files inside the broker’s partition directory:

  1. .log — The binary file storing the actual event contents.
  2. .index — A position index file mapping message offsets to physical byte positions inside the .log file. This speeds up random lookups when consumers request reading from a specific offset.
  3. .timeindex — A time-based index file mapping event timestamps to offset numbers. This is used for time-based data lookups (for example, finding data from 2 hours ago).

Only one segment actively accepts new data writes at a time, called the Active Segment. When the active segment’s .log file reaches the maximum size limit (usually configured via log.segment.bytes at 1 GB) or the maximum time limit (log.roll.hours at 7 days), that segment is closed and becomes read-only, and Kafka opens a new empty active segment.

This physical segmentation is what lets Kafka apply data retention policies extremely efficiently. When our data passes the retention time limit (for example, older than 7 days), Kafka simply deletes the oldest read-only segment file directly from disk. A complete file deletion operation at the OS level is far faster and cheaper than traditional database operations that must scan tables and delete rows one by one with random access, causing disk fragmentation.


Linear Log Speed: O(1) vs B-Tree Indexes #

One of the biggest myths in software development is that disk storage (HDD/SSD) is always slower than RAM. This is only true for random disk access. However, with sequential disk access, behavior changes drastically. Sequential writes on modern disks, even traditional platter HDDs, reach speeds nearly equal to RAM (hundreds of megabytes per second) because the disk’s read head doesn’t need to constantly move around hunting for randomly scattered physical memory sectors.

Traditional relational databases (like PostgreSQL or MySQL) are designed to support flexible search queries and dynamic data updates. To facilitate this, they use complex balanced tree data structures like B-Tree or its variants. When we store new data or update a row in a relational database, the database doesn’t just write that data to disk; it must also update the B-Tree index structure. This operation involves tree node traversal, page splitting, rebalancing, and random writes to various disk sector locations. The time complexity of these operations is $O(\log N)$ where $N$ is the number of data records.

Additionally, to maintain data consistency during these updates, the database must apply table or row locking that blocks other read or write threads. When table sizes swell from millions to billions of rows, traditional database write performance degrades exponentially due to B-Tree index degradation.

Apache Kafka completely reverses this approach by treating disk as a purely linear transaction log. Because Kafka uses the append-only model, every new event write always happens at the end of the physical file sequentially. The broker doesn’t need to search for tree node locations or balance indexes; it just performs a binary append operation to the end of the file descriptor. The write complexity to a Kafka commit log is a constant O(1). It doesn’t matter whether we have 10 Megabytes or 10 Terabytes of data in the broker — the speed to write one new message to the end of the log is always the same because it’s unaffected by the total dataset size ($N$). No table locking, no page splitting, no performance degradation over time.


Performance Diagram: Log Writes vs B-Tree #

For a clear visualization, let’s compare the complexity of the data write workflow on a relational database’s complex B-Tree structure against the simplicity of the append-only operation on a Kafka commit log.

flowchart TD
    subgraph BTreeSection["Traditional Database Operation (B-Tree Index)"]
        direction TB
        InputB["New Event"] --> SearchNode["1. Find Node Location (Traversal O(log N))"]
        SearchNode --> CheckSpace{"2. Is the Page Full?"}
        CheckSpace -- "Yes (High Overhead)" --> PageSplit["3. Perform Page Splitting & Balancing"]
        CheckSpace -- No --> WriteRandom["4. Random Write (Random IO) to Disk Sectors"]
        PageSplit --> LockTable["5. Lock Page/Table (Blocking Other Threads)"]
        LockTable --> WriteRandom
    end

    subgraph CommitLogSection["Apache Kafka Operation (Distributed Commit Log)"]
        direction TB
        InputLog["New Event"] --> GetOffset["1. Get Next Offset (Sequential)"]
        GetOffset --> AppendLog["2. Write Directly to End of File (Sequential Append O(1))"]
        AppendLog --> IPCache["3. Store in OS Page Cache (Fast Memory)"]
    end

    style PageSplit fill:#ffdddd,stroke:#ff8888
    style LockTable fill:#ffdddd,stroke:#ff8888
    style AppendLog fill:#ddffdd,stroke:#88ff88
    style IPCache fill:#ddffdd,stroke:#88ff88

From the diagram above, we can see that Kafka’s execution path is much shorter and more direct. Kafka eliminates all the extra computational overhead that usually slows down data processing in traditional databases.


Maximum Efficiency: OS Page Cache and Zero-Copy Transfer #

To maximize data delivery and read throughput, Kafka relies very cleverly on the operating system (OS) architecture rather than trying to build its own caching layer inside JVM heap memory.

In general, Java (JVM) applications tend to allocate internal memory caching inside the JVM heap. However, this internal JVM caching approach has several fatal weaknesses for high-scale data processing systems:

  1. Garbage Collection (GC) Overhead — Storing gigabytes of data in JVM heap memory triggers very frequent and long memory cleanup (garbage collection) processes. This causes GC pause phenomena (brief system freezes) that destroy message delivery latency consistency.
  2. Object Memory Footprint — Java object structures in heap memory are very space-wasteful. A simple string or small byte array can bloat 2x to 4x in size due to JVM object headers and internal class metadata.
  3. Double Copying — Caching in JVM memory means data must be copied twice: first from the OS into the JVM application buffer, and second from JVM back to the OS kernel when sent over the network.

Kafka solves this by discarding internal JVM heap caching and using the OS Page Cache directly. When a producer sends data to a Kafka broker, the broker writes that data to file using standard I/O APIs. The Linux OS automatically handles that file by storing it in free RAM converted into Page Cache.

Kafka brokers don’t need to rush forced disk syncs (fsync()) for every single message; Kafka lets the OS perform dirty page flushing to disk asynchronously in the background using the pdflush kernel thread. If a Kafka broker suddenly crashes, our data in Page Cache isn’t lost because Page Cache memory is managed directly by the OS kernel, not by the dead Kafka JVM process. As long as our server’s Linux OS doesn’t suffer a total power outage, the data remains safely stored.

When a consumer comes asking for data from a specific offset, Kafka checks whether that data is still in the OS Page Cache. If yes, the data is read directly from super-fast RAM without triggering any physical disk read.

However, Kafka’s biggest optimization lies in how data in the Page Cache is sent to the network. In traditional systems, moving data from Page Cache to a network socket involves manual copying through User Space (the application):

  1. Disk copies data into the OS Kernel Page Cache.
  2. The application (User Space) reads data from Page Cache into the application’s local memory buffer (JVM Heap).
  3. The application writes that data back into the Socket Buffer in Kernel Space.
  4. Kernel Space copies data from the Socket Buffer to the network card (NIC Buffer).

This traditional flow requires 4 data copy operations and 4 context switches between User Space and Kernel Space. This process wastes enormous CPU cycles and RAM memory bandwidth.

Apache Kafka cuts this bureaucratic chain by leveraging the Zero-Copy Data Transfer feature through the sendfile system call on Linux. When a consumer requests data, the Kafka broker calls sendfile at the kernel level. This command lets the OS copy data directly from the Kernel Page Cache to the Network Card Buffer (NIC Buffer) without first copying the data into JVM application memory (User Space).

Let’s look at the comparison between the traditional data flow and the Zero-Copy optimization in the diagram below:

flowchart TD
    subgraph TraditionalFlow["Traditional Data Flow (Without Zero-Copy)"]
        direction TB
        DiskT["Disk Storage"] -->|"1. Copy to Kernel Space"| CacheT["Kernel Page Cache"]
        CacheT -->|"2. Copy to User Space"| JVMHeap["JVM Heap Memory (Application)"]
        JVMHeap -->|"3. Copy to Kernel Socket Buffer"| SocketT["Socket Buffer"]
        SocketT -->|"4. Copy to NIC Buffer"| NICT["NIC Buffer (Hardware)"]
    end

    subgraph ZeroCopyFlow["Zero-Copy Data Flow (sendfile)"]
        direction TB
        DiskZ["Disk Storage"] -->|"1. Copy to Kernel Space"| CacheZ["Kernel Page Cache"]
        CacheZ -->|"2. Direct Transfer (Zero Copy)"| NICZ["NIC Buffer (Hardware)"]
    end

    style JVMHeap fill:#ffdddd,stroke:#ff8888
    style NICZ fill:#ddffdd,stroke:#88ff88

With Zero-Copy, the flow is reduced to only 2 data copies and 2 context switches, and it never touches JVM heap memory or triggers Garbage Collection. This is the secret behind why Kafka can saturate a server’s gigabit ethernet bandwidth completely while using minimal CPU.


Immutable Data Stream: Why We Reject Mutability #

In relational database application development, mutable state (changeable data) is something we do every day. We update user addresses, change transaction statuses from PENDING to SUCCESS, or delete rows considered invalid. However, in large-scale distributed system architecture, mutability is a primary source of complexity, race conditions, and data inconsistency.

Why does Kafka insist on rejecting mutability and applying the immutability principle (data can’t be changed) to the commit log?

1. Free from Cross-Thread Safety Problems #

Because data in the log is immutable, no producer or consumer thread can modify already-written data. We don’t need memory locking mechanisms or complex concurrency transaction controls (MVCC) when reading data. Thousands of consumers can read the same log area simultaneously at maximum RAM speed without worrying about data changing mid-stream.

2. Consistent Distributed Data Replication #

In a distributed Kafka cluster, data is replicated from Leader brokers to several Follower brokers. Because the log is append-only and immutable, the replication process becomes very simple and deterministic. Follower brokers simply request data starting from the last offset they have. The Leader broker only needs to send the new byte stream in order. There’s no risk of data in the middle of the log changing on the Leader while Followers still hold old data (divergent state), which is usually a nightmare in relational database replication sync.

3. Audit Trail and Single Source of Truth #

In business systems, past events are history that can’t be changed. If a customer bought something at our store at 10 AM, that’s a historical fact. If that customer cancels the purchase at 10:15 AM, the cancellation shouldn’t erase the 10 AM purchase record. The cancellation is a new event happening at a new time. By storing all these historical facts as an immutable append-only log, we have a perfect, tamper-proof audit trail.

4. Replayability #

Log immutability enables Kafka’s most powerful feature: Replayability. Because the physical log isn’t deleted or modified after being read, consumers can rewind their read position (reset offset) to the beginning of the log or to a specific time in the past. This is very useful when our application has a bug or crashes, and we need to rebuild application memory state by reprocessing all historical events from the start as if time were rewound.


Anti-pattern vs Solution in the Append-Only Mindset #

One of the biggest mental mistakes new developers make when starting to adopt Apache Kafka is treating Kafka like a relational database (CRUD mindset). They try to find ways to update old, wrong messages inside the partition log asynchronously.

Case Study: Updating Customer Transaction Status #

Imagine our application manages e-commerce orders. When the order status changes from ORDER_CREATED to ORDER_SHIPPED, a developer with a CRUD mindset tries to tinker with Kafka to edit the old event at the initial offset, or creates a special query to change the status field value inside an already-sent message.

Consequences: Apache Kafka provides absolutely no API for randomly modifying or deleting messages by specific offset. Forcibly modifying the disk log directly outside the Kafka system corrupts the .index and .timeindex structures, triggers fatal corruption errors on the broker, stops replication processes, and destroys the data consistency of consumers relying on linear numeric offset ordering.

Let’s see the implementation difference in Java between the wrong mutable mindset and the correct immutable stream mindset:

// =========================================================================
// ANTI-PATTERN: Mutable Database Mindset (CRUD Mindset)
// Trying to treat the Kafka log like a database whose rows can be updated.
// =========================================================================
public class OrderProcessorAntiPattern {
    public void processOrderUpdate(String orderId, String newStatus) {
        // ✗ DON'T: Look for ways to find the old message offset and modify its contents in Kafka.
        // Kafka doesn't support random UPDATE operations.
        System.err.println("Error: No Kafka API for random offset updates!");
        throw new UnsupportedOperationException("Kafka log is immutable!");
    }
}

// =========================================================================
// THE CORRECT SOLUTION: Append-Only Immutable Stream Mindset
// We represent status changes as new events appended to the end of the log.
// Consumers reconstruct the final state by processing the event sequence chronologically.
// =========================================================================
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;

public class OrderProcessorSolution {
    private final KafkaProducer<String, String> producer;
    private final String topicName = "ecommerce-orders";

    public OrderProcessorSolution() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "localhost:9092");
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("enable.idempotence", "true"); // Guarantee delivery reliability
        
        this.producer = new KafkaProducer<>(props);
    }

    public void processOrderUpdate(String orderId, String newStatus) {
        // ✓ CORRECT: Create a new event describing the historical fact of the status change
        // First event: ORDER_CREATED (offset 100)
        // Second event: ORDER_SHIPPED (offset 101) -> Appended at the end of the log
        String eventPayload = String.format("{\"order_id\":\"%s\",\"status\":\"%s\",\"timestamp\":%d}", 
            orderId, newStatus, System.currentTimeMillis());
            
        ProducerRecord<String, String> record = new ProducerRecord<>(topicName, orderId, eventPayload);
        
        // Send the event asynchronously without blocking the thread
        producer.send(record, (metadata, exception) -> {
            if (exception != null) {
                // Error handling if transmission fails
                System.err.println("Failed to send order update event: " + exception.getMessage());
            } else {
                // ✓ Successfully wrote to the end of the commit log sequentially O(1)
                System.out.printf("Successfully wrote sequential event to topic %s, partition %d, offset %d%n",
                    metadata.topic(), metadata.partition(), metadata.offset());
            }
        });
    }
}

By adopting the immutable stream mindset above, consumers reading the ecommerce-orders topic receive two separate events in sequence:

  1. ORDER_CREATED at 10:00
  2. ORDER_SHIPPED at 10:15

Consumers simply store that order’s status in their own local database (materialized view) and perform status updates there locally. On the Kafka side, the entire status change history is perfectly and safely preserved in the commit log.


Characteristic Comparison: Commit Log vs Relational Database #

For a more concrete picture, here’s a comprehensive comparison table between the operational characteristics of Kafka’s distributed commit log and a traditional relational database (RDBMS):

CharacteristicDistributed Commit Log (Kafka)Relational Database (RDBMS)
Write ModelAppend-only (always added at the end of the physical log).In-place update (data overwritten at the same physical location).
Write ComplexityConstant $O(1)$, unaffected by data size.$O(\log N)$ because the B-Tree index must be updated.
ImmutabilityImmutable (once written, can’t be edited/deleted).Mutable (fully supports UPDATE and DELETE operations).
Caching MechanismUses OS Page Cache directly.Uses internal application memory cache (Buffer Pool).
Data TransferZero-Copy Transfer (sendfile bypasses user space).Data copied repeatedly from kernel space to JVM user space.
Data AccessSequential read (consumers scan data in order).Random read (random lookups of specific rows via indexes).
Consumption ModelPull model (consumers pull data by offset).Interactive real-time SQL queries.
Retention PolicyBased on time/size physical file segments.Must be manually deleted with planned delete queries.

Summary #

  • Commit Log Definition — A distributed commit log is a linear, append-only, immutable data structure where new events are always added at the end of the physical log and given a unique identity number called an offset.
  • O(1) Complexity — Writes to a Kafka commit log have constant O(1) complexity because they don’t use dynamic B-Tree indexes requiring tree traversal, balancing, or table locking.
  • Log Segmentation — A partition’s commit log is divided into small physical files called segments (.log, .index, .timeindex) to ease data retention and disk cleanup.
  • OS Page Cache — Kafka discards internal JVM heap caching and uses the OS Page Cache directly to avoid GC pauses and double memory copying.
  • Zero-Copy Optimization — Uses the sendfile system call to send data directly from kernel Page Cache to the NIC buffer, bypassing the overhead of copying data to User Space.
  • Immutability Principle — The log’s immutable nature guarantees a race-condition-free system, simplifies distributed data replication, and facilitates a robust historical audit trail.
  • Event Replayability — Log immutability lets consumers rewind offset positions to reprocess historical events anytime without damaging original data.
  • Avoid the CRUD Mindset — Never design systems by trying to modify messages at a specific offset mid-log; use the immutable event streaming mindset by appending new change events to the end of the log.

← Previous: Cluster
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact