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,
pgvectoradiciona 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:
enqueueinsere um job.?::jsonbconverte a string JSON para o tipoJSONB.processNextroda dentro de uma transação. A querySELECT ... FOR UPDATE SKIP LOCKEDbloqueia exatamente um job que ninguém recebeu.- Se
handlerexecuta com sucesso, o job é deletado e a transação é confirmada. - Se
handlerlanç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
statuselocked_until, ou migrar para uma biblioteca de fila especializada. Você também deve adicionar uma colunaattemptspara 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çãosimpleapenas converte para minúsculas, sem stemming. Essa escolha é apropriada para português, já que o PostgreSQL não tem dicionário português nativo.setweightatribui pesoAao nome eBà 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çãounaccentpadrão não é marcada comoIMMUTABLE, então para usá-la em coluna gerada ou índice, você precisa envolve-la em uma funçãoIMMUTABLEprópria. Para tolerar erros de digitação, você pode adicionar a extensãopg_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
WHEREcom alta seletividade com índice HNSW, o número de resultados retornados pode ser menor queLIMIT. 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/UPDATEcontí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.