Решение проблемы OOM для больших файлов Excel

Решение проблемы OOM для больших файлов Excel

- SXSSF, FastExcel, StreamingResponseBody применение -

1. Введение

Система, разработанная внутри компании, представляет собой панель мониторинга для легковесной платформы обмена сообщениями, которая обеспечивает функциональность, аналогичную Kafka и NATS JetStream. Она позволяет контролировать состояние Pub/Sub событий и просматривать журналы записей событий.

Во время разработки функции скачивания журналов записей событий в Excel возникла проблема OOM. Изначально реализация была основана на обычном методе с использованием Apache POI, и проблем не было, когда объем данных был невелик.

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

  • Резкое увеличение времени ответа

  • Повторные полные сборки мусора

  • Резкое увеличение использования памяти кучи

  • Перезапуск Kubernetes Pod и возникновение OOMKilled

Из-за ограничения памяти в Kubernetes (800Mi) возникла ситуация, при которой Pod был OOMKilled. Чтобы срочно решить проблему, мы временно увеличили память с 800Mi до 1500Mi, но это было временной мерой. При постоянном увеличении объема данных была очевидна необходимость в неизбежности повторения той же проблемы, и коренная причина была ясна.

"Структура, в которой все данные загружаются в память для генерации Excel"

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

2. Проблемы существующей структуры

2-1. Структура, в которой все данные загружаются в память

Существующий способ скачивания имел следующий процесс.

DB 전체 조회
→ List 메모리 적재
→ XSSFWorkbook 생성
→ ByteArrayOutputStream 생성
→ byte[] 변환
→ HTTP 응답 반환

Практически каждый этап этого процесса работал на основе JVM Heap. Структура была такова, что данные List для запроса, объекты Workbook и Row, буфер ByteArrayOutputStream и конечный byte[] все сохранялись в памяти во время возврата ответа. По мере увеличения количества данных использование Heap возросло более чем линейно, и полный сбор мусора (Full GC) происходил многократно.

2-2. Проблема N+1 из-за повторного использования логики отображения

Логика загрузки Excel использовала логику отображения без изменений. В результате возникали дополнительные запросы связанных сущностей на основе ленивой загрузки, что приводило к резкому увеличению числа выполнения SQL по мере роста количества данных, что создало проблему N+1.

Чтобы это исправить, я отдельно написал запрос, предназначенный для загрузки Excel, и изменил его, чтобы данные связывались сразу с помощью JOIN и затем напрямую отображались в DTO. Кроме того, обычный метод LIMIT / OFFSET имел проблему, из-за которой стоимость повторного сканирования передних данных в базе данных линейно увеличивалась с увеличением offset, поэтому я изменил его на метод курсора на основе условия lastOffset > ?. Метод курсора всегда обрабатывается как сканирование диапазона индексов, что обеспечивает стабильную производительность извлечения даже при больших объемах данных.

3. Первичная попытка: SXSSFWorkbook + chunkSize

3-1. Направление подхода

Сначала я решил, что «проблема в самом Apache POI». Я заменил его на потоковый метод SXSSFWorkbook, который официально предоставляется POI, и изменил его, чтобы данные извлекались не все сразу, а по определенному количеству (chunkSize), вставляя их в строки Excel.

int chunkSize = 1000;
int offset = 0;
while (true) {
    List<EventEntryDto> chunk = repository.findByChunk(offset, chunkSize);
    if (chunk.isEmpty()) break;
    for (EventEntryDto item : chunk) {
        Row row = sheet.createRow(rowIndex++);
        row.createCell(0).setCellValue(item.getOffset());
        row.createCell(1).setCellValue(item.getPartitionNo());
        // ...
    }
    offset += chunkSize;
}

3-2. Результаты и проблемы

В тестировании локальной среды это работало без OOM. Для 80 000 запросов скорость составила около 1 минуты 48 секунд, что было проблемой со скоростью, но, по крайней мере, оно работало.

Однако после развертывания в окружении разработки (Docker) возникла неожиданная ошибка.

java.lang.NullPointerException
    at sun.awt.FontConfiguration.getVersion(FontConfiguration.java:1264)
    at sun.awt.FontConfiguration.readFontConfigFile(FontConfiguration.java:219)
    at sun.awt.FontConfiguration.init(FontConfiguration.java:107)
    at sun.awt.X11FontManager.createFontConfiguration(X11FontManager.java:774)
    at sun.font.SunFontManager$2.run(SunFontManager.java:431)
    ...

SXSSFWorkbook ссылался на системные шрифты, которые не были установлены в контейнере Docker, и поэтому произошла ошибка.

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

Вывод первичной попытки: работало локально, но при развертывании в разработческой среде возникла ошибка шрифта. Оставались проблемы со скоростью и зависимостями от среды.

4. Вторая попытка: FastExcel + StreamingResponseBody

4-1. Предпосылки выбора библиотеки

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

  • EasyExcel: хотя сама обработка потоков была хороша, при развертывании в разработческой среде возникала та же ошибка шрифта, что и у SXSSFWorkbook. Это произошло из-за того, что внутреннее устройство ссылается на системные шрифты.

  • FastExcel (dhatim/fastexcel): работает на основе потоковой обработки по строкам и почти не имеет зависимости от системных шрифтов, что позволяло ему корректно работать в среде Docker без дополнительных настроек. Согласно официальной документации, он использует примерно в 12 раз меньше кучи памяти по сравнению с Apache POI (непотоковой).

4-2. Дополнительно обнаруженная проблема: преобразование byte[]

В процессе замены библиотеки мы обнаружили еще одну проблему с существующей структурой. Процесс преобразования ByteArrayOutputStream в byte[] после завершения создания Excel фиксировал дополнительный всплеск памяти для больших объемов данных.

기존: 전체 생성 완료 → byte[] 변환 → 응답 시작
개선: Row 생성 → 즉시 OutputStream write → 클라이언트 전송 시작

Это было решено с помощью StreamingResponseBody. Структура пишется непосредственно в OutputStream, одновременно отправляя HTTP-ответ. Используя StreamingResponseBody, загрузка Excel начинается до того, как он будет полностью создан. Общее время выполнения остается прежним, но момент, когда файл начинает загружаться в браузере, становится быстрее, что значительно улучшает воспринимаемую скорость пользователя.

@PostMapping("/export-entries/fetch")
public ResponseEntity<StreamingResponseBody> exportEntries(@RequestBody ExportEntriesFetch fetch) {
    fetch.validate();

    StreamingResponseBody response = out ->
        entryExportService.exportToExcel(out, fetch.getStreamId(), fetch.getPartitionId(),
            fetch.getName(), fetch.getEntryOffset());

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"entries-%s.xlsx\"".formatted(fetch.getStreamId()))
        .body(response);
}
public void exportToExcel(OutputStream out, String streamId, String partitionId,
                           String name, String searchOffset) {
    try (Workbook workbook = new Workbook(out, "MyApp", "1.0")) {
        Worksheet sheet = workbook.newWorksheet("Entries");
        String[] headers = {"No", "Partition", "Offset", "Produced At",
                             "Route Key", "Compression", "Format", "Checksum", "Payload"};
        for (int i = 0; i < headers.length; i++) {
            sheet.value(0, i, headers[i]);
        }

        for (PartitionSummaryDto partition : partitions) {
            while (true) {
                List<EntryExportDto> entries = entryRepository.findExportEntries(
                    streamId, partition.getId(), name, searchOffset, lastOffset, CHUNK_SIZE);

                for (EntryExportDto entry : entries) {
                    sheet.value(rowIdx, 0, rowIdx);
                    // ... 각 컬럼 값 삽입
                    rowIdx++;
                }
                sheet.flush();
                if (entries.size() < CHUNK_SIZE) break;
                lastOffset = entries.get(entries.size() - 1).getEntryOffset();
            }
        }
        workbook.finish();
        out.flush();
    }
}

4-3. Результаты и проблемы

Ошибка шрифта была устранена, а воспринимаемая скорость значительно улучшилась. По локальным данным, время обработки около 20000 записей составило примерно 1,92 секунды, 60000 записей — около 6,03 секунд, что является заметным улучшением по сравнению с первой итерацией.

Однако при 170000 записях снова возникла ошибка OOM. При анализе причин было установлено, что, хотя запросы в базу данных были разбиты на части по размеру chunkSize, сущности, обрабатываемые внутри каждого chunk, не были собраны сборщиком мусора и накапливались. JPA-сущности хранят помимо фактических данных также множество метаданных, таких как информация о связях, прокси-объекты, ссылки на PersistenceContext и др. Даже если chunks разбиваются, если эти сущности продолжают ссылаться друг на друга в цикле, память в итоге не освобождается.

Вывод второго этапа: решение проблемы с ошибкой шрифта, улучшение скорости. Однако из-за накопления сущностей снова произошла ошибка OOM при больших объемах.

5. Третья попытка (окончательная): переход на легкие DTO + фактическая настройка chunkSize

5-1. Основная причина: сущности парализовали работу chunk

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

  • Поля связей (включая ленивый прокси)

  • Внутренние метаданные Hibernate

  • Ссылка на 1-й уровень кеша PersistenceContext

Даже если вы перебираете по частям, предыдущие сущности не станут объектом сборки мусора, если они все еще используются. Например, при обходе тысяч сущностей при загрузке Excel эта накопительная ситуация в конечном итоге приведет к OOM.

Решение было простым. Если запросить легковесный DTO, содержащий только необходимые поля для Excel, объекты управления JPA не будут созданы, и они сразу станут кандидатом на сборку мусора после обработки по частям.

// 기존: JPA 엔티티 조회 — 메타데이터, 연관 관계까지 메모리에 올라옴
List<EventEntry> entries = entryRepository.findByStreamId(streamId);
// 변경: 경량 DTO 조회 — 엑셀에 필요한 필드만 포함
List<EntryExportDto> entries = entryRepository.findExportEntries(...);
public record EntryExportDto(
    Integer partitionNo,
    Long entryOffset,
    String producedAt,
    String routeKey,
    String compressionType,
    String payloadFormat,
    String checksum,
    String payload
) {}

5-2. Процесс настройки реального значения chunkSize

Даже после преобразования в DTO результаты зависели от того, как мы устанавливаем chunkSize. Мы нашли оптимальное значение на основе реальных измерений.

chunkSize

Результат

5 000

OOM возникнет - нагрузка на память по частям все еще велика

1 500

Иногда OOM - нестабильность в крайних случаях

500

Нет OOM - но с увеличением числа запросов к БД общая скорость становится слишком медленной

1 000 (в конечном итоге принято)

Нет OOM + допустимый диапазон скорости

chunkSize не просто "чем меньше, тем безопаснее". Если размер слишком мал, количество обращений к базе данных увеличивается, а если слишком велик, нагрузка на память во время обработки чанка снова возрастает. Важно принимать решение, учитывая фактический размер данных (особенно в случаях с колонками переменной длины, как в поле Payload), основываясь на измерениях.

Скачивание Excel — это пакетная задача. Хотя это может занять некоторое время, лучше всего, чтобы процесс завершился без OOM, чем чтобы сервер завис во время загрузки. Гораздо хуже ждать 2 минуты, чем потерять сервер в процессе скачивания.

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

5-3. Конечный результат

В таблице ниже локальные цифры представляют собой замеры с 1-2 проб, а данные для разработки основаны на текущей окончательной версии.

Количество данных

Среда

1-я попытка (SXSSF)

2-я попытка (FastExcel)

Итоговый (DTO + chunk 1000)

80 тысяч записей

Локальный

1 минута 48 секунд

20 тысяч записей

локальный

1.92с

60 тысяч записей

локальный

6.03с

170 тысяч записей

локальный

OOM

разработка

ошибка шрифта

50 тысяч

разработка

примерно 4s

120 тысяч

разработка

примерно 5.5s

320 тысяч записей

разработка

OOM (будет улучшено)

Производительность по состоянию на последнюю версию разработки следующая:

Количество данных

Время ответа

Наличие OOM

50 тысяч записей

примерно 4 секунды

нет

120 тысяч записей

примерно 5.5 секунд

нет

320000 дел

возникновение (ожидается улучшение)

6. Дополнительные направления для улучшения (анализ OOM на 320000 дел)

В настоящее время OOM снова возникает на 320000 дел. Изначально я думал, что это просто связано с тем, что chunkSize всё еще слишком велик. Однако при дальнейшем отслеживании причины стало очевидно, что структура хранения больших уникальных строк, таких как Payload, на уровне Workbook остается ключевым моментом.

Мост большинства библиотек xlsx, включая FastExcel, в основном поддерживают внутреннюю shared string table. Когда строка записывается через sheet.value(), эта строка регистрируется в кэше, и даже если данные Row экспортируются с помощью sheet.flush(), shared string table сохраняется в памяти до закрытия Workbook.

Освобождение памяти по chunk работало хорошо, но кэш shared string, который остается в живых на протяжении всей жизни Workbook, становится отдельной точкой накопления. Используя sheet.inlineString() вместо sheet.value(), можно записывать строку непосредственно в ячейку без сохранения в shared string cache, увеличивая вероятность освобождения памяти после flush. Также необходимо рассмотреть необходимость устранения дубликатов при создании строк Payload, ограничения длины вывода и другие аспекты. Это следующий объект улучшения.

7. Заключение

Эта работа позволила мне лично убедиться, что «это работает локально» и «это стабильно работает в производственной среде» — совершенно разные вещи. Выбор библиотеки также не должен основываться только на производительности; необходимо учитывать такие эксплуатационные ограничения, как зависимость шрифтов в контейнерной среде.

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

Честно оставляя этот вопрос незавершённым, я думаю, что это будет более полезно для коллег, которые сталкиваются с такой же проблемой.

Ссылки

pong

Site footer