PostgreSQL vs MariaDB para aplicações CRUD transacionais
PostgreSQL vs MariaDB: um olhar prático sobre indexação, migrações, JSON e recursos de consulta que começam a importar quando um app CRUD cresce além do protótipo.

Quando um app CRUD ultrapassa o protótipo
Um protótipo de app CRUD geralmente parece rápido porque os dados são pequenos, a equipe é reduzida e o tráfego é previsível. Você se vira com consultas simples, alguns índices e ajustes de esquema manuais. Aí o app ganha usuários reais, fluxos reais e prazos reais.
O crescimento muda a carga de trabalho. Listas e dashboards ficam abertos o dia todo. Mais pessoas editam os mesmos registros. Jobs em background começam a escrever em lotes. É aí que “funcionou ontem” vira páginas lentas, timeouts aleatórios e waits de lock durante horários de pico.
Você provavelmente ultrapassou o limite se estiver vendo coisas como páginas de lista que ficam lentas após a página 20, releases que incluem backfills de dados (não só colunas novas), mais “campos flexíveis” para metadados e payloads de integração, ou tickets de suporte dizendo “salvar demora demais” em horários de pico.
É aí que comparar PostgreSQL e MariaDB deixa de ser preferência de marca e vira uma questão prática. Para cargas transacionais CRUD, os detalhes que geralmente decidem são opções de indexação à medida que as consultas ficam mais complexas, segurança de migrações quando tabelas estão grandes, armazenamento e consulta de JSON, e recursos de consulta que reduzem trabalho no lado da aplicação.
Este texto foca nesses comportamentos do banco. Não entra profundamente em dimensionamento de servidor, preços de nuvem ou contratos de fornecedor. Isso importa, mas geralmente é mais fácil mudar depois do que um estilo de esquema e consulta do qual seu produto dependa.
Comece pelos requisitos do app, não pela marca do banco
O ponto de partida melhor não é “PostgreSQL vs MariaDB.” É o comportamento diário do seu app: criar registros, atualizar alguns campos, listar resultados filtrados e manter a correção quando muitas pessoas clicam ao mesmo tempo.
Anote o que suas telas mais movimentadas fazem. Quantas leituras ocorrem por cada escrita? Quando ocorrem picos (logins matinais, relatórios de fim de mês, grandes imports)? Capture os filtros e ordenações exatos de que você depende, porque eles guiarão o design de índices e os padrões de consulta depois.
Depois defina seus inegociáveis. Para muitas equipes isso significa consistência estrita para dinheiro ou inventário, um trail de auditoria de “quem mudou o quê” e consultas de relatório que não desmoronem cada vez que o esquema evoluir.
A realidade operacional importa tanto quanto os recursos. Decida se você vai usar um banco gerenciado ou self-host, quão rápido precisa restaurar de backups e qual sua tolerância a janelas de manutenção.
Por fim, defina “rápido o suficiente” em metas claras. Por exemplo: latência p95 da API sob carga normal (200 a 400 ms), p95 sob concorrência máxima (talvez 2x o normal), waits máximos aceitáveis de lock durante updates (abaixo de 100 ms) e limites de tempo para backup e restore.
Fundamentos de indexação que impulsionam a velocidade CRUD
A maioria dos apps CRUD parece rápida até que tabelas atinjam milhões de linhas e toda tela vire “uma lista filtrada com ordenação”. Nesse ponto, indexação é a diferença entre uma consulta de 50 ms e um timeout de 5 segundos.
Índices B-tree são o trator padrão tanto no PostgreSQL quanto no MariaDB. Eles ajudam quando você filtra por uma coluna, faz join por chaves e quando seu ORDER BY combina com a ordem do índice. A diferença real de performance normalmente vem da seletividade (quantas linhas batem) e se o índice consegue satisfazer filtro e ordenação sem escanear linhas extras.
Conforme os apps amadurecem, índices compostos importam mais que índices por coluna única. Um padrão comum é filtro multi-tenant mais um status mais ordenação por tempo, tipo (tenant_id, status, created_at). Ponha o filtro mais consistente primeiro (frequentemente tenant_id), depois o próximo filtro e por último a coluna que você ordena. Isso tende a vencer índices separados que o otimizador não consegue combinar eficientemente.
Diferenças aparecem com índices “mais inteligentes”. PostgreSQL suporta índices parciais e por expressão, o que é ótimo para telas focadas (por exemplo, indexar apenas tickets “abertos”). São poderosos, mas podem surpreender equipes se as consultas não baterem exatamente com o predicado.
Índices não são de graça. Todo insert e update também precisa atualizar cada índice, então é fácil melhorar uma tela e silenciosamente deixar cada escrita mais lenta.
Uma forma simples de manter disciplina:
- Adicione um índice apenas para um caminho de consulta real (uma tela ou chamada de API que você consiga nomear).
- Prefira um bom índice composto a muitos índices sobrepostos.
- Reavalie índices após mudanças de feature e remova peso morto.
- Planeje manutenção: PostgreSQL precisa de
VACUUM/ANALYZEregulares para evitar bloat; MariaDB também depende de boas estatísticas e limpeza ocasional. - Meça antes e depois, em vez de confiar na intuição.
Indexação para telas reais: listas, busca e paginação
A maior parte do tempo dos apps CRUD passa em algumas telas: uma lista com filtros, uma caixa de busca e uma página de detalhes. A escolha do banco importa menos do que se seus índices batem com essas telas, mas os dois motores dão ferramentas diferentes quando as tabelas crescem.
Para páginas de lista, pense nesta ordem: filtro primeiro, depois ordenação, depois paginação. Um padrão comum é “todos os tickets da conta X, status em (open, pending), mais recentes primeiro.” Um índice composto que começa com as colunas de filtro e termina com a coluna de ordenação geralmente vence.
Paginação merece cuidado especial. Paginação por offset (página 20 com OFFSET 380) fica mais lenta à medida que você rola porque o banco ainda tem que percorrer as linhas anteriores. Paginação por keyset é mais estável: você passa o último valor visto (como created_at e id) e pede “próximos 20 mais antigos que aquele.” Também reduz duplicatas e lacunas quando novas linhas chegam no meio da rolagem.
PostgreSQL tem uma opção útil para telas de lista: índices “covering” usando INCLUDE, que podem permitir index-only scans quando o visibility map permite. MariaDB também pode fazer leituras cobrindo colunas, mas normalmente você alcança isso incluindo as colunas necessárias diretamente na definição do índice. Isso pode deixar índices mais largos e mais caros de manter.
Você provavelmente precisa de índices melhores se um endpoint de lista ficar lento à medida que a tabela cresce mesmo retornando só 20 a 50 linhas, se ordenações ficarem lentas a não ser que você remova o ORDER BY, ou se I/O saltar durante filtros simples. Consultas mais longas também tendem a aumentar waits de lock em períodos de maior atividade.
Exemplo: uma tela de pedidos que filtra por customer_id e status e ordena por created_at geralmente se beneficia de um índice que comece por (customer_id, status, created_at). Se depois você adicionar “buscar por número do pedido”, isso tipicamente vira um índice separado, não algo que você pregue no índice da lista.
Migrações: manter releases seguras à medida que os dados crescem
Migrações deixam de ser “mudar uma tabela” bem rápido. Quando há usuários reais e histórico real, você precisa também lidar com backfills de dados, apertar constraints e limpar formas antigas sem quebrar o app.
Um padrão seguro é expandir, backfill, contrair. Adicione o que precisa de forma que não interrompa o código existente, copie ou calcule dados em passos pequenos, depois remova o caminho antigo só quando tiver confiança.
Na prática isso costuma significar adicionar uma nova coluna ou tabela nullable, fazer backfill em lotes enquanto mantém as escritas consistentes, validar depois com constraints como NOT NULL, chaves estrangeiras e regras únicas, e só então remover colunas, índices e caminhos de código antigos.
Nem toda mudança de esquema é igual. Adicionar uma coluna é muitas vezes de baixo risco. Criar um índice pode ser caro em tabelas grandes, então planeje para horários de baixo tráfego e meça. Alterar o tipo de uma coluna é frequentemente o mais arriscado porque pode reescrever dados ou bloquear escritas. Um padrão mais seguro é: crie uma nova coluna com o novo tipo, faça backfill, então mude leituras e escritas.
Rollbacks também mudam de significado em escala. Reverter esquema às vezes é fácil; reverter dados muitas vezes não é. Seja explícito sobre o que você pode desfazer, especialmente se uma migração incluir deletes destrutivos ou transformações com perda.
JSON: campos flexíveis sem dor futura
Campos JSON são tentadores porque permitem lançar mais rápido: campos extras de formulário, payloads de integração, preferências de usuário e notas de sistemas externos cabem sem mudar esquema. O truque é decidir o que pertence ao JSON e o que merece colunas reais.
No PostgreSQL e no MariaDB, JSON costuma funcionar melhor quando é raramente filtrado e principalmente exibido, guardado para debugging, mantido como um blob de “configuração” por usuário/tenant, ou usado para pequenos atributos opcionais que não dirigem relatórios.
Indexar JSON é onde equipes se surpreendem. Consultar uma chave JSON uma vez é fácil. Filtrar e ordenar por ela em tabelas grandes é onde a performance pode colapsar. PostgreSQL tem boas opções para indexar caminhos JSON, mas você ainda precisa de disciplina: escolha poucas chaves que realmente filtra e indexe essas, depois mantenha o resto como payload não indexado. MariaDB também pode consultar JSON, mas padrões complexos de “buscar dentro do JSON” frequentemente ficam frágeis e mais difíceis de manter rápidos.
JSON também enfraquece constraints. É mais difícil impor “deve ser um destes valores” ou “sempre presente” dentro de um blob não estruturado, e ferramentas de relatório geralmente preferem colunas tipadas.
Uma regra que escala: comece com JSON para incógnitas, mas normalize em colunas ou tabelas filhas quando você (1) filtrar ou ordenar sobre isso, (2) precisar de constraints, ou (3) vê-lo aparecer em dashboards toda semana. Armazenar a resposta completa da API de frete de um pedido como JSON costuma ser OK. Campos como delivery_status e carrier geralmente merecem colunas reais assim que suporte e relatórios dependerem deles.
Recursos de consulta que aparecem em apps maduros
No início, a maioria dos apps CRUD usa SELECT, INSERT, UPDATE e DELETE simples. Depois você adiciona feeds de atividade, views de auditoria, relatórios administrativos e buscas que precisam parecer instantâneas. Aí a escolha vira uma troca de recursos.
CTEs e subqueries ajudam a manter consultas complexas legíveis. Elas são úteis quando você constrói um resultado em passos (filtrar pedidos, juntar pagamentos, calcular totais). Mas a legibilidade pode esconder custo. Quando uma consulta fica lenta, pode ser preciso reescrever um CTE como subquery ou join e rechecar o plano de execução.
Funções de janela importam na primeira vez que alguém pede “rankear clientes por gasto”, “mostrar totais acumulados” ou “último status por ticket”. Elas frequentemente substituem loops complicados na aplicação e reduzem o número de consultas.
Writes idempotentes são outro requisito adulto. Quando retries acontecem (redes móveis, jobs), upserts permitem escrever com segurança sem criar registros duplicados:
- PostgreSQL:
INSERT ... ON CONFLICT - MariaDB:
INSERT ... ON DUPLICATE KEY UPDATE
Busca é a funcionalidade que pega equipes desprevenidas. Full-text integrado pode cobrir catálogos de produtos, bases de conhecimento e notas de suporte. Buscas tipo trigram são úteis para type-ahead e tolerância a erros de digitação. Se busca virar central (rankings complexos, muitos filtros, tráfego pesado), uma ferramenta de busca externa pode valer a pena apesar da complexidade extra.
Exemplo: um portal de pedidos começa com “listar pedidos”. Um ano depois precisa “mostrar o último pedido de cada cliente, ranquear por gasto mensal e buscar por nomes com erros de digitação.” Essas são capacidades do banco, não só trabalho de UI.
Transações, locks e concorrência sob carga
Com pouco tráfego, a maioria dos bancos parece OK. Sob carga, a diferença é frequentemente sobre quão bem você lida com mudanças concorrentes nos mesmos dados, não velocidade crua. Tanto PostgreSQL quanto MariaDB conseguem rodar cargas transacionais CRUD, mas você precisa projetar para contenção.
Isolamento em linguagem simples
Uma transação é um grupo de passos que devem dar certo juntos. Isolamento controla o que outras sessões podem ver enquanto esses passos rodam. Isolamentos mais altos evitam leituras surpreendentes, mas podem aumentar espera. Muitas equipes começam com padrões e apertam o isolamento só para os fluxos que realmente precisam (como cobrar um cartão e atualizar um pedido).
O que realmente causa dor de locks
Problemas de locking em apps CRUD geralmente vêm de alguns culpados recorrentes: linhas quentes que todo mundo atualiza, contadores que mudam a cada ação, filas de jobs onde muitos workers tentam pegar o mesmo “próximo job” e transações longas que mantêm locks enquanto outro trabalho (ou tempo do usuário) passa.
Para reduzir contenção, mantenha transações curtas, atualize só as colunas necessárias e evite chamadas de rede dentro da transação.
Um hábito que ajuda é reexecutar em conflitos. Se dois agentes de suporte salvam edições no mesmo ticket ao mesmo tempo, não falhe silenciosamente. Detecte o conflito, recarregue a linha mais recente e peça ao usuário para reaplicar as mudanças.
Para detectar problemas cedo, monitore deadlocks, transações longas e consultas que passam tempo esperando em vez de rodar. Faça logs de queries lentas parte da rotina, especialmente após releases que adicionam novas telas ou jobs em background.
Operações que se tornam importantes após o lançamento
Depois do lançamento, você não está só otimizando velocidade de consulta. Está otimizando recuperação, mudança segura e performance previsível.
Um passo comum é adicionar uma réplica. O primário lida com writes, e uma réplica pode servir páginas de leitura pesada como dashboards ou relatórios. Isso muda como você pensa sobre frescor: algumas leituras podem sofrer segundos de lag, então seu app precisa saber quais telas devem ler do primário (por exemplo, “pedido acabado de fazer”) e quais toleram dados um pouco mais antigos (por exemplo, resumos semanais).
Backups são só metade do trabalho. O que importa é se você consegue restaurar rápido e corretamente. Agende restores de teste regulares em um ambiente separado, então valide o básico: o app conecta, tabelas chave existem e consultas críticas retornam resultados esperados. Equipes muitas vezes descobrem tarde demais que fizeram backup do que não era, ou que o tempo de restore ultrapassa seu orçamento de downtime.
Upgrades também deixam de ser “clicar e torcer”. Planeje janela de manutenção, leia notas de compatibilidade e teste o caminho de upgrade com uma cópia dos dados de produção. Mesmo atualizações menores podem mudar planos de consulta ou comportamento de índices e funções JSON.
Observabilidade simples compensa cedo. Comece com logs de consultas lentas e principais queries por tempo total, saturação de conexões, lag de replicação (se usar réplicas), taxa de acerto de cache e pressão de I/O, além de waits de lock e eventos de deadlock.
Como escolher: um processo prático de avaliação
Se estiver em dúvida, pare de ler listas de recursos e rode um pequeno teste com sua própria carga. O objetivo não é um benchmark perfeito. É evitar surpresas quando tabelas atingirem milhões de linhas e seu ciclo de releases acelerar.
1) Construa um mini teste parecido com produção
Escolha uma fatia do seu app que represente dor real: uma ou duas tabelas chave, algumas telas e os caminhos de escrita por trás delas. Colete suas principais queries (as que servem listas, detalhes e jobs). Carregue contagens de linhas realistas (pelo menos 100x seus dados de protótipo, com forma similar). Adicione os índices que acha necessários, então rode as mesmas queries com os mesmos filtros e ordenações e capture os tempos. Repita enquanto escritas acontecem (um script simples inserindo e atualizando linhas basta).
Um exemplo rápido é uma lista de “Clientes” que filtra por status, busca por nome, ordena por última atividade e pagina. Essa tela muitas vezes revela se sua indexação e comportamento do planner vão envelhecer bem.
2) Ensaiar migrações como um release real
Crie uma cópia de staging do dataset e pratique mudanças que você sabe que virão: adicionar coluna, mudar tipo, backfill, criar índice. Meça quanto demora, se bloqueia escritas e o que rollback realmente significa quando os dados já mudaram.
3) Use um placar simples
Após testar, pontue cada opção em performance para suas queries reais, correção e segurança (constraints, transações, edge cases), risco de migração (locks, downtime, opções de recuperação), esforço operacional (backup/restore, replicação, monitoramento) e conforto da equipe.
Escolha o banco que reduz risco para os próximos 12 meses, não o que vence um micro-teste.
Erros comuns e armadilhas
Os problemas mais caros muitas vezes começam como “vitórias rápidas”. Ambos os bancos rodam um app CRUD transacional, mas hábitos errados prejudicam qualquer um quando tráfego e dados crescem.
Uma armadilha comum é tratar JSON como atalho para tudo. Um campo “extras” flexível é OK para dados realmente opcionais, mas campos centrais como status, timestamps e chaves estrangeiras devem ficar como colunas reais. Caso contrário você acaba com filtros lentos, validação complicada e refactors dolorosos quando relatórios viram prioridade.
Indexação tem sua própria armadilha: adicionar um índice para cada filtro que aparece numa tela. Índices aceleram leituras, mas desaceleram escritas e tornam migrações mais pesadas. Indexe o que os usuários realmente usam e valide com carga medida.
Migrações podem morder quando bloqueiam tabelas. Mudanças “big-bang” como reescrever uma coluna grande, adicionar NOT NULL com default ou criar um índice grande podem bloquear escritas por minutos. Divida mudanças arriscadas em passos e agende quando o app estiver quieto.
Também não dependa para sempre de defaults do ORM. Quando uma view de lista vai de 1.000 linhas para 10 milhões, você precisa ler planos de execução, identificar índices faltantes e corrigir joins lentos.
Sinais de alerta rápidos: campos JSON usados para filtragem e ordenação primárias, número de índices crescendo sem medir a performance de escrita, migrações que reescrevem grandes tabelas em um deploy, e paginação sem ordenação estável (que leva a linhas faltantes ou duplicadas).
Checklist rápido antes de se comprometer
Antes de escolher um lado, faça um reality check rápido baseado nas suas telas mais movimentadas e no seu processo de release.
- Suas telas principais podem se manter rápidas no pico? Teste a página de lista mais lenta com filtros reais, ordenação e paginação, e confirme que seus índices batem exatamente nessas queries.
- Você consegue fazer mudanças de esquema com segurança? Escreva um plano expand-backfill-contract para a próxima mudança crítica.
- Tem regra clara para JSON vs colunas? Decida quais chaves JSON devem ser buscáveis/ordenáveis e quais são realmente flexíveis.
- Está dependendo de recursos de consulta específicos? Cheque comportamento de upsert, funções de janela, CTEs e se precisa de índices funcionais ou parciais.
- Você consegue operar isso após o lançamento? Comprove que pode restaurar de backup, meça queries lentas e baselina latência e waits de lock.
Exemplo: de rastreamento simples de pedidos a um portal de clientes movimentado
Imagine um portal de clientes que começa simples: clientes fazem login, veem pedidos, baixam notas fiscais e abrem tickets. Na semana um, quase qualquer banco transacional parece OK. Páginas carregam rápido e o esquema é pequeno.
Alguns meses depois, os momentos de crescimento aparecem. Clientes pedem filtros como “pedidos enviados nos últimos 30 dias, pagos por cartão, com reembolso parcial.” Suporte quer exports rápidos para CSV para revisões semanais. Financeiro quer um trail de auditoria: quem mudou o status de uma nota, quando e de qual valor para qual.
É aí que a decisão vira sobre recursos específicos e como eles se comportam sob carga real.
Se você adicionar campos flexíveis (instruções de entrega, atributos customizados, metadata de tickets), suporte a JSON importa porque você vai querer eventualmente consultar dentro desses campos. Seja honesto sobre se sua equipe vai indexar caminhos JSON, validar formatos e manter a performance previsível conforme o JSON cresce.
Relatórios são outro ponto de pressão. No momento em que você junta pedidos, notas, pagamentos e tickets com muitos filtros, você vai se importar com índices compostos, planejamento de consultas e o quão fácil é evoluir índices sem downtime. Migrações também param de ser “rodar um script na sexta” e viram parte de cada release, porque uma pequena mudança de esquema pode tocar milhões de linhas.
Um caminho prático é anotar cinco telas reais e exports que você espera em seis meses, incluir tabelas de histórico de auditoria cedo, benchmark com tamanho de dados realista usando suas queries mais lentas (não um CRUD exemplo) e documentar regras de equipe para uso de JSON, indexação e migrações.
Se quiser avançar rápido sem construir cada camada manualmente, AppMaster (appmaster.io) pode gerar backends prontos para produção, web apps e apps móveis nativos a partir de um modelo visual. Ele também incentiva você a tratar telas, filtros e processos de negócio como cargas de consulta reais cedo, o que ajuda a identificar riscos de indexação e migração antes de chegarem à produção.
FAQ
Comece anotando sua carga real: suas telas de lista mais movimentadas, filtros, ordenações e caminhos de escrita em picos. Ambos podem rodar CRUD bem, mas a escolha mais segura é a que se encaixa em como você vai indexar, migrar e consultar seus dados no próximo ano — não necessariamente o nome que soa mais familiar.
Se páginas de lista ficam mais lentas conforme você avança nas páginas, provavelmente você está pagando o custo de varreduras com OFFSET. Se salvar às vezes trava nas horas de pico, pode haver contenção de locks ou transações longas. Se os deploys agora incluem backfills e índices grandes, as migrações viraram um problema de confiabilidade, não só uma mudança de esquema.
Padrão: um índice composto por tela importante, ordenado pelos filtros mais constantes primeiro e a coluna de ordenação por último. Por exemplo, listas multi-tenant costumam funcionar bem com (tenant_id, status, created_at), pois suporta filtro e ordenação sem varreduras extras.
Paginação por OFFSET fica mais lenta em páginas altas porque o banco ainda precisa avançar sobre as linhas anteriores. A paginação por keyset (usando o último created_at e id visto) mantém a performance mais estável e reduz duplicatas ou lacunas quando novas linhas chegam enquanto alguém está rolando.
Só adicione um índice quando você conseguir nomear a tela ou chamada de API exata que precisa dele, e reavalie após cada release. Índices sobrepostos demais podem desacelerar silenciosamente cada insert e update, fazendo seu app parecer “aleatoriamente” lento nos picos de escrita.
Use a abordagem expandir, backfill, contrair: adicione estruturas de forma compatível, faça o backfill em pequenos lotes, valide com constraints depois e só remova o caminho antigo após trocar leituras e escritas. Assim as releases ficam mais seguras quando tabelas são grandes e o tráfego é constante.
Mantenha JSON para dados tipo payload que são principalmente exibidos ou armazenados para debugging, e promova campos para colunas reais quando você filtrar, ordenar ou reportar sobre eles regularmente. Isso evita consultas lentas demais baseadas em JSON e facilita aplicar constraints como valores obrigatórios.
Upserts se tornam essenciais quando retries viram algo normal (redes móveis, jobs em background, timeouts). PostgreSQL usa INSERT ... ON CONFLICT, enquanto MariaDB usa INSERT ... ON DUPLICATE KEY UPDATE; em ambos os casos, defina chaves únicas cuidadosamente para que retries não criem duplicatas.
Mantenha transações curtas, evite chamadas de rede dentro da transação e reduza "linhas quentes" que todo mundo atualiza (como contadores compartilhados). Quando conflitos ocorrerem, tente reexecutar ou mostre um conflito claro ao usuário para que edições não se percam silenciosamente.
Sim, se você tolera um pouco de lag em páginas de leitura intensiva como dashboards e relatórios. Mantenha leituras críticas imediatas no primário (por exemplo, logo após criar um pedido) e monitore o lag de replicação para não mostrar dados surpreendentemente desatualizados.


