October 1

«У меня CSV-файл размером 10 ГБ и всего 512 МБ оперативной памяти» — вопрос на собеседовании, который поставил меня в тупик

Это перевод оригинальной статьи “I Have a 10GB CSV File and Only 512MB RAM” — The Interview Question That Stumped Me.

Подписывайтесь на телеграм-канал usr_bin, где я публикую много полезного по Linux, в том числе ссылки на статьи в этом блоге.

И как отчаянное упоминание Кафки спасло мой ответ (в некотором роде).

Я проходил собеседование на позицию Java-разработчика. Всё шло хорошо — я ответил на вопросы по Spring Boot, микросервисам и даже решил несколько задач в стиле LeetCode.

Затем интервьюер откинулся назад и спросил:

«Представьте, что у вас есть очень большой файл — допустим, CSV размером 10 ГБ. При этом вычислительные ресурсы сильно ограничены — например, всего 512 МБ оперативной памяти и одно ядро процессора. Как бы вы обработали этот файл, чтобы выполнить предварительную обработку данных и сохранить их в другом формате?»

Я замер. Мысли в голове сменяли друг друга.

«Просто читать файл построчно?» — слишком очевидно, вероятно, неправильно.
«Использовать многопоточность?» — но ресурсы ограничены.
«Загрузить в базу данных?» — но тогда базе данных также потребуется память.

Я понимал, что нужно сказать что-то действительно разумное.

Мой первый ответ (спойлер: он был не самым удачным)

Я попросил уточнить: «Какие именно данные? Они структурированы?»

Интервьюер: «Это CSV-файл — строки и столбцы. Вам нужно его очистить, преобразовать некоторые столбцы и сохранить в формате JSON или Parquet».

После нескольких секунд раздумий я ответил:

«Мы можем разделить строки на фрагменты и обрабатывать каждый фрагмент итеративно. Мы будем отслеживать смещение в байтах для каждого фрагмента — например, считывать по 10 000 строк за раз, обрабатывать их в памяти, записывать результат в новый файл, а затем переходить к следующему смещению. Таким образом, нам не придется загружать весь файл целиком».

Интервьюер медленно кивнул, но по выражению его лица было ясно: «Это всё ещё слишком наивно».

Он возразил: «Даже чтение 10 000 строк за раз может оказаться слишком затратным, если каждая строка большая. И что насчёт отказоустойчивости? Что будет, если процесс завершится с ошибкой посередине? Как вы продолжите обработку?»

Я зашёл в тупик. Я понимал, что мой ответ неполный.

Тогда, почти от отчаяния, я сказал:

«Я не уверен на сто процентов в деталях реализации на низком уровне, но знаю, что подобные задачи обычно решаются с помощью фреймворков потоковой обработки данных, таких как Apache Kafka или Apache Flink. Они из коробки обеспечивают exactly-once семантику, разбиение данных на партиции и отказоустойчивость».

Брови интервьюера слегка приподнялись. Он не сказал, что я не прав. Но и не сказал, что я прав.

Я вышел с собеседования с ощущением, что смог выкрутиться, но так и не дал настоящего ответа.

Что я узнал после собеседования

После интервью я вернулся домой и занялся поиском информации. Проблема оказалась классической: обработка больших файлов при ограниченном объеме памяти.

Идеальное решение — это не Kafka или Flink, поскольку они избыточны для обработки одного файла на одной машине. Настоящее решение гораздо проще и элегантнее.

Вот что мне следовало ответить.

Идеальное решение: потоковая обработка + разбивка на фрагменты + отслеживание смещения

Шаг 1: Никогда не загружайте весь файл в память

Используйте потоковое чтение CSV, обрабатывая файл по одной строке за раз.

В Java для этого подходят библиотеки OpenCSV или Super CSV. Для простых случаев достаточно даже обычного BufferedReader вместе с String.split(",").

List<String> batch = new ArrayList<>(5000);
while ((line = reader.readLine()) != null) {
 batch.add(line);
 if (batch.size() == 5000) {
 processBatch(batch);
 batch.clear(); // free memory
 }
}

Такой подход практически не расходует память — требуется лишь столько памяти, сколько занимает одна строка.

Шаг 2: Если пакетная обработка необходима (для повышения производительности), используйте её разумно

Если разбор CSV сам по себе ресурсоемкий (например, включает сложную валидацию), можно обрабатывать данные пакетами по 1000–5000 строк.

Но объем пакета никогда не должен занимать значительную часть доступной оперативной памяти.

List<String> batch = new ArrayList<>(5000);
while ((line = reader.readLine()) != null) {
    batch.add(line);
    if (batch.size() == 5000) {
        processBatch(batch);
        batch.clear(); // free memory
    }
}

Шаг 3: Отслеживайте прогресс для обеспечения отказоустойчивости

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

Можно использовать небольшой checkpoint-файл:

offset = 1048576   # bytes processed so far

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

В Java для перехода к нужной позиции можно использовать RandomAccessFile.

RandomAccessFile raf = new RandomAccessFile("large.csv", "r");
raf.seek(lastKnownOffset);   // resume from here

Шаг 4: Используйте модель producer-consumer (необязательно)

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

  • Поток 1 (Producer) — считывает строки из файла и помещает их в небольшую очередь.
  • Поток 2 (Consumer) — извлекает строки из очереди и обрабатывает их.

Так диск продолжает читать данные, пока процессор занимается обработкой. Но при очень ограниченных ресурсах даже это может быть избыточным.

Подождите — а может быть, Kafka или Flink — это и есть правильный ответ?

Да, но только если задача распределенная.

Если под «ограниченными вычислительными ресурсами» подразумевается одна небольшая машина, Kafka и Flink — не лучший выбор. Они лишь добавят дополнительные накладные расходы.

Однако если файл хранится в распределенной файловой системе (например, HDFS), а обработка выполняется на кластере из нескольких узлов, тогда Kafka вместе с Flink (или Spark Streaming) становятся вполне оправданным решением. Они разбивают данные на партиции, обрабатывают их параллельно и обеспечивают exactly-once семантику.

Возможно, интервьюер хотел услышать не названия конкретных инструментов, а понимание самой концепции потоковой обработки.

Моя ошибка заключалась в том, что я упомянул инструменты, не объяснив принципы их работы:

  • Разбиение данных на партиции
  • Контрольные точки
  • Семантика exactly-once

Правильный ответ в одном предложении

Если бы я мог вернуться в прошлое, я бы сказал:

«Я использовал бы потоковый CSV-ридер для построчной обработки файла, при необходимости обрабатывая данные небольшими пакетами по несколько тысяч строк, чтобы не перегружать память. Кроме того, я реализовал бы механизм checkpointing, периодически сохраняя смещение в байтах, чтобы в случае сбоя можно было продолжить обработку с последнего сохраненного места, а не начинать всё сначала».

А если бы интервьюер спросил о распределенных системах:

«Если файл распределен между несколькими машинами, я использовал бы фреймворк потоковой обработки данных, например Kafka вместе с Flink, чтобы разбить данные на партиции, обрабатывать каждую партицию независимо и использовать checkpointing для обеспечения отказоустойчивости».

Чему это меня научило

  • Не стоит сразу переходить к использованию крупных фреймворков, не разобравшись в сути проблемы.
  • Всегда уточняйте масштаб — это разовая задача на одной машине или непрерывный конвейер в кластере?
  • Отказоустойчивость и возможность продолжить обработку после сбоя зачастую важнее максимальной скорости.
  • Даже неправильный ответ может быть ценным — он подтолкнул меня к изучению правильного.

На этом все! Спасибо за внимание! Если статья была интересна, подпишитесь на телеграм-канал usr_bin, где будет еще больше полезной информации.