Node.js: backup de uma base MySQL
Olá meus Unicórnios! 🦄✨
Backup é aquele assunto que todo mundo acha que resolveu. 😅 Você chama o mysqldump lá do Node, vê o arquivo aparecer na pasta, o tamanho parece razoável, e pronto: dorme tranquila.
Até o dia em que precisa restaurar.
Este artigo é sobre o que fica entre "o arquivo apareceu" e "os dados voltaram". É um script de backup em Node.js — simples, sem dependência nenhuma — que eu rodei de verdade contra um MySQL de verdade e restaurei para conferir. 🗄️
Se você prefere ver o código antes da explicação, ele está todo no GitHub:
💣 O erro que quase todo mundo comete primeiro
A primeira versão que a gente escreve é sempre essa:
const { exec } = require('child_process');
exec(`mysqldump -u root -psenha minhabase > backup.sql`, (err) => {
if (err) throw err;
console.log('Backup pronto!');
});
Funciona lindamente na base de teste com três tabelas. E quebra na base de produção. 💥
O motivo é o maxBuffer. O exec() guarda toda a saída do processo na memória antes de te entregar — e o padrão do Node é 1 MB. Passou disso, o processo é morto e você recebe um ENOBUFS. Aumentar o maxBuffer "resolve" no sentido de que o erro some, e cria outro: agora você está carregando um dump de 8 GB na RAM do servidor. 🙃
A correção não é ajustar o número. É não usar exec. Use spawn, que te dá o stdout como um stream, e mande esse stream direto para o disco. Os bytes passam pela memória em pedacinhos e nunca se acumulam:
await pipeline(proc.stdout, fs.createWriteStream(destino));
O pipeline() de stream/promises é importante aqui, e não é só açúcar sintático. Ele propaga o erro de qualquer elo da corrente e fecha os demais. Se você encadear com .pipe() na mão, um erro no meio deixa o arquivo aberto e o processo pendurado.
🎭 A mentira mais perigosa deste artigo: o exit code
Se você só for ler um pedaço deste artigo, leia este. 🙏
Existe um jeito de escrever esse código que parece absolutamente correto e produz backups corrompidos em silêncio. É este:
// ERRADO — não faça isso
await pipeline(proc.stdout, fs.createWriteStream(destino));
console.log('Backup pronto!'); // 😱
Parece ótimo. O pipeline resolveu sem erro, logo deu tudo certo, certo?
Não. O pipeline resolve quando o stdout do mysqldump acaba. E o stdout acaba nas duas situações:
- o
mysqldumpterminou de exportar tudo — ✅ ótimo; - o
mysqldumpmorreu no meio — perdeu a conexão, estourou um timeout, esbarrou numa tabela corrompida, levou umkilldo OOM killer. ☠️
Nos dois casos o stream fecha normalmente e o pipeline resolve com sucesso. No segundo, você ficou com um .sql perfeitamente legível, que abre no editor sem reclamar, e que contém metade do seu banco. Ele fica lá na pasta, com um tamanho plausível, esperando o dia da restauração para te dar a notícia.
A única coisa que distingue os dois casos é o código de saída do processo. Por isso é preciso esperar as duas coisas — o stream terminar e o processo sair — e só então decidir:
let saidaProc = null;
const fimProc = new Promise((resolve, reject) => {
proc.on('close', code => { saidaProc = code; resolve(); });
proc.on('error', reject); // mysqldump nem existe no PATH
});
await Promise.all([
pipeline(proc.stdout, fs.createWriteStream(temporario)),
fimProc,
]);
if (saidaProc !== 0) {
fs.unlinkSync(temporario);
throw new Error(`mysqldump saiu com codigo ${saidaProc}`);
}
O Promise.all com as duas promessas é o coração do script. Esperar só o processo também é bug, aliás — pelo outro lado: o mysqldump pode ter saído com 0 enquanto o arquivo ainda está terminando de esvaziar o buffer no disco.
São duas esperas. Isso mesmo, duas! 🤯 Uma sem a outra é um bug diferente.
📛 O arquivo .parcial
Repare que o pipeline ali escreve num arquivo temporario, não no destino final. Isso é de propósito:
const destino = path.join(cfg.saida, `${cfg.database}_${carimboDeData()}.sql`);
// .parcial ate terminar: quem varre a pasta nunca acha um dump pela metade
// com cara de dump pronto.
const temporario = destino + '.parcial';
O nome definitivo só é dado depois que o exit code foi conferido:
fs.renameSync(temporario, destino);
Por que isso importa: o seu rsync para o storage remoto, o seu monitoramento que conta arquivos, o colega que entra na pasta para pegar "o backup de hoje" — todos eles procuram *.sql. Se o dump em andamento já tiver esse nome, alguém vai copiar um arquivo pela metade achando que é um backup pronto. Com o .parcial, o arquivo só ganha a extensão "oficial" no instante em que passa a ser confiável. E o rename é atômico no mesmo sistema de arquivos, então não existe meio-termo.
Um detalhe do carimbo de data no nome: ele usa a data local, não UTC. Um backup das 23h de terça precisa se chamar terça. Com UTC no fuso de Brasília ele viraria quarta, e aí o "backup de quarta" que você procura é o de terça à noite. Confusão garantida na hora errada. ⏰
🔐 A senha: a parte que quase todo tutorial erra
Todo exemplo de mysqldump na internet faz assim:
mysqldump -u root -pMinhaSenha123 minhabase
E o próprio MySQL te avisa que isso é ruim — aquele "Using a password on the command line interface can be insecure" que todo mundo aprendeu a ignorar. 🙈
O motivo do aviso: os argumentos de um processo são públicos para os outros usuários da máquina. No Linux, qualquer um roda ps aux ou lê /proc/<pid>/cmdline; no Windows, o Win32_Process expõe a CommandLine. Enquanto o dump de uma base grande roda por 40 minutos, a senha do seu banco fica pendurada ali, à vista.
A saída é passar a senha pelo ambiente do processo filho, que não aparece na listagem:
const ambiente = Object.assign({}, process.env);
if (cfg.password != null) ambiente.MYSQL_PWD = cfg.password;
const proc = spawn(cfg.mysqldump, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: ambiente,
});
MYSQL_PWD é lido nativamente pelo cliente MySQL. A alternativa igualmente boa — e a preferida da documentação oficial — é um --defaults-extra-file apontando para um arquivo temporário com permissão 0600:
[client]
password="MinhaSenha123"
Escolhi MYSQL_PWD no script por ser mais simples e não deixar arquivo com senha no disco. Vale saber que ele tem um contra: em alguns sistemas, o ambiente de um processo também é legível por outros processos do mesmo usuário. Se o seu modelo de ameaça inclui isso, vá de --defaults-extra-file.
Duas observações honestas sobre o que eu de fato verifiquei aqui:
- Confirmado empiricamente: rodando o script com
MYSQL_PWD, a linha de comando do processomysqldumpnão contém a senha, e ela também não vaza para o log da aplicação. - Não consegui reproduzir na minha máquina: a exposição do
-psenhanoWin32_Process. Os dumps de teste eram rápidos demais para eu capturar o processo a tempo. Que argumentos de processo são legíveis por outros usuários é comportamento documentado dos sistemas operacionais — mas não é uma medição minha, e prefiro dizer isso a fingir que rodei.
E mascare o log 🎭
Logar o comando executado é ótimo para depurar. Só que o comando pode ter a senha dentro. Uma função de máscara resolve — e ela tem um detalhe esperto:
function mascarar(args) {
return args.map(a => (a.startsWith('-p') && a.length > 2 ? '-p[***]' : a));
}
O a.length > 2 não é enfeite: um -p sozinho significa "pergunte a senha no terminal" e não tem nada a esconder. Só -pseguido de alguma coisa é que é segredo.
🎚️ As flags do mysqldump que importam de verdade
Estas são as que o script usa, e o porquê de cada uma:
const args = [
`--host=${cfg.host}`,
`--port=${cfg.port}`,
`--user=${cfg.user}`,
'--single-transaction', // dump consistente sem travar a base (InnoDB)
'--quick', // linha a linha, nao carrega a tabela na RAM
'--skip-lock-tables', // sem isso o --single-transaction perde o efeito
'--routines', // procedures e functions
'--triggers',
'--events',
'--default-character-set=utf8mb4',
'--max_allowed_packet=1024M',
cfg.database,
];
--single-transaction é a mais importante. Sem ela, o mysqldump tranca as tabelas enquanto exporta — e o seu site fica fora do ar durante o backup. Com ela, o InnoDB abre uma transação e exporta uma foto consistente do banco naquele instante, sem bloquear ninguém. Só vale para InnoDB: em MyISAM ela não faz nada, porque MyISAM não é transacional.
--skip-lock-tables anda de mãos dadas com a anterior. O comportamento padrão do mysqldump é travar as tabelas, e isso desfaz o benefício do --single-transaction. É o par que quase todo tutorial esquece de citar junto.
--quick manda o mysqldump buscar as linhas do servidor uma a uma em vez de carregar a tabela inteira na memória antes de escrever. Em tabela de 20 milhões de linhas, é a diferença entre funcionar e ser morto pelo OOM killer.
--routines, --triggers, --events — atenção que --routines e --events não são padrão. Sem elas, seu backup restaura todas as tabelas e todos os dados, e você só descobre que as stored procedures sumiram quando a aplicação quebrar. É o tipo de coisa que só aparece no pior momento possível.
▶️ Rodando de verdade
Chega de teoria. Subi um MySQL de testes, criei uma base lojinha com duas tabelas e alguns registros, e rodei:
$ MYSQL_PWD=minhasenha node backup.js --database=lojinha \
--host=127.0.0.1 --port=3399 --user=root --saida=./saida
=== BACKUP INICIADO ===
Base : lojinha
Servidor : 127.0.0.1:3399
Usuario : root
Destino : saida\lojinha_2026-08-05_20-28-29.sql
Node.js : v24.18.0 — win32 x64
Comando : mysqldump --host=127.0.0.1 --port=3399 --user=root --single-transaction
--quick --skip-lock-tables --routines --triggers --events
--default-character-set=utf8mb4 --max_allowed_packet=1024M lojinha
PID : 47584
Tamanho : 3.1 KB
Tempo : 48 ms
=== BACKUP CONCLUIDO ===
Repare que a senha não aparece na linha Comando. Ela foi por MYSQL_PWD.
Mas o backup presta? 🤔
Arquivo gerado não é backup. Backup é o que restaura. Então joguei o dump numa base novinha e fui conferir:
$ mysql -e "CREATE DATABASE lojinha_restaurada;"
$ mysql lojinha_restaurada < saida/lojinha_2026-08-05_20-28-29.sql
$ mysql -e "SELECT Nome,Email FROM lojinha_restaurada.clientes;"
Nome Email
Paloma Macetko [email protected]
Unicornio Silva [email protected]
Coruja Souza [email protected]
$ mysql -e "SELECT COUNT(*) AS pedidos FROM lojinha_restaurada.pedidos;"
pedidos
4
Três clientes e quatro pedidos, exatamente o que entrou. ✅ Esse é o teste que vale — e é o que separa quem tem backup de quem acha que tem. Um dump nunca restaurado é uma hipótese, não um backup. 🙏
E quando dá errado?
Testei também o caminho da falha, apontando para uma base que não existe:
$ node backup.js --database=base_que_nao_existe --saida=./falha
stderr: mysqldump: Got error: 1049: Unknown database 'base_que_nao_existe'
when selecting the database
FALHA: mysqldump saiu com codigo 2: mysqldump: Got error: 1049: Unknown
database 'base_que_nao_existe' when selecting the database
$ ls ./falha/
(vazio)
Saída 1, e — o que importa — a pasta ficou vazia. Nenhum .parcial esquecido, nenhum .sql de zero byte com cara de backup. É o comportamento que a gente quer: falhou, não deixa rastro que confunda.
📄 O script completo
Aqui está o arquivo inteiro, exatamente como eu rodei. Sem dependência nenhuma além do próprio Node — só child_process, stream e fs:
#!/usr/bin/env node
'use strict';
/**
* Backup de uma base MySQL em Node.js.
*
* Uso:
* node backup.js --database=minhabase --host=127.0.0.1 --user=root --saida=./backups
*
* A senha vem da variavel de ambiente MYSQL_PWD, nunca de argumento.
*/
const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
const fs = require('fs');
const path = require('path');
const os = require('os');
// ---------------------------------------------------------------- argumentos
function lerArgs(argv) {
const opcoes = {};
for (const arg of argv.slice(2)) {
const casa = /^--([^=]+)=?(.*)$/.exec(arg);
if (casa) opcoes[casa[1]] = casa[2] === '' ? true : casa[2];
}
return opcoes;
}
// ------------------------------------------------------------------ ajudantes
// Nome do arquivo ordenavel: 2026-08-05_14-30-00. Data local, nao UTC —
// backup das 23h de terca nao pode aparecer como quarta no nome.
function carimboDeData(agora) {
const d = agora || new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+ `_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
}
function formatarBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
const mb = bytes / 1024 / 1024;
if (mb < 1) return `${(bytes / 1024).toFixed(1)} KB`;
if (mb < 1024) return `${mb.toFixed(2)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
function formatarDuracao(ms) {
if (ms < 1000) return `${ms} ms`;
const seg = Math.floor(ms / 1000);
if (seg < 60) return `${seg}s ${ms % 1000}ms`;
return `${Math.floor(seg / 60)}min ${seg % 60}s`;
}
// Esconde a senha antes de logar o comando. Mesmo passando por MYSQL_PWD,
// quem editar o script para usar -p nao vai vazar a senha no log por descuido.
function mascarar(args) {
return args.map(a => (a.startsWith('-p') && a.length > 2 ? '-p[***]' : a));
}
// ------------------------------------------------------------------- backup
async function fazerBackup(cfg) {
const inicio = Date.now();
fs.mkdirSync(cfg.saida, { recursive: true });
const destino = path.join(cfg.saida, `${cfg.database}_${carimboDeData()}.sql`);
// .parcial ate terminar: quem varre a pasta nunca acha um dump pela metade
// com cara de dump pronto.
const temporario = destino + '.parcial';
const args = [
`--host=${cfg.host}`,
`--port=${cfg.port}`,
`--user=${cfg.user}`,
'--single-transaction', // dump consistente sem travar a base (InnoDB)
'--quick', // linha a linha, nao carrega a tabela na RAM
'--skip-lock-tables', // sem isso o --single-transaction perde o efeito
'--routines', // procedures e functions
'--triggers',
'--events',
'--default-character-set=utf8mb4',
'--max_allowed_packet=1024M',
cfg.database,
];
console.log('=== BACKUP INICIADO ===');
console.log(`Base : ${cfg.database}`);
console.log(`Servidor : ${cfg.host}:${cfg.port}`);
console.log(`Usuario : ${cfg.user}`);
console.log(`Destino : ${destino}`);
console.log(`Node.js : ${process.version} — ${process.platform} ${process.arch}`);
console.log(`Comando : ${cfg.mysqldump} ${mascarar(args).join(' ')}`);
// MYSQL_PWD nao aparece em `ps` — diferente de -psenha na linha de comando,
// que qualquer usuario da maquina consegue ler enquanto o dump roda.
const ambiente = Object.assign({}, process.env);
if (cfg.password != null) ambiente.MYSQL_PWD = cfg.password;
const proc = spawn(cfg.mysqldump, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: ambiente,
});
console.log(`PID : ${proc.pid}`);
const erros = [];
proc.stderr.on('data', d => {
for (const linha of d.toString().split('\n')) {
const texto = linha.trim();
if (!texto) continue;
erros.push(texto);
console.log(` stderr: ${texto}`);
}
});
// A armadilha central deste script: mysqldump pode falhar NO MEIO e mesmo
// assim o pipeline termina "com sucesso" — o stdout simplesmente acabou.
// Por isso esperamos as DUAS coisas: o pipeline fechar e o exit code sair 0.
let saidaProc = null;
const fimProc = new Promise((resolve, reject) => {
proc.on('close', code => { saidaProc = code; resolve(); });
proc.on('error', reject); // mysqldump nem existe no PATH
});
try {
await Promise.all([
pipeline(proc.stdout, fs.createWriteStream(temporario)),
fimProc,
]);
} catch (err) {
try { fs.unlinkSync(temporario); } catch { }
throw err;
}
if (saidaProc !== 0) {
try { fs.unlinkSync(temporario); } catch { }
throw new Error(`mysqldump saiu com codigo ${saidaProc}: ${erros.join(' | ') || 'sem mensagem'}`);
}
// So agora o arquivo ganha o nome definitivo.
fs.renameSync(temporario, destino);
const bytes = fs.statSync(destino).size;
const duracao = Date.now() - inicio;
console.log(`Tamanho : ${formatarBytes(bytes)}`);
console.log(`Tempo : ${formatarDuracao(duracao)}`);
console.log('=== BACKUP CONCLUIDO ===');
return { destino, bytes, duracao };
}
// --------------------------------------------------------------------- main
async function main() {
const args = lerArgs(process.argv);
if (!args.database) {
console.error('Uso: node backup.js --database=NOME [--host=] [--port=] [--user=] [--saida=]');
console.error('\nA senha vem da variavel de ambiente MYSQL_PWD.');
process.exit(2);
}
const cfg = {
database: args.database,
host: args.host || '127.0.0.1',
port: args.port || '3306',
user: args.user || 'root',
password: process.env.MYSQL_PWD,
saida: args.saida || path.join(os.homedir(), 'backups'),
mysqldump: process.env.MYSQLDUMP_PATH || 'mysqldump',
};
try {
await fazerBackup(cfg);
} catch (err) {
console.error(`\nFALHA: ${err.message}`);
process.exit(1);
}
}
if (require.main === module) main();
module.exports = { fazerBackup, carimboDeData, formatarBytes, mascarar };
É só salvar como backup.js, garantir que o mysqldump está no PATH (ou apontar o MYSQLDUMP_PATH para ele) e rodar. 💾
📦 O repositório
Se preferir clonar em vez de copiar e colar, está tudo lá — o script, o README com as flags explicadas e um .env.example para você não deixar senha no lugar errado:
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: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
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.