Pular para o conteúdo
MySQL

MyISAM para InnoDB em massa com Node.js

Paloma Macetko
Ilustração colorida de uma coruja mágica de óculos derrubando uma estante empoeirada de pergaminhos enquanto um unicórnio de crina luminosa empurra um carrinho de tabelas de dados até cofres de cristal reluzentes, com um castelo de velas flutuantes ao fundo e uma varinha lançando um feixe que sela cada cofre

Olá meus Unicórnios! 🦄✨

Sabe quando você abre o phpMyAdmin de um servidor antigo, olha a coluna Tipo e vê MyISAM descendo pela tela inteira? 😅 Pois é. Era esse o meu cenário: 339 tabelas espalhadas por cinco bancos, quase todas na engine que a gente já devia ter aposentado há uns dez anos.

MyISAM não tem transação. Não tem chave estrangeira. E — o motivo que me tirou o sono — ela trava a tabela inteira a cada escrita. Num sistema de atendimento, onde várias pessoas gravam ao mesmo tempo, isso significa fila. Fila significa lentidão. E lentidão, no fim do mês, significa telefone tocando. 📞

A solução é conhecida: ALTER TABLE ... ENGINE=InnoDB. O problema é a escala. Ninguém vai rodar isso 302 vezes na mão, conferindo uma por uma se funcionou.

Então escrevi um script em Node.js para fazer a varredura completa: lista os bancos, encontra as MyISAM, converte, e confere se a conversão pegou de verdade. Este artigo é o script inteiro — e, mais importante, as três armadilhas que ele me mostrou e que eu não teria descoberto convertendo à mão.

E se você prefere o código antes da explicação, está tudo num repositório pronto para clonar e rodar:

cmacetko/Exemplos_MyISAMInnoDBO conversor completo, com a checagem do InnoDB, o modo simulação e uma base de teste para experimentar sem riscogithub.com

🎯 Por que sair do MyISAM (e por que em massa)

Vamos ser justas com a coitada: MyISAM foi a engine padrão do MySQL por muito tempo e é rápida para leitura pura. Se sua tabela é um catálogo de estados brasileiros que ninguém escreve desde 2011, ela funciona muito bem.

O problema aparece quando alguém escreve. Compare:

RecursoMyISAMInnoDB
Transações (COMMIT/ROLLBACK)❌ não✅ sim
Chave estrangeira❌ ignora silenciosamente✅ obriga
Bloqueio na escrita🔒 tabela inteira🔑 só a linha
Recuperação após queda de energia😬 REPAIR TABLE na mão✅ automática

Aquele "ignora silenciosamente" na chave estrangeira merece um parágrafo. MyISAM aceita a sintaxe FOREIGN KEY sem reclamar — e não cria a restrição. Seu CREATE TABLE roda, você acha que tem integridade referencial, e não tem. É o pior tipo de bug: o que não dá erro. 🙃

Mas o argumento decisivo para mim foi o bloqueio. Numa tabela de log de atendimento, com dezenas de gravações por segundo, cada INSERT em MyISAM faz todo mundo esperar. Sair disso não é refinamento — é a diferença entre o sistema responder ou não.

🧱 O desenho do script

A estrutura é proposital e simples, quatro arquivos:

src/
├── index.js            # orquestra e imprime o relatório final
├── report.js           # só relata, não converte (o modo seguro)
├── config/database.js  # conexão com retry
├── converter/index.js  # a lógica: varre, converte, confere
└── logger/index.js     # winston: console colorido + arquivo

Duas dependências de verdade — mysql2 e winston — mais o dotenv. Sem ORM, sem framework de migração. Para um script que roda uma vez na vida de um servidor, ORM seria peso morto.

A configuração vai toda no .env:

DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=secret
DB_CONNECT_TIMEOUT=10000

# Databases a excluir (separadas por vírgula, sem espaços)
EXCLUDE_DATABASES=information_schema,mysql,performance_schema,sys

LOG_LEVEL=info

O EXCLUDE_DATABASES não é enfeite. Os bancos internos do MySQL precisam ficar de fora: as tabelas de mysql guardam as permissões do próprio servidor, e várias delas nem podem ser InnoDB no 5.6. Convertê-las é o caminho mais curto para um servidor que não sobe. Deixe a lista padrão em paz. ⚠️

🔌 A conexão, com retry porque servidor antigo é assim

Nada exótico aqui, mas repare no multipleStatements: false e no laço de tentativas:

const mysql = require('mysql2/promise');
const logger = require('../logger');

async function createConnection(retries = 3, delayMs = 2000) {
  const config = {
    host:               process.env.DB_HOST     || '127.0.0.1',
    port:               parseInt(process.env.DB_PORT || '3306', 10),
    user:               process.env.DB_USER     || 'root',
    password:           process.env.DB_PASSWORD || '',
    connectTimeout:     parseInt(process.env.DB_CONNECT_TIMEOUT || '10000', 10),
    multipleStatements: false,
  };

  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const connection = await mysql.createConnection(config);
      logger.info(`Conexão estabelecida com sucesso (tentativa ${attempt}/${retries})`, {
        host: config.host, port: config.port,
      });
      return connection;
    } catch (err) {
      logger.warn(`Falha na tentativa ${attempt}/${retries} de conexão: ${err.message}`);
      if (attempt < retries) {
        await new Promise(resolve => setTimeout(resolve, delayMs));
      } else {
        throw new Error(`Não foi possível conectar ao MySQL após ${retries} tentativas: ${err.message}`);
      }
    }
  }
}

module.exports = { createConnection };

Repare que a conexão é uma só, e é sequencial. Dá vontade de abrir um pool e converter dez tabelas em paralelo, né? 😏 Não faça isso. ALTER TABLE reescreve a tabela inteira em disco; dez ao mesmo tempo transformam o I/O do servidor num engarrafamento e o total sai mais lento, além de deixar o banco inutilizável para quem estiver usando o sistema. Uma de cada vez é lento e é certo.

🔍 Achando as MyISAM: SHOW TABLE STATUS, não SHOW TABLES

Aqui mora um detalhe que economiza muita linha de código. SHOW TABLES devolve só nomes — você teria que perguntar a engine de cada uma, uma consulta por tabela. Já SHOW TABLE STATUS devolve nome, engine, número de linhas e tamanho de uma vez só:

async function getDatabases(connection) {
  const excludeRaw = process.env.EXCLUDE_DATABASES
    || 'information_schema,mysql,performance_schema,sys';
  const excluded = excludeRaw.split(',').map(db => db.trim().toLowerCase());

  const [rows] = await connection.query('SHOW DATABASES');
  const databases = rows
    .map(row => row.Database)
    .filter(db => !excluded.includes(db.toLowerCase()));

  logger.info(`Databases encontradas para verificar: ${databases.length}`, { databases });
  return databases;
}

async function getMyISAMTables(connection, database) {
  const [rows] = await connection.query(`SHOW TABLE STATUS IN \`${database}\``);

  return rows
    .filter(row => row.Engine && row.Engine.toUpperCase() === 'MYISAM')
    .map(row => ({ name: row.Name, rows: row.Rows, dataSize: row.Data_length }));
}

Dois cuidados nesse trecho, e os dois já me morderam:

O row.Engine && antes do .toUpperCase() não é paranoia. Uma tabela quebrada — arquivo .frm órfão, view apontando para tabela que sumiu — aparece no SHOW TABLE STATUS com Engine: null. Sem essa guarda, o script morre com Cannot read properties of null no meio da varredura, e você fica sem saber quantas já foram.

A comparação é .toUpperCase() porque o MySQL não é consistente. Dependendo da versão e da plataforma, a mesma engine volta como MyISAM, MYISAM ou myisam. Comparar com === 'MyISAM' funciona na sua máquina e falha calada no servidor — a pior combinação possível: o script diz "nenhuma tabela MyISAM encontrada" e você acredita. 😳

✅ Converter é fácil; conferir é o que importa

Esta é a função central, e o coração dela não é o ALTER TABLE — é o que vem depois:

async function convertTable(connection, database, tableName) {
  logger.warn(`[${database}.${tableName}] Engine MyISAM detectada — iniciando conversão para InnoDB`);

  try {
    await connection.query(
      `ALTER TABLE \`${database}\`.\`${tableName}\` ENGINE=InnoDB`
    );

    // Verifica se a conversão foi aplicada corretamente
    const [verifyRows] = await connection.query(
      `SHOW TABLE STATUS IN \`${database}\` LIKE '${tableName}'`
    );

    if (verifyRows.length > 0 && verifyRows[0].Engine.toUpperCase() === 'INNODB') {
      logger.info(`[${database}.${tableName}] Conversão concluída com sucesso → InnoDB`);
      return true;
    } else {
      const currentEngine = verifyRows.length > 0 ? verifyRows[0].Engine : 'DESCONHECIDA';
      logger.error(`[${database}.${tableName}] Conversão falhou — engine atual: ${currentEngine}`);
      return false;
    }
  } catch (err) {
    logger.error(`[${database}.${tableName}] Erro ao converter tabela: ${err.message}`, {
      code: err.code, sqlState: err.sqlState,
    });
    return false;
  }
}

Por que reler a engine se o ALTER TABLE não deu erro? Porque no MySQL não dar erro não quer dizer que funcionou. Existe um caso clássico: se a engine de destino não estiver disponível, dependendo da configuração o servidor emite um warning — não uma exceção — e mantém a tabela como estava. Seu catch nunca dispara, seu contador soma +1, e a tabela continua MyISAM.

Aquele bloco de verificação é o que separa "o comando rodou" de "a tabela mudou". São seis linhas que transformam um relatório otimista num relatório confiável — e num script de migração, um relatório em que você não pode confiar não serve para nada.

Repare também no return true / return false em vez de deixar a exceção subir: uma tabela que falha não pode derrubar as outras 301. O erro é registrado, o laço continua, e no fim você tem a lista do que sobrou.

🌀 O laço principal

Com as peças no lugar, a orquestração fica quase óbvia — e o interessante dela são as estatísticas, que existem para responder à única pergunta que importa no fim: sobrou alguma?

async function runConversion(connection) {
  const stats = {
    databasesVerificadas: 0,
    tabelasVerificadas:   0,
    tabelasMyISAM:        0,
    tabelasConvertidas:   0,
    tabelasComErro:       0,
  };

  const databases = await getDatabases(connection);
  stats.databasesVerificadas = databases.length;

  for (const database of databases) {
    logger.info(`━━━ Verificando database: ${database} ━━━`);

    let allTables;
    try {
      const [rows] = await connection.query(`SHOW TABLE STATUS IN \`${database}\``);
      allTables = rows;
    } catch (err) {
      logger.error(`Falha ao listar tabelas da database "${database}": ${err.message}`);
      continue;   // um banco sem permissão não aborta os outros
    }

    const myisamTables = allTables.filter(
      row => row.Engine && row.Engine.toUpperCase() === 'MYISAM'
    );

    stats.tabelasVerificadas += allTables.length;
    stats.tabelasMyISAM      += myisamTables.length;

    if (myisamTables.length === 0) {
      logger.info(`[${database}] Nenhuma tabela MyISAM encontrada — nada a converter.`);
      continue;
    }

    for (const table of myisamTables) {
      const success = await convertTable(connection, database, table.Name);
      if (success) stats.tabelasConvertidas++;
      else         stats.tabelasComErro++;
    }
  }

  return stats;
}

Aquele continue depois do catch tem nome e endereço: usuário sem permissão em um dos bancos. É comum em servidor compartilhado — o SHOW DATABASES lista o banco do vizinho, o SHOW TABLE STATUS nele dá access denied, e sem o continue a varredura inteira morre ali. Com ele, o banco problemático vira uma linha de erro no log e a vida segue.

💥 A primeira mentira: 339 tabelas, zero convertidas

Agora a parte boa. 😅 Rodei o script pela primeira vez, ele varreu tudo lindamente, e cuspiu isto:

[07:25:10] INFO    | ════════════════════════════════════════════════════
[07:25:10] INFO    |    RELATÓRIO FINAL
[07:25:10] INFO    | ════════════════════════════════════════════════════
[07:25:10] INFO    | Databases verificadas : 5
[07:25:10] INFO    | Tabelas verificadas   : 339
[07:25:10] INFO    | Tabelas MyISAM        : 339
[07:25:10] INFO    | Tabelas convertidas   : 0
[07:25:10] WARN    | Tabelas com erro      : 339
[07:25:10] WARN    | Verifique logs/error.log para detalhes dos erros.

Trezentas e trinta e nove com erro. Todas. 🤯 E o error.log, com a mesma linha repetida 339 vezes:

[2026-04-10 07:25:09] ERROR | [loja.pagamentos_log] Erro ao converter tabela:
  Unknown storage engine 'InnoDB' {"code":"ER_UNKNOWN_STORAGE_ENGINE","sqlState":"42000"}

Unknown storage engine 'InnoDB'. Minha primeira reação foi achar que era erro de digitação no meu código. Não era — e não era erro nenhum do script. O InnoDB estava desativado no servidor. 😳

Isso acontece de verdade: um my.ini herdado de sabe-se-lá-quando com skip-innodb, ou o InnoDB falhando ao iniciar por causa de um ib_logfile incompatível, e o MySQL sobe assim mesmo — sem InnoDB, sem avisar ninguém. O servidor funciona, os sites funcionam, e ninguém descobre até tentar usar a engine.

Confirmar leva dez segundos:

mysql> SHOW ENGINES;

+--------------------+---------+
| Engine             | Support |
+--------------------+---------+
| MyISAM             | DEFAULT |
| InnoDB             | NO      |     <-- aqui está o problema
| MEMORY             | YES     |
+--------------------+---------+

Se o Support do InnoDB vier NO, nenhum script do mundo vai converter nada. Tirei o skip-innodb do my.ini, reiniciei o serviço, e o mesmo script sem uma vírgula alterada passou a converter tudo:

[07:26:45] INFO | Conexão estabelecida com sucesso (tentativa 1/3)
[07:26:45] INFO | [central.com_cidades] Conversão concluída com sucesso → InnoDB
[07:26:45] INFO | [central.com_estados] Conversão concluída com sucesso → InnoDB
[07:26:45] INFO | [central.logs_campainha] Conversão concluída com sucesso → InnoDB

A lição que fica: rode SHOW ENGINES antes de qualquer conversão em massa. Custa nada e teria me poupado a certeza momentânea de que eu tinha escrito 300 linhas de bug. 🙏

⏱️ A segunda mentira: a média esconde o monstro

A execução final levou 7 minutos e 1 segundo para converter 302 tabelas. Média: 1,4 segundo por tabela. Tranquilo, né?

Não. Olhe os carimbos de hora de uma tabela específica:

[2026-04-10 07:31:21] WARN | [loja.atendimentos] Engine MyISAM detectada — iniciando conversão
[2026-04-10 07:37:33] INFO | [loja.atendimentos] Conversão concluída com sucesso → InnoDB

07:31:21 até 07:37:33. Seis minutos e doze segundos numa tabela só. 🤯 De um total de 7 minutos — ou seja, uma única tabela consumiu 88% do tempo da migração inteira. As outras 301 couberam nos 49 segundos restantes.

Isso muda completamente como você planeja a janela de manutenção. Se eu tivesse estimado pela média — "302 tabelas × 1,4s, uns 7 minutos, faço na hora do almoço" — o número final até bateria, mas por coincidência. Numa base maior, com três tabelas grandes assim, a conta pela média erraria feio.

E tem o agravante: ALTER TABLE em MyISAM bloqueia a tabela durante toda a reescrita. Aqueles 6 minutos não foram só espera do script — foram 6 minutos em que a tabela central de atendimentos ficou indisponível para o sistema. Em horário comercial, isso é o telefone tocando. 📞

Por isso o SHOW TABLE STATUS já traz Data_length. Antes de converter, veja quem são os monstros:

SELECT table_schema, table_name,
       ROUND(data_length/1024/1024) AS mb,
       table_rows
FROM information_schema.tables
WHERE engine = 'MyISAM'
ORDER BY data_length DESC
LIMIT 10;

As dez primeiras linhas dessa consulta são o seu cronograma real. O resto é ruído.

📋 O modo seguro: relatar antes de converter

É por causa dessa imprevisibilidade que existe o report.js — um irmão do conversor que faz exatamente a mesma varredura e não executa nenhum ALTER TABLE. Ele só grava a lista em arquivo:

const reportsDir = path.resolve(process.cwd(), 'reports');
if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir, { recursive: true });

const dateTag  = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
const filename = `myisam_report_${dateTag}.txt`;

fs.writeFileSync(path.join(reportsDir, filename), content, 'utf8');

Cada linha do arquivo é um database: tabela. O nome carrega a data e hora, então rodar duas vezes gera dois arquivos — e a diferença entre eles é a prova do que foi convertido no intervalo.

É assim que se usa na prática:

npm run report    # antes: fotografa o estado
npm start         # converte
npm run report    # depois: confere o que sobrou

O terceiro comando é o que fecha a migração. Se o arquivo sair vazio, acabou de verdade. 🎉

E o bug que o relatório me mostrou

Falando em conferir — o report.js tem um defeito, e ele está no código publicado acima. Olhe o resumo de duas execuções seguidas:

[07:29:39] INFO | Databases com MyISAM         : 3
[07:30:44] INFO | Databases com MyISAM         : undefined

Entre uma execução e outra eu mexi no arquivo — e o número virou undefined. 😅 O motivo: o objeto totalStats declara databasesVerificadas e tabelasMyISAMEncontradas, mas o resumo final imprime também um stats.databasesComMyISAMque ninguém nunca preenche. Em JavaScript, ler uma propriedade que não existe não dá erro: devolve undefined e o programa segue feliz. Nenhum crash, nenhum aviso: só um buraco no relatório.

Faltava incrementá-lo dentro do laço, junto dos outros:

const totalStats = {
  databasesVerificadas:     databases.length,
  databasesComMyISAM:       0,          // <-- faltava declarar
  tabelasMyISAMEncontradas: 0,
};

// ...dentro do laço, quando a database tem tabelas MyISAM:
totalStats.databasesComMyISAM++;        // <-- e faltava incrementar

Ele nunca quebrou nada — é só um número errado num resumo. Mas é o meu tipo favorito de bug para contar, porque mostra por que o relatório em arquivo vale mais que o resumo na tela: enquanto o resumo exibia um undefined, o arquivo .txt — que lista tabela por tabela — continuou correto o tempo todo. Quando o resumo e o detalhe discordam, confie no detalhe.

📝 O log em arquivo não é luxo

Uma varredura dessas gera mais de mil linhas — o terminal do Windows engole as primeiras e você perde justamente o começo, onde aparecem os erros de conexão. Por isso o winston escreve em três lugares ao mesmo tempo:

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  transports: [
    new winston.transports.Console({ format: consoleFormat }),

    new winston.transports.File({
      filename: path.join(logsDir, 'combined.log'),
      format: fileFormat,
    }),

    new winston.transports.File({
      filename: path.join(logsDir, 'error.log'),
      level: 'error',                 // só os erros
      format: fileFormat,
    }),
  ],
});

O error.log separado é o detalhe que mais me serviu. Quando 339 tabelas falharam, eu não precisei filtrar mil linhas atrás do problema: abri um arquivo que só tinha erro, li a primeira linha, e ali estava o ER_UNKNOWN_STORAGE_ENGINE. Diagnóstico em dez segundos.

E note que o formato do arquivo é diferente do formato do console: o console usa HH:mm:ss e cores; o arquivo usa YYYY-MM-DD HH:mm:ss sem cor nenhuma. Não é preciosismo — código de cor ANSI dentro de arquivo de log vira lixo na hora que você abrir no bloco de notas, e sem a data completa você não consegue cruzar o log com o horário de um incidente.

const fileFormat = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  winston.format.errors({ stack: true }),
  winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
    const metaStr  = Object.keys(meta).length ? ' ' + JSON.stringify(meta) : '';
    const stackStr = stack ? `\n${stack}` : '';
    return `[${timestamp}] ${level.toUpperCase().padEnd(7)} | ${message}${metaStr}${stackStr}`;
  })
);

Aquele ...meta no final é o que registra o {"code":"ER_UNKNOWN_STORAGE_ENGINE","sqlState":"42000"} junto da mensagem. O código do erro é muito mais fácil de pesquisar que o texto traduzido dele.

🧭 O roteiro que funcionou

Juntando tudo que aprendi apanhando, esta é a ordem que deu certo:

1. Faça backup. Sério. ALTER TABLE ENGINE=InnoDB reescreve a tabela e não tem volta — não existe ROLLBACK de DDL no MySQL. Um mysqldump antes é a diferença entre um susto e um desastre.

mysqldump -u root -p --all-databases --routines > backup_antes.sql

2. Rode SHOW ENGINES. Se o InnoDB não estiver com Support: YES ou DEFAULT, pare tudo e resolva isso primeiro. (Custou-me 339 erros.)

3. Meça as grandes. A consulta de data_length lá de cima. Elas definem sua janela de manutenção, não a média.

4. Confira o espaço em disco. Durante o ALTER TABLE, o MySQL mantém a tabela antiga e a nova ao mesmo tempo. Você precisa de espaço livre maior que a maior tabela — e InnoDB costuma ocupar mais que MyISAM pelos índices em árvore.

5. Rode o report.js. A foto do antes.

6. Converta fora do horário. Pelo bloqueio de escrita das grandes.

7. Rode o report.js de novo. Arquivo vazio = missão cumprida.

8. Confira o FULLTEXT. A ressalva que só vale se você usa: no MySQL 5.5 e anteriores, InnoDB não tinha índice FULLTEXT — a conversão falhava. Do 5.6 em diante ele existe, mas o comportamento da busca não é idêntico ao do MyISAM. Se alguma tabela sua tem FULLTEXT, teste a busca depois de converter em vez de confiar no "convertido com sucesso".

🎁 O resultado

O relatório da execução que deu certo:

[07:37:58] INFO | ════════════════════════════════════════════════════
[07:37:58] INFO |    RELATÓRIO FINAL
[07:37:58] INFO | ════════════════════════════════════════════════════
[07:37:58] INFO | Databases verificadas : 5
[07:37:58] INFO | Tabelas verificadas   : 339
[07:37:58] INFO | Tabelas MyISAM        : 302
[07:37:58] INFO | Tabelas convertidas   : 302
[07:37:58] INFO | Tabelas com erro      : 0
[07:37:58] INFO | ════════════════════════════════════════════════════
[07:37:58] INFO | Conversão concluída! 302 tabela(s) migrada(s) com sucesso para InnoDB.

302 de 302, zero erros, 7 minutos. 🎉 E repare no detalhe: "tabelas verificadas 339, MyISAM 302". As 37 de diferença já eram InnoDB de antes — o script simplesmente as ignorou, sem precisar que eu dissesse quais eram.

É essa a graça de automatizar em vez de converter na mão: o script não precisa saber o que já foi feito. Rode quantas vezes quiser — se não houver MyISAM, ele diz "nada a converter" e vai embora. Isso o torna seguro para deixar num agendador mensal, só para garantir que ninguém criou tabela nova na engine errada.

Porque, spoiler: alguém sempre cria. 😅

📦 O código completo

Montei um repositório com tudo que este artigo mostra, funcionando junto: o conversor, o relatório em arquivo e — o que faltava na minha primeira versão — a checagem do InnoDB antes de começar, que teria transformado meus 339 erros numa mensagem só. Tem também um modo simulação (npm run simular), que lista o que seria convertido sem tocar em nada.

Os nomes lá estão em português (converterTudo, listarDatabases) e o código é mais enxuto que os trechos deste artigo — é material para ler e entender, não biblioteca. A lógica e as armadilhas são exatamente as que você acabou de ver, com um comentário em cada ponto onde a pegadinha mora.

E vem um criar-base-teste.sql que monta duas databases de exemplo com tabelas MyISAM: dá para rodar a conversão inteira sem encostar em dado de verdade.

cmacetko/Exemplos_MyISAMInnoDBConversor MyISAM para InnoDB em Node.js, com as armadilhas deste artigo já resolvidas e comentadas no códigogithub.com

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

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

Leia também