MyISAM para InnoDB em massa com Node.js
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:
🎯 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:
| Recurso | MyISAM | InnoDB |
|---|---|---|
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.databasesComMyISAM — que 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.
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.