Migração de dados em produção sem perder o sono
Recentemente precisei resolver um problema clássico em um sistema financeiro que cresceu: o modelo de dados não acompanhou a realidade do negócio.
O cenário era assim:
Um
Card(cartão físico/virtual) carregava sozinho todos os dados de conta... limite, dia de fechamento, dia de vencimento. Funcionou por um tempo. Depois o produto evoluiu: um cliente pode ter múltiplos cartões vinculados à mesma conta (titular + adicional, físico + virtual). O modelo flat quebrou.
A solução de modelagem é conhecida: extrair uma entidade CardAccount e normalizar. O desafio real é migrar os dados que já existem em produção sem downtime e sem risco de inconsistência.
Por que não dá pra fazer só com migration SQL?
Uma ALTER TABLE move estrutura. Ela não sabe criar um CardAccount por card, gerar um novo ID distribuído (usamos Snowflake ID - 64 bits, ordenável por tempo, sem coordenação central), atualizar o card com a FK e repontuar faturas e transações existentes... tudo como uma unidade atômica.
Para isso você precisa de uma migration de dados, separada da migration de schema.
A estratégia: script idempotente + transação por registro
for (const card of cards) {
await prisma.$transaction(async (tx) => {
const cardAccountId = snowflake()
// 1. Cria a nova entidade
await tx.cardAccount.create({ data: { id: cardAccountId, ...dados } })
// 2. Vincula o cartão
await tx.card.update({ where: { id: card.id }, data: { card_account_id: cardAccountId } })
// 3. Reclassifica faturas e transações históricas
await tx.cardInvoice.updateMany({ where: { card_id: card.id, card_account_id: null }, ... })
await tx.cardTransaction.updateMany({ where: { card_id: card.id, card_account_id: null }, ... })
})
}
Alguns princípios que guiaram essa implementação:
1. Idempotência como primeiro cidadão
O filtro where: { card_account_id: null } garante que rodar o script duas vezes não cria dados duplicados. Fundamental para poder re-executar com segurança se algo falhar no meio.
2. Uma transação por registro, não uma transação para tudo
Uma única transação englobando centenas de cards aumenta o tempo de lock e o risco de rollback massivo. Transacionar por unidade mínima (o card) limita o blast radius: se um falhar, os anteriores já foram commitados.
3. Contadores de progresso + exit code
O script reporta [n/total] por card, acumula failed e sai com process.exit(1) se houve falha. Isso torna a execução observável em CI/CD e permite detectar falhas parciais sem olhar os logs manualmente.
4. Separação entre schema migration e data migration
O Prisma migration (prisma migrate) adicionou as colunas card_account_id como nullable. O schema vive em versionamento junto com o código. O script de dados é executado uma única vez após o deploy — ou via seed pipeline, ou via task agendada. Responsabilidades bem separadas.
O que aprendi (ou reafirmei) com isso
- Schema evolution não é só DDL. A parte mais delicada é sempre os dados históricos.
- IDs distribuídos (Snowflake, ULID, UUID v7) resolvem um problema real em migrações: você pode gerar o ID no código antes de persistir, sem depender de
AUTO_INCREMENTdo banco. - Migrações são código de produção. Merecem o mesmo cuidado: idempotência, observabilidade e tratamento de erro.
- A abstração do ORM (Prisma aqui) ajuda muito quando você precisa garantir que o client type-check cubra os dados migrados... se o schema mudou, o TypeScript avisa antes de você rodar.
Se você já passou por uma migração de dados em produção que não saiu como planejado... ou saiu melhor do que esperava... comenta aqui. Adoro trocar experiências sobre isso.
sistema rodando em: node+prisma+mysql+scalar