Java - 如何有效地编写带有偶发孔的顺序文件

我需要将记录写入文件,其中数据根据数字键的值写入文件位置(即查找位置)。例如,如果键是 100,我可能会写在位置 400。

记录由数字键和一段数据组成。记录不会很大(几个字节)。但是,可能有很多记录(数百万)。

有两种可能的情况:

  1. 键单调地增加。在这种情况下,最好的方法是使用包装 a 进行写入,将缓冲区大小设置为某个数字(例如 64k)以最大化 I/O 吞吐量。DataOutputStreamBufferedOutputStream

  2. 关键正在增加,但可能存在很大的差距。在这种情况下,使用 OutputStream 需要在文件的间隙中写入零。为了避免这种情况,a会更好,因为它可以寻找间隙,如果可以在整个块上寻找,则可以节省空间。缺点是,据我所知,它不会缓冲,因此对于顺序键,此方法将很慢。RandomAccessFileRandomAccessFile

但是,可能的情况是文件是两者兼而有之。有单调增加键的序列。有些键之间的间隙很小,而有些键之间的间隙非常大。

我正在寻找的是一个两全其美的解决方案。如果检测到键之间的间隙,我可能会在两种I / O模式之间切换。但是,如果有一个标准的Java类可以同时执行这两件事,那就更好了。我已经看过了,但我不确定这是如何运作的。FileImageOutputStream

请注意,我不是在寻找代码示例(尽管这对于演示复杂的解决方案很有帮助),只是一个通用策略。最好了解顺序数据的最佳缓冲区大小,以及在什么点(间隙大小)需要从顺序策略切换到随机访问策略。

编辑:

为了接受答案,我希望得到一些保证,即所提出的解决方案可以同时处理这两个问题,而不仅仅是它可能。这将需要:

  • 确认顺序模式已缓冲。
  • 确认随机访问模式在文件中留下孔。

此外,该解决方案需要具有内存效率,因为可能同时打开许多这些文件。

编辑 2

文件可能位于 NAS 上。这不是设计使然,而只是认识到在企业环境中,这种架构被大量使用,解决方案应该处理它(可能不是最佳),而不是阻止它的使用。AFAIK,这不应影响基于 和 的解决方案,但可能会使一些更深奥的解决方案无效。write()lseek()


答案 1

编辑/警告:此解决方案存在潜在的陷阱,因为它大量使用,并且不清楚如何/何时发布相应的资源。请参阅此问答JDK-4724038:(fs)将unmap方法添加到MappedByteBufferMappedByteBuffer

话虽如此,也请参阅本文的结尾


我会完全按照Nim的建议去做:

将其包装在一个类中,该类映射在“块”中,然后在编写时移动块。这样做的算法相当简单。只需选择一个对您正在写入的数据有意义的块大小。

事实上,我几年前就这样做了,只是挖掘了代码,它是这样的(在演示中剥离到最低限度,使用单一方法来写入数据):

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;

public class SlidingFileWriterThingy {

    private static final long WINDOW_SIZE = 8*1024*1024L;
    private final RandomAccessFile file;
    private final FileChannel channel;
    private MappedByteBuffer buffer;
    private long ioOffset;
    private long mapOffset;

    public SlidingFileWriterThingy(Path path) throws IOException {
        file = new RandomAccessFile(path.toFile(), "rw");
        channel = file.getChannel();
        remap(0);
    }

    public void close() throws IOException {
        file.close();
    }

    public void seek(long offset) {
        ioOffset = offset;
    }

    public void writeBytes(byte[] data) throws IOException {
        if (data.length > WINDOW_SIZE) {
            throw new IOException("Data chunk too big, length=" + data.length + ", max=" + WINDOW_SIZE);
        }
        boolean dataChunkWontFit = ioOffset < mapOffset || ioOffset + data.length > mapOffset + WINDOW_SIZE;
        if (dataChunkWontFit) {
            remap(ioOffset);
        }
        int offsetWithinBuffer = (int)(ioOffset - mapOffset);
        buffer.position(offsetWithinBuffer);
        buffer.put(data, 0, data.length);
    }

    private void remap(long offset) throws IOException {
        mapOffset = offset;
        buffer = channel.map(FileChannel.MapMode.READ_WRITE, mapOffset, WINDOW_SIZE);
    }

}

下面是一个测试代码段:

SlidingFileWriterThingy t = new SlidingFileWriterThingy(Paths.get("/tmp/hey.txt"));
t.writeBytes("Hello world\n".getBytes(StandardCharsets.UTF_8));
t.seek(1000);
t.writeBytes("Are we there yet?\n".getBytes(StandardCharsets.UTF_8));
t.seek(50_000_000);
t.writeBytes("No but seriously?\n".getBytes(StandardCharsets.UTF_8));

输出文件的外观:

$ hexdump -C /tmp/hey.txt
00000000  48 65 6c 6c 6f 20 77 6f  72 6c 64 0a 00 00 00 00  |Hello world.....|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
000003e0  00 00 00 00 00 00 00 00  41 72 65 20 77 65 20 74  |........Are we t|
000003f0  68 65 72 65 20 79 65 74  3f 0a 00 00 00 00 00 00  |here yet?.......|
00000400  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
02faf080  4e 6f 20 62 75 74 20 73  65 72 69 6f 75 73 6c 79  |No but seriously|
02faf090  3f 0a 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |?...............|
02faf0a0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
037af080

我希望我没有通过删除不必要的位和重命名来破坏一切......至少偏移量计算看起来是正确的(0x3e0 + 8 = 1000,0x02faf080 = 50000000)。

文件占用的块数(左列)以及另一个相同大小的非稀疏文件:

$ head -c 58388608 /dev/zero > /tmp/not_sparse.txt
$ ls -ls /tmp/*.txt
    8 -rw-r--r-- 1 nug nug 58388608 Jul 19 00:50 /tmp/hey.txt
57024 -rw-r--r-- 1 nug nug 58388608 Jul 19 00:58 /tmp/not_sparse.txt

块的数量(和实际的“稀疏性”)将取决于操作系统和文件系统,以上是在Debian Buster上,ext4 - 稀疏文件在macOS的HFS+上不受支持,在Windows上,它们需要程序做一些我不知道的特定的事情,但这似乎并不容易,甚至从Java中也不可行,不确定。

我没有新的数字,但当时这种“滑动技术”非常快,正如您在上面看到的,它确实在文件中留下了漏洞。
你需要适应一些对你有意义的东西,添加你需要的所有方法,也许通过包装,任何适合你的方法。此外,在此状态下,它将根据需要扩展文件,但可能需要调整块。MappedByteBufferWINDOW_SIZEwriteThingywriteBytesWINDOW_SIZE

除非有很好的理由不这样做,否则最好使用这种单一机制保持简单,而不是维护复杂的双模系统。


关于脆弱性和内存消耗,我已经在Linux上运行了下面的压力测试,没有任何问题,在具有800GB RAM的计算机上,以及另一个具有1G RAM的非常适度的VM上。系统看起来非常健康,Java进程不使用任何大量的堆内存。

    String path = "/tmp/data.txt";
    SlidingFileWriterThingy w = new SlidingFileWriterThingy(Paths.get(path));
    final long MAX = 5_000_000_000L;
    while (true) {
        long offset = 0;
        while (offset < MAX) {
            offset += Math.pow(Math.random(), 4) * 100_000_000;
            if (offset > MAX/5 && offset < 2*MAX/5 || offset > 3*MAX/5 && offset < 4*MAX/5) {
                // Keep 2 big "empty" bands in the sparse file
                continue;
            }
            w.seek(offset);
            w.writeBytes(("---" + new Date() + "---").getBytes(StandardCharsets.UTF_8));
        }
        w.seek(0);
        System.out.println("---");
        Scanner output = new Scanner(new ProcessBuilder("sh", "-c", "ls -ls " + path + "; free")
                .redirectErrorStream(true).start().getInputStream());
        while (output.hasNextLine()) {
            System.out.println(output.nextLine());
        }
        Runtime r = Runtime.getRuntime();
        long memoryUsage = (100 * (r.totalMemory() - r.freeMemory())) / r.totalMemory();
        System.out.println("Mem usage: " + memoryUsage + "%");
        Thread.sleep(1000);
    }

所以,是的,这是经验性的,也许它只能在最近的Linux系统上正常工作,也许它只是运气好,因为特定的工作负载......但我开始认为这是某些系统和工作负载的有效解决方案,它可能很有用。


答案 2

你说几字节的数百万条记录。因此,让我们假设它是1000万个10字节,这意味着要写入的文件将具有大约100 MB。在我们这个时代,这并不多。

我只需创建一个存储所有键值对的 Map。然后编写一个函数,将映射的内容序列化为 。然后简单地 Files.write() 将字节写入磁盘。然后将旧文件替换为新文件。或者,更好的是,先移动旧文件,然后再移动新文件。byte[]