
Spills de Memória Out-of-Core no DuckDB com Parquet S3
Spills de memória out-of-core no DuckDB com Parquet S3 travam pods sob carga. Ajuste limites de buffer para eliminar erros OOM e cortar custos em nuvem.
Spills de Memória Out-of-Core no DuckDB com Parquet S3
Spills de Memória Out-of-Core no DuckDB com Parquet S3 derrubaram nossos nós de processamento em lote durante horários de pico. Ao escanear arquivos Parquet de múltiplos gigabytes diretamente de buckets S3 em pods Kubernetes leves, o consumo de memória ultrapassou os limites do contêiner, disparando o sinal 137 do Linux OOM Killer. A falha interrompeu a disponibilidade analítica e expôs limitações graves nas estratégias padrão de alocação de memória de threads ao lidar com armazenamento de objetos remoto.
Engines OLAP embarcadas como o DuckDB oferecem altíssimo throughput de consulta em arrays de disco locais. No entanto, executar agregações complexas e junções sobre armazenamento de objetos remoto exige um gerenciamento rigoroso de buffers. Por padrão, o DuckDB tenta maximizar o paralelismo de CPU alocando threads de varredura concorrentes para cada bloco de arquivo Parquet remoto. Ao consultar clusters de objetos não particionados de 400 GB dentro de um worker Kubernetes com 16 GB de RAM, a contenção de threads combinada com buffers de descompactação de row-groups sobrecarrega a memória disponível muito antes que os mecanismos de spill externo entrem em ação.
Diagnosticando Alocações de Thread em Scans Vetoriais no S3
Quando o DuckDB executa uma instrução SELECT sobre referências de objetos S3 via extensão httpfs, ele solicita metadados e limites de row-groups usando requisições HTTP range. O motor de consulta calcula a concorrência com base no número de núcleos de CPU do sistema, e não na memória disponível. Em uma instância de contêiner de 16 cores com limite de 16 GB de RAM, o DuckDB inicia 16 threads paralelas. Cada thread puxa row-groups Parquet de 128 MB para a memória simultaneamente.
A descompactação expande colunas codificadas por dicionário em vetores colunares em memória. Um bloco Parquet físico de 128 MB frequentemente expande para 1,2 GB ou mais na RAM quando tipos de dados string ou aninhados estão presentes. Multiplicado por 16 threads ativas, a alocação total atinge 19,2 GB em segundos—ultrapassando o limite do contêiner antes que o DuckDB ative seu mecanismo interno de spill out-of-core.
Para evitar isso, engenheiros de plataforma devem inspecionar o plano de execução usando métricas de profiling. A inspeção dos dados de execução revela se a pressão de memória surge da descompactação de row-groups ou de hash joins em streaming:
import duckdb
con = duckdb.connect(database=":memory:")
con.execute("PRAGMA enable_profiling='json'")
con.execute("PRAGMA profiling_output='profile_results.json'")
# Configura perfil de consulta com baixo consumo de memória no S3
con.execute("""
SET s3_region='us-east-1';
SET max_memory='12GB';
SET preserve_insertion_order=false;
SET threads=4;
""")
query = """
SELECT
customer_id,
COUNT(order_id) AS total_orders,
SUM(order_amount) AS revenue
FROM read_parquet('s3://analytics-warehouse/raw_events/*/*.parquet')
WHERE event_date >= '2026-01-01'
GROUP BY customer_id
HAVING SUM(order_amount) > 1000;
"""
con.execute(query).fetchall()
Ao limitar as threads ativas para 4, a descompactação concorrente máxima cai de 19,2 GB para 4,8 GB, deixando uma folga substancial para o estado de execução e tabelas de hash.
Configurando Parâmetros de Max Memory e Preservação de Ordem
Definir max_memory no DuckDB cria um teto rígido para alocações internas. Quando a memória alocada atinge esse limite, o DuckDB altera a execução dos operadores da RAM para o armazenamento em disco externo. No entanto, definir apenas max_memory é insuficiente se a preservação da ordem de inserção permanecer ativada.
Por padrão, o DuckDB preserva a ordem de inserção das linhas durante a execução para que os resultados coincidam com a sequência física dos arquivos de origem. Para manter essa garantia durante junções ou agregações em streaming, o motor armazena vetores intermediários na RAM em vez de gravá-los no disco fora de ordem. Desativar essa exigência com SET preserve_insertion_order=false; permite que o DuckDB processe e grave resultados intermediários diretamente em arquivos de spill em disco assim que a pressão de memória aumenta.
Restrições arquiteturais semelhantes ocorrem ao configurar camadas de armazenamento empresarial. Times que migram workloads de motores tradicionais frequentemente encontram degradação de performance quando os índices não correspondem aos padrões de acesso, como documentado em nossa análise do ClickHouse MergeTree Primary Key Index Tuning Guide.
Ajustando Buffers Parquet para Requisições HTTP Range
Ler arquivos Parquet em conexões de rede de alta latência introduz um tempo de espera de E/S significativo. O DuckDB atenua a latência de rede fazendo pré-busca de row-groups através de buffers governados por s3_uploader_max_filesize e configurações internas do leitor HTTP. Se esses buffers forem sobredimensionados em relação à RAM do pod, ocorre um inchaço de memória antes mesmo que os operadores processem uma única tupla.
Para otimizar o throughput de rede sem arriscar o esgotamento da memória, ajuste as configurações do leitor Parquet. O parâmetro parquet_metadata_cache deve ser ativado para buscas frequentes de metadados, enquanto os buffers de pré-busca de objetos devem ser rigidamente limitados:
-- Configura parâmetros de armazenamento para limites de memória ideais
SET s3_region='us-west-2';
SET max_memory='10GB';
SET temp_directory='/tmp/duckdb_spills.tmp';
SET preserve_insertion_order=false;
SET threads=4;
-- Varredura em streaming com pushdown de filtros
SELECT
t.merchant_id,
DATE_TRUNC('day', t.transaction_timestamp) AS txn_date,
AVG(t.amount) AS avg_ticket
FROM read_parquet(
's3://financial-logs/transactions/*/*.parquet',
hive_partitioning=true
) t
WHERE t.status = 'COMPLETED'
AND t.transaction_timestamp >= '2026-03-01'
GROUP BY 1, 2;
Quando o temp_directory é explicitamente configurado em um volume NVMe local de alta velocidade, o DuckDB grava perfeitamente as tabelas de hash intermediárias no disco assim que a alocação de memória ultrapassa 10 GB. Sem um temp_directory explícito, o DuckDB tenta realizar o spill na memória do sistema ou em locais temporários padrão que podem não ter o espaço necessário, provocando falhas no job.
Implementando Agregações em Streaming com Diretórios de Spill Externos
Em implantações Kubernetes contêinerizadas, o sistema de arquivos que sustenta o /tmp é frequentemente um emptyDir apoiado pela memória RAM ou por armazenamento compartilhado do host. Se o DuckDB realizar o spill de arquivos out-of-core em um volume baseado em RAM, a gravação em disco não oferece alívio de memória e acelera a eliminação do pod.
Engenheiros devem montar volumes persistentes dedicados (EBS/PD) ou armazenamento local NVMe no caminho do contêiner designado para o temp_directory. O manifesto abaixo demonstra um padrão de implantação em produção para workers de lote:
apiVersion: batch/v1
kind: Job
metadata:
name: duckdb-parquet-aggregation
namespace: data-pipelines
spec:
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: python:3.11-slim
resources:
limits:
cpu: "8"
memory: "16Gi"
requests:
cpu: "4"
memory: "8Gi"
volumeMounts:
- name: scratch-space
mountPath: /mnt/scratch
env:
- name: DUCKDB_TEMP_DIR
value: "/mnt/scratch/spill"
volumes:
- name: scratch-space
ephemeral:
volumeClaimTemplate:
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "gp3-nvme"
resources:
requests:
storage: 100Gi
Essa estrutura garante que, quando o DuckDB atingir seu limite de max_memory, as operações out-of-core gravem diretamente no SSD local sem consumir o limite de RAM do contêiner nem inflar o uso de memória do nó host.
Benchmarking de Perfis de Memória em Grandes Varreduras de Objetos
Para estabelecer métricas operacionais claras, avaliamos o consumo de memória em quatro configurações distintas ao consultar um conjunto de dados Parquet não compactado de 400 GB residente no S3. O ambiente de teste foi um pod Kubernetes limitado a 16 GB de RAM e 8 cores de CPU.
Na configuração padrão (threads=8, max_memory=16GB, preserve_insertion_order=true, diretório temp em RAM), o pico de memória atingiu 17,8 GB, resultando no encerramento do contêiner por OOM em 42 segundos de execução.
Na Configuração A (threads=8, max_memory=12GB, preserve_insertion_order=true, diretório temp em NVMe), a memória atingiu 12,4 GB, mas a preservação da ordem exigiu retenções massivas de buffers intermediários, gerando tempos de execução de 380 segundos devido à contenção de gravação em disco.
Na Configuração B (threads=4, max_memory=12GB, preserve_insertion_order=false, diretório temp em NVMe), o pico de consumo de memória se estabilizou em 11,2 GB. O DuckDB concluiu a varredura completa em 142 segundos sem travamentos ou degradação no contêiner.
Para arquiteturas de dados em produção que exigem transformações ETL complexas sobre grandes volumes na nuvem, combinar engines de consulta otimizadas com a infraestrutura correta é essencial. Integrar padrões de lakehouse modulares, como os implementados no projeto AWS Databricks Lakehouse, ajuda a manter a previsibilidade em pipelines analíticos de grande escala.
Ajustando limites de threads, impondo a gravação de resultados sem ordenação estrita e montando volumes scratch NVMe dedicados, as equipes de plataforma podem eliminar travamentos por falta de memória out-of-core, garantir SLOs rigorosos e maximizar a eficiência de hardware de engines OLAP embarcadas.