【杂谈】Kafka的日志段为什么不用内存映射?

什么是内存映射(Memory-Mapped File)?

内存映射(mmap)是一种将文件内容映射到内存中的技术,应用程序可以像操作内存一样对文件内容进行读写,而不需要显式地进行磁盘 I/O 操作。修改的内容会自动由操作系统同步到磁盘。

内存映射需要读取磁盘文件吗?

需要。毕竟,内存中的数据来源于磁盘文件。操作系统会将文件的部分或全部内容加载到内存,供程序访问。

为什么不直接读取文件?

直接读取文件,缓存到用户进程内,这样不也可以随意访问吗?相比这种方式,mmap有何优势?

  1. 数据拷贝次数少

内存映射相比直接读取文件的一个主要优势是减少了数据拷贝的次数

  • 内存映射:磁盘 => 内核空间
  • 直接读取:磁盘 => 内核空间 => 用户空间

正常情况下,应用程序不能直接访问内核空间中的数据。要访问这些数据,通常需要触发系统调用将数据从内核空间拷贝到用户空间。

而内存映射通过将文件内容直接映射到进程的虚拟地址空间,消除了这种额外的拷贝开销,从而提高了效率。

  1. 加载范围与按需加载

直接读取文件时,通常需要将整个文件加载到进程的内存缓存中,这对于大文件来说非常低效。而内存映射则更加高效,操作系统会根据需要按需加载文件的部分内容。

对于用户来说,内存映射的效果是可以像操作内存一样访问文件内容,而无需担心数据加载的问题。

  1. 自动写回磁盘
  • 内存映射:修改的内容会自动同步到磁盘,操作系统会处理文件内容的写回。
  • 直接读取:如果是直接读取,文件内容的修改要么全部写回磁盘,要么应用程序需要识别哪些区域发生了变化并单独写回磁盘,这样的管理工作相对繁琐。

Kafka在哪里使用了内存映射?

从源码中可以看到,Kafka 只在索引文件 中使用了内存映射(mmap)。内存映射的优势在于它允许随机访问,这与索引文件的应用场景非常匹配。

Kafka的索引文件通过二分法查找消息的存储位置,而内存映射的随机访问特性使得这个过程更加高效。

但是看源码可以发现,日志段则没有使用文件映射,而是直接使用FileChannel.write(buffer)写出数据。

//kafka 3.9.0部分源码

LogSegment.java

复制代码
package org.apache.kafka.storage.internals.log
复制代码
...

public class LogSegment implements Closeable {
    ...
    
    private final FileRecords log;
    ...

    /**
     * Append the given messages starting with the given offset. Add
     * an entry to the index if needed.
     *
     * It is assumed this method is being called from within a lock, it is not thread-safe otherwise.
     *
     * @param largestOffset The last offset in the message set
     * @param largestTimestampMs The largest timestamp in the message set.
     * @param shallowOffsetOfMaxTimestamp The last offset of earliest batch with max timestamp in the messages to append.
     * @param records The log entries to append.
     * @throws LogSegmentOffsetOverflowException if the largest offset causes index offset overflow
     */
    public void append(long largestOffset,
                       long largestTimestampMs,
                       long shallowOffsetOfMaxTimestamp,
                       MemoryRecords records) throws IOException {
        if (records.sizeInBytes() > 0) {
            LOGGER.trace("Inserting {} bytes at end offset {} at position {} with largest timestamp {} at offset {}",
                records.sizeInBytes(), largestOffset, log.sizeInBytes(), largestTimestampMs, shallowOffsetOfMaxTimestamp);
            int physicalPosition = log.sizeInBytes();
            if (physicalPosition == 0)
                rollingBasedTimestamp = OptionalLong.of(largestTimestampMs);

            ensureOffsetInRange(largestOffset);

            // append the messages
            long appendedBytes = log.append(records);
            LOGGER.trace("Appended {} to {} at end offset {}", appendedBytes, log.file(), largestOffset);
            // Update the in memory max timestamp and corresponding offset.
            if (largestTimestampMs > maxTimestampSoFar()) {
                maxTimestampAndOffsetSoFar = new TimestampOffset(largestTimestampMs, shallowOffsetOfMaxTimestamp);
            }
            // append an entry to the index (if needed)
            // 稀疏索引,有一定的间隔。可以减少索引量
            if (bytesSinceLastIndexEntry > indexIntervalBytes) {
                offsetIndex().append(largestOffset, physicalPosition);
                timeIndex().maybeAppend(maxTimestampSoFar(), shallowOffsetOfMaxTimestampSoFar());
                bytesSinceLastIndexEntry = 0;
            }
            bytesSinceLastIndexEntry += records.sizeInBytes();
        }
    }

   ...
}

FileRecords.java

复制代码
package org.apache.kafka.common.record;

...

public class FileRecords extends AbstractRecords implements Closeable {
     ...  
     private final FileChannel channel;
     ....

    public int append(MemoryRecords records) throws IOException {
        if (records.sizeInBytes() > Integer.MAX_VALUE - size.get())
            throw new IllegalArgumentException("Append of size " + records.sizeInBytes() +
                    " bytes is too large for segment with current file position at " + size.get());

        int written = records.writeFullyTo(channel);
        size.getAndAdd(written);
        return written;
    }
  
    ...


}

MemoryRecords.java

复制代码
package org.apache.kafka.common.record;
....

public class MemoryRecords extends AbstractRecords {
      ...
      private final ByteBuffer buffer;
      ...

          /**
     * Write all records to the given channel (including partial records).
     * @param channel The channel to write to
     * @return The number of bytes written
     * @throws IOException For any IO errors writing to the channel
     */
    public int writeFullyTo(GatheringByteChannel channel) throws IOException {
        buffer.mark();
        int written = 0;
        while (written < sizeInBytes())
            written += channel.write(buffer);
        buffer.reset();
        return written;
    }

    ....
}

为什么日志段不使用内存映射?

按理说,直接读写内存不是更快吗?日志段为什么不使用内存映射。

1. 内存消耗过大

Kafka 每个主题和分区都有多个日志段文件。如果将所有日志段文件都映射到内存中,将消耗大量的内存资源。尤其是在日志数据量非常大的情况下,这种做法会极大增加内存的负担,可能会在内存受限的环境中不可行。

2. 顺序读写已足够高效

**连续区域:**Kafka 的写入和读取操作通常涉及批量消息,这些消息在磁盘上是按顺序存储的。由于数据在物理存储上是连续的,操作系统可以通过一次磁盘寻道就定位到所需的区域,从而减少寻道时间和开销。

**页缓存(Page Cache):**操系统的页缓存机制(Page Cache)能够将频繁访问的文件内容缓存到内存中。操作系统也会预读取一部分文件后续内容到缓存中,提高缓存命中的概率,避免频繁从磁盘加载数据。

零拷贝(sendfile) :Kafka 的日志文件主要由远端消费者触发读取。由于日志在写入文件的时候都已经处理好了,而且读取也是顺序进行的,故Kafka Broker无需进行额外处理,数据可以直接从磁盘通过 sendfile() 系统调用发送到客户端,从内核直接拷贝到 socket 缓冲区,而不需要先载入到用户空间内存中。

总结

内存映射技术通过将文件内容映射到内存,有效避免了多次拷贝和高昂的 I/O 成本,非常适合需要随机访问的场景。然而,对于 Kafka 的日志段文件,顺序写入和读取已经足够高效,因此 Kafka 选择不使用内存映射,而是依赖操作系统的页缓存来提高性能。通过这种设计,Kafka 在内存消耗和 I/O 性能之间实现了良好的平衡。

参考内容

https://stackoverflow.com/questions/2100584/difference-between-sequential-write-and-random-write

https://storedbits.com/sequential-vs-random-data/

https://www.mail-archive.com/users@kafka.apache.org/msg30260.html

https://lists.freebsd.org/pipermail/freebsd-questions/2004-June/050371.html