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, pgvector ajoute 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 :

  • enqueue insère un job. ?::jsonb convertit la chaîne JSON en type JSONB.
  • processNext s’exécute dans une transaction. La requête SELECT ... FOR UPDATE SKIP LOCKED verrouille exactement un job non traité.
  • Si handler se termine correctement, le job est supprimé et la transaction se valide.
  • Si handler lè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 status et locked_until, ou migrez vers une bibliothèque queue spécialisée. Vous devriez aussi ajouter une colonne attempts pour é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 configuration simple convertit 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.
  • setweight assigne un poids A au nom et B à 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 fonction unaccent n’est pas marquée IMMUTABLE par défaut, donc pour l’utiliser dans une colonne générée ou un index, vous devez la wrapper dans une fonction IMMUTABLE personnalisée. Pour tolérer les fautes de frappe, ajoutez l’extension pg_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 WHERE trè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/UPDATE continus. 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.

postgresqlspring-bootpgvectorfull-text-searcharchitecture