Подтвердить что ты не робот

Java - как эффективно писать последовательный файл с случайными отверстиями в нем

У меня есть требование записать записи в файл, где данные записываются в местоположении файла (т.е. в позиции поиска) в зависимости от значения числового ключа. Например, если ключ равен 100, я могу записать в позиции 400.

Записи состоят из числового ключа и части данных. Запись не будет очень большой (несколько байтов). Однако может быть много записей (миллионов).

Возможны два сценария:

  • Ключи монотонно увеличиваются. В этом случае наилучшим подходом является запись с использованием DataOutputStream wrapping a BufferedOutputStream, установка размера буфера на некоторое число (например, 64k), чтобы максимизировать пропускную способность ввода-вывода.

  • Клавиши увеличиваются, но возможны большие пробелы. В этом случае использование OutputStream потребует, чтобы нули записывались в промежутках в файле. Чтобы избежать этого, RandomAccessFile будет лучше, поскольку он может искать пробелы, экономя пространство, если можно запросить весь блок. Недостатком является то, что, насколько мне известно, RandomAccessFile не буферизуется, поэтому этот метод будет медленным для последовательных ключей.

Однако, вероятная ситуация заключается в том, что файл немного для обоих. Есть последовательности монотонно увеличивающих ключей. Есть несколько ключей с небольшими промежутками между и другими с очень большими пробелами.

То, что я ищу, - это решение, которое дает лучшее из обоих миров. Возможно, я переключаюсь между двумя режимами ввода/вывода, если обнаружен разрыв между ключами. Однако было бы лучше, если бы был стандартный Java-класс, который может выполнять обе эти вещи. Я видел FileImageOutputStream, но я не уверен, как это работает.

Обратите внимание, что я не ищу образцы кода (хотя это было бы полезно для демонстрации сложных решений), а всего лишь общая стратегия. Было бы хорошо знать размеры буферов оптимального размера для последовательных данных и в какой момент (размер разрыва) вам нужно перейти от последовательной стратегии к стратегии произвольного доступа.

EDIT:

Чтобы ответ был принят, я хотел бы получить некоторую уверенность в том, что предлагаемое решение обрабатывает оба, а не только то, что оно может. Для этого потребуется:

  • Подтверждение буферизации последовательного режима.
  • Подтверждение того, что режим произвольного доступа оставляет дыры в файле.

Кроме того, решение должно быть эффективным с точки зрения памяти, так как многие из этих файлов могут открываться одновременно.

РЕДАКТИРОВАТЬ 2

Файлы могут быть на NAS. Это не дизайн, а просто признание того, что в корпоративной среде эта архитектура используется много, и решение должно, вероятно, справиться с ней (возможно, не оптимально), а не предотвращать ее использование. AFAIK, это не должно влиять на решение, основанное на write() и lseek(), но может привести к недействительности некоторых более эзотерических решений.  

4b9b3361

Ответ 1

Изменить/предупреждение: есть потенциальные проблемы с этим решением, потому что он сильно использует MappedByteBuffer, и неясно, как/когда соответствующие ресурсы будут выпущены. См. этот Q & A и JDK-4724038: (fs) Добавить метод unmap в MappedByteBuffer.

При этом, пожалуйста, также см. конец этого сообщения


Я бы сделал именно то, что предложил Ним:

оберните это в класс, который отображается в "блоках", а затем перемещает блок по мере написания. Алгоритм для этого достаточно прост. Просто выберите размер блока, который имеет смысл для данных, которые вы пишете..

На самом деле, я сделал именно то, что лет назад, и просто выкопал код, он выглядит следующим образом (разделяется до минимума для демонстрации, с единственным методом для записи данных):

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 - разреженные файлы не поддерживаются в HFS + для macOS, а в Windows им требуется, чтобы программа что-то делала я не знаю достаточно, но это не кажется легким или даже выполнимым с Java, но не уверен.

У меня нет свежих чисел, но в то время эта "техника скольжения MappedByteBuffer" была очень быстрой, и, как вы можете видеть выше, она оставляет дыры в файле.
Вам нужно будет адаптировать WINDOW_SIZE к чему-то, что имеет смысл для вас, добавить все методы writeThingy, которые вам нужны, возможно, обернув writeBytes, что вам подходит. Кроме того, в этом состоянии он будет увеличивать файл по мере необходимости, но кусками WINDOW_SIZE, которые также могут потребоваться для адаптации.

Если нет очень веской причины не делать этого, лучше всего держать его простым с этим единственным механизмом, а не поддерживать сложную двухрежимную систему.


О хрупкости и потреблении памяти, я провел стресс-тест ниже на Linux без каких-либо проблем в течение часа, на машине с 800 ГБ ОЗУ, а также на другой очень скромной виртуальной машине с 1 ГБ ОЗУ. Система выглядит совершенно здоровой, 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

Вы говорите миллионы записей нескольких байтов. Поэтому допустим, что это 10 миллионов 10 байт, а это значит, что файл для записи будет иметь около 100 мб. В наше время этого не много.

Я бы просто создал карту, в которой были сохранены все пары ключ-значение. Затем напишет функционал, который сериализует содержимое карты на byte[]. А затем просто Files.write() байты на диск. Затем замените старый файл новым файлом. Или, еще лучше, сначала переместите старый файл, а затем переместите новый.

Ответ 3

Я предполагаю, что когда ваши ключи после увеличения последовательно, а затем сделают пробел, не будет другого ключа, добавляющего к "законченной" последовательности. Если это правильно, я бы выполнил следующее решение

Пока ваши ключи продолжают увеличиваться, последовательно продолжайте работать с вашим 1-м подходом:

напишите, используя DataOutputStream wrapping a BufferedOutputStream, установив размер буфера на некоторое число (например, 64k), чтобы максимизировать пропускную способность ввода-вывода.

записывайте данные в временный файл. Как только разрыв возникает, начните запись в следующий временный файл и сохраните запись ваших временных файлов. Таким образом, вы получаете файл за последовательность записей без пробелов. После того, как вы закончили обработку dat для вашего основного файла, у вас есть отдельный метод, который бы умело объединил ваши временные файлы в окончательный файл. Это было бы легкой задачей, поскольку вы знаете, что в каждом временном файле нет пробелов

Ответ 4

Мое первое усилие в этом состояло в том, чтобы просто использовать RandomAccessFile наивно и посмотреть, достаточно ли он достаточно. Я бы действительно был удивлен, если он медленный, хотя Java не будет его буферизовать, реализация файловой системы будет.


Если возникают проблемы с производительностью, я бы сделал следующее: обернуть RandomAccessFile в фазе буферизации с логикой записи по строкам (java-ish псевдокод):

void write(record, location) {
     if(location != lastLocation + recordLength) {
          flushBufferToRandomAccessFile();
     )
     addToBuffer(record);
     flushBufferToRandomAccessFileIfFull();
     lastLocation = location;
}

Буфер будет byte[]. Потенциальная победа здесь в том, что вы делаете меньше randomAccessFile.write(buffer, 0, longLength) вместо randomAccessFile.write(record, 0, shortLength).

Вы можете немного убрать это, инкапсулируя всю необходимую информацию о буферизованном блоке в Buffer class - bytes, start location, end location. Вам также понадобится очистить буфер до файла в методе close()).

То есть вы собираете блоки записей в памяти кучи, сбрасывая до RandomAccessFile:

  • когда вы достигнете размера своего буфера,
  • когда местоположение записи не смежно с текущим буферизованным блоком.
  • после последней записи

Я ценю, что вы не хотите тратить память - но независимо от того, находится ли она в куче или где-то еще, память - это память, и вы не можете без нее буферизировать. С помощью этого решения вы можете настроить размер своего буфера - и даже если он будет достаточно для двух записей, он может сократить число записей в два раза.

Если вы хотите быть фанатичным в отношении использования памяти, вы используете неправильный язык.


Если бы это было еще недостаточно быстро, я бы подумал о переносе записей в другой поток. Поэтому записывайте свои записи в очередь и позволяйте потоку записи файлов из очереди. Это не приведет к тому, что файл, написанный быстрее, сам по себе, но означает, что потребитель может догнать отставание, в то время как производитель выполняет другую работу, поэтому его полезность зависит от того, имеет ли производитель другую работу.

Ответ 5

Я изменил свое мнение об этом. Вы должны использовать MappedByteBuffer. Он выгружается операционной системой как часть подсистемы виртуальной памяти, которая удовлетворяет требованиям буферизации; он записывается в память при записи; и это зависит от поведения операционной системы при записи файлов с отверстиями, которые удовлетворяют этому требованию.