← Alle berichten

Saai is beter: 67k telemetrie-events per seconde in Postgres pompen

Op deze pagina

Wij verzamelen een enorme hoeveelheid telemetriedata. Prompts, gebruiksmetrieken, sessiedata, inzichten uit elke AI-codeassistent en desktop-app die de engineers van onze klanten gebruiken. Alles stroomt van duizenden gebruikers naar ons insights-platform.

Bij Flowstate meten we AI-uitgaven met OpenTelemetry. Elke API-call, elke codeersessie, elke modelaanroep, gekoppeld aan teams, projecten en kostenplaatsen. Het volume is aanzienlijk en het stopt nooit.

Dus moeten we veel data opslaan. Geen “analytics-dashboard”-data. Geen “maandrapport”-data. Elke span, elke metric, elke logregel van elke gebruiker bij elke klant, bewaard zodat onze analysepipeline er later doorheen kan malen.

Ieders eerste reflex is hetzelfde: “Je hebt een datawarehouse nodig.”

Ik heb het geprobeerd. Echt waar.

Rondshoppen

Even voor de duidelijkheid: de producten die ik bekeken heb zijn stuk voor stuk uitstekend in waarvoor ze gebouwd zijn. Ze zijn gemaakt voor organisaties met grote datateams en complexe analytische workloads. Ons probleem was simpeler: veel telemetriedata snel wegschrijven en later opvragen. Daarvoor waren de meeste van deze oplossingen meer dan we nodig hadden.

Er was ook een praktisch bezwaar dat bleef knagen. Ik moet in een vliegtuig kunnen ontwikkelen. Niet dat ik dat echt doe, maar het is de kern van het probleem. Als een onderdeel van mijn stack internet nodig heeft om te werken, kan ik niet lokaal bouwen, testen en itereren. Dan kan ik het op een zaterdagochtend niet even in Docker starten en er data tegenaan gooien. Veel van die datawarehouses voelden alsof ze problemen oplosten die een goed afgestelde Postgres ook aankon.

BigQuery was de eerste halte. Geweldig om op schaal te querien, minder geschikt voor continue schrijfvolumes. De streaming-insert-API rekent per rij en dat loopt bij ons volume snel op. Het latentieprofiel past ook niet bij bijna-realtime ingestie. Een fenomenale analytische engine, alleen niet de juiste voor een schrijfintensieve telemetriepipeline.

Snowflake is een krachtig dataplatform, maar het prijsmodel is ingewikkeld (rekencredits, opslag, dataverkeer) en voor wat wij nodig hadden was het aanzienlijk meer infrastructuur dan het probleem vroeg. Als het alternatief een goed afgestelde Postgres is, is de kostenvergelijking niet eens leuk meer.

Databricks is indrukwekkend als je een eigen data-engineeringafdeling hebt. De lakehouse-architectuur en de Spark-integratie zijn echt sterk. Maar wij hebben geen team van twaalf data engineers, en een platform van die omvang erbij halen voor wat in wezen “rijen schrijven, rijen lezen” is, voelde als met een kanon op een mug schieten.

Amazon Redshift, Azure Synapse en andere beheerde analytische diensten zijn allemaal degelijke producten, maar elk brengt beheerlast mee die niet paste bij waar wij stonden. Nog een systeem om te monitoren, nog een set credentials, nog een leveranciersrelatie.

ClickHouse was de meest aantrekkelijke optie. Kolomgeoriënteerd, speciaal gebouwd voor schrijfintensieve analytische workloads, open source en echt snel. Ik vond het erg goed. Ik haakte niet af vanwege de technologie, maar vanwege de deployment. ClickHouse betrouwbaar draaiend krijgen in een beheerde omgeving kostte meer gedoe dan ik wilde. ClickHouse Cloud bestaat, maar dat is weer een leveranciersafhankelijkheid terwijl ik al beheerde Postgres op Google Cloud SQL heb. En analytische queries kun je in Postgres toch ook draaien, dus ClickHouse voelde als extra stappen.

Zo kwam ik terug bij de database die ik al had draaien.

Ik heb in de loop der jaren rare dingen op Postgres gebouwd, en steeds komt de vraag: “Weten we zeker dat één database genoeg is?” Uit ervaring kan ik zeggen: één database kan flink wat klappen hebben. Er zijn verhalen over mensen die Postgres op petabyteschaal draaien. Het kan, met moeite, maar het kan. Laten we dus kijken wat we kunnen met onze bescheiden use case.

Ik heb al Postgres. Het werkt al. Laten we uitzoeken waar het ophoudt met werken.

De mislukkingen

Wat volgt is een ingekorte tijdlijn van mij die dingen op de harde manier leert.

Poging 1: gewoon naar de productiedatabase schrijven

De eerste versie was precies zo dom als het klinkt. Bij elk binnenkomend telemetrie-event vuurden we een INSERT af op de productie-Postgres. Eén rij tegelijk. Los van elkaar. Als brieven posten.

Het werkte bij laag volume. Het werkte bij gemiddeld volume. Het hield op te werken om 3 uur ‘s nachts op een dinsdag, toen de proxyservice een verkeerspiek te verwerken kreeg en we ineens 10.000 losse INSERTs per seconde deden op een database die ook nog de eigenlijke applicatie moest bedienen.

Ik kon letterlijk geen schrijflock krijgen om een migratie te draaien. De connection pool zat vol, elke beschikbare verbinding stond geblokkeerd op een INSERT en de migratierunner zat te wachten op een lock die nooit zou komen. Uiteindelijk heb ik handmatig verbindingen gekilld om de migratie erdoor te krijgen. Om 3 uur ‘s nachts. Op een dinsdag.

Poging 2: batching (het wordt warmer)

De voor de hand liggende oplossing: stop met één rij tegelijk schrijven. Buffer events in het geheugen en flush ze in batches van 500-1000 rijen met multi-row INSERTs.

Dit was een stuk beter. In plaats van 10.000 transacties krijg je er 10-20 grotere. De database kan ademhalen. De connection pool loopt leeg. Migraties draaien.

Maar nu hadden we een nieuw probleem: wat gebeurt er als de applicatie crasht tussen twee batches? Alles wat in de buffer zit, ben je kwijt.

Poging 3: Redis als buffer

Dus voegden we Redis toe als tussenbuffer. Events komen binnen, worden toegevoegd aan een Redis Stream (XADD), en een achtergrondworker leest de stream (XREADGROUP) en flusht batches naar Postgres.

Dit werkte echt, en Redis zit nog steeds in de uiteindelijke architectuur. Maar niet als datastore. Het bewaart verwijzingen naar binnenkomende objecten, houdt bij wat er is aangekomen en wat al is geflusht, en laat ons slim batchen zonder dat de applicatie zelf state hoeft vast te houden. Als de firehose zoveel waterdruk heeft dat het je huid van je lijf zou rukken, is Redis het ventiel dat er iets beheersbaars van maakt.

Het inzicht was dat je Redis niet als database moet gebruiken. Het is een coördinatielaag. De telemetriedata zelf gaat rechtstreeks naar Postgres. Redis vertelt ons alleen wat er wacht en wat al verwerkt is.

De vraag

Op dit punt had ik een systeem dat werkte. Postgres voor opslag, Redis voor coördinatie, gebatchte COPY-writes voor doorvoer. Maar ik wist niet echt hoeveel Postgres aankon. Kon het 10x de huidige belasting aan? 100x? Wat is het echte plafond?

Er is maar één manier om daar achter te komen.

Laten we het gewoon… testen

Ik deed wat elk redelijk mens zou doen: ik startte Postgres in een Docker-container op mijn laptop en gooide er steeds absurdere hoeveelheden telemetrie tegenaan tot er iets brak.

De opzet:

  • Postgres 16 in Docker met 512MB shared buffers en 500 max connections
  • pgBouncer in transaction pooling mode voor de connection-pooling-tests
  • Een schema in OpenTelemetry-stijl: de tabellen spans, metrics en logs met JSONB-attributen
  • Een telemetriegenerator die realistische traces maakt: ~1.900 events per gesimuleerde gebruiker van ~3KB elk, verdeeld over ~100 traces met 5-12 spans, metrics en logs
  • Vier schrijfstrategieën, getest bij oplopende aantallen gebruikers
  • Voor de extreme tests (10K+ gebruikers) draaien we bursttests van 30 seconden, want bij ~200MB/sec aanhoudend schrijven zou mijn SSD van 512GB snel vollopen. Langer en ik zou de faalmodus van de opslag van mijn laptop benchmarken, niet Postgres.

Dit alles draaide op een MacBook Air M2 uit 2022 met 24GB RAM en een SSD van 512GB. Geen server. Een laptop.

De vier strategieën

Naive INSERT: één INSERT-statement per telemetrie-event. 50 gelijktijdige verbindingen. De “doe dit alsjeblieft niet”-baseline, en precies wat ik in productie deed om 3 uur ‘s nachts op die dinsdag.

Batched INSERT: events verzamelen in batches van 500 rijen en in één multi-row INSERT wegschrijven. 20 verbindingen in een pool.

COPY-protocol: Postgres heeft een ingebouwd protocol voor bulksgewijs laden, COPY. Het streamt door tabs gescheiden data rechtstreeks de tabel in en slaat de SQL-parser helemaal over. Dit gebruiken ETL-tools. Batches van 5.000 rijen via pg-copy-streams.

Pooled + Batched: dezelfde batchstrategie, maar via pgBouncer in transaction pooling mode. Test of connection pooling bij hoge concurrency echt doorvoer toevoegt.

De cijfers

Bij 1.000 gesimuleerde gebruikers, elk goed voor ~1.900 events van ~3KB, schrijven we ongeveer 1,8 miljoen rijen realistische telemetrie over drie tabellen. Echte JSONB-payloads met modelnamen, tokenaantallen, kostendata en sessie-ID’s. Het soort data dat je in een echte AI-telemetriepipeline in productie zou zien.

Dit is wat er gebeurde.

Writes per seconde

Bij laag volume lijkt alles ongeveer hetzelfde. Je ziet het verschil tussen strategieën niet als je een paar duizend rijen schrijft.

Maar schaal op en de lijnen lopen uit elkaar. De naïeve aanpak vlakt af op ongeveer 14.000 writes/sec en blijft daar. Je zit vast op overhead per statement en strijd om verbindingen.

Het COPY-protocol haalt 67.000 writes per seconde. Op een laptop. In een Docker-container. Met max_wal_size op 4GB en standaard checkpoint-instellingen. Bij payloads van 3KB is dat ruwweg 200MB/sec aanhoudende schrijfdoorvoer. Hadden we de checkpoint-timeouts en WAL-compressie afgesteld, dan hadden we er waarschijnlijk meer uit gehaald.

Eén kanttekening bij COPY: het is alles of niets. Als één rij in een batch van 5.000 rijen ongeldige JSON heeft of een constraint schendt, mislukt de hele batch. Bij Batched INSERT kun je met ON CONFLICT netjes omgaan met duplicaten en rommelige data. In productie valideren we vooraf voor COPY en vallen we terug op batched INSERT voor alles wat er verdacht uitziet.

Batched INSERT komt in het midden uit op ~34K writes/sec. Solide en praktisch, en je hoeft er geen nieuwe API voor te leren.

De gepoolde strategie via pgBouncer kwam uit op ~43-49K writes/sec, sneller dan rauwe batching, omdat pgBouncer verbindingen efficiënter hergebruikt dan onze pool op applicatieniveau.

Kop aan kop bij 1.000 gebruikers

Bij 1.000 gebruikers (1,8M events) doet COPY 67K writes/sec terwijl naive blijft hangen op 14K. Dat is bijna 5x sneller voor dezelfde data. Bij deze payloadgroottes schreef COPY 916K events in 13,6 seconden. Naive had er meer dan een minuut voor nodig.

Maar dit verraste me: geen enkele strategie klapte. Ik verwachtte dat Postgres zou gaan hijgen. Verbindingsfouten, OOM-kills, WAL-opzwelling. Niets van dat alles gebeurde. Postgres slikte het gewoon.

Dus ik zette de knop natuurlijk verder open.

Even een reality check

Laten we eerlijk zijn: we hebben op dit moment geen 10 miljoen gelijktijdige gebruikers die 1.900 telemetrie-events per dag sturen. Als dat zo was, verdienden we genoeg om een hele afdeling in te huren die zich zorgen maakt over databasearchitectuur.

Ons echte productievolume wordt nu zonder moeite verwerkt door onze huidige Postgres-opzet. Waarom dan de simulatie opschroeven naar miljoenen gebruikers die honderden megabytes per seconde genereren?

Puur om een punt te maken.

Ik wilde weten wat er gebeurt als de “saaie” keuze eindelijk tegen de muur loopt. En wat ik vond is dat Postgres, zelfs op absurde, theoretische schaal waar het wel vertraagt en queries verslechteren, niet gewoon doodgaat. Het degradeert netjes. En belangrijker: als het vertraagt, heb je standaard, saaie hendels waaraan je kunt trekken om de snelheid terug te krijgen.

De echte test: schrijven EN lezen tegelijk

Het punt met schrijfbenchmarks op zichzelf: ze liegen tegen je.

In productie kun je het lezen niet pauzeren terwijl je data binnenhaalt. Onze analysepipeline draait aggregatiequeries op deze data terwijl die geschreven wordt. Percentielberekeningen over miljoenen spans. Afhankelijkheidskaarten van services. Uitsplitsingen van foutpercentages. Queries waar Postgres hard over moet nadenken.

Dus deed ik het voor de hand liggende: streaming COPY-writes en 8 analytische queries tegelijk draaien, opschalend van 1.000 gebruikers tot 10 miljoen. Voor de grotere tests gebruikte ik burstvensters van 30 seconden, want bij deze schrijfsnelheden zou de SSD van 512GB in mijn MacBook in minder dan 10 minuten letterlijk vollopen.

Bij 1.000 gebruikers (volledig schrijven, 1,8M events) hield COPY 39K writes/sec vol terwijl het tegelijk ruim 7.000 analytische queries bediende met een p95 van 9ms. De traagste query, de JOIN voor de afhankelijkheidskaart, deed er 1,2 seconden over.

Bij 10 miljoen gesimuleerde gebruikers, als burst van 30 seconden, hield Postgres 16.763 writes/sec vol terwijl het analytische queries bediende met een p95 onder 200ms. In 30 seconden schreef het 514.536 rijen telemetriedata van 3KB. Op alle schaalpunten was de query voor de afhankelijkheidskaart (een self-join over de spans-tabel) consequent de traagste, met een piek van ongeveer 1,2 seconden.

De schrijfdoorvoer daalt wel naarmate de tabel groeit en lezen om I/O concurreert. Dat is te verwachten. Maar Postgres crashte nooit, kreeg nooit een OOM en corrumpeerde niets. Het werd langzamer en ging door. Elke query gaf correcte resultaten. Elke write werd gecommit.

En vergeet niet: dit draaide met maar 512MB aan shared_buffers. De database was aanzienlijk groter dan de cache. Had ik hem 4GB shared_buffers gegeven op deze Mac van 24GB, dan had de hele working set in RAM gezeten en waren die queries veel sneller geweest. Dat deed ik bewust niet, omdat productiedatabases niet altijd alles in het geheugen mogen houden.

Oké, maar kunnen we de trage reads fixen?

Die query voor de afhankelijkheidskaart bleef me dwarszitten. Dus voegde ik indexen toe en draaide de test opnieuw tegen ~920K rijen (de telemetrie van 500 gebruikers).

Zeven gerichte indexen: opzoekingen op servicenaam, filters op statuscode, trace-ID-joins, parent-spanrelaties, samengestelde metric-opzoekingen en severity-verdelingen. Daarna ANALYZE om de statistieken van de query planner bij te werken.

Het opvallendste resultaat:

Query voor recente fouten: 51ms → 1,2ms. Een versnelling van 43x. Voor de duidelijkheid: status_code en start_time zijn kolommen op het hoogste niveau in het schema, niet begraven in de JSONB-payload. De JSONB-kolom attributes bevat de flexibele dingen (HTTP-headers, tokenaantallen, modelnamen, kostendata). De velden die we vaak bevragen zijn losse kolommen met echte types. De partiële index op status_code = 2 in combinatie met de index op start_time DESC liet Postgres de volledige tabelscan helemaal overslaan. Hij loopt gewoon door de index en pakt de top 50.

Verdeling van logseverity: 65ms → 33ms. De samengestelde index op (severity, service_name) maakt van een sequential scan een index-only scan. 2x sneller.

De query voor de afhankelijkheidskaart zakte van 456ms naar 320ms. Een verbetering van 1,4x. Beter, maar niet transformerend. Die query doet een self-join over honderdduizenden rijen en geen enkele index kan de fundamentele kosten wegnemen van het correleren van parent-childspans op die schaal.

Maar dat is prima. Er zijn duidelijke schaalpaden voor wanneer je ze nodig hebt.

De schaalroutekaart (voor als je het echt nodig hebt)

Hier ga ik pleiten tegen te vroeg optimaliseren. Wat we gebouwd hebben werkt. Het verwerkt onze huidige belasting comfortabel en het haalt 10x zonder zweten. Maar als we ooit honderden miljoenen events gaan verwerken, zijn er twee duidelijke paden vooruit, allebei nog steeds met Postgres.

Pad 1: read replicas

De simpelste schaalzet. Zet een replica met streaming replication op en richt alle analytische queries daarop. Writes gaan naar de primary, reads naar de replica. De primary kan zich volledig op ingestie richten en de replica kan complexe JOINs verwerken zonder de schrijfdoorvoer te raken.

Dit is een wijziging voor een dinsdagmiddag. Google Cloud SQL, AWS RDS en Azure ondersteunen allemaal native read replicas. Je voegt een connection string en een routeringsregel toe. Je schrijfdoorvoer gaat terug naar de 67K writes/sec van de losstaande opzet, omdat die niet meer met reads vecht, en je analytische queries mogen op de replica zo lang duren als ze willen zonder dat iemand het merkt. Op echte serverhardware met meer CPU-cores en snellere opslag zou dat getal aanzienlijk hoger liggen.

Voor ons geval, waar de analyse niet realtime hoeft te zijn, is een replica met een paar seconden replicatielag prima.

Pad 2: tabelpartitionering

Partitionering op tijd knipt je tabellen in stukken. Eén partitie per dag, per week of per maand. Queries die op tijd filteren scannen alleen de relevante partities in plaats van de hele tabel. Die query voor de afhankelijkheidskaart van 1,2 seconden? Als je alleen naar de laatste 24 uur kijkt in plaats van de hele historie, scan je een fractie van de data. De query zakt van seconden naar milliseconden.

Partitionering maakt ook het beheer van de datalevenscyclus triviaal. Data ouder dan 90 dagen weggooien? DROP TABLE spans_2025_q4. Geen vacuum, geen bloat, geen vergrendelde tabel. Direct klaar.

De megaschaaloptie

Als we ooit echt gek moesten gaan, honderden miljoenen gebruikers en miljarden telemetrie-events, heeft Postgres daar ook een antwoord op. Citus is een Postgres-extensie (volledig open source gemaakt door Microsoft) die je data over meerdere Postgres-nodes verdeelt met sharding op basis van hashes. Je doet CREATE EXTENSION citus; en je bent vertrokken. Je schema blijft hetzelfde. Je queries blijven hetzelfde. Je hebt alleen meer nodes die het werk doen.

Shard de spans-tabel op trace_id en elke node verwerkt een deel van de totale dataset. Een query voor één specifieke trace raakt één node. Een aggregatiequery waaiert uit over alle nodes en voegt de resultaten samen. Lineair horizontaal schalen binnen het Postgres-ecosysteem. Dezelfde tooling, dezelfde monitoring, dezelfde expertise.

We hebben dit niet nodig. We hebben dit waarschijnlijk nog heel lang niet nodig. Maar dat dit pad bestaat zonder dat je Postgres verlaat, is precies waarom simpel beginnen de juiste keuze was.

Optimaliseer niet te vroeg

Ik wil hier heel duidelijk over zijn: de architectuur die we nu draaien is niet de meest optimale voor dit probleem. Het is de meest passende.

We hebben een datapipeline. We kunnen complexe queries draaien terwijl we er writes op spammen. Hij verwerkt gelijktijdige analytische workloads. Hij valt niet om. Waarom zou ik dan een ander databaseproduct nodig hebben?

Natuurlijk kan disk-I/O ooit een grens worden. Maar er zijn duidelijke schaalopties, en we weten precies welke, omdat we hebben gemeten waar de knelpunten zitten. Dat is het voordeel van simpel beginnen: als je moet schalen, weet je wat je moet schalen.

We hadden met ClickHouse kunnen beginnen. We hadden vanaf dag één Citus kunnen opzetten. We hadden een Lambda-architectuur kunnen bouwen met Kafka en een streamprocessor en een serving layer en een batch layer en een… je snapt het.

Maar al die complexiteit kost iets. Elk extra systeem is weer iets om te monitoren, weer iets dat om 3 uur ‘s nachts kan omvallen, weer iets dat je team moet begrijpen. Als je de schaal niet hebt om het te rechtvaardigen, betaal je de complexiteitsbelasting zonder het schaalvoordeel te krijgen.

Postgres op Cloud SQL, met Redis als coördinatielaag, met het COPY-protocol voor batchingestie. Dat is onze architectuur. Hij verwerkt onze huidige belasting. Hij haalt 10x onze huidige belasting. En als hij 100x niet aankan, weten we precies waar de knelpunten zitten, want we hebben ze gemeten.

Het goede nieuws is dat deze service niet snel hoeft te zijn. De telemetriedata wordt in batches geanalyseerd en niet realtime aan gebruikers getoond. Een query die 4 seconden duurt in plaats van 40 milliseconden is voor ons gebruik helemaal prima.

Maar als hij ooit wel snel moest zijn… tja, je hebt de cijfers gezien. Er is nog genoeg ruimte.

Wat ik echt geleerd heb

  • Gebruik nooit losse INSERTs voor schrijven met hoge doorvoer. Batch of COPY. Altijd.
  • Het COPY-protocol bestaat niet voor niets. Het is er niet alleen voor de eerste dataload. Het is een volwaardige ingestiestrategie voor productie.
  • Connection pooling is op schaal niet optioneel. pgBouncer in transaction mode. Geen excuses.
  • Test writes EN reads samen. Benchmarks met alleen writes misleiden. Het echte prestatieprofiel is wat er gebeurt als je database beide tegelijk doet.
  • Indexen doen ertoe, maar niet voor alles. Een goed geplaatste partiële index kan 43x versnellen op gerichte queries. Maar aggregatiequeries over miljoenen rijen blijven traag, wat je ook doet. Dan grijp je naar replicas en partitionering.
  • Optimaliseer niet te vroeg. Begin met de simpelste architectuur die werkt. Meet waar de knelpunten zitten. Schaal de onderdelen die het nodig hebben. Niet alles, niet allemaal tegelijk.
  • Postgres kan meer aan dan mensen denken. 67K writes/sec van telemetrie-events van 3KB. 39K writes/sec terwijl het tegelijk analytische queries bedient. Opgeschaald naar 10M gesimuleerde gebruikers en het crashte nog steeds niet. Op een laptop. In Docker.
  • Test je aannames. Ik heb weken blogposts gelezen over vergelijkingen van datawarehouses. Ik had ook een middag met Docker en een script kunnen besteden en echte cijfers kunnen hebben. De cijfers vertelden een beter verhaal.

Oh, nog één ding. We hebben dit hele artikel Postgres door de hel gesleurd. Miljoenen rijen, gelijktijdige reads, self-joins over enorme spans. En we hebben er niet eens bij stilgestaan dat er een Redis voor zit. Die Redis die dit alles coördineert, de firehose van binnenkomende telemetrie afhandelt, verwijzingen bijhoudt en batchstatus beheert. Hij trok niet eens een wenkbrauw op. We hebben hem niet gebenchmarkt, want er viel niets te benchmarken. Hij werkte gewoon.

De meeste problemen los je echt op met een webserver, een Redis en een Postgres.

De benchmarkcode is geschreven in Go en staat op github.com/willhackett/bench-postgres. docker-compose up -d, go run . -mode=full voor de schrijfstrategieën, go run . -mode=chaos voor de gecombineerde write+read-chaostest en go run . -mode=optimize voor de indexvergelijking.