Postgres voor alles: wat de 'just use Postgres'-hype betekent voor je .NET-stack

Een artikel op de HN-voorpagina bepleit PostgreSQL als queue, cache, zoekmachine en vector-store tegelijk. Dit is hoe die keuze uitpakt in een echte .NET-codebase, met werkende C#, en waar de grens ligt.

Jean-Pierre Broeders

Freelance .NET Developer

20 augustus 202611 min. leestijd
Postgres voor alles: wat de 'just use Postgres'-hype betekent voor je .NET-stack

Deze week stond een artikel met de titel "PostgreSQL for Everything" bovenaan Hacker News. De strekking: je hebt geen Kafka, geen Redis, geen Elasticsearch en geen MongoDB nodig, want Postgres kan het allemaal. Queue, cache, full-text search, JSON-documenten, tijdreeksen, zelfs vectoren voor je AI-features. Alles in één database die je toch al draait.

De reacties gingen precies zoals je verwacht. De ene helft roept "eindelijk zegt iemand het", de andere helft plakt er meteen een grafiek bij van het moment waarop Postgres als queue omviel onder load. Beide kampen hebben gelijk, en dat is precies waarom het onderwerp de moeite waard is.

Ik bouw al jaren .NET-systemen waar Postgres de standaarddatabase is. Dus laat ik het concreter maken dan een lijstje met extensies. Wat betekent "Postgres voor alles" als je het in C# moet schrijven, en waar knapt het touw?

Waarom dit nu weer opduikt

Het argument is niet nieuw. Wat veranderd is, is de rekensom eromheen. Elke extra piece infrastructuur die je draait kost je iets: een Redis-instance, een Kafka-cluster, een Elasticsearch-node. Dat is geld, maar vooral operationele aandacht. Iemand moet die dingen patchen, monitoren, back-uppen en om drie uur 's nachts weer overeind trappen.

Voor een klein tot middelgroot team is dat de echte kostenpost. Niet de licentie, maar de mens die 's nachts wakker wordt. Als je Postgres toch al draait en toch al goed back-upt, dan is elke workload die je erin kunt houden een systeem minder dat je hoeft te begrijpen. Dat is de kern van het verhaal, en die klopt.

De valkuil is dat "Postgres kan het" en "Postgres is er goed in" niet hetzelfde zijn. Postgres kan Tetris draaien in een recursieve query. Dat wil je niet in productie. De vraag is dus per workload: waar is het echt goed genoeg, en waar begin je jezelf voor de gek te houden?

De queue: hier wint Postgres bijna altijd

Dit is het sterkste voorbeeld, en het is ook het voorbeeld waar mensen het vaakst verkeerd om beginnen. Ze pakken Kafka omdat "we misschien ooit gaan schalen", en zitten anderhalf jaar later met een cluster voor een workload van tweehonderd berichten per minuut.

De truc in Postgres heet SKIP LOCKED. Sinds versie 9.5 kun je een rij claimen zonder dat een tweede worker op dezelfde rij blijft wachten. Die tweede worker slaat de vergrendelde rij gewoon over en pakt de volgende. Zo bouw je een concurrent job-queue met niks anders dan een tabel.

De tabel:

CREATE TABLE jobs (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload     jsonb NOT NULL,
    status      text NOT NULL DEFAULT 'queued',
    attempts    int  NOT NULL DEFAULT 0,
    locked_at   timestamptz,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_jobs_queued ON jobs (created_at) WHERE status = 'queued';

Die partiële index is belangrijk. Je queue-query kijkt alleen naar rijen met status queued, dus je index hoeft alleen die rijen te bevatten. Zodra een job klaar is valt hij uit de index, en blijft je index klein ongeacht hoeveel history je bewaart.

De claim, vanuit een .NET-worker met Npgsql:

public async Task<Job?> ClaimNextAsync(NpgsqlConnection conn, CancellationToken ct)
{
    const string sql = """
        UPDATE jobs
        SET status = 'processing', locked_at = now(), attempts = attempts + 1
        WHERE id = (
            SELECT id FROM jobs
            WHERE status = 'queued'
            ORDER BY created_at
            FOR UPDATE SKIP LOCKED
            LIMIT 1
        )
        RETURNING id, payload;
        """;

    await using var cmd = new NpgsqlCommand(sql, conn);
    await using var reader = await cmd.ExecuteReaderAsync(ct);

    if (!await reader.ReadAsync(ct))
        return null;

    return new Job(reader.GetInt64(0), reader.GetString(1));
}

Eén statement. De subquery pakt de oudste beschikbare job, vergrendelt hem, en de UPDATE eromheen zet hem meteen op processing. Draai je dit vanuit tien workers tegelijk, dan pakt elke worker een andere job. Geen dubbele verwerking, geen aparte broker.

Waar het knapt: doorvoer. Een enkele Postgres kan comfortabel duizenden jobs per seconde aan als het werk per job kort is en de payload klein blijft. Push je richting tienduizenden per seconde met veel workers die allemaal op dezelfde tabel hameren, dan ga je lock-contention en autovacuum voelen. Dat is het punt waarop een dedicated broker zijn geld waard wordt. Maar dat punt ligt veel verder weg dan de meeste teams denken. Ik heb systemen live zien gaan op deze aanpak en er drie jaar op zien draaien zonder ooit de grens te raken.

LISTEN/NOTIFY: van pollen naar duwen

Een tabel-queue die je elke seconde pollt werkt, maar het is zonde. Postgres heeft een ingebouwd pub/sub-mechanisme, LISTEN/NOTIFY, waarmee je worker meteen wakker wordt als er een job binnenkomt in plaats van blind te blijven pollen.

public async Task WaitForJobsAsync(ChannelWriter<string> signal, CancellationToken ct)
{
    await using var conn = new NpgsqlConnection(_connectionString);
    await conn.OpenAsync(ct);

    conn.Notification += (_, e) => signal.TryWrite(e.Payload);

    await using (var listen = new NpgsqlCommand("LISTEN new_job;", conn))
        await listen.ExecuteNonQueryAsync(ct);

    while (!ct.IsCancellationRequested)
        await conn.WaitAsync(ct);
}

Aan de insert-kant stuur je een signaal met NOTIFY new_job of via pg_notify('new_job', job_id::text) in een trigger. Je worker blokkeert op WaitAsync tot er iets binnenkomt, verwerkt de queue leeg, en gaat weer slapen. Geen poll-interval om af te stemmen, geen wakkere loop die niks doet.

Let op één ding: LISTEN/NOTIFY levert geen garanties als je worker even offline is. Notificaties die binnenkomen terwijl niemand luistert zijn weg. Daarom blijft de tabel de bron van waarheid en is NOTIFY alleen de wekker. Bij het opstarten doe je altijd eerst een gewone claim-ronde voordat je gaat luisteren, zodat je niks mist wat tijdens je downtime binnenkwam.

JSONB: MongoDB in een kolom

Voor semi-gestructureerde data, denk aan productattributen die per categorie verschillen, grijpen mensen naar een documentdatabase. Postgres heeft jsonb, een binair JSON-type met echte indexering. In EF Core map je dat rechtstreeks.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public JsonDocument Attributes { get; set; } = default!;
}

protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Product>()
        .Property(p => p.Attributes)
        .HasColumnType("jsonb");
}

Wil je zoeken op een veld binnen dat JSON, dan zet je er een GIN-index op:

CREATE INDEX idx_product_attrs ON products USING gin (attributes);

Daarna is een query als "geef me alle producten met kleur rood" een indexed lookup, geen full scan:

SELECT id, name FROM products WHERE attributes @> '{"color": "red"}';

Dit is het geval waar Postgres een compleet aparte database vervangt zonder dat je iets inlevert. Je krijgt schemavrijheid waar je die wilt, en je houdt ondertussen transacties en joins voor de rest van je model. Een echte documentdatabase daarnaast zetten is voor de meeste .NET-apps puur extra werk.

Full-text search en vectoren: hier ligt de nuance

Postgres heeft ingebouwde full-text search met tsvector en tsquery. Voor een blog, een documentatiesite of een productcatalogus is dat ruim genoeg.

SELECT id, title
FROM articles
WHERE to_tsvector('dutch', title || ' ' || body)
      @@ plainto_tsquery('dutch', 'skip locked queue');

Zet er een GIN-index op de tsvector bij en het schaalt prima tot in de miljoenen rijen. Wat je niet krijgt is wat Elasticsearch je geeft: fuzzy matching out of the box, geavanceerde relevance-tuning, aggregaties over facetten, en een ecosysteem dat rond zoeken is gebouwd. Heb je een zoekbalk waar mensen af en toe iets intikken, dan is Postgres genoeg. Bouw je een product waar zoeken de kernfunctie is, dan ga je die grens raken en dan is een echte zoekmachine geen luxe.

Hetzelfde geldt voor vectoren. De pgvector-extensie geeft je een vector-type en similarity search, en met Pgvector.EntityFrameworkCore map je dat netjes in je model:

public class Document
{
    public int Id { get; set; }
    public string Content { get; set; } = "";
    public Vector Embedding { get; set; } = default!;
}

// Nearest neighbours op cosine distance
var similar = await db.Documents
    .OrderBy(d => d.Embedding.CosineDistance(queryEmbedding))
    .Take(10)
    .ToListAsync(ct);

Voor een RAG-feature bovenop een bestaande app is dit ideaal. Je embeddings staan naast je data, in dezelfde transactie, met dezelfde back-up. Pas als je richting honderden miljoenen vectoren gaat met strenge latency-eisen wordt een gespecialiseerde vector-database interessant. Daar zit bijna geen enkele line-of-business-app.

Waar ik nee zeg

Cache is het voorbeeld waar de "alles in Postgres"-lijn me te ver gaat. Ja, je kunt een UNLOGGED tabel maken die niet naar de WAL schrijft en die als key-value store gebruiken. Maar een cache die je database-connectie-pool opeet en meelift op dezelfde CPU als je transacties is geen upgrade ten opzichte van Redis. Het is een manier om je duurste, moeilijkst te schalen component ook nog eens te belasten met werk dat juist bedoeld is om die component te ontlasten. Als je een echte hot cache nodig hebt, zet er Redis naast. Dat is de ene keer dat de extra doos zich terugverdient.

En dan de grote: dit is en blijft één machine. Elke workload die je in Postgres stopt deelt dezelfde CPU, hetzelfde geheugen en dezelfde I/O. Zolang je binnen wat één flinke server aankan blijft, is dat een feature, want alles zit op één plek met één back-up en één plek om te debuggen. Zodra je die grens nadert wordt het een gedeelde bottleneck waar je zoekopdrachten je queue vertragen en je vector-search je transacties in de weg zit. De kunst is niet om alles in Postgres te proppen, maar om te weten wanneer je een workload eruit tilt.

Hoe ik het aanpak

Begin met alles in Postgres. Serieus. Queue, JSON, zoeken, embeddings, allemaal in de database die je toch al draait. Je krijgt er een enorme hoeveelheid eenvoud voor terug: één connectiestring, één back-up-strategie, transacties die over je hele systeem heen kloppen.

Meet daarna waar het pijn doet. Niet speculatief, echt meten. Als je queue-doorvoer tegen een muur loopt, til dan die queue eruit en niks anders. Als zoeken de kernfunctie wordt, geef zoeken een eigen systeem. Je haalt workloads eruit één voor één, op basis van cijfers, niet op basis van een blogpost die zegt dat je Kafka nodig hebt.

Dat is het echte punt van "Postgres voor alles". Niet dat Postgres letterlijk alles het beste doet, maar dat het bijna alles goed genoeg doet om mee te beginnen, en dat "goed genoeg om mee te beginnen" precies is wat de meeste projecten nodig hebben.

Veelgestelde vragen over Postgres als allrounder in .NET

Wanneer is een tabel-queue met SKIP LOCKED niet meer genoeg? Zodra je richting tienduizenden jobs per seconde gaat met veel gelijktijdige workers op dezelfde tabel, ga je lock-contention en autovacuum-druk voelen. Voor de meeste line-of-business-systemen ligt dat punt veel verder weg dan verwacht, dus meet je echte doorvoer voordat je een dedicated broker aanslingert.

Kan ik LISTEN/NOTIFY vertrouwen voor kritieke jobs? Nee, niet als enige mechanisme. Notificaties die binnenkomen terwijl je worker offline is gaan verloren, dus de tabel blijft je bron van waarheid en NOTIFY is alleen de wekker. Doe bij het opstarten altijd eerst een claim-ronde voordat je gaat luisteren.

Vervangt jsonb echt een documentdatabase in EF Core? Voor de meeste .NET-apps wel. Je map jsonb rechtstreeks, zet er een GIN-index op voor containment-queries, en je houdt transacties en joins voor de rest van je model. Een aparte documentdatabase erbij zetten levert dan vooral extra operationeel werk op.

Is pgvector genoeg voor een AI-feature? Voor een RAG- of similarity-feature bovenop een bestaande app bijna altijd. Je embeddings staan naast je data in dezelfde transactie en back-up. Pas bij honderden miljoenen vectoren met strenge latency-eisen wordt een gespecialiseerde vector-database de moeite waard.

Waarom raad je Postgres af als cache? Omdat een cache-workload dezelfde CPU, connectie-pool en I/O deelt als je transacties, en juist je duurste component belast met werk dat bedoeld is om die te ontlasten. Heb je een echte hot cache nodig, zet dan Redis ernaast; dat is de uitzondering waar de extra component zich terugverdient.

Verder lezen


Conclusie: Behandel "Postgres voor alles" als een startpunt, niet als een dogma. Begin met queue, JSON, zoeken en vectoren in de database die je toch al draait, meet waar het echt pijn doet, en til dan pas die ene workload eruit die de cijfers je aanwijzen.

Bronnen: PostgreSQL for Everything · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op