Postgres lo hace todo: colas, búsqueda, vectores en una base de datos
1. Descripción general
Un sistema típico suele tener muchos componentes: PostgreSQL almacena datos, un message broker actúa como cola, un motor de búsqueda realiza búsquedas, y últimamente se agrega una base de datos vectorial para características de IA. Cada componente trae consigo tareas operacionales, monitoreo, copias de seguridad y sincronización de datos.
La tendencia “Postgres lo hace todo” plantea la pregunta inversa: si PostgreSQL ya puede hacer la mayoría de esas cosas de manera suficientemente buena, ¿realmente necesitas agregar más sistemas? En este artículo, usarás PostgreSQL como job queue, full-text search y vector search con Spring Boot 3.x. También aprenderás cuándo deberías detenerte y elegir herramientas especializadas.
2. Por qué esta tendencia atrae atención
Hay varias razones principales:
- Reducir la complejidad operacional. Cada sistema adicional es algo más que puede fallar en mitad de la noche. Para equipos pequeños, este costo suele ser mayor que el beneficio de rendimiento.
- Consistencia transaccional. Cuando el pedido y el job de envío de correo están en la misma base de datos, escribes ambos en una única transacción. No hay situaciones donde el pedido se guarda pero el mensaje se pierde, o viceversa.
- Ecosistema de extensiones. PostgreSQL tiene un mecanismo de extensiones sólido. Por ejemplo,
pgvectorañade un tipo de dato vectorial e índices de búsqueda del vecino más cercano. - Las características de IA conllevan la necesidad de almacenar embeddings. Muchos equipos quieren agregar búsqueda semántica sin necesidad de montar una base de datos adicional solo para eso.
El espíritu de esta tendencia es comenzar simple, separar cuando tengas datos que justifiquen la separación. No significa que PostgreSQL reemplace todos los sistemas.
3. Preparación
3.1. Dependencias Maven
Necesitas JDBC, el driver de PostgreSQL y Testcontainers para las pruebas. Spring Boot ya gestiona las versiones, así que no necesitas declararlas:
<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. Ejecutar PostgreSQL con pgvector preinstalado
La imagen pgvector/pgvector incluye PostgreSQL y la extensión vector preinstalada. Elige la etiqueta que corresponda a la versión major de PostgreSQL que uses:
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
Configura Spring Boot para conectarse y ejecutar automáticamente schema.sql al iniciar:
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.sql.init.mode=always
En un proyecto real, deberías usar Flyway o Liquibase en lugar de schema.sql.
4. Job queue con FOR UPDATE SKIP LOCKED
4.1. Schema
Una cola mínima solo necesita una tabla:
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);
La columna run_at te permite programar la ejecución de jobs. El índice en run_at ayuda al worker a obtener el job más próximo sin necesidad de escanear toda la tabla.
4.2. Obtener y procesar jobs
El punto clave es la cláusula FOR UPDATE SKIP LOCKED. Múltiples workers ejecutan la misma consulta. Cada worker bloquea una fila, y los otros workers omiten la fila bloqueada en lugar de esperar:
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;
}
}
Explicación:
enqueueinserta un job.?::jsonbconvierte la cadena JSON al tipoJSONB.processNextse ejecuta dentro de una transacción. La consultaSELECT ... FOR UPDATE SKIP LOCKEDbloquea exactamente un job sin procesar.- Si
handlerse ejecuta correctamente, el job se elimina y la transacción se confirma. - Si
handlerlanza una excepción, la transacción se revierte. El job permanece en la tabla y será procesado nuevamente. Si el worker muere en mitad del proceso, PostgreSQL libera automáticamente el bloqueo cuando se cierra la conexión.
El worker puede ser un método @Scheduled simple:
@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 correo electrónico para job " + job.id() + ": " + job.payload()))) {
// continuar hasta que no haya más jobs
}
}
}
Recuerda agregar @EnableScheduling en una clase de configuración. Cuando hay jobs, la consola imprime:
Enviando correo electrónico para job 1: {"orderId": 42, "email": "an@example.com"}
Nota: Mantener la transacción durante el procesamiento del job es muy simple y seguro cuando el worker falla. A cambio, la transacción durará tanto como tarde el procesamiento. Si un job tarda mucho (llamadas a API lentas, procesamiento de archivos grandes), deberías cambiar a un modelo con columnas
statusylocked_until, o migrar a una librería de colas especializada. También deberías agregar una columnaattemptspara evitar que un job fallido se reintente infinitamente.
4.3. Beneficio real: encolar en la misma transacción del negocio
Este es el motivo más fuerte para usar Postgres como cola:
@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));
}
}
El pedido y el job se confirman o revierten juntos. Con un broker separado, tendrías que implementar el patrón de outbox transaccional para lograr esta garantía. (El ejemplo usa formatted para brevedad. El código real debería serializar el payload con Jackson para escapar caracteres correctamente.)
Si no quieres que el worker consulte continuamente, puedes combinar LISTEN/NOTIFY de PostgreSQL para despertar el worker cuando hay jobs nuevos. El polling aún debería mantenerse como plan de contingencia.
5. Full-text search con tsvector e índice GIN
5.1. Schema
PostgreSQL tiene búsqueda full-text integrada. Usa una columna generada para actualizar automáticamente el vector de búsqueda cada vez que los datos cambian:
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);
Explicación:
to_tsvector('simple', ...)divide el texto en tokens. La configuraciónsimplesolo convierte a minúsculas, sin stemming. Esta opción es adecuada para vietnamita, ya que PostgreSQL no tiene diccionario vietnamita predeterminado.setweightasigna pesoAal nombre yBa la descripción. Los resultados que coincidan en el nombre se clasificarán más alto.- El índice GIN permite búsquedas rápidas en la columna
tsvector.
5.2. Consulta
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 acepta sintaxis similar a una barra de búsqueda normal: "áo khoác" -len significa contiene la frase “áo khoác” y no contiene “len”. El operador @@ comprueba si el vector coincide con la consulta. ts_rank se usa para ordenar los resultados.
Con datos de ejemplo que incluyen “Áo khoác gió” y “Quần jean” (descripción contiene “khoác ngoài”), la consulta khoác devuelve:
ProductHit[id=1, name=Áo khoác gió, rank=0.6079271]
ProductHit[id=2, name=Quần jean, rank=0.24317084]
Nota: Los usuarios frecuentemente escriben sin tildes (“ao khoac”). Puedes usar la extensión
unaccent. La funciónunaccentpor defecto no está marcada comoIMMUTABLE, así que para usarla en una columna generada o índice, necesitas envolverla en tu propia funciónIMMUTABLE. Para búsqueda tolerante a errores tipográficos, puedes usar además la extensiónpg_trgm.
6. Vector search con pgvector
6.1. Schema
Los embeddings son arrays de números reales generados por modelos de IA. Textos con significado similar producirán vectores cercanos. Habilita la extensión y agrega la columna:
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);
El ejemplo usa 3 dimensiones por claridad. En la práctica, el número de dimensiones debe coincidir exactamente con el modelo de embedding que elijas, usualmente cientos a miles de dimensiones. El índice HNSW permite encontrar vecinos aproximados muy rápidamente. vector_cosine_ops indica que el índice sirve para distancia coseno.
6.2. Guardar y buscar por vector
No necesitas librerías adicionales. Conviertes float[] a una cadena como [0.1,0.2,0.3] y luego la conviertes al tipo ::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();
}
}
El operador <=> calcula la distancia coseno. ORDER BY ... LIMIT obtiene los productos más cercanos. El vector de consulta proviene del mismo modelo que generó los embeddings de los productos, por ejemplo cuando el usuario escribe “ropa para cuando llueve”.
Dado que el producto y el embedding están en la misma tabla, puedes combinar filtros fácilmente: WHERE category = 'outerwear' AND price < 500000 ORDER BY embedding <=> ?. También puedes mezclar la puntuación ts_rank con la distancia vectorial para hacer hybrid search. Con una base de datos vectorial separada, necesitarías sincronizar estos atributos.
Nota: Cuando combinas
WHEREcon selectividad alta con un índice HNSW, el número de resultados devueltos puede ser menor queLIMIT. La razón es que el índice filtra después de explorar un conjunto limitado de candidatos. Prueba cuidadosamente con datos reales y consulta los parámetros de ajuste de pgvector.
7. Escribir tests con Testcontainers
Los tests se ejecutan en PostgreSQL real, con la imagen que tiene pgvector. @ServiceConnection configura automáticamente el 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 caído");
})).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 ('Áo khoác gió', 'Chống nước nhẹ')");
jdbc.update("INSERT INTO product (name, description) VALUES ('Quần jean', 'Vải dày')");
long jacketId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Áo khoác gió'", Long.class);
long jeansId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Quần jean'", 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("khoác"))
.extracting(ProductHit::name)
.containsExactly("Áo khoác gió");
assertThat(vectorSearch.findSimilar(new float[]{1.0f, 0.0f, 0.0f}, 1))
.containsExactly("Áo khoác gió");
}
}
La segunda prueba verifica el comportamiento más importante de la cola: cuando el handler falla, el job no se pierde. Las pruebas insertan datos directamente en cada método. En un proyecto real, deberías limpiar las tablas en @BeforeEach para que los tests sean independientes.
8. Cuándo NO deberías usar Postgres para todo
Consolidar todo en un lugar también tiene costos. Deberías considerar un sistema especializado cuando:
- El throughput de la cola es muy alto o necesitas streaming. Si necesitas reproducir el flujo de eventos, tienes muchos consumer groups independientes, o necesitas guardar mensajes durante mucho tiempo, usa Kafka o similar. Las colas en Postgres crean muchas dead tuples por
DELETE/UPDATEconstantes. Debes monitorear autovacuum cuidadosamente. - La búsqueda es compleja. Facetas multidimensionales, sugerencias mientras escribes, procesamiento de lenguaje natural bueno para vietnamita, ajuste fino detallado de relevancia: un motor de búsqueda especializado lo hace mucho mejor.
- La cantidad de vectores es muy grande. Los índices HNSW consumen mucha RAM. Cuando los datos vectoriales dominan los recursos, compiten con tu carga de trabajo transaccional principal.
- La carga es irregular y se interfieren entre sí. Una consulta vectorial pesada o una ola de jobs puede ralentizar el API de pagos. Las réplicas de lectura ayudan a separar parte de la carga de lectura, pero no la resuelven completamente.
- Tu equipo ya opera bien un sistema especializado. Si la infraestructura ya existe y es estable, mover todo a Postgres solo por “simplificar” quizá no valga la pena.
Nota: Una estrategia sensata es envolver cada funcionalidad detrás de una interfaz (
JobQueue,ProductSearch…). Cuando las métricas de monitoreo muestren que Postgres es el cuello de botella, puedes reemplazar la implementación subyacente sin tocar el código de negocio.
9. Conclusión
PostgreSQL tiene suficiente poder para manejar job queues gracias a FOR UPDATE SKIP LOCKED, full-text search con tsvector e índices GIN, y vector search a través de la extensión pgvector. El beneficio más grande es tener menos sistemas que operar, y poder escribir datos de negocio, jobs y embeddings en la misma transacción.
Piensa en esto como un punto de partida predeterminado para sistemas pequeños y medianos, no como un destino final. Comienza con Postgres, mide cuidadosamente, y solo migra a herramientas especializadas cuando los datos justifiquen la necesidad.