Pular para o conteúdo
Node.js

Node.js: backup de uma base MySQL

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris operando uma alavanca que despeja tabelas brilhantes dentro de um cofre, com uma coruja arquivista carimbando pergaminhos e cilindros de banco de dados flutuando ao lado de um castelo

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:

Exemplos_MyISAMBackup no GitHubO script completo deste artigo, num arquivo só, sem dependência nenhuma além do próprio Node.github.com

💣 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 mysqldump terminou de exportar tudo — ✅ ótimo;
  • o mysqldump morreu no meio — perdeu a conexão, estourou um timeout, esbarrou numa tabela corrompida, levou um kill do 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 processo mysqldump nã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 -psenha no Win32_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:

Exemplos_MyISAMBackup no GitHubO script deste artigo pronto para clonar, com README e exemplo de configuração. Licença MIT.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também