Como Implementar Processamento Assíncrono de Relatórios Pesados em PHP e MySQL

Como Implementar Processamento Assíncrono de Relatórios Pesados em PHP e MySQL

Um dos problemas mais comuns em aplicações web maduras é o estouro de memória (Fatal error: Allowed memory size exhausted) e o estouro de tempo limite (HTTP 504 Gateway Timeout) ao tentar gerar relatórios em PDF ou CSV com milhares de registros dentro de uma requisição HTTP tradicional.

Quando o usuário clica em “Exportar Relatório”, o servidor web tenta buscar todos os dados no banco MySQL, formatá-los e gerar o arquivo de uma só vez, mantendo a conexão aberta. Isso paralisa a experiência do usuário e consome recursos do servidor de forma destrutiva.

A solução definitiva para este problema é desacoplar a requisição HTTP da execução da tarefa pesada, implementando uma arquitetura de processamento assíncrono em background nativa em PHP e MySQL.


A Arquitetura do Processamento em Background

Em vez de gerar o arquivo imediatamente, a requisição HTTP apenas registra a intenção de gerar o relatório na base de dados e devolve uma resposta instantânea ao usuário.

1. Modelagem da Tabela de Trabalhos (Jobs)

Crie uma tabela dedicada no MySQL para gerenciar a fila de processamento:

CREATE TABLE relatorios_queue (
    id INT AUTO_INCREMENT PRIMARY KEY,
    usuario_id INT NOT NULL,
    status ENUM('pendente', 'processando', 'concluido', 'falha') DEFAULT 'pendente',
    parametros JSON NULL,
    arquivo_path VARCHAR(255) NULL,
    erro_mensagem TEXT NULL,
    criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    atualizado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_status (status)
) ENGINE=InnoDB;

2. A Requisição HTTP (Rápida e Não-Bloqueante)

No endpoint acessado pelo usuário, você insere um registro com o status pendente e retorna o ID da solicitação imediatamente:

// endpoint_exportar.php
$usuarioId = $_SESSION['user_id'];
$filtros = json_encode(['data_inicio' => '2023-01-01', 'data_fim' => '2023-12-31']);

$stmt = $pdo->prepare("INSERT INTO relatorios_queue (usuario_id, parametros) VALUES (?, ?)");
$stmt->execute([$usuarioId, $filtros]);

echo json_encode([
    'sucesso' => true, 
    'mensagem' => 'Seu relatório está sendo gerado. Você será notificado assim que estiver pronto.',
    'job_id' => $pdo->lastInsertId()
]);

O Worker CLI e a Gestão de Memória no PHP

Para processar o relatório sem travar a interface web, utilizamos um script PHP executado via linha de comando (CLI). O segredo para não esgotar a memória RAM no MySQL é utilizar leitura paginada ou cursores do PDO (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false).

Em sistemas PHP que desenvolvo para cenários de alta carga, aplico o processamento em lotes (chunking) para garantir um consumo de memória constante, independentemente se o banco possui 10 mil ou 10 milhões de registros:

// worker.php (Executado via CLI/Cron)
$stmt = $pdo->prepare("SELECT * FROM relatorios_queue WHERE status = 'pendente' LIMIT 1 FOR UPDATE");
$stmt->execute();
$job = $stmt->fetch();

if ($job) {
    // Transição atômica de status
    $pdo->prepare("UPDATE relatorios_queue SET status = 'processando' WHERE id = ?")->execute([$job['id']]);

    try {
        $filePath = '/var/www/exports/relatorio_' . $job['id'] . '.csv';
        $file = fopen($filePath, 'w');

        // Escreve o cabeçalho CSV
        fputcsv($file, ['ID', 'Cliente', 'Valor', 'Data']);

        // Leitura paginada com OFFSET para manter baixo uso de memória
        $limit = 1000;
        $offset = 0;

        do {
            $query = $pdo->prepare("SELECT id, cliente, valor, data FROM vendas LIMIT :limit OFFSET :offset");
            $query->bindValue(':limit', $limit, PDO::PARAM_INT);
            $query->bindValue(':offset', $offset, PDO::PARAM_INT);
            $query->execute();

            $rows = $query->fetchAll(PDO::FETCH_ASSOC);
            foreach ($rows as $row) {
                fputcsv($file, $row);
            }

            $offset += $limit;
        } while (count($rows) === $limit);

        fclose($file);

        // Atualiza job como concluído
        $update = $pdo->prepare("UPDATE relatorios_queue SET status = 'concluido', arquivo_path = ? WHERE id = ?");
        $update->execute([$filePath, $job['id']]);

    } catch (Exception $e) {
        $update = $pdo->prepare("UPDATE relatorios_queue SET status = 'falha', erro_mensagem = ? WHERE id = ?");
        $update->execute([$e->getMessage(), $job['id']]);
    }
}

Fluxo Recomendado de Execução

  1. Solicitação: O usuário solicita o relatório; o backend grava o trabalho no MySQL com status pendente e responde em menos de 200ms.
  2. Agendamento: Um Cron Job no servidor executa o script worker.php a cada minuto ou um daemon (Supervisor) mantém o worker ativo em segundo plano.
  3. Processamento em Lotes: O script busca o job pendente, consome o banco de dados em pequenos pedaços (chunks) e grava direto no disco em formato CSV/PDF.
  4. Finalização: O status é alterado para concluido e a URL do arquivo é disponibilizada no painel do usuário para download.

Precisa Otimizar a Performance da sua Aplicação PHP?

Se o seu sistema atual sofre com lentidão, quedas de servidor ao gerar exportações ou problemas de consumo excessivo de memória no MySQL, a reestruturação da arquitetura para modelos assíncronos é o caminho mais seguro e escalável.

Como especialista em desenvolvimento e arquitetura PHP, ajudo empresas a eliminar gargalos de performance e construir sistemas estáveis. Entre em contato para agendarmos uma consultoria técnica sobre seu projeto.

Preencha o formulário abaixo para que eu consiga entrar em contato com você.