Экспорт данных из веб-приложения: реализация под ключ
Согласно Статье 20 GDPR, каждый субъект данных имеет право получить свои персональные данные в структурированном, общеиспользуемом и машиночитаемом формате. Но на практике выгрузка данных на 500 000 строк часто заваливает сервер: тайм-аут 30 секунд и 502 Bad Gateway. Причина — синхронная архитектура и лимиты PHP. Мы решаем эту проблему на уровне инфраструктуры: асинхронная генерация через очереди, потоковая запись в S3 и временные signed URLs. Требования GDPR (ст. 20) обязывают предоставить персональные данные пользователя в машиночитаемом формате — мы реализуем экспорт под ключ. Свяжитесь с нами для оценки вашего проекта.
Проблемы, которые решаем
- N+1 запросы при выгрузке данных: при выгрузке заказов с позициями каждый запрос к БД плодит новые — для 10 000 заказов это 10 001 запрос. Используем lazy loading через
lazy(200)и жадную загрузку отношений. - Тайм-ауты и падение памяти при синхронной выгрузке >100 000 строк: PHP-скрипт упирается в
memory_limitиmax_execution_time. Решение — асинхронная обработка через очереди с таймаутом 600 сек. - Гидратация несоответствия при SSR (hydration mismatch): если данные экспорта встраиваются на фронт, React/Nuxt могут рассинхронизироваться. Экспортируем всё через API с уникальными ID.
Почему асинхронный экспорт лучше для больших данных?
Синхронный подход подходит только для малых объёмов (<10k строк). При росте данных он ведёт к блокировке интерфейса и сбросу соединения. Асинхронная схема с очередью и уведомлениями снимает нагрузку с HTTP-воркера и масштабируется горизонтально. Когда один клиент генерирует отчёт на 200k строк, другие продолжают работать без задержек.
Типичный пример: один клиент попросил выгрузку 150 000 заказов в Excel. Изначально разработчик написал синхронный скрипт — на 40-й секунде PHP упал по memory_limit. После перехода на очередь генерация заняла 2 минуты, пользователь получил письмо со ссылкой без блокировок.
| Критерий | Синхронный экспорт | Асинхронный экспорт |
|---|---|---|
| Макс. объём | до 10 000 строк | без ограничений |
| Блокировка воркера | да | нет |
| Тайм-аут | 30-60 сек | 600+ сек |
| Уведомление пользователя | нет (ожидание) | email / websocket |
| Работа с S3 | нет | да (потоковая загрузка) |
[User request] → [Create ExportJob record] → [Queue Worker]
↓
[Generate files]
↓
[Upload to S3 (zip)]
↓
[Email with download link]
(signed URL, 7 days TTL)
Как мы это делаем: Laravel-реализация
Для серверной части используем Laravel с PHP 8.3+. Ключевой паттерн — сервис DataExportService, который ставит задачу в очередь. Воркер ExportUserDataJob генерирует файлы профиля (JSON), заказов (CSV с BOM), сообщений (JSON) и упаковывает их в ZIP с последующей загрузкой в S3.
class DataExportService
{
public function exportUserData(User $user): Export
{
$export = Export::create([
'user_id' => $user->id,
'status' => 'pending',
'expires_at' => now()->addDays(7),
]);
ExportUserDataJob::dispatch($user, $export);
return $export;
}
}
class ExportUserDataJob implements ShouldQueue
{
public int $timeout = 600;
public function __construct(private User $user, private Export $export) {}
public function handle(): void
{
$this->export->update(['status' => 'processing']);
$tempDir = sys_get_temp_dir() . '/export_' . $this->export->id;
mkdir($tempDir, 0777, true);
try {
// Профиль
file_put_contents(
"$tempDir/profile.json",
json_encode($this->exportProfile(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
);
// Заказы
$this->exportOrders("$tempDir/orders.csv");
// Сообщения
$this->exportMessages("$tempDir/messages.json");
// Создать ZIP
$zipPath = sys_get_temp_dir() . "/export_{$this->export->id}.zip";
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (glob("$tempDir/*") as $file) {
$zip->addFile($file, basename($file));
}
$zip->close();
// Загрузить в S3
$s3Key = "exports/{$this->user->id}/{$this->export->id}/data.zip";
Storage::disk('s3')->put($s3Key, file_get_contents($zipPath));
$this->export->update([
'status' => 'ready',
's3_key' => $s3Key,
]);
$this->user->notify(new ExportReadyNotification($this->export));
} finally {
exec("rm -rf {$tempDir}");
if (file_exists($zipPath ?? '')) unlink($zipPath);
}
}
private function exportProfile(): array
{
return [
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
'created_at' => $this->user->created_at->toIso8601String(),
'profile' => $this->user->profile?->toArray(),
];
}
private function exportOrders(string $path): void
{
$handle = fopen($path, 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM
fputcsv($handle, ['Номер', 'Дата', 'Сумма', 'Статус', 'Позиции'], ';');
$this->user->orders()->with('items')->lazy(200)->each(function (Order $order) use ($handle) {
$items = $order->items->map(fn($i) => "{$i->name} x{$i->quantity}")->join(', ');
fputcsv($handle, [
$order->number,
$order->created_at->format('d.m.Y H:i'),
number_format($order->total, 2),
$order->status,
$items,
], ';');
});
fclose($handle);
}
private function exportMessages(string $path): void
{
$messages = $this->user->messages()
->select('id', 'subject', 'body', 'created_at')
->get()
->map(fn($m) => [
'id' => $m->id,
'subject' => $m->subject,
'body' => strip_tags($m->body),
'date' => $m->created_at->toIso8601String(),
]);
file_put_contents($path, json_encode($messages, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
public function failed(\Throwable $e): void
{
$this->export->update(['status' => 'failed']);
Log::error('Export failed', ['export_id' => $this->export->id, 'error' => $e->getMessage()]);
}
}
Как работает signed URL для скачивания?
Чтобы не хранить архивы вечно, используем временные ссылки S3 с TTL 15 минут. Пользователь получает письмо с уникальной ссылкой, которая ведёт напрямую в S3 — минуя наш сервер.
public function download(Export $export): RedirectResponse
{
$this->authorize('download', $export);
if ($export->status !== 'ready' || $export->expires_at->isPast()) {
abort(410, 'Экспорт недоступен или устарел');
}
$url = Storage::disk('s3')->temporaryUrl($export->s3_key, now()->addMinutes(15));
return redirect()->away($url);
}
Как подобрать формат экспорта?
Выбор формата зависит от цели: для API и GDPR-выгрузки подходит JSON, для таблиц и Excel — CSV с BOM, для бухгалтерии — XLSX с форматированием, для B2B интеграций — XML. Если требуется архив нескольких файлов, используем ZIP.
| Формат | Применение | Инструмент |
|---|---|---|
| JSON | API, GDPR-выгрузка | PHP json_encode, Node JSON.stringify |
| CSV | Таблицы, Excel | fputcsv, csv-writer |
| XLSX | Бухгалтерия, аналитика | PhpSpreadsheet, ExcelJS |
| XML | B2B интеграции | SimpleXML, xmlbuilder2 |
| ZIP | Архив нескольких файлов | ZipArchive, archiver (Node) |
Процесс работы
- Аналитика — изучаем структуру данных, объёмы, требования к форматам.
- Проектирование — выбираем стек (очередь, хранилище), проектируем схему БД для экспортов.
- Разработка — пишем сервис, воркеры, контроллеры, тесты.
- Тестирование — нагрузочное тестирование с реальными объёмами, проверка тайм-аутов.
- Деплой — настраиваем очереди (Redis, SQS), S3, кроны для очистки.
- Документация — передаём архитектурную документацию и инструкцию.
Что входит в работу
- Архитектурная документация с описанием потоков данных.
- Исходный код с комментариями и строгим типизированием.
- Инструкция по деплою с примерами конфигурации очередей и S3.
- Обучение сотрудников поддержки.
- Гарантия 3 месяца на бесперебойную работу.
Сроки и стоимость
Базовый экспорт данных пользователя (JSON + CSV в ZIP): 2–3 дня. С очередью, S3 и email-уведомлением: 3–4 дня. GDPR-совместимый экспорт всех персональных данных: 4–5 дней. Стоимость рассчитывается индивидуально и зависит от объёмов и количества форматов: базовый вариант от 15 000 ₽, комплексный — от 45 000 ₽. Опыт нашей команды — 5+ лет и 50+ проектов по экспорту данных. Закажите экспорт для вашего проекта — получите консультацию и оценку в течение 1 дня бесплатно.
Свяжитесь с нами, чтобы обсудить ваш проект и получить точную оценку.







