Por Que JSONB no PostgreSQL Pode Substituir o MongoDB em Projetos Reais Sem Perda de Performance

Published on: 2026-01-25
Post image
pt postgresql-jsonb mongo-db-vs-postgres jsonb-performance banco-de-dados-documentos postgres-como-mongodb arquitetura-de-dados-moderna benchmark-postgres-mongo banco-de-dados-para-eventos logs-em-postgres banco-de-dados-escalavel performance-banco

Comparar PostgreSQL com MongoDB costuma virar uma discussão sobre filosofia: dados “relacionais” contra dados “flexíveis”. Na prática, a decisão quase sempre é mais simples e mais concreta, porque envolve desempenho real, custos operacionais e o tipo de consulta que o sistema realmente executa no dia a dia.

O ponto central deste tema é entender por que o JSONB do Postgres (um tipo binário otimizado para armazenar e consultar JSON) frequentemente entrega desempenho competitivo, e às vezes superior, para cargas de “documentos” que muitas equipes associam automaticamente ao MongoDB. Também entra em cena a arquitetura: operar duas bases exige duplicar rotinas, monitoramento, incidentes e decisões de modelagem.

PostgreSQL, MongoDB e o que significa “documento” na prática

PostgreSQL é um banco de dados relacional, conhecido por tabelas, colunas e SQL (uma linguagem declarativa para consulta e manipulação de dados). MongoDB é um banco orientado a documentos, onde registros são “documentos” em formato parecido com JSON, com campos aninhados e listas. Em sistemas reais, “documento” geralmente significa eventos, logs e entidades com metadados variáveis. A confusão comum é assumir que, por ter JSON, o caso exige obrigatoriamente um banco de documentos.

No Postgres, o tipo JSONB permite armazenar documentos JSON com suporte a índices e operadores de consulta. Isso muda o jogo, porque deixa de ser apenas “guardar JSON em texto” e vira “consultar JSON com estrutura”. No MongoDB, a estrutura de documento é nativa e a linguagem de consulta foi desenhada para isso desde o início. A comparação correta não é ideológica, mas sim medir: índices usados, padrões de consulta e volume real.

O que é JSONB e por que ele costuma ser rápido

JSONB é uma representação binária do JSON dentro do Postgres, o que permite buscas e operações mais eficientes do que percorrer texto puro. O Postgres também possui um otimizador de consultas maduro, que decide planos de execução com base em estatísticas. Além disso, existe suporte a índices especializados como GIN (Generalized Inverted Index), úteis para acelerar buscas em estruturas do JSON. O resultado típico é que consultas por chave, existência de campo e filtros por valores podem ficar bem rápidas.

Uma vantagem prática é que o Postgres mantém consistência de transação com ACID (atomicidade, consistência, isolamento e durabilidade) e permite misturar dados tipados e dados semi-estruturados no mesmo banco. Isso reduz a necessidade de “um banco para cada coisa”. Quando a carga de dados “flexível” na verdade é bem repetitiva, o JSONB funciona como um caminho intermediário: flexível o suficiente e otimizado o suficiente. Esse cenário é muito comum em eventos e telemetria.

Estrutura de evento típica e por que ela influencia a escolha

Um formato recorrente em sistemas de eventos inclui um identificador de usuário, um nome de evento, metadados aninhados e listas de tags. Esse desenho parece “feito para MongoDB” porque envolve objetos dentro de objetos e arrays. Ao mesmo tempo, ele também é um caso onde o Postgres consegue indexar bem e filtrar com eficiência. O fator decisivo costuma ser o padrão de consulta: por usuário, por campo aninhado e por presença de tag.

Uma representação típica de evento pode ser armazenada inteira em um campo JSONB. Em muitos sistemas, a estrutura permanece idêntica por longos períodos, mesmo quando se espera mudanças constantes. Quando isso acontece, a promessa de “não precisar de migração” perde relevância, porque a migração não seria frequente. E se a estrutura é estável, surge ainda a opção de promover partes do JSONB para colunas tipadas, ganhando validação e desempenho.

{
  "user_id": "u_8x7k2m",
  "event": "page_view",
  "metadata": {
    "url": "/products/widget",
    "device": "mobile",
    "referrer": "google.com"
  },
  "tags": ["organic", "returning"]
}

Consultas reais: por que medir muda a decisão

Benchmarks úteis tentam reproduzir consultas reais extraídas de logs de aplicação. Isso evita um erro comum: comparar “consulta que ninguém faz” com “consulta crítica do sistema”. Três padrões aparecem o tempo todo: busca por ID, filtro por campo aninhado e checagem de “array contém”. Um quarto padrão, analítico, é agregação por período, usuário ou tipo de evento.

Quando o Postgres vence em várias dessas categorias, o motivo geralmente é uma combinação de índice correto, boa seleção de plano pelo otimizador e eficiência no processamento em lote. Quando o MongoDB vence em operação de array, costuma ser porque o modelo e os índices dele são muito diretos para esse tipo de consulta. Mesmo assim, ganhos pequenos em um único padrão podem não compensar perdas grandes no conjunto. A decisão fica mais clara quando a medição usa o mesmo hardware e dados equivalentes.

Modelando eventos no Postgres com JSONB de forma profissional

Uma forma sólida de começar é criar uma tabela de eventos com um campo jsonb e campos auxiliares tipados para aquilo que sempre existe. Campos tipados ajudam em filtros frequentes, ordenações e particionamento, além de reduzir custo de extração do JSON. Essa abordagem preserva flexibilidade, mas não sacrifica desempenho. Também facilita impor regras mínimas, como “todo evento precisa de user_id e event”.

A seguir está um exemplo completo em SQL com tabela, coluna JSONB e índices típicos. O índice BTREE atende bem filtros por valores simples e ordenação, enquanto o índice GIN é útil para consultas que exploram o conteúdo do JSONB. Uma escolha comum é indexar tanto chaves específicas quanto o documento quando há variedade de filtros. O equilíbrio certo depende do volume e da taxa de escrita.

-- Tabela para armazenar eventos com JSONB e campos auxiliares tipados
CREATE TABLE IF NOT EXISTS events (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- Campos tipados para filtros comuns
    user_id TEXT NOT NULL,
    event_name TEXT NOT NULL,

    -- Documento completo
    data JSONB NOT NULL
);

-- Índice para consultas frequentes por usuário e tempo
CREATE INDEX IF NOT EXISTS idx_events_user_created
ON events (user_id, created_at DESC);

-- Índice para filtrar por tipo de evento
CREATE INDEX IF NOT EXISTS idx_events_event_name
ON events (event_name);

-- Índice GIN no JSONB para buscas por conteúdo e operadores do JSONB
CREATE INDEX IF NOT EXISTS idx_events_data_gin
ON events USING GIN (data);

-- Exemplo de consulta: por user_id (campo tipado)
SELECT id, created_at, event_name
FROM events
WHERE user_id = 'u_8x7k2m'
ORDER BY created_at DESC
LIMIT 50;

-- Exemplo de consulta: campo aninhado (url em metadata) dentro do JSONB
SELECT id, created_at
FROM events
WHERE data #> '{metadata,url}' = '"/products/widget"';

-- Exemplo de consulta: array contém valor (tags contém "organic")
SELECT id, created_at
FROM events
WHERE data #> 'tags' @> '["organic"]'::jsonb;

Consultas por campos aninhados e arrays: operadores essenciais do JSONB

O Postgres oferece operadores específicos para navegar em JSONB. O operador -> acessa um campo retornando JSON, e o ->> retorna texto, o que é útil para comparar como string. Para percorrer caminhos aninhados, o operador #> retorna JSON e #>> retorna texto. Para “contém”, o operador @> verifica se um JSONB contém outro JSONB, funcionando bem para arrays e objetos.

Esses operadores ficam realmente rápidos quando combinados com índices adequados. Um índice GIN no documento inteiro atende bem muitas consultas com @>, mas pode ter custo de escrita maior. Para campos muito usados, índices por expressão podem ser melhores, porque indexam apenas o que interessa. Essa decisão costuma ser guiada por cardinalidade, frequência de consulta e custo aceitável de inserção.

A lista a seguir resume os operadores mais usados nesse tipo de modelagem.

  • ->: acessa um campo e retorna JSONB, útil para compor outras operações.
  • ->>: acessa um campo e retorna texto, útil para comparações diretas.
  • #> e #>>: acessa caminho aninhado usando um array de chaves.
  • @>: verifica se um JSONB contém outro, muito usado em arrays e objetos.
  • ?: verifica existência de chave em um objeto JSONB.

Agregações: por que SQL costuma vencer em análises

Consultas analíticas normalmente agregam eventos por período, por usuário, por tipo e por dimensões extraídas do JSON. O Postgres se destaca porque SQL é muito forte em GROUP BY, janelas (window functions) e otimizações do planejador. Além disso, filtros e agregações podem ser combinados com índices e particionamento por data. Isso tende a reduzir leituras e acelerar relatórios.

No MongoDB, o equivalente costuma ser o aggregation pipeline, que é poderoso, mas pode ser mais sensível a modelagem e a uso de memória. Em cargas mistas, onde a mesma base atende leitura transacional e análises moderadas, o Postgres frequentemente simplifica a arquitetura. Quando o volume analítico é muito alto, pode existir a necessidade de estratégias adicionais, mas o ponto principal é que SQL resolve uma grande faixa de casos com boa performance. O benefício adicional é manter consistência entre consultas simples e relatórios.

O exemplo a seguir mostra uma agregação típica contando eventos por dia e por tipo de evento.

SELECT
    date_trunc('day', created_at) AS dia,
    event_name,
    COUNT(*) AS total
FROM events
WHERE created_at >= now() - interval '30 days'
GROUP BY 1, 2
ORDER BY dia ASC, total DESC;

Quando MongoDB faz sentido e quando vira complexidade desnecessária

MongoDB costuma fazer sentido quando há documentos realmente polimórficos, isto é, com estruturas muito diferentes entre si, e quando a equipe explora fortemente recursos nativos dele. Também aparece em cenários de distribuição horizontal com sharding (particionamento automático por múltiplas máquinas) como exigência central, não como hipótese. Outro caso é quando o ecossistema já está todo desenhado ao redor da forma de trabalhar do Mongo, incluindo padrões de modelagem e operações. Nesses cenários, ele pode ser a ferramenta mais direta.

Já a complexidade aparece quando a base de documentos tem estrutura muito parecida e muda pouco ao longo do tempo. Nesse caso, a “flexibilidade” não é usada, mas a operação de um segundo banco continua existindo. Isso inclui pools de conexão separados, rotinas de backup e restauração duplicadas, e diagnósticos mais lentos em incidentes. O custo não é só financeiro: é cognitivo, porque decisões simples passam a ter duas versões.

Arquitetura com um único Postgres: tabelas relacionais, eventos JSONB e busca textual

Uma arquitetura comum e eficiente une dados relacionais tradicionais com eventos em JSONB no mesmo Postgres. Dados relacionais ficam em tabelas tipadas com chaves e relacionamentos, enquanto eventos ficam em uma tabela de append-only, isto é, que cresce com inserções. Quando há necessidade de busca por texto, o Postgres oferece full-text search (busca textual) para indexar e pesquisar conteúdo textual. Tudo isso reduz a fragmentação da plataforma.

O ganho operacional vem de unificar backup, observabilidade e controle de acesso. Um único mecanismo de replicação e uma única política de retenção simplificam decisões. Também há ganhos de consistência, porque transações podem envolver tabelas relacionais e eventos no mesmo banco. Isso evita sincronizações frágeis entre tecnologias diferentes.

Migração de MongoDB para Postgres: estratégia e cuidados essenciais

Migrar dados exige tratar volume, consistência e performance. A abordagem típica lê documentos do Mongo em lotes, transforma cada documento em JSON e insere no Postgres, também em lotes, para evitar overhead por registro. O tamanho do lote precisa respeitar memória e tempo de transação, porque transações enormes podem prender recursos e atrasar checkpoints. Também é importante registrar falhas e permitir retomada, para evitar reprocessar tudo do zero.

Além de mover dados, é comum definir colunas tipadas mínimas (como user_id e event_name) extraídas do documento, porque isso acelera consultas frequentes. Outra decisão é se o documento original será guardado integralmente para auditoria e reprocessamento. Em muitos sistemas, manter o JSONB completo dá segurança para evoluir sem perda de informação. Com esse desenho, uma aplicação pode continuar produzindo eventos sem depender de múltiplos bancos.

O exemplo a seguir mostra um script completo em Python usando pymongo e psycopg para migrar em lotes. Os comentários indicam apenas o essencial e mantêm o foco em robustez básica.

import os
import json
from datetime import datetime, timezone

from pymongo import MongoClient
import psycopg
from psycopg.types.json import Json


def extrair_texto(doc, chave, padrao=""):
    valor = doc.get(chave)
    if valor is None:
        return padrao
    return str(valor)


def migrar():
    mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
    pg_dsn = os.environ.get("PG_DSN", "postgresql://postgres:postgres@localhost:5432/postgres")

    cliente_mongo = MongoClient(mongo_uri)
    colecao = cliente_mongo["app"]["events"]

    conn_pg = psycopg.connect(pg_dsn)
    conn_pg.execute("SET statement_timeout = '0'")  # evita timeout durante migração grande

    with conn_pg.cursor() as cur_pg:
        cur_pg.execute("""
            CREATE TABLE IF NOT EXISTS events (
                id BIGSERIAL PRIMARY KEY,
                created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
                user_id TEXT NOT NULL,
                event_name TEXT NOT NULL,
                data JSONB NOT NULL
            );
        """)
        conn_pg.commit()

    tamanho_lote = 10_000
    lote = []

    cursor = colecao.find({}, no_cursor_timeout=True).batch_size(tamanho_lote)

    try:
        for doc in cursor:
            # Normaliza _id e datas para um JSON serializável
            doc_normalizado = dict(doc)
            if "_id" in doc_normalizado:
                doc_normalizado["_id"] = str(doc_normalizado["_id"])

            created_at = doc_normalizado.get("created_at")
            if isinstance(created_at, datetime):
                if created_at.tzinfo is None:
                    created_at = created_at.replace(tzinfo=timezone.utc)
            else:
                created_at = datetime.now(timezone.utc)

            user_id = extrair_texto(doc_normalizado, "user_id", padrao="desconhecido")
            event_name = extrair_texto(doc_normalizado, "event", padrao="desconhecido")

            lote.append((created_at, user_id, event_name, Json(doc_normalizado)))

            if len(lote) >= tamanho_lote:
                with conn_pg.cursor() as cur_pg:
                    cur_pg.executemany(
                        "INSERT INTO events (created_at, user_id, event_name, data) VALUES (%s, %s, %s, %s)",
                        lote
                    )
                conn_pg.commit()
                lote.clear()

        if lote:
            with conn_pg.cursor() as cur_pg:
                cur_pg.executemany(
                    "INSERT INTO events (created_at, user_id, event_name, data) VALUES (%s, %s, %s, %s)",
                    lote
                )
            conn_pg.commit()
            lote.clear()

    finally:
        cursor.close()
        conn_pg.close()
        cliente_mongo.close()


if __name__ == "__main__":
    migrar()

Como era e como fica: impacto operacional ao sair de duas bases

Manter dois bancos geralmente significa duplicar rotinas: dois agendamentos de backup, dois modelos de permissão e duas formas de monitorar latência e espaço. Também significa duas bibliotecas de acesso, dois pools de conexão e duas fontes de gargalo, aumentando o consumo de memória e complexidade no runtime. Em incidentes, a pergunta “qual banco está degradando?” vira parte do trabalho, atrasando o diagnóstico. Esse custo aparece em horas de engenharia e em risco operacional.

Ao consolidar em um único Postgres, essas duplicidades diminuem. O sistema passa a ter uma visão mais unificada de dados, facilitando auditoria e rastreio. A performance tende a ficar previsível porque o otimizador trabalha em um conjunto consistente de estatísticas. O benefício final costuma aparecer em menos incidentes e em menos pontos de falha.

O que os benchmarks realmente ensinam sobre decisão tecnológica

Benchmarks úteis revelam que o “banco certo” depende do padrão de consulta, não do rótulo do dado. Se a maioria das consultas é por chaves bem definidas, filtros por campos específicos e agregações, o Postgres tende a ser muito forte mesmo com JSONB. Se o sistema depende de documentos com estruturas extremamente diferentes, e consultas centradas nessa variabilidade, o MongoDB pode ser mais natural. A diferença prática aparece quando se mede com dados e consultas reais.

O ensinamento final é que JSONB não é um recurso “lento por ser JSON” por definição. Com índices corretos e modelagem pragmática, ele se comporta como um componente de primeira linha dentro do Postgres. Isso permite reduzir complexidade sem abrir mão de flexibilidade, especialmente em eventos e logs com estrutura estável. O tema se encerra com uma conclusão clara: a arquitetura mais simples que atende o desempenho medido costuma ser a mais sustentável.

Conclusão

PostgreSQL com JSONB pode ser mais rápido do que o senso comum imagina, inclusive em consultas típicas de documentos. A combinação de operadores de JSONB, índices como GIN e o poder do SQL em agregações cria um conjunto muito competitivo para eventos, logs e metadados. Quando a estrutura dos documentos é repetitiva e muda pouco, a vantagem operacional de consolidar dados em um único banco se torna significativa. O resultado é um sistema mais simples, com menos peças para operar e com desempenho alinhado às consultas que realmente importam.