Node.js: exportando grandes volumes do Elasticsearch
Olá meus Unicórnios! 🦄✨
Sabe quando o pedido parece o mais simples do mundo? 😅 "Me exporta esses registros num CSV." Era um índice do Elasticsearch, alguns milhões de documentos, e eu pensei: isso é uma busca e um laço, meia hora no máximo.
Foi aí que eu bati numa parede que o Elasticsearch levanta bem no meio do caminho — e que ele levanta de propósito, para o seu próprio bem. Descobri que o jeito óbvio de paginar simplesmente para de funcionar depois de um certo ponto, com um erro que não é nada óbvio.
Este artigo é o caminho completo: como exportar um índice inteiro para CSV com Node.js, por que a paginação normal não serve, e a armadilha que fica escondida depois que a exportação termina bonitinho. Sem nenhuma dependência — só o que já vem no Node.
🧱 A parede dos 10.000
Vamos começar pelo jeito que todo mundo tenta primeiro, porque é o jeito certo em qualquer outro banco: paginar com from e size. Pede os primeiros 5.000, depois os próximos 5.000, e vai empurrando o from para frente.
Funciona. Funciona lindamente… até você passar do documento número dez mil. Aí vem isto:
from=0 size=5000 -> ok, 5000 documentos (total 12500)
from=9000 size=1000 -> ok, 1000 documentos (total 12500)
from=10000 size=1000 -> HTTP 400 -> Result window is too large, from + size must be less than or equal to: [10000] but was [11000]
Repare no detalhe cruel: não é um limite de quantos documentos existem. O índice tem 12.500 ali, e ele mesmo diz isso no total. O limite é sobre até onde você pode empurrar a janela. O nome do parâmetro é index.max_result_window e o valor padrão dele é 10.000.
E por que essa parede existe? Porque from: 10000 obriga o Elasticsearch a ordenar e descartar os dez mil documentos anteriores em cada estilhaço, só para te entregar os mil seguintes. Quanto mais fundo você vai, mais caro fica — e um índice grande com um from alto derruba o nó de memória. A parede é o Elasticsearch te protegendo de si mesmo. 🙃
📜 A Scroll API, que é uma fotografia
O jeito certo de varrer um índice inteiro é a Scroll API. E a diferença de conceito é o que faz tudo encaixar, então vale parar um segundo aqui.
Uma busca normal é uma pergunta: "me dá os documentos 10.000 a 11.000". Cada pergunta é independente, e o Elasticsearch precisa se reposicionar do zero toda vez — é isso que fica caro lá no fundo.
O scroll é outra coisa: ele tira uma fotografia do índice e te entrega um marcador de página. Você abre o scroll uma vez, e depois só diz "próximo, próximo, próximo". O servidor sabe onde você parou, e não precisa contar nada de novo. Duas consequências ótimas:
- O custo é o mesmo no primeiro lote e no milésimo — não tem ladeira.
- Como é uma fotografia, o resultado não muda no meio da exportação. Documento gravado depois de você abrir o scroll não aparece, e documento apagado continua vindo. Para exportar, isso é exatamente o que se quer: um retrato consistente.
Abrir o scroll é uma busca comum com um parâmetro a mais na URL:
// Abre o scroll e devolve o primeiro lote.
async function abrirScroll() {
const corpo = {
size: TAMANHO_DO_LOTE,
_source: CAMPOS,
query: { match_all: {} },
// Ordenar por _doc e a ordenacao mais barata que existe: o Elasticsearch
// le na ordem em que os documentos estao no disco, sem calcular relevancia.
sort: ["_doc"],
};
return pedir(
"/" + INDICE + "/_search?scroll=" + TEMPO_DO_SCROLL,
"POST",
corpo
);
}
Aquele sort: ["_doc"] não é enfeite, e é a otimização mais barata do artigo. Por padrão o Elasticsearch ordena por relevância — ele calcula uma nota para cada documento para saber quem vem primeiro. Numa exportação você não quer ranking nenhum, quer todo mundo. O _doc manda ler na ordem em que os documentos estão no disco, sem calcular nota de nada.
O _source é a segunda economia: pedindo só os campos que vão para o CSV, o Elasticsearch não precisa trafegar o documento inteiro. Num índice com campos grandes, isso sozinho já muda o tempo da exportação.
🔁 O laço, e o único jeito de saber que acabou
Com o scroll aberto, o laço pede lote após lote. Aqui mora a pergunta que me travou por uns minutos: como eu sei que acabou?
A resposta é menos elegante do que eu esperava — e é importante saber, porque a intuição erra:
while (true) {
const documentos = resposta.hits.hits;
// O scroll acabou quando o lote volta vazio. Nao existe outro aviso:
// o _scroll_id continua chegando igual mesmo no ultimo lote.
if (documentos.length === 0) {
break;
}
for (const documento of documentos) {
arquivo.write(linha(documento._source));
}
total = total + documentos.length;
console.log("Gravados " + total + " registros...");
resposta = await proximoLote(scrollId);
// O Elasticsearch pode devolver um scroll_id NOVO a cada lote. Guardar
// sempre o ultimo evita pedir a pagina seguinte de um scroll que mudou.
scrollId = resposta._scroll_id;
}
O scroll acabou quando o lote volta vazio. Só isso. Não existe um campo terminou: true, e o _scroll_id continua chegando normalmente no último lote, igualzinho aos outros — se você esperar ele sumir para parar o laço, o laço não para nunca.
Repare também que eu regravo o scrollId a cada volta. Parece redundante, porque na prática ele quase sempre vem igual. Mas a documentação avisa que o identificador pode mudar entre os lotes, e no dia em que mudar, quem estiver reenviando o primeiro vai pedir a página seguinte de um scroll que não existe mais. É uma linha para evitar um bug que só aparece em produção. 😳
E tem um detalhe de memória escondido no laço: eu escrevo cada lote no arquivo na hora, com arquivo.write(). A tentação é juntar tudo num array e gravar no fim — e é assim que se estoura a memória do Node com um índice grande, porque você acaba com os milhões de registros todos na RAM de uma vez. O arquivo é um fluxo; use como fluxo.
🧹 O scroll que ninguém fecha
Agora a parte que eu mais quero que você leve deste artigo, porque é a que não dá erro nenhum. 🙏
Quando a exportação termina, está tudo certo, né? O CSV está lá, o total bate. Só que a fotografia continua aberta no servidor. Aquele retrato consistente que o Elasticsearch guardou para você custa memória, e ele segura essa memória até o tempo do scroll expirar.
Eu quis medir o tamanho do estrago em vez de acreditar. Rodei a exportação três vezes seguidas sem fechar o scroll, contando os contextos abertos no servidor depois de cada uma:
########## TRES EXPORTACOES **SEM** fecharScroll ##########
apos exportacao 1: {"contextos_abertos":1,"pico_de_contextos":1}
apos exportacao 2: {"contextos_abertos":2,"pico_de_contextos":2}
apos exportacao 3: {"contextos_abertos":3,"pico_de_contextos":3}
Um por exportação, empilhando, sem nunca baixar. E lembre que toda exportação terminou com sucesso — o CSV saiu perfeito nas três. Agora imagine isso num endpoint que roda várias vezes por dia. O vazamento não aparece no log de erro; aparece no gráfico de memória do cluster, semanas depois, e ninguém liga uma coisa à outra.
A cura é uma chamada só, e ela é literalmente um DELETE:
// Fecha o scroll no servidor.
async function fecharScroll(scrollId) {
await pedir("/_search/scroll", "DELETE", { scroll_id: [scrollId] });
}
Com ela no lugar, as mesmas três exportações seguidas:
########## TRES EXPORTACOES **COM** fecharScroll ##########
apos exportacao 1: {"contextos_abertos":3,"pico_de_contextos":4}
apos exportacao 2: {"contextos_abertos":3,"pico_de_contextos":4}
apos exportacao 3: {"contextos_abertos":3,"pico_de_contextos":4}
O número para de subir. 🎉 (Os três que aparecem ali são justamente os que vazaram no teste anterior e continuam presos até expirar — a prova de que o vazamento é real e de que ele só sai sozinho com o tempo.)
🛟 Fechar mesmo quando dá errado
Só que tem um jeito muito fácil de escrever esse fechamento errado: colocar ele no fim do try. Se o servidor tossir no meio do laço — e numa exportação de milhões de registros, que demora, ele tosse — a exceção pula direto para o catch e o fechamento nunca acontece. Ou seja: justo quando você fez o cluster trabalhar mais, é quando você deixa o contexto aberto.
Por isso o fechamento vai no finally, que roda dos dois jeitos:
} catch (erro) {
console.error("Falhou: " + erro.message);
process.exitCode = 1;
} finally {
// Fechar o scroll e obrigatorio, inclusive quando deu erro no meio: cada
// scroll aberto segura memoria no servidor ate o tempo do scroll expirar.
if (scrollId) {
try {
await fecharScroll(scrollId);
} catch (erro) {
console.error("Nao consegui fechar o scroll: " + erro.message);
}
}
arquivo.end();
}
Repare no try de dentro do finally. Ele parece paranoia, mas evita uma troca de erro bem cruel: se o fechamento também falhar, a exceção dele substituiria a exceção original — e você perderia a mensagem que explica o que de fato quebrou, trocando "no shard available" por "não consegui fechar o scroll". O erro que interessa é sempre o primeiro.
Derrubei o servidor no meio da exportação de propósito, para ver esse caminho acontecer:
Gravados 5000 registros...
Falhou: Elasticsearch respondeu 503: no shard available
exit=1
-- contextos abertos apos a falha (o finally fechou?) --
{"contextos_abertos":0,"pico_de_contextos":1}
É esse contextos_abertos: 0 que eu queria ver. A exportação falhou, o processo saiu com código 1 avisando que falhou, o CSV ficou pela metade — e mesmo assim o servidor ficou limpo. É o que separa um erro de um erro que deixa lixo para trás.
📄 O CSV, e as aspas que estragam tudo
Falta a parte boba que quebra exportação de verdade: escrever o CSV. Eu não instalei biblioteca para isso — são cinco linhas, e entender essas cinco linhas vale mais que a dependência.
// Transforma um valor em celula de CSV, protegendo virgula, aspas e quebra de linha.
function celula(valor) {
if (valor === null || valor === undefined) {
return "";
}
const texto = String(valor);
// As aspas de dentro do texto viram duas aspas: e assim que o CSV escapa.
return '"' + texto.replace(/"/g, '""') + '"';
}
// Monta uma linha do CSV na ordem dos campos pedidos.
function linha(documento) {
const celulas = [];
for (const campo of CAMPOS) {
celulas.push(celula(documento[campo]));
}
return celulas.join(",") + "\n";
}
O problema do CSV é que a vírgula separa as colunas e pode estar dentro do dado. Um endereço como Rua das Flores, 120 vira duas colunas e desloca a planilha inteira dali para frente. Por isso toda célula sai entre aspas.
E aí nasce o segundo problema: e se o dado tiver aspas? A regra do CSV é ótima de tão estranha — uma aspa dentro do texto vira duas aspas. É o que aquele replace(/"/g, '""') faz. Sem essa linha, um único apelido escrito com aspas no meio do nome desalinha o arquivo inteiro e você só descobre quando alguém abre no Excel. 😅
🚀 O script inteiro
Junta tudo e é isto — um arquivo, de cima para baixo, sem nenhum npm install. O fetch e o fs já vêm no Node:
// Exporta um indice inteiro do Elasticsearch para CSV, usando a Scroll API.
// Sem dependencia nenhuma: fetch e fs ja vem no Node.
const fs = require("fs");
const ELASTIC = process.env.ELASTIC_URL || "http://localhost:9200";
const INDICE = "clientes";
const CAMPOS = ["nome", "cidade", "valor"];
const TAMANHO_DO_LOTE = 5000;
const TEMPO_DO_SCROLL = "5m";
const ARQUIVO = "clientes.csv";
// Uma requisicao ao Elasticsearch. Devolve o JSON ja convertido.
async function pedir(caminho, metodo, corpo) {
const resposta = await fetch(ELASTIC + caminho, {
method: metodo,
headers: { "content-type": "application/json" },
body: JSON.stringify(corpo),
});
const dados = await resposta.json();
if (!resposta.ok) {
// O erro do Elasticsearch vem dentro de "error.reason". Sem isso voce
// recebe "[object Object]" e nao descobre o que aconteceu.
const motivo = dados.error ? dados.error.reason : resposta.statusText;
throw new Error("Elasticsearch respondeu " + resposta.status + ": " + motivo);
}
return dados;
}
// Transforma um valor em celula de CSV, protegendo virgula, aspas e quebra de linha.
function celula(valor) {
if (valor === null || valor === undefined) {
return "";
}
const texto = String(valor);
// As aspas de dentro do texto viram duas aspas: e assim que o CSV escapa.
return '"' + texto.replace(/"/g, '""') + '"';
}
// Monta uma linha do CSV na ordem dos campos pedidos.
function linha(documento) {
const celulas = [];
for (const campo of CAMPOS) {
celulas.push(celula(documento[campo]));
}
return celulas.join(",") + "\n";
}
// Abre o scroll e devolve o primeiro lote.
async function abrirScroll() {
const corpo = {
size: TAMANHO_DO_LOTE,
_source: CAMPOS,
query: { match_all: {} },
// Ordenar por _doc e a ordenacao mais barata que existe: o Elasticsearch
// le na ordem em que os documentos estao no disco, sem calcular relevancia.
sort: ["_doc"],
};
return pedir(
"/" + INDICE + "/_search?scroll=" + TEMPO_DO_SCROLL,
"POST",
corpo
);
}
// Pede o proximo lote do scroll ja aberto.
async function proximoLote(scrollId) {
return pedir("/_search/scroll", "POST", {
scroll: TEMPO_DO_SCROLL,
scroll_id: scrollId,
});
}
// Fecha o scroll no servidor.
async function fecharScroll(scrollId) {
await pedir("/_search/scroll", "DELETE", { scroll_id: [scrollId] });
}
async function main() {
const arquivo = fs.createWriteStream(ARQUIVO);
arquivo.write(CAMPOS.join(",") + "\n");
let scrollId = null;
let total = 0;
try {
let resposta = await abrirScroll();
scrollId = resposta._scroll_id;
while (true) {
const documentos = resposta.hits.hits;
// O scroll acabou quando o lote volta vazio. Nao existe outro aviso:
// o _scroll_id continua chegando igual mesmo no ultimo lote.
if (documentos.length === 0) {
break;
}
for (const documento of documentos) {
arquivo.write(linha(documento._source));
}
total = total + documentos.length;
console.log("Gravados " + total + " registros...");
resposta = await proximoLote(scrollId);
// O Elasticsearch pode devolver um scroll_id NOVO a cada lote. Guardar
// sempre o ultimo evita pedir a pagina seguinte de um scroll que mudou.
scrollId = resposta._scroll_id;
}
console.log("Pronto! " + total + " registros em " + ARQUIVO);
} catch (erro) {
console.error("Falhou: " + erro.message);
process.exitCode = 1;
} finally {
// Fechar o scroll e obrigatorio, inclusive quando deu erro no meio: cada
// scroll aberto segura memoria no servidor ate o tempo do scroll expirar.
if (scrollId) {
try {
await fecharScroll(scrollId);
} catch (erro) {
console.error("Nao consegui fechar o scroll: " + erro.message);
}
}
arquivo.end();
}
}
main();
Para rodar, aponte para o seu servidor e mande ver:
ELASTIC_URL=http://localhost:9200 node exportar.js
Gravados 5000 registros...
Gravados 10000 registros...
Gravados 12500 registros...
Pronto! 12500 registros em clientes.csv
Os 12.500 registros saíram inteiros — dois mil e quinhentos a mais do que a paginação comum conseguiria alcançar, que era o problema todo lá no começo. 🎯
🎓 O que eu levo daqui
O que me pegou nessa história não foi a Scroll API em si, que é bem documentada. Foi perceber que o erro barulhento era o problema fácil. A parede dos 10.000 grita, mostra um HTTP 400 com a explicação escrita por extenso, e me obrigou a aprender o jeito certo em cinco minutos.
O problema difícil foi o silencioso: uma exportação que termina com sucesso, entrega o arquivo certo, e deixa memória presa no servidor sem uma linha de log. Esse eu só encontrei porque fui medir o que acontecia depois do "Pronto!" aparecer na tela.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.
Servidor RTMP que grava e apaga sozinho
Como receber video de cameras por RTMP, gravar em arquivos pequenos e apagar os antigos sozinho, usando o MediaMTX e um pouco de Node.js.