PostgreSQL no lugar de Redis, Elasticsearch e Kafka: arquitetura, código, benchmarks e limites

Published on: 2026-07-21
Post image
pt postgresql redis elasticsearch kafka backend arquitetura cache full-text-search filas mensageria python benchmark performance devops

É fácil entender como uma aplicação termina com PostgreSQL, Redis, Elasticsearch e Kafka ao mesmo tempo. O banco guarda o estado oficial, o Redis acelera leituras, o Elasticsearch responde buscas e o Kafka transporta eventos. Cada escolha parece correta isoladamente. O problema aparece depois: quatro sistemas significam quatro modelos de consistência, quatro rotinas de backup, quatro formas de monitorar, quatro políticas de segurança e uma coleção de sincronizações que podem falhar justamente entre uma ferramenta e outra.

Daí nasce uma ideia provocativa: e se o PostgreSQL assumisse também o cache, a busca e parte da mensageria? Ele tem JSONB, índices GIN e GiST, full-text search, tabelas não registradas em WAL, bloqueio de linhas, SKIP LOCKED, LISTEN/NOTIFY, replicação lógica e transações. Em muitos produtos, isso é suficiente para retirar três dependências sem perder a propriedade que realmente importa.

Mas “suficiente” não significa “equivalente”. PostgreSQL não vira um Redis em memória, um mecanismo distribuído de relevância como Elasticsearch ou um log particionado como Kafka. Este artigo mostra exatamente onde a consolidação funciona, como implementá-la, quais garantias cada exemplo oferece, como medir o resultado e em que ponto as ferramentas especializadas voltam a ser a escolha correta.

A tese correta não é substituir três produtos por decreto. É evitar sistemas distribuídos antes de o problema exigir suas garantias — e manter cada ferramenta especializada quando sua semântica for indispensável.

Índice completo do artigo

1 O que essa substituição realmente quer dizer

O relato que inspirou este estudo descreve uma equipe que retirou Redis, Elasticsearch e Kafka e concentrou casos de uso no PostgreSQL. A conclusão mais útil não é que todas as empresas deveriam repetir a troca. É que muitos projetos instalam infraestrutura para um volume imaginado e passam anos pagando a complexidade antes de alcançar o volume que a justificaria.

Na prática, “substituir” pode significar coisas muito diferentes. Um cache de respostas com alguns milhões de chaves e TTL de minutos é diferente de um contador distribuído com latência submilissegundo. Buscar título e descrição em cem mil produtos é diferente de pesquisar bilhões de documentos em vários idiomas. Executar jobs em segundo plano é diferente de preservar um log de eventos por meses para centenas de grupos consumidores.

1.1 O custo invisível de quatro fontes de estado

Imagine a atualização de um produto. A transação confirma o novo preço no PostgreSQL, mas o processo cai antes de invalidar o Redis. O índice do Elasticsearch pode receber a alteração segundos depois, enquanto um evento duplicado entra no Kafka durante o retry. Nenhum componente está necessariamente “quebrado”; a inconsistência nasceu nos espaços entre eles.

A equipe precisa decidir se usa dual write, CDC, outbox, reconciliação periódica ou consistência eventual. Também precisa operar clusters, credenciais, upgrades, métricas, retenção e restauração. Essa infraestrutura pode ser necessária, mas nunca é gratuita.

Comparação entre arquitetura fragmentada com conexões complexas e plataforma consolidada com camadas de cache, busca e filas
Consolidar reduz sincronizações e pontos de falha, mas concentra carga e aumenta a importância de isolar cada tipo de trabalho.

1.2 Consolidação não é equivalência

O PostgreSQL consegue oferecer a capacidade necessária para muitos casos, mas por mecanismos diferentes. Linhas permanecem em páginas compartilhadas de memória e disco; não são estruturas exclusivamente em RAM como no Redis. GIN cria um índice invertido, mas não entrega sozinho todo o ecossistema de análise e distribuição do Elasticsearch. SKIP LOCKED permite consumidores concorrentes, mas uma tabela de jobs não se torna um log Kafka reexecutável.

Essa distinção protege o projeto de dois erros opostos: adotar três clusters cedo demais ou insistir em um único banco quando a carga já exige especialização.

2 Comece pelas garantias, não pelas ferramentas

Antes de remover qualquer componente, escreva o contrato do caso de uso. Para cache: qual latência p99, cardinalidade, taxa de acerto, política de expulsão e tolerância a perda? Para busca: quais idiomas, operadores, relevância, tolerância a erros, volume e frequência de atualização? Para eventos: é tarefa ou log, quantas vezes pode executar, precisa preservar ordem, reter histórico ou permitir replay?

2.1 Matriz de decisão

NecessidadePostgreSQL atende bem quandoEspecialista ganha quando
CacheOs dados são derivados do próprio banco, a latência aceita rede + SQL e consistência vale mais que microssegundos.Há altíssimo throughput, estruturas atômicas em memória, expiração/eviction sofisticada ou p99 submilissegundo.
BuscaO corpus cabe no banco, a linguagem é conhecida e FTS + trigramas entregam relevância suficiente.São necessários shards independentes, analyzers complexos, busca vetorial distribuída, agregações pesadas ou relevância avançada.
JobsO trabalho nasce junto aos dados transacionais, tem volume moderado e pode ser processado com retry/idempotência.É preciso um broker com roteamento amplo, isolamento operacional ou picos que não podem pressionar o banco principal.
Log de eventosRetenção curta, poucos consumidores e outbox resolvem a integração.Replay independente, retenção longa, particionamento, streaming contínuo e muitos grupos consumidores são requisitos centrais.

3 PostgreSQL como cache compartilhado

O primeiro ganho é eliminar a duplicação. Se o valor cacheado depende apenas de tabelas do PostgreSQL, armazená-lo no mesmo banco permite preencher ou invalidar o cache dentro da transação que altera o dado original. Isso remove a janela na qual a escrita confirmou, mas a invalidação externa falhou.

3.1 Tabela, TTL e UPSERT

CREATE SCHEMA IF NOT EXISTS platform;

    CREATE TABLE platform.cache_entries (
    cache_key   text PRIMARY KEY,
    value       jsonb NOT NULL,
    expires_at  timestamptz NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT clock_timestamp()
    );

    CREATE INDEX cache_entries_expires_idx
    ON platform.cache_entries (expires_at);

    INSERT INTO platform.cache_entries (cache_key, value, expires_at)
    VALUES ('product:42', '{"id":42,"name":"Teclado"}', now() + interval '5 minutes')
    ON CONFLICT (cache_key) DO UPDATE
    SET value = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at,
    updated_at = clock_timestamp();

    SELECT value
    FROM platform.cache_entries
    WHERE cache_key = 'product:42'
  AND expires_at > now();

A chave primária torna a leitura direta. O índice de expires_at serve à limpeza em lotes; não tente criar um índice parcial com WHERE expires_at > now(), porque o predicado de um índice precisa ser estável para os dados indexados e now() muda com o tempo.

Expiração lógica e exclusão física são coisas diferentes. Assim que o TTL vence, a consulta deixa de devolver o valor. Um job separado pode apagar entradas antigas em pequenos lotes para não criar picos de WAL e autovacuum:

WITH expired AS (
    SELECT cache_key
    FROM platform.cache_entries
    WHERE expires_at <= now()
    ORDER BY expires_at
    LIMIT 5000
    )
    DELETE FROM platform.cache_entries c
    USING expired e
    WHERE c.cache_key = e.cache_key;

3.2 Implementação Python completa

O repositório abaixo usa Psycopg 3 e pool de conexões. Ele diferencia miss de um valor JSON nulo, parametriza todas as consultas e mantém o TTL no relógio do banco.

from __future__ import annotations

from datetime import timedelta
from typing import Any, Callable, TypeVar

from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool

T = TypeVar("T")
MISS = object()


class PostgresCache:
    def __init__(self, pool: ConnectionPool):
        self.pool = pool

    def get(self, key: str) -> Any:
        with self.pool.connection() as conn:
            row = conn.execute(
                """
                SELECT value
                FROM platform.cache_entries
                WHERE cache_key = %s AND expires_at > now()
                """,
                (key,),
            ).fetchone()
            return MISS if row is None else row["value"]

    def set(self, key: str, value: Any, ttl: timedelta) -> None:
        with self.pool.connection() as conn:
            conn.execute(
                """
                INSERT INTO platform.cache_entries(cache_key, value, expires_at)
                VALUES (%s, %s, now() + %s)
                ON CONFLICT (cache_key) DO UPDATE
                SET value = EXCLUDED.value,
                    expires_at = EXCLUDED.expires_at,
                    updated_at = clock_timestamp()
                """,
                (key, value, ttl),
            )

    def delete(self, key: str) -> None:
        with self.pool.connection() as conn:
            conn.execute(
                "DELETE FROM platform.cache_entries WHERE cache_key = %s",
                (key,),
            )

    def get_or_compute(
        self, key: str, ttl: timedelta, loader: Callable[[], T]
    ) -> T:
        cached = self.get(key)
        if cached is not MISS:
            return cached
        value = loader()
        self.set(key, value, ttl)
        return value


pool = ConnectionPool(
    "postgresql://app:secret@localhost/app",
    min_size=2,
    max_size=20,
    kwargs={"row_factory": dict_row},
)
cache = PostgresCache(pool)

Uma otimização comum é colocar uma pequena LRU dentro de cada processo e usar PostgreSQL como segundo nível. A memória local absorve as chaves mais quentes; o banco compartilha os demais resultados entre instâncias. Esse desenho reduz consultas sem introduzir outro cluster.

3.3 Stampede, locks e invalidação

Se uma chave popular expirar, cem requisições podem tentar recomputá-la ao mesmo tempo. Um advisory lock transacional, derivado da chave, permite que apenas uma faça o trabalho. As demais esperam e leem o resultado. Como colisões de hash são possíveis, o código sempre precisa verificar a chave real depois do lock.

BEGIN;
    SELECT pg_advisory_xact_lock(hashtextextended('product:42', 0));

    SELECT value
    FROM platform.cache_entries
    WHERE cache_key = 'product:42' AND expires_at > now();

    -- Se ainda for miss, calcule e faça o UPSERT na mesma transação.
    COMMIT;

Para invalidação, a melhor situação é quando a própria transação conhece as chaves afetadas:

BEGIN;
    UPDATE catalog.products SET price = 199.90 WHERE id = 42;
    DELETE FROM platform.cache_entries
    WHERE cache_key IN ('product:42', 'home:featured-products');
    COMMIT;

Essa atomicidade é a grande vantagem sobre um cache externo. Se a transação falhar, nem o produto nem o cache mudam. Se confirmar, ambos mudam juntos.

3.4 UNLOGGED, materialized views e limites

Uma tabela UNLOGGED reduz escrita em WAL e pode servir a valores totalmente descartáveis. O preço é importante: ela não é crash-safe, pode ser truncada após falha e não é replicada para standbys. Portanto, não armazene nela sessão, rate limit ou qualquer estado cuja perda altere o comportamento correto.

CREATE UNLOGGED TABLE platform.disposable_cache (
    cache_key text PRIMARY KEY,
    value jsonb NOT NULL,
    expires_at timestamptz NOT NULL
    );

Para agregações atualizadas em intervalos maiores, uma materialized view pode ser melhor que uma tabela chave-valor. Ela guarda o resultado de uma consulta e pode ser atualizada com REFRESH MATERIALIZED VIEW CONCURRENTLY quando existe índice único apropriado. Ainda assim, refresh não é TTL automático e pode consumir I/O considerável.

Redis continua melhor para estruturas em memória, expiração de enorme cardinalidade, contadores e operações atômicas específicas, distributed locks já padronizados, pub/sub efêmero de altíssima taxa e latência extremamente baixa. O cache PostgreSQL deve ser escolhido porque sua latência medida atende o SLO, não porque “já temos o banco”.

4 PostgreSQL como mecanismo de busca

A busca textual do PostgreSQL não é um ILIKE '%texto%' disfarçado. O banco transforma documentos em tsvector: tokens normalizados, com posições e pesos. A consulta vira um tsquery. O operador @@ decide o match e um índice GIN localiza os documentos por uma estrutura invertida.

Banco gerando índice invertido e selecionando documentos relevantes com busca textual e trigramas
Full-text search resolve linguagem, operadores e ranking; pg_trgm complementa o fluxo quando o usuário digita parcialmente ou comete erros.

4.1 Full-text search, pesos e índice GIN

CREATE EXTENSION IF NOT EXISTS pg_trgm;

    CREATE TABLE catalog.articles (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title       text NOT NULL,
    summary     text NOT NULL DEFAULT '',
    body        text NOT NULL,
    published_at timestamptz,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('portuguese', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('portuguese', coalesce(summary, '')), 'B') ||
        setweight(to_tsvector('portuguese', coalesce(body, '')), 'D')
    ) STORED
    );

    CREATE INDEX articles_search_gin
    ON catalog.articles USING GIN (search_vector);

    CREATE INDEX articles_title_trgm
    ON catalog.articles USING GIN (title gin_trgm_ops);

O título recebe peso A, o resumo B e o corpo D. Assim, “PostgreSQL” no título tende a valer mais que a mesma palavra no fim do corpo. Usar explicitamente a configuração portuguese evita que a sessão altere silenciosamente a análise. O valor gerado fica consistente com a linha sem trigger manual.

4.2 Consulta segura, ranking e destaque

WITH q AS (
    SELECT websearch_to_tsquery('portuguese', $1) AS query
    )
    SELECT
    a.id,
    a.title,
    ts_rank_cd(a.search_vector, q.query, 32) AS rank,
    ts_headline(
        'portuguese',
        a.summary,
        q.query,
        'StartSel=<mark>, StopSel=</mark>, MaxWords=28, MinWords=12'
    ) AS excerpt
    FROM catalog.articles a
    CROSS JOIN q
    WHERE a.search_vector @@ q.query
    ORDER BY rank DESC, a.published_at DESC NULLS LAST, a.id DESC
    LIMIT 20;

websearch_to_tsquery é adequado à entrada humana: aceita texto simples, frases entre aspas, OR e exclusão com hífen, sem lançar erro de sintaxe por pontuação aleatória. ts_rank_cd considera proximidade dos termos e a normalização 32 comprime o score entre zero e um; isso não transforma o valor em probabilidade.

O ranking real normalmente combina relevância textual com sinais do domínio. Um artigo novo ou um produto disponível pode receber bônus. Faça isso explicitamente e teste com uma coleção de consultas reais:

ORDER BY
    (0.75 * ts_rank_cd(a.search_vector, q.query, 32)) +
    (0.20 * exp(-extract(epoch FROM (now() - a.published_at)) / 2592000)) +
    (0.05 * CASE WHEN a.featured THEN 1 ELSE 0 END) DESC;

4.3 Erros de digitação com pg_trgm

Full-text search normaliza palavras, mas não corrige automaticamente “postgrees”. A extensão pg_trgm divide texto em trigramas e calcula similaridade. Seus operator classes GIN e GiST aceleram similaridade, LIKE e ILIKE.

SELECT id, title, similarity(title, $1) AS score
    FROM catalog.articles
    WHERE title % $1
    ORDER BY score DESC, id DESC
    LIMIT 10;

Uma busca robusta tenta FTS primeiro e usa trigramas como fallback ou componente adicional. Não una tudo com OR sem analisar o plano: o planner pode escolher um caminho caro. Também limite tamanho da entrada, paginação e tempo de consulta para evitar que busca pública vire vetor de consumo de CPU.

4.4 Onde Elasticsearch continua superior

Elasticsearch foi construído para distribuir índices e consultas por shards, administrar analyzers, sinônimos, filtros, agregações, highlighting e múltiplos tipos de busca em escala. PostgreSQL é excelente quando busca e dados transacionais vivem próximos, mas perde atratividade quando o índice precisa escalar independentemente do OLTP, quando as agregações concorrem com pedidos e pagamentos ou quando a relevância exige pipelines especializados.

O sinal de saída não é apenas quantidade de linhas. Observe p95/p99, cache hit dos índices, tamanho do GIN, tempo de atualização, impacto no WAL, CPU durante consultas e qualidade dos primeiros resultados. Uma busca rápida e irrelevante também falhou.

5 PostgreSQL como fila durável

Uma fila transacional é o caso em que PostgreSQL pode entregar um benefício difícil de reproduzir com um broker externo: criar o trabalho na mesma transação que cria o dado de negócio. Se um pedido só existe quando o envio de e-mail foi agendado, as duas linhas confirmam ou falham juntas.

5.1 Modelo de jobs e índices

CREATE TYPE platform.job_status AS ENUM (
    'pending', 'running', 'succeeded', 'dead'
    );

    CREATE TABLE platform.jobs (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    queue           text NOT NULL,
    payload         jsonb NOT NULL,
    status          platform.job_status NOT NULL DEFAULT 'pending',
    priority        integer NOT NULL DEFAULT 100,
    attempts        integer NOT NULL DEFAULT 0,
    max_attempts    integer NOT NULL DEFAULT 8,
    run_at          timestamptz NOT NULL DEFAULT now(),
    locked_at       timestamptz,
    locked_by       text,
    last_error      text,
    idempotency_key text,
    created_at      timestamptz NOT NULL DEFAULT now(),
    finished_at     timestamptz
    );

    CREATE UNIQUE INDEX jobs_idempotency_idx
    ON platform.jobs (queue, idempotency_key)
    WHERE idempotency_key IS NOT NULL;

    CREATE INDEX jobs_ready_idx
    ON platform.jobs (queue, priority, run_at, id)
    WHERE status = 'pending';

    CREATE INDEX jobs_running_idx
    ON platform.jobs (locked_at)
    WHERE status = 'running';

O índice de disponibilidade contém apenas jobs pendentes. run_at permite agendamento e backoff. O índice único parcial da chave de idempotência impede a mesma intenção de entrar duas vezes, mas permite que jobs sem essa chave sejam criados normalmente.

5.2 Claim atômico com SKIP LOCKED

WITH picked AS (
    SELECT id
    FROM platform.jobs
    WHERE queue = $1
      AND status = 'pending'
      AND run_at <= now()
    ORDER BY priority ASC, run_at ASC, id ASC
    FOR UPDATE SKIP LOCKED
    LIMIT $2
    )
    UPDATE platform.jobs j
    SET status = 'running',
    locked_at = clock_timestamp(),
    locked_by = $3,
    attempts = attempts + 1
    FROM picked
    WHERE j.id = picked.id
    RETURNING j.*;

FOR UPDATE impede outro consumidor de reclamar as mesmas linhas. SKIP LOCKED manda cada worker ignorar linhas já bloqueadas e procurar as próximas, comportamento que a própria documentação aponta como apropriado para tabelas semelhantes a filas. O CTE e o UPDATE ... RETURNING fazem seleção e mudança de estado no mesmo comando.

A ordem é aproximadamente prioridade, horário e ID entre jobs disponíveis. Não prometa ordem global estrita com workers paralelos: o job 2 pode terminar antes do job 1. Quando a ordem por entidade importa, particione logicamente por chave ou serialize aquele grupo.

5.3 Worker Python, retry e dead letter

import logging
import os
import socket
import time
from collections.abc import Callable
from typing import Any

from psycopg import Connection
from psycopg.rows import dict_row

log = logging.getLogger(__name__)
WORKER_ID = f"{socket.gethostname()}:{os.getpid()}"

CLAIM_SQL = """
WITH picked AS (
    SELECT id FROM platform.jobs
    WHERE queue=%s AND status='pending' AND run_at <= now()
    ORDER BY priority, run_at, id
    FOR UPDATE SKIP LOCKED
    LIMIT %s
)
UPDATE platform.jobs j
SET status='running', locked_at=clock_timestamp(),
    locked_by=%s, attempts=attempts + 1
FROM picked
WHERE j.id=picked.id
RETURNING j.*
"""


def claim(conn: Connection, queue: str, batch: int = 10) -> list[dict[str, Any]]:
    with conn.transaction():
        return conn.execute(CLAIM_SQL, (queue, batch, WORKER_ID)).fetchall()


def succeed(conn: Connection, job_id: int) -> None:
    conn.execute(
        """UPDATE platform.jobs
           SET status='succeeded', finished_at=clock_timestamp(),
               locked_at=NULL, locked_by=NULL
           WHERE id=%s AND locked_by=%s""",
        (job_id, WORKER_ID),
    )


def fail(conn: Connection, job: dict[str, Any], exc: Exception) -> None:
    terminal = job["attempts"] >= job["max_attempts"]
    delay = min(3600, 2 ** min(job["attempts"], 10))
    conn.execute(
        """UPDATE platform.jobs
           SET status = CASE WHEN %s THEN 'dead'::platform.job_status
                             ELSE 'pending'::platform.job_status END,
               run_at = CASE WHEN %s THEN run_at
                             ELSE now() + make_interval(secs => %s) END,
               last_error=%s, locked_at=NULL, locked_by=NULL,
               finished_at=CASE WHEN %s THEN clock_timestamp() ELSE NULL END
           WHERE id=%s AND locked_by=%s""",
        (terminal, terminal, delay, str(exc)[:4000], terminal, job["id"], WORKER_ID),
    )


def run(dsn: str, handlers: dict[str, Callable[[dict], None]]) -> None:
    with Connection.connect(dsn, row_factory=dict_row) as conn:
        while True:
            jobs = claim(conn, "default")
            if not jobs:
                time.sleep(0.5)
                continue

            for job in jobs:
                try:
                    handlers[job["payload"]["type"]](job["payload"])
                    succeed(conn, job["id"])
                    conn.commit()
                except Exception as exc:
                    conn.rollback()
                    fail(conn, job, exc)
                    conn.commit()
                    log.exception("job %s falhou", job["id"])

O exemplo confirma o claim antes de executar a operação externa. Isso evita manter uma transação e locks durante uma chamada lenta, mas produz semântica at least once: se o processo enviar o e-mail e morrer antes de marcar sucesso, o job será retomado e pode executar de novo. A correção vem da idempotência, não de fingir que o intervalo entre dois sistemas é atômico.

5.4 Idempotência e recuperação de workers

Um reaper devolve jobs abandonados para a fila. O timeout deve ser maior que a duração saudável do trabalho ou combinado com heartbeat; caso contrário, um job lento será executado em paralelo por outro worker.

UPDATE platform.jobs
    SET status = 'pending',
    locked_at = NULL,
    locked_by = NULL,
    run_at = now() + interval '5 seconds',
    last_error = concat_ws(E'\n', last_error, 'worker lease expired')
    WHERE status = 'running'
  AND locked_at < now() - interval '15 minutes';

O handler também precisa de uma chave idempotente no destino. Para um webhook, envie o ID do evento; para uma cobrança, use a idempotency key aceita pelo provedor; para uma escrita local, grave o job processado em uma tabela com restrição única dentro da mesma transação.

Fila durável no banco distribuindo jobs entre workers com retry e dead letter
O banco coordena o claim; retry, lease, idempotência e dead letter completam a semântica que um simples SELECT não oferece.

6 LISTEN/NOTIFY sem transformar sinal em fila

Polling a cada meio segundo é simples, porém cria consultas vazias e adiciona atraso. NOTIFY pode acordar os workers assim que um job é inserido. O payload deve carregar apenas uma pista, como o nome da fila; a tabela continua sendo a fonte durável.

INSERT INTO platform.jobs(queue, payload)
    VALUES ('emails', '{"type":"welcome","user_id":42}');

    SELECT pg_notify('jobs_ready', 'emails');

Notificações só são entregues a sessões que já estão ouvindo, aparecem depois do commit e possuem payload pequeno. Se o worker estiver desconectado, não recebe o sinal. Por isso, ao conectar ele deve executar LISTEN, confirmar a transação e então consultar a tabela; depois combina notificações com polling de segurança. A documentação também alerta para a corrida inicial entre registrar o listener e inspecionar o estado.

import select
from psycopg import connect

with connect(DSN, autocommit=True) as listener:
    listener.execute("LISTEN jobs_ready")

    while True:
        # Sempre drene a tabela; a notificação apenas reduz o tempo até acordar.
        process_available_jobs()
        select.select([listener], [], [], 5.0)
        for notification in listener.notifies(timeout=0, stop_after=100):
            if notification.payload == "emails":
                process_available_jobs()

Uma fila baseada apenas em NOTIFY perde trabalho durante desconexões. Uma fila baseada na tabela, com NOTIFY como campainha, permanece correta mesmo quando a campainha falha.

7 Transactional outbox: dado e evento na mesma transação

O problema clássico de dual write aparece quando a aplicação salva um pedido e publica um evento em operações separadas. Se o banco confirmar e o broker falhar, o pedido existe sem evento. Se publicar primeiro e a transação falhar, consumidores enxergam algo que nunca existiu.

No outbox, o evento é uma linha escrita junto ao agregado:

BEGIN;

    INSERT INTO sales.orders(customer_id, total, status)
    VALUES (42, 299.90, 'confirmed')
    RETURNING id;

    INSERT INTO platform.outbox(
    aggregate_type, aggregate_id, event_type, payload
    )
    VALUES (
    'order', 987, 'order.confirmed',
    '{"order_id":987,"customer_id":42,"total":"299.90"}'
    );

    COMMIT;

Se o commit acontece, dado e evento existem. Se falha, nenhum existe. Isso não garante que o consumidor executará uma única vez; garante que a intenção de publicar não se perde entre o banco e o transporte.

7.1 Publicador e consumidores

A outbox pode ser processada pela mesma técnica de SKIP LOCKED. Se o PostgreSQL é o destino final, consumidores registram seus próprios checkpoints ou jobs. Se Kafka continua necessário, um relay publica no broker e marca a linha. Duplicidade ainda é possível se o relay publicar e cair antes de marcar; consumidores devem ser idempotentes.

CREATE TABLE platform.outbox (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id bigint NOT NULL,
    event_type text NOT NULL,
    payload jsonb NOT NULL,
    occurred_at timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz
    );

    CREATE INDEX outbox_pending_idx
    ON platform.outbox (id)
    WHERE published_at IS NULL;

Triggers podem preencher a outbox, mas escondem fluxo e precisam de testes cuidadosos. Em muitos domínios, inserir explicitamente no serviço de aplicação torna a intenção mais legível. CDC e logical decoding são alternativas quando não podemos alterar todos os escritores, porém replication slots exigem monitoramento: um consumidor parado pode reter WAL e encher disco.

8 Por que uma tabela não é um Kafka

Kafka mantém um log ordenado por partição. Consumidores avançam offsets próprios, diferentes grupos releem os mesmos registros e a retenção não depende de todos terem processado. Esse modelo é poderoso para streaming, replay, integração entre muitos sistemas e reconstrução de projeções.

Em uma fila PostgreSQL tradicional, um job muda de pendente para concluído e normalmente é removido depois. Vários workers dividem o trabalho, mas não representam automaticamente grupos independentes. Para imitar Kafka seria necessário guardar eventos imutáveis, offsets por consumidor, retenção, particionamento, backpressure e limpeza. É possível construir, mas a equipe começa a manter seu próprio broker.

Mantenha Kafka quando o produto exige:

  • retenção e replay de grande volume por longos períodos;
  • muitos grupos consumidores independentes;
  • ordem por chave e particionamento explícito;
  • stream processing contínuo e integração com seu ecossistema;
  • escala de escrita que precisa crescer separada do OLTP;
  • isolamento para que backlog de eventos não pressione o banco transacional.

Use a fila no PostgreSQL quando a unidade é “execute este trabalho” e o evento nasce junto ao dado. Use Kafka quando a unidade é “preserve este fato para que diversos consumidores possam percorrer o log”.

9 O preço operacional da consolidação

Retirar três clusters reduz despesas e mecanismos de sincronização, mas transforma PostgreSQL em uma plataforma ainda mais crítica. Busca, cache e jobs passam a disputar CPU, memória, I/O, conexões e WAL com as transações do produto. A arquitetura fica mais simples no desenho e exige mais disciplina dentro do banco.

9.1 WAL, bloat, autovacuum e índices

Cache com TTL e fila com mudanças frequentes criam muitas versões mortas de linha pelo MVCC. Autovacuum precisa removê-las. Índices demais multiplicam escrita e podem impedir HOT updates. Em tabelas quentes:

  • mantenha apenas índices usados por claim, limpeza e observabilidade;
  • evite atualizar colunas que participam de vários índices;
  • ajuste autovacuum por tabela em vez de desligá-lo;
  • monitore n_dead_tup, tempo de vacuum, tamanho, WAL e replica lag;
  • apague em lotes e considere particionamento temporal para outbox de alta retenção;
  • use EXPLAIN (ANALYZE, BUFFERS) com dados representativos.
SELECT relname, n_live_tup, n_dead_tup,
       last_autovacuum, autovacuum_count
    FROM pg_stat_user_tables
    WHERE schemaname = 'platform'
    ORDER BY n_dead_tup DESC;

    SELECT pg_size_pretty(pg_total_relation_size('platform.jobs'));
    SELECT pg_notification_queue_usage();

9.2 Isolamento, conexões e blast radius

Separe schemas, roles e pools. A API não precisa de permissão para reanimar dead letters; o worker não precisa alterar tabelas administrativas. Defina statement_timeout, lock_timeout e limites de pool diferentes. PgBouncer ajuda a controlar conexões, mas transaction pooling exige atenção a recursos dependentes de sessão, especialmente LISTEN e advisory locks de sessão.

Para reduzir blast radius, use pools ou réplicas distintas para busca somente leitura, limites de concorrência nos workers e circuit breakers que desaceleram jobs quando o OLTP sofre. Uma única instância não deve significar uma fila com concorrência infinita.

Backup também muda. Cache descartável não deveria inflar retenção; jobs pendentes e outbox devem estar no PITR. Teste restauração, não apenas criação de backup. Depois de um restore para um ponto anterior, integrações externas podem já ter executado efeitos; idempotência continua necessária.

10 Benchmark reproduzível e honesto

O relato de outra empresa mostra possibilidade, não capacidade do seu ambiente. Números publicados sem dataset, durabilidade, concorrência, hardware, aquecimento e percentis não servem para decidir arquitetura. O benchmark útil reproduz o caminho da aplicação e compara SLO, custo e operação, não apenas operações por segundo.

10.1 Cenários, comandos e métricas

Crie uma base exclusiva, gere volume semelhante ao futuro próximo e execute cada cenário em duas fases: aquecimento e medição. Registre throughput, p50, p95, p99, erros, CPU, IOPS, WAL, conexões e tamanho dos índices.

# Inicializa o pgbench e gera dados base
    createdb consolidation_bench
    pgbench -i -s 100 consolidation_bench

    # Cache: script com SELECT por chave e 10% de UPSERT
    pgbench -n -c 32 -j 8 -T 300 -P 10 \
  -f bench/cache-read.sql \
  -f bench/cache-read.sql \
  -f bench/cache-read.sql \
  -f bench/cache-read.sql \
  -f bench/cache-write.sql consolidation_bench

    # Fila: consumidores concorrentes reclamando lotes
    pgbench -n -c 16 -j 8 -T 300 -P 10 \
  -f bench/queue-claim.sql consolidation_bench

    # Busca: conjunto fixo de consultas reais
    pgbench -n -c 16 -j 8 -T 300 -P 10 \
  -f bench/search.sql consolidation_bench

Um script de cache parametrizado pode usar variáveis do próprio pgbench:

\set key random(1, 1000000)
    SELECT value
    FROM platform.cache_entries
    WHERE cache_key = 'product:' || :key
  AND expires_at > now();

Para comparar com Redis, Elasticsearch ou Kafka, mantenha o significado igual. Não compare um GET sem persistência com uma transação que gera WAL; não compare busca exata com ranking e highlighting; não compare enqueue em memória com entrega durável. Execute também o teste misto: pedidos + cache + buscas + workers, porque o risco real é interferência entre cargas.

CenárioDataset mínimoO que observar
Cache quenteDistribuição Zipf, valores com tamanhos reais e TTLs variadosHit rate, p99, buffer hit, conexões e escrita da limpeza
BuscaTextos e consultas coletadas do produtoQualidade top 10, p95, CPU, tamanho e atualização do GIN
FilaJobs curtos, longos, falhas e picosTempo até iniciar, throughput, locks, retries, bloat e backlog
FalhaWorker morto, listener desconectado, réplica atrasadaRecuperação, duplicidade, perda e tempo de drenagem

10.2 Como interpretar sem se enganar

O objetivo não é provar que PostgreSQL vence cada especialista na operação isolada. Ele pode ser mais lento por requisição e ainda vencer no sistema completo ao remover CDC, atrasos de indexação, cache inconsistente e custo operacional. O inverso também ocorre: economizar três serviços e degradar o p99 do checkout não é sucesso.

Defina limites antes do teste, por exemplo: busca p95 abaixo de 150 ms, jobs começando em até dois segundos, checkout sem regressão superior a 5%, recuperação de worker sem perda e custo mensal menor. Depois decida com os resultados, não mova a trave.

11 Migração gradual com rollback

  1. Inventarie semânticas. Liste comandos Redis, mappings e queries Elasticsearch, tópicos, partições, grupos e políticas Kafka. Nome do produto não descreve o uso.
  2. Escolha um caso reversível. Comece por um cache derivável, busca interna ou fila de baixo risco; não pelos pagamentos.
  3. Construa schema e observabilidade. Métricas de miss, latência, backlog, retries, dead letters, tamanho e autovacuum existem antes do tráfego.
  4. Faça shadow traffic. Escreva ou consulte os dois caminhos, devolvendo apenas o antigo. Compare conteúdo, ordem e tempo.
  5. Valide falhas. Mate workers, pause listeners, gere lock contention, reinicie o banco e restaure um backup.
  6. Migre por porcentagem. Use feature flag, tenant ou tipo de job. Preserve o caminho antigo durante a janela de confiança.
  7. Retire sincronizações. Só depois do cutover remova dual writes, índices externos e infraestrutura. Manter os dois indefinidamente preserva o pior dos dois mundos.

Rollback precisa ser desenhado antes. Para cache, basta voltar a ler do Redis e aquecê-lo. Para busca, mantenha a indexação até confirmar paridade. Para eventos, defina a fronteira de offsets e IDs para não perder nem duplicar uma faixa inteira durante a reversão.

12 Quando consolidar e quando não consolidar

Consolidar tende a funcionar muito bem em SaaS, backoffices, marketplaces médios e APIs cujo volume ainda cabe confortavelmente em uma instância PostgreSQL bem operada. O melhor caso é quando cache, busca e trabalho assíncrono derivam dos mesmos dados e a transação elimina inconsistências importantes.

Não consolide por ideologia quando:

  • o banco principal já opera perto do limite de CPU, I/O, WAL ou conexões;
  • o cache exige estruturas/latência que a tabela não entrega;
  • a busca precisa escalar e evoluir separadamente do OLTP;
  • Kafka é usado como log reexecutável, não apenas como fila cara;
  • times diferentes precisam de isolamento de falha e ciclo de vida próprio;
  • o custo de uma indisponibilidade conjunta é maior que a economia operacional.

Também existe um meio-termo: PostgreSQL para fila transacional e outbox, Redis apenas para o conjunto realmente quente, Elasticsearch só para a busca pública complexa e Kafka apenas para streams com replay. Arquitetura madura não maximiza nem minimiza ferramentas; ela justifica cada uma.

13 Conclusão

PostgreSQL pode assumir muito mais do que tabelas relacionais. JSONB e UPSERT constroem um cache coerente; tsvector, GIN e pg_trgm entregam busca competente; FOR UPDATE SKIP LOCKED coordena workers; LISTEN/NOTIFY reduz polling; e transactional outbox fecha a janela perigosa entre dado e evento.

O maior ganho costuma ser menos dramático que um gráfico de throughput: menos estados duplicados, menos falhas entre sistemas, menos runbooks e uma equipe capaz de compreender o fluxo inteiro. Ao mesmo tempo, o preço é concentrar cargas diferentes no banco mais importante da empresa. Sem limites, pools, autovacuum, testes de falha e métricas, simplicidade vira contenção.

Portanto, a pergunta não é “PostgreSQL substitui Redis, Elasticsearch e Kafka?”. A pergunta profissional é: “quais garantias deste caso de uso já existem no PostgreSQL, quanto custam sob a nossa carga e o que perderíamos ao abandonar a ferramenta especializada?”. Quando essa resposta é medida, consolidar deixa de ser moda e vira engenharia.

14 Referências