Экспорт из PHP в CSV большого кол-во данных

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

В чем же проблема больших массивов данных? Ответ прост — серверная память. Если вы ее экономите, то работать привычным образом с массивом не получится. Что я имел ввиду под «привычным образом»? Ну что-то вроде такого:

// кол-во элементов этого массива может быть 10, а может быть и 100К
$arr = [
 ['value1', 'value2', ... 'valueN'],
 ['value1', 'value2', ... 'valueN'],
 ...
 ['value1', 'value2', ... 'valueN'],
];

$result = null;
foreach ($arr as $row) {
 // некоторые операции по обработке элементов строки
 $result[] = $row;
}

Что же здесь не так? Ну, во-первых, мы дублируем переменную с массивом. Была одна $arr, теперь их две: $arr и $result. Если речь идет про массив в 100 элементов — это будет не существенно, но, если мы говорим за массив в котором 100К элементов… Второе — опять таки, весь результат наших агрегаций мы кладем в переменную, тем самым создавая косяк с дублированием.

Как же это можно оптимизировать? В PHP есть очень удобный инструмент php://output. Этот инструмент позволяет подобно языковым конструкциям print и echo выводить данные в выходной буфер, тем самым не занимая памяти на сервере. Окей, но как им пользоваться? Перед тем, как я опишу пример, хочу заметить, что если вы работаете с ООП, то сам экспорт (см. сохранение) должно происходить в контроллере, но вся бизнес-логика (выборка, агрегация и тд) как и положено — в моделе. Я же опишу простой пример без учета ООП. И так:

// установим несколько заголовков
// disable caching
$now = gmdate("D, d M Y H:i:s");
header("Expires: Tue, 03 Jul 20017 12:00:00 GMT");
header("Cache-Control: max-age=0, no-cache, must-revalidate, proxy-revalidate");
header("Last-Modified: " . $now . " GMT");

// force download
header("Content-Type: application/force-download");
header("Content-Type: application/octet-stream");
header("Content-Type: application/download");
header("Content-type: text/csv");

// disposition / encoding on response body
header("Content-Disposition: attachment;filename=filename.csv"); // 'filename' может быть любым словом
header("Content-Transfer-Encoding: binary");

// здесь мы выполняем операции по получению списка данных из бд или еще откуда-то
$list = // получаем обработанный массив данных

// устанавливаем заголовки для нашего файла csv
$titles = [
 'ID',
 'Date',
 'User',
 // прочие заголовки
];

// пишем в файл
$df = fopen("php://output", 'w');

foreach ($list as $row) {
 fputcsv($df, [
  $row['_id'],
  date('Y-m-d H:i:s', $row['date']),
  $row['user'],
 // прочие поля
 ]);
}

fclose($fd);

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

Несмотря на то, что таким образом можно сохранять сотни тысяч строк без нагрузки на сервер, есть риск обрыва связи между вами и сервером. В таком случае часть данных будет утеряна.

Что тут еще не так? Несмотря на то, что такой способ позволяет существенно экономить серверную память, у него все же есть один минус. Суть в том, что пока идет стриминг данных в файл — скрипт продолжает работу. Но у нас есть такой параметр, как время работы скрипта и равен он около 15 секунд (если не установить параметр вручную). Таким образом, если вы сохраняете 50К записей более 15 секунд — получите fatal error по таймауту.