Postgres faz tudo: fila, busca, vector em um banco de dados

1. Visão geral

Um sistema típico costuma ter muitos componentes: PostgreSQL armazenando dados, um message broker funcionando como fila, um search engine para buscas, e recentemente um vector database para recursos de IA. Cada componente vem com responsabilidades de operação, monitoramento, backup e sincronização de dados.

A tendência “Postgres faz tudo” faz a pergunta oposta: se o PostgreSQL já consegue fazer a maioria dessas coisas razoavelmente bem, precisamos realmente de mais um sistema? Neste artigo, você vai usar PostgreSQL como job queue, full-text search e vector search com Spring Boot 3.x. Você também vai entender quando parar e escolher uma ferramenta especializada.

2. Por que essa tendência chama atenção

Existem alguns motivos principais:

  • Reduz a complexidade operacional. Cada sistema adicionado é mais uma coisa que pode dar problema às 3 da manhã. Para times pequenos, esse custo geralmente supera os ganhos de desempenho.
  • Consistência transacional. Quando o pedido e a tarefa de enviar e-mail estão no mesmo banco de dados, você grava ambos em uma transação. Não há situação em que o pedido foi salvo mas a mensagem se perdeu, ou vice-versa.
  • Ecossistema de extensões. PostgreSQL tem um mecanismo de extensões poderoso. Por exemplo, pgvector adiciona um tipo de dado vetorial e índices para busca de vizinhos mais próximos.
  • IA puxando a demanda por armazenamento de embeddings. Muitos times querem adicionar busca semântica sem ter que manter um banco de dados separado só para isso.

O espírito dessa tendência é começar simples e separar quando os dados comprovarem necessidade. Não significa que o PostgreSQL substitua todos os sistemas.

3. Preparação

3.1. Dependências Maven

Você vai precisar do JDBC, driver PostgreSQL e Testcontainers para a parte de testes. Spring Boot já gerencia as versões, então não precisa declará-las:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3.2. Rodar PostgreSQL com pgvector pré-instalado

A imagem pgvector/pgvector vem com PostgreSQL e a extensão vector já inclusos. Você escolhe a tag correspondente à versão major do PostgreSQL que está usando:

services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"

Configure o Spring Boot para conectar e executar automaticamente schema.sql na inicialização:

spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.sql.init.mode=always

Em um projeto real, use Flyway ou Liquibase em vez de schema.sql.

4. Fila de jobs com FOR UPDATE SKIP LOCKED

4.1. Schema

Uma fila mínima precisa de apenas uma tabela:

CREATE TABLE IF NOT EXISTS job_queue (
    id         BIGSERIAL PRIMARY KEY,
    job_type   TEXT        NOT NULL,
    payload    JSONB       NOT NULL,
    run_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_job_queue_run_at ON job_queue (run_at);

A coluna run_at permite agendar a execução de um job. O índice em run_at ajuda o worker a pegar o job mais antigo sem escanear a tabela inteira.

4.2. Pegar e processar um job

O ponto crucial é a cláusula FOR UPDATE SKIP LOCKED. Vários workers rodam a mesma query. Cada worker bloqueia uma linha, e os outros workers pulam as linhas bloqueadas em vez de ficar esperando:

public record Job(long id, String type, String payload) {}

@Repository
public class JobQueue {

    private final JdbcTemplate jdbc;

    public JobQueue(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public void enqueue(String type, String payloadJson) {
        jdbc.update("INSERT INTO job_queue (job_type, payload) VALUES (?, ?::jsonb)",
                type, payloadJson);
    }

    @Transactional
    public boolean processNext(Consumer<Job> handler) {
        List<Job> jobs = jdbc.query("""
                SELECT id, job_type, payload::text
                FROM job_queue
                WHERE run_at <= now()
                ORDER BY run_at
                LIMIT 1
                FOR UPDATE SKIP LOCKED
                """,
                (rs, i) -> new Job(rs.getLong(1), rs.getString(2), rs.getString(3)));

        if (jobs.isEmpty()) {
            return false;
        }
        Job job = jobs.getFirst();
        handler.accept(job);
        jdbc.update("DELETE FROM job_queue WHERE id = ?", job.id());
        return true;
    }
}

Explicação:

  • enqueue insere um job. ?::jsonb converte a string JSON para o tipo JSONB.
  • processNext roda dentro de uma transação. A query SELECT ... FOR UPDATE SKIP LOCKED bloqueia exatamente um job que ninguém recebeu.
  • Se handler executa com sucesso, o job é deletado e a transação é confirmada.
  • Se handler lança uma exception, a transação sofre rollback. O job permanece na tabela e será processado novamente. Se o worker morre no meio, o PostgreSQL libera automaticamente o bloqueio quando a conexão fecha.

O worker pode ser um simples método @Scheduled:

@Component
public class EmailWorker {

    private final JobQueue jobQueue;

    public EmailWorker(JobQueue jobQueue) {
        this.jobQueue = jobQueue;
    }

    @Scheduled(fixedDelay = 1000)
    public void poll() {
        while (jobQueue.processNext(job ->
                System.out.println("Enviando e-mail para job " + job.id() + ": " + job.payload()))) {
            // continua até não haver mais jobs
        }
    }
}

Lembre-se de adicionar @EnableScheduling em uma classe de configuração. Quando há jobs, o console imprime:

Enviando e-mail para job 1: {"orderId": 42, "email": "an@example.com"}

Atenção: Manter a transação durante o processamento é muito simples e seguro quando o worker falha. A contrapartida é que a transação será prolongada pelo tempo de processamento do job. Se o job demora (chamada de API lenta, processamento de arquivo grande), você deve mudar para um modelo com colunas status e locked_until, ou migrar para uma biblioteca de fila especializada. Você também deve adicionar uma coluna attempts para evitar que um job com erro seja retentado infinitamente.

4.3. O verdadeiro benefício: enfileirar na mesma transação do negócio

Essa é a razão mais forte para usar Postgres como fila:

@Service
public class OrderService {

    private final JdbcTemplate jdbc;
    private final JobQueue jobQueue;

    public OrderService(JdbcTemplate jdbc, JobQueue jobQueue) {
        this.jdbc = jdbc;
        this.jobQueue = jobQueue;
    }

    @Transactional
    public void placeOrder(long orderId, String customerEmail) {
        jdbc.update("INSERT INTO orders (id, customer_email) VALUES (?, ?)", orderId, customerEmail);
        jobQueue.enqueue("SEND_CONFIRMATION_EMAIL",
                "{\"orderId\": %d, \"email\": \"%s\"}".formatted(orderId, customerEmail));
    }
}

O pedido e o job fazem commit ou rollback juntos. Com um broker separado, você teria que implementar o padrão transactional outbox para alcançar essa garantia. (O exemplo usa formatted para brevidade. Código real deve serializar o payload com Jackson para escapar caracteres corretamente.)

Se preferir não fazer polling contínuo, você pode combinar LISTEN/NOTIFY do PostgreSQL para acordar o worker quando há novos jobs. O polling ainda deve ser mantido como plano B.

5. Busca full-text com tsvector e índice GIN

5.1. Schema

PostgreSQL tem busca full-text nativa. Use uma coluna gerada automaticamente para atualizar o vetor de busca sempre que os dados mudam:

CREATE TABLE IF NOT EXISTS product (
    id            BIGSERIAL PRIMARY KEY,
    name          TEXT NOT NULL,
    description   TEXT NOT NULL,
    search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('simple', name), 'A') ||
        setweight(to_tsvector('simple', description), 'B')
    ) STORED
);

CREATE INDEX IF NOT EXISTS idx_product_search ON product USING GIN (search_vector);

Explicação:

  • to_tsvector('simple', ...) quebra o texto em tokens. A configuração simple apenas converte para minúsculas, sem stemming. Essa escolha é apropriada para português, já que o PostgreSQL não tem dicionário português nativo.
  • setweight atribui peso A ao nome e B à descrição. Resultados que correspondem ao nome serão classificados mais alto.
  • O índice GIN permite consultas rápidas na coluna tsvector.

5.2. Consultas

public record ProductHit(long id, String name, double rank) {}

@Repository
public class ProductSearch {

    private final JdbcTemplate jdbc;

    public ProductSearch(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public List<ProductHit> search(String userQuery) {
        return jdbc.query("""
                SELECT id, name, ts_rank(search_vector, q) AS rank
                FROM product, websearch_to_tsquery('simple', ?) AS q
                WHERE search_vector @@ q
                ORDER BY rank DESC
                LIMIT 10
                """,
                (rs, i) -> new ProductHit(rs.getLong("id"), rs.getString("name"), rs.getDouble("rank")),
                userQuery);
    }
}

websearch_to_tsquery aceita sintaxe similar a uma caixa de busca comum: "jaqueta de chuva" -comprido significa contém a frase “jaqueta de chuva” e não contém “comprido”. O operador @@ verifica se o vetor corresponde à query. ts_rank é usado para ordenar os resultados.

Com dados de exemplo contendo “Jaqueta à prova de chuva” e “Calça jeans” (descrição com palavra “chuva”), a query chuva retorna:

ProductHit[id=1, name=Jaqueta à prova de chuva, rank=0.6079271]
ProductHit[id=2, name=Calça jeans, rank=0.24317084]

Atenção: Usuários frequentemente digitam sem acentos (“jaqueta chuva”). Você pode usar a extensão unaccent. A função unaccent padrão não é marcada como IMMUTABLE, então para usá-la em coluna gerada ou índice, você precisa envolve-la em uma função IMMUTABLE própria. Para tolerar erros de digitação, você pode adicionar a extensão pg_trgm.

6. Busca vetorial com pgvector

6.1. Schema

Embeddings são arrays de números reais gerados por um modelo de IA. Textos com significado próximo produzem vetores próximos. Ative a extensão e adicione uma coluna:

CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE product ADD COLUMN IF NOT EXISTS embedding vector(3);

CREATE INDEX IF NOT EXISTS idx_product_embedding
    ON product USING hnsw (embedding vector_cosine_ops);

O exemplo usa 3 dimensões para facilitar leitura. Na prática, o número de dimensões deve corresponder exatamente com o modelo de embedding que você escolher, geralmente alguns cem até alguns milhares de dimensões. O índice HNSW permite encontrar vizinhos próximos muito rapidamente. vector_cosine_ops indica que o índice funciona com distância de cosseno.

6.2. Salvar e buscar por vetor

Nenhuma biblioteca adicional necessária. Você converte float[] para string no formato [0.1,0.2,0.3] e depois converte para ::vector:

@Repository
public class ProductVectorSearch {

    private final JdbcTemplate jdbc;

    public ProductVectorSearch(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public void saveEmbedding(long productId, float[] embedding) {
        jdbc.update("UPDATE product SET embedding = ?::vector WHERE id = ?",
                toLiteral(embedding), productId);
    }

    public List<String> findSimilar(float[] queryEmbedding, int limit) {
        return jdbc.queryForList("""
                SELECT name FROM product
                WHERE embedding IS NOT NULL
                ORDER BY embedding <=> ?::vector
                LIMIT ?
                """, String.class, toLiteral(queryEmbedding), limit);
    }

    private static String toLiteral(float[] v) {
        StringJoiner joiner = new StringJoiner(",", "[", "]");
        for (float f : v) {
            joiner.add(Float.toString(f));
        }
        return joiner.toString();
    }
}

O operador <=> calcula a distância de cosseno. ORDER BY ... LIMIT pega os produtos mais próximos. O vetor de consulta vem do mesmo modelo que gerou os embeddings dos produtos, por exemplo quando o usuário digita “roupas para usar quando chove”.

Como produtos e embeddings estão na mesma tabela, você combina filtros facilmente: WHERE category = 'jaquetas' AND price < 200 ORDER BY embedding <=> ?. Você também pode misturar pontuação de ts_rank com distância vetorial para fazer hybrid search. Com um banco de dados vetorial separado, você teria que sincronizar esses atributos lá.

Atenção: Ao combinar WHERE com alta seletividade com índice HNSW, o número de resultados retornados pode ser menor que LIMIT. Isso acontece porque o índice filtra depois de já ter percorrido um conjunto limitado de candidatos. Teste bem com dados reais e consulte os parâmetros de ajuste fino do pgvector.

7. Escrever testes com Testcontainers

Os testes rodam no PostgreSQL real, com a mesma imagem que tem pgvector. @ServiceConnection configura automaticamente o datasource:

@SpringBootTest
@Testcontainers
class PostgresFeaturesTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
            DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"));

    @Autowired JobQueue jobQueue;
    @Autowired ProductSearch productSearch;
    @Autowired ProductVectorSearch vectorSearch;
    @Autowired JdbcTemplate jdbc;

    @Test
    void processesEachJobExactlyOnce() {
        jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 1}");
        jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 2}");
        List<Long> processed = new ArrayList<>();

        while (jobQueue.processNext(job -> processed.add(job.id()))) { }

        assertThat(processed).hasSize(2).doesNotHaveDuplicates();
        assertThat(jdbc.queryForObject("SELECT count(*) FROM job_queue", Long.class)).isZero();
    }

    @Test
    void failedJobStaysInQueue() {
        jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 3}");

        assertThatThrownBy(() -> jobQueue.processNext(job -> {
            throw new IllegalStateException("SMTP fora do ar");
        })).isInstanceOf(IllegalStateException.class);

        assertThat(jdbc.queryForObject("SELECT count(*) FROM job_queue", Long.class)).isEqualTo(1);
    }

    @Test
    void searchesByTextAndVector() {
        jdbc.update("INSERT INTO product (name, description) VALUES ('Jaqueta à prova de chuva', 'Leve e resistente à água')");
        jdbc.update("INSERT INTO product (name, description) VALUES ('Calça jeans', 'Tecido grosso')");
        long jacketId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Jaqueta à prova de chuva'", Long.class);
        long jeansId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Calça jeans'", Long.class);
        vectorSearch.saveEmbedding(jacketId, new float[]{0.9f, 0.1f, 0.0f});
        vectorSearch.saveEmbedding(jeansId, new float[]{0.0f, 0.2f, 0.9f});

        assertThat(productSearch.search("chuva"))
                .extracting(ProductHit::name)
                .containsExactly("Jaqueta à prova de chuva");
        assertThat(vectorSearch.findSimilar(new float[]{1.0f, 0.0f, 0.0f}, 1))
                .containsExactly("Jaqueta à prova de chuva");
    }
}

O segundo teste verifica o comportamento mais importante da fila: quando o handler falha, o job não se perde. Os testes colocam dados direto em cada método. Em um projeto real, você deve limpar as tabelas no @BeforeEach para que os testes sejam independentes um do outro.

8. Quando NÃO usar Postgres para tudo

Concentrar tudo em um lugar também tem seu preço. Você deve considerar um sistema especializado quando:

  • Throughput de fila muito alta ou precisa streaming. Se você precisa fazer replay do fluxo de eventos, tem muitos grupos de consumers independentes, ou mantém mensagens por muito tempo, use Kafka ou similar. Filas em Postgres criam muitas dead tuples por causa de DELETE/UPDATE contínuo. Você precisa monitorar autovacuum com cuidado.
  • Busca complexa. Facetamento em múltiplas dimensões, sugestões ao digitar, suporte linguístico robusto para português, ajuste fino de relevância detalhado: um mecanismo de busca especializado faz muito melhor.
  • Quantidade de vetores muito grande. Índices HNSW precisam de muita RAM. Quando dados vetoriais ocupam a maior parte dos recursos, concorrem com sua carga de trabalho transacional principal.
  • Carga desigual causando contenção. Uma consulta vetorial pesada ou um lote de jobs descontrolado podem deixar lento o processamento de pagamentos. Réplicas de leitura ajudam a separar parte da carga de leitura, mas não resolvem completamente.
  • O time já opera bem um sistema especializado. Se a infraestrutura já existe e é estável, mover tudo de volta para Postgres só para “simplificar” pode não valer a pena.

Atenção: Uma estratégia sensata é encapsular cada funcionalidade atrás de uma interface (JobQueue, ProductSearch…). Quando métricas de monitoramento mostram que o Postgres é o gargalo, você troca a implementação embaixo sem tocar no código de negócio.

9. Conclusão

PostgreSQL é capaz de gerenciar filas de jobs graças ao FOR UPDATE SKIP LOCKED, busca full-text com tsvector e índice GIN, e busca vetorial através da extensão pgvector. O maior benefício é ter menos sistemas para operar e poder gravar dados de negócio, jobs e embeddings todos em uma mesma transação.

Veja isso como seu ponto de partida padrão para sistemas pequenos e médios, não como destino final. Comece com Postgres, meça cuidadosamente, e só separe para ferramentas especializadas quando os dados comprovarem que é necessário.

postgresqlspring-bootpgvectorfull-text-searcharquitetura