PostgreSQL fait tout : queue, search, vector dans une seule base
1. Vue d’ensemble
Un système typique comprend souvent plusieurs composants : PostgreSQL pour les données, un message broker pour la queue, un moteur de recherche pour la requête, et récemment une vector database pour l’IA. Chaque composant entraîne des frais de maintien, de supervision, de sauvegarde et de synchronisation des données.
La tendance « PostgreSQL fait tout » pose la question à l’envers : si PostgreSQL peut déjà faire la plupart de ces tâches assez bien, avez-vous vraiment besoin d’ajouter de nouveaux systèmes ? Dans cet article, vous allez utiliser PostgreSQL comme job queue, full-text search et vector search avec Spring Boot 3.x. Vous saurez aussi quand arrêter et opter pour un outil spécialisé.
2. Pourquoi cette tendance attire l’attention
Il y a plusieurs bonnes raisons :
- Réduire la complexité opérationnelle. Chaque système qu’on ajoute est un truc de plus qui peut tomber en panne à 3 heures du matin. Pour une petite équipe, ce coût dépasse souvent le gain en performance.
- La cohérence transactionnelle. Quand une commande et un job d’email se trouvent dans la même base, vous écrivez les deux dans une seule transaction. Pas de risque que la commande soit sauvegardée mais le message perdu, ou l’inverse.
- L’écosystème d’extensions. PostgreSQL a un système d’extension puissant. Par exemple,
pgvectorajoute un type de données vecteur et des index de recherche par proximité. - L’IA entraîne le besoin de stocker les embeddings. Beaucoup d’équipes veulent ajouter la recherche sémantique sans déployer une base de données supplémentaire juste pour ça.
L’esprit de cette tendance est de commencer simplement, découpler quand vous avez la preuve que c’est nécessaire. Ça ne signifie pas que PostgreSQL remplace tous les systèmes.
3. Préparation
3.1. Dépendances Maven
Vous avez besoin de JDBC, du driver PostgreSQL et de Testcontainers pour les tests. Spring Boot gère les versions, vous n’avez pas à les déclarer :
<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. Lancer PostgreSQL avec pgvector
L’image pgvector/pgvector contient PostgreSQL et l’extension vector. Choisissez le tag correspondant à votre version majeure de PostgreSQL :
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
Configurez Spring Boot pour qu’il se connecte et exécute automatiquement schema.sql au démarrage :
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.sql.init.mode=always
En production, utilisez Flyway ou Liquibase au lieu de schema.sql.
4. Job queue avec FOR UPDATE SKIP LOCKED
4.1. Schéma
Une queue minimale n’a besoin que d’une table :
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 colonne run_at vous permet de planifier l’exécution d’un job. L’index sur run_at aide le worker à récupérer le job le plus ancien sans scanner toute la table.
4.2. Récupérer et traiter un job
Le point clé est la clause FOR UPDATE SKIP LOCKED. Plusieurs workers exécutent la même requête. Chacun verrouille une ligne, et les autres la sautent au lieu d’attendre :
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;
}
}
Explication :
enqueueinsère un job.?::jsonbconvertit la chaîne JSON en typeJSONB.processNexts’exécute dans une transaction. La requêteSELECT ... FOR UPDATE SKIP LOCKEDverrouille exactement un job non traité.- Si
handlerse termine correctement, le job est supprimé et la transaction se valide. - Si
handlerlève une exception, la transaction est annulée. Le job reste dans la table et sera retraité. Si le worker meurt en cours de route, PostgreSQL libère automatiquement le verrou à la fermeture de la connexion.
Un worker peut être une simple méthode @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("Envoi d'email pour le job " + job.id() + ": " + job.payload()))) {
// continuer jusqu'à épuisement des jobs
}
}
}
N’oubliez pas d’ajouter @EnableScheduling à une classe de configuration. Avec un job en attente, la console affiche :
Envoi d'email pour le job 1: {"orderId": 42, "email": "an@example.com"}
Note : Garder la transaction pendant le traitement du job est très simple et sûr quand un worker plante. En contrepartie, la transaction s’étire sur la durée du traitement. Si le job est long (appel API lent, traitement de fichier volumineux), passez à un modèle avec colonnes
statusetlocked_until, ou migrez vers une bibliothèque queue spécialisée. Vous devriez aussi ajouter une colonneattemptspour éviter qu’un job défaillant soit retenté indéfiniment.
4.3. L’avantage réel : enqueuer dans la même transaction métier
C’est la raison forte d’utiliser Postgres comme queue :
@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));
}
}
Commande et job se valident ou s’annulent ensemble. Avec un broker externe, il faut implémenter le pattern transactional outbox pour cette garantie. (On utilise formatted pour la concision. En production, sérializez le payload avec Jackson pour bien échapper les caractères.)
Si vous ne voulez pas que le worker fasse du polling continu, combinez LISTEN/NOTIFY de PostgreSQL pour réveiller le worker à l’arrivée d’un job. Le polling reste une bonne stratégie de fallback.
5. Full-text search avec tsvector et index GIN
5.1. Schéma
PostgreSQL a déjà la recherche full-text. Utilisez une colonne générée pour mettre à jour automatiquement le vecteur de recherche à chaque modification :
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);
Explication :
to_tsvector('simple', ...)tokenise le texte. La configurationsimpleconvertit en minuscules seulement, sans stemming. Ce choix convient au français et à d’autres langues où PostgreSQL n’a pas de dictionnaire par défaut.setweightassigne un poidsAau nom etBà la description. Les correspondances au niveau du nom se classent plus haut.- L’index GIN permet d’interroger rapidement la colonne
tsvector.
5.2. Requête
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 accepte la syntaxe d’une barre de recherche habituelle : "veste imperméable" -léger signifie contient le groupe “veste imperméable” et ne contient pas “léger”. L’opérateur @@ teste si le vecteur correspond à la requête. ts_rank sert à classer les résultats.
Avec des données comme “Veste imperméable” et “Jean épais” (description contenant “imperméable”), la requête imperméable retourne :
ProductHit[id=1, name=Veste imperméable, rank=0.6079271]
ProductHit[id=2, name=Jean épais, rank=0.24317084]
Note : Les utilisateurs tapent souvent sans accents (“veste imperme”). Vous pouvez utiliser l’extension
unaccent. La fonctionunaccentn’est pas marquéeIMMUTABLEpar défaut, donc pour l’utiliser dans une colonne générée ou un index, vous devez la wrapper dans une fonctionIMMUTABLEpersonnalisée. Pour tolérer les fautes de frappe, ajoutez l’extensionpg_trgm.
6. Vector search avec pgvector
6.1. Schéma
Un embedding est un tableau de nombres réels généré par un modèle IA. Les textes ayant des sens proches produisent des vecteurs proches. Activez l’extension et ajoutez une colonne :
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);
Ici, 3 dimensions pour la lisibilité. En réalité, le nombre de dimensions doit correspondre exactement au modèle d’embedding choisi, généralement quelques centaines à quelques milliers. L’index HNSW permet une recherche par proximité très rapide. vector_cosine_ops indique que l’index sert pour la distance cosinus.
6.2. Sauvegarder et chercher par vecteur
Pas besoin de bibliothèque supplémentaire. Convertissez float[] en chaîne [0.1,0.2,0.3] puis castez en ::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();
}
}
L’opérateur <=> calcule la distance cosinus. ORDER BY ... LIMIT récupère les produits les plus proches. Le vecteur de requête provient du même modèle qui a généré les embeddings produit, par exemple quand l’utilisateur tape « vêtements pour la pluie ».
Puisque produit et embedding sont dans la même table, combinez les filtres facilement : WHERE category = 'outerwear' AND price < 500 ORDER BY embedding <=> ?. Vous pouvez aussi mélanger le score ts_rank et la distance vectorielle pour faire du hybrid search. Avec une vector database externe, vous devriez synchroniser ces attributs.
Note : Quand vous combinez un
WHEREtrès sélectif avec un index HNSW, le nombre de résultats peut être inférieur àLIMIT. Raison : l’index filtre après avoir parcouru un ensemble de candidats limité. Testez bien avec vos vraies données et consultez les paramètres de tuning de pgvector.
7. Écrire des tests avec Testcontainers
Les tests s’exécutent sur un vrai PostgreSQL, avec l’image pgvector. @ServiceConnection configure automatiquement la source de données :
@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 down");
})).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 ('Veste imperméable', 'Tissu léger résistant à l'eau')");
jdbc.update("INSERT INTO product (name, description) VALUES ('Jean épais', 'Tissu épais')");
long jacketId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Veste imperméable'", Long.class);
long jeansId = jdbc.queryForObject("SELECT id FROM product WHERE name = 'Jean épais'", 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("imperméable"))
.extracting(ProductHit::name)
.containsExactly("Veste imperméable");
assertThat(vectorSearch.findSimilar(new float[]{1.0f, 0.0f, 0.0f}, 1))
.containsExactly("Veste imperméable");
}
}
Le deuxième test vérifie le comportement le plus important de la queue : quand le handler échoue, le job ne se perd pas. Le test insère les données directement dans chaque méthode. En production, videz les tables dans une méthode @BeforeEach pour que les tests restent indépendants.
8. Quand NE PAS utiliser Postgres pour tout
Tout regrouper au même endroit a aussi son prix. Envisagez un outil spécialisé si :
- Le débit de la queue est très élevé ou vous besoin du streaming. Si vous devez rejouer le flux d’événements, avoir plusieurs groupes de consommateurs indépendants, ou garder les messages longtemps, optez pour Kafka ou équivalent. Une queue sur Postgres crée beaucoup de dead tuples dues aux
DELETE/UPDATEcontinus. Vous devez surveiller l’autovacuum attentivement. - La recherche est complexe. Facettes multi-dimensionnelles, complétage à la saisie, traitement de langue avancé pour le français, tuning fin de la pertinence : un moteur de recherche spécialisé excelle là-dedans.
- Le volume de vecteurs est énorme. Les index HNSW consomment beaucoup de RAM. Quand les vecteurs occupent la majorité des ressources, ils rivalisent avec la charge transactionnelle principale.
- Les charges très différentes s’impactent mutuellement. Une requête vectorielle lourde ou un flot de jobs massif peut ralentir l’API de paiement. Les read replicas aident à séparer un peu la charge en lecture, mais ne résolveni pas complètement le problème.
- L’équipe maîtrise déjà un système spécialisé en production. Si l’infrastructure existe et tourne bien, tout migrer vers Postgres « juste pour être minimaliste » n’en vaut peut-être pas la peine.
Note : Une stratégie sensée est de wraper chaque fonction derrière une interface (
JobQueue,ProductSearch, …). Quand les métriques montrent que Postgres est un goulot, remplacez l’implémentation sous-jacente sans toucher au code métier.
9. Conclusion
PostgreSQL est capable de gérer une job queue grâce à FOR UPDATE SKIP LOCKED, la full-text search avec tsvector et index GIN, et la vector search via l’extension pgvector. Le plus grand avantage est d’avoir moins de systèmes à maintenir, et la possibilité d’écrire données métier, jobs et embeddings dans une seule transaction.
Voyez ceci comme un point de départ par défaut pour les systèmes petits et moyens, pas comme la destination finale. Commencez par Postgres, mesurez rigoureusement, et ne passez à des outils spécialisés que si les chiffres le justifient.