Snelle drilldown-dashboards vanuit één enkel Parquet-bestand
Eén Parquet-datacube van 40MB, een reader van 18KB, een R2-bucket en een paar onopvallende HTTP range requests.
Elke maand zien we nieuwe, slimme toepassingen voor objectopslag, wat waarschijnlijk de meest actieve hoek is van de niet-AI-software-infrastructuur ter wereld. De meest recente "lavabom" was het verslag van Vicent Martí over de S3 + WAL-aanpak van Cursor Origin voor het beheren van Git-repositories op schaal. Het is een meesterwerk van technische documentatie, in tegenstelling tot dit bericht. Ik geef toe dat ik, zelfs vóór het lezen ervan, al dagdroomde over een totaal ander soort taak waar objectopslag waarschijnlijk gewoon werkt: klantgerichte analytics-dashboards. Een vriend van mij heeft klantgebruiksgegevens in Iceberg op R2 en wil zijn gebruikers enkele basisgrafieken met filters tonen. Hij vertelde me dat hij geen extra leveranciers wilde toevoegen, wat MotherDuck — het cloud-gehoste DuckDB-databasebedrijf waar ik werk — uitsloot.
In analytics ziet alles eruit als een range request wanneer je alleen objectopslag hebt. We zouden dit soort gegevens kunnen aggregeren in een Parquet-backed datacube en range queries gebruiken om de geaggregeerde rijen op te halen die nodig zijn voor het dashboard. Normaal gesproken zou ik voor dit soort BI-workloads uitwijken naar DuckDB-Wasm, maar toen besefte ik dat Hyparquet, een JavaScript Parquet-reader die in de browser draait, dezelfde range scans kan uitvoeren in 18KB in plaats van meerdere MB's en een speciale worker. Hiermee kun je een echt drilldown-dashboard serveren zonder database of query-engine. De cube kan zelfs tientallen (of honderden) MB's groot zijn, aangezien een correct ingedeeld bestand betekent dat je telkens slechts een paar kleine fragmenten ervan leest. Je hebt alleen een datapipeline nodig om de cubes te produceren — wat overigens precies is waar het geld naartoe gaat wanneer je wel een echte analytische database hebt.
Deze ketterij was te mooi om te laten schieten. Om het te testen, nam ik de bekende NYC 311-dataset van serviceverzoeken die ik op mijn computer had — ongeveer 34 miljoen rijen op verzoekniveau over een periode van ongeveer 15 jaar — en aggregeerde deze tot een Parquet-cube van 40MB. Hierbij zijn filters toegevoegd voor stadsagentschap, type klacht, indieningsmethode en stadsdeel, plus één kolom voor de creatietijd voor de tijdreeks. Vervolgens heb ik dit op R2 geplaatst. 40MB is groot genoeg om de pijn van het downloaden van het volledige bestand te voelen.
Het demo-dashboard leest direct uit dat bestand met behulp van Hyparquet. De bytes passeren via een kleine Cloudflare Worker, omdat de gratis r2.dev-URL rate-limited is. Eerlijk gezegd was ik verrast over hoe snel nieuwe gegevens laden, gezien het feit dat het zowel een echte database als een krachtige query-engine overslaat. De UI doet al het eigenlijke leeswerk en is licht genoeg om direct in dit bericht te worden ingebed zonder de paginalaadtijd te beïnvloeden. De werkelijke complexiteit is bijna volledig verschoven naar de lay-out van de datacube.
Hoe werkt dit dashboard?
Datacubes en groeperingssets
Een dashboard zoals dit is ontworpen om een beperkte set analytische vragen te beantwoorden: verzoeken per dag, verzoeken per dag voor één agentschap, of totaalaantallen per stadsdeel over alle tijd. Elke vraag kan worden beantwoord met GROUP BY-queries. We kunnen deze dus vooraf berekenen en elk resultaat opslaan als een eigen kleine tabel, genaamd een grouping set. Stapel alle groeperingssets in één Parquet-bestand, met één sectie per set, en je hebt een datacube.
Een groeperingsset is alleen nuttig als deze een vraag beantwoordbaar maakt of de latentie bij het ophalen van gegevens vermindert. Dit bestand bevat beide soorten. De totaalaantallen over alle tijd voeden de leaderboards, en een dagelijkse groeperingsset voor elke combinatie van filters levert de gegevens voor de lijngrafiek. De wekelijkse en jaarlijkse groeperingssets verminderen het aantal gescande rijen dat voortvloeit uit het selecteren van een periode in de grafiek. Dezelfde totalen zouden kunnen worden opgeteld vanuit dagelijkse rijen, maar er zijn minder rijen te halen als we vooraf berekenen per week en jaar.
Rijgroepen en de footer
Het bestand bevat nu de groeperingssets die het dashboard renderen, maar de browser moet nog steeds alleen de rijen ophalen die nodig zijn. Twee functies van het Parquet-formaat maken dit mogelijk:
- Een Parquet-bestand is verdeeld in row groups van enkele tienduizenden rijen.
- Het bestand eindigt met een footer die metadata bevat over de byte-bereiken van de rijgroepen en de minimum/maximumwaarden van elke kolom daarin.
De client leest de footer één keer. Elke query gebruikt vervolgens de min/max-waarden om de rijgroepen te selecteren die mogelijk overeenkomen, haalt die byte-bereiken op en aggregeert de rijen in de browser.
Sortering en latentie
De lage latentie bij de dashboard-aanvragen is te danken aan de manier waarop de rijen in het Parquet-bestand zijn gesorteerd en gescand. Als de rijen in het bestand willekeurig geordend waren, zouden de min/max-waarden van elke rijgroep bijna het volledige bereik van elke kolom beslaan, waardoor een query het grootste deel van het bestand zou moeten lezen om slechts een klein percentage rijen op te halen.
In plaats daarvan zijn de rijen van elke groeperingsset gesorteerd op de kolommen waarop de queries filteren. De overeenkomstige rijen vormen zo meestal een aaneengesloten stuk van het bestand, en de min/max-statistieken stellen de reader in staat om de rest van de rijgroepen te negeren. Dat is de reden waarom het klikken op 'NYPD' in het leaderboard van agentschappen ongeveer 260KB leest uit het bestand van 40MB, in plaats van het hele bestand.
Lay-out van het bestand (in bytes en groeperingssets)
| Groeperingsset | Rijen | Grootte | Functie |
|---|---|---|---|
| Totals | 831.1k | 1.7mb | Voedt het totaal "requests in view" en de vier leaderboards |
| → All time | 1 row group | 103kb | Gelezen wanneer geen datumreeks is geselecteerd |
| → By week | 16 row groups | 1.3mb | Gelezen bij selectie: de overgebleven weken aan de randen |
| → By ISO year | 2 row groups | 272kb | Gelezen bij selectie: de volledige jaren in het midden |
| Daily $\cdot$ no dimensions | 1 row group | 171kb | Tekent de lijngrafiek wanneer geen filters actief zijn |
| Daily $\cdot$ one dimension | 770.3k | 2.4mb | Tekent de lijngrafiek wanneer één filter actief is |
| → Channel | 1 row group | 171kb | |
| → Borough | 2 row groups | 330kb | |
| → Complaint | 13 row groups | 1.5mb | |
| → Agency | 3 row groups | 383kb | |
| Daily $\cdot$ two dimensions | 4.5m | 10.6mb | Tekent de lijngrafiek wanneer twee filters actief zijn |
| → Borough + Channel | 3 row groups | 401kb | |
| → Complaint + Channel | 23 row groups | 2.6mb | |
| → Complaint + Borough | 42 row groups | 4.5mb | |
| → Agency + Channel | 5 row groups | 640kb | |
| → Agency + Borough | 8 row groups | 997kb | |
| → Agency + Complaint | 13 row groups | 1.5mb | |
| Daily $\cdot$ three dimensions | 7.3m | 15.8mb | Tekent de lijngrafiek wanneer drie filters actief zijn |
| → Complaint + Borough + Channel | 65 row groups | 6.8mb | |
| → Agency + Borough + Channel | 17 row groups | 1.9mb | |
| → Agency + Complaint + Channel | 22 row groups | 2.4mb | |
| → Agency + Complaint + Borough | 43 row groups | 4.6mb | |
| Daily $\cdot$ all four dimensions | 64 row groups | 6.6mb | Tekent de lijngrafiek wanneer alle vier de filters actief zijn |
| Footer $\cdot$ the index | — | 195kb | Byte-bereiken en min/max statistieken; eerst één keer lezen |
Voorwaarden en beperkingen
Deze opzet werkt onder twee voorwaarden. Ten eerste moet de combinatoriek van je grafieken en filters klein blijven. Ten tweede moet je pipeline het bestand van elke klant snel genoeg kunnen herbouwen om aan de updatefrequentie te voldoen. De meeste pagina's voor verbruik en facturering voldoen aan beide eisen. Het gaat om een vaste set grafieken (gebeurtenissen over tijd, aantallen of sommen per uur of dag, een paar filters of leaderboards) over gegevens die op een grove schema worden bijgewerkt in plaats van in real-time. Vanuit het perspectief van latentie maakt de grootte van de cube niet uit, maar je wilt dat deze toch relatief klein is, aangezien je er per klant een genereert volgens een schema.
In mijn voorbeeld is de tijdseenheid duidelijk dominant, aangezien de dagelijkse secties het grootste deel van de bytes van het bestand beslaan. Kardinaliteit is de andere factor: het klachttype heeft 485 verschillende waarden, en elke grote sectie in het diagram bevat dit. Sterker nog, het kiezen van een dagelijkse korrel voor de lijngrafiek maakte het bestand ongeveer 7 keer groter dan het wekelijkse equivalent (5,6 MB). Toch had de dagelijkse korrel geen significante invloed op de latentie van de range requests, aangezien elke interactie slechts een paar rijgroepen leest. Voor dit specifieke geval is het bovendien prettig om grote pieken op één dag te zien, omdat een sterke stijging in serviceverzoeken kan optreden door grote gebeurtenissen zoals orkanen of sneeuwstormen.
Dashboards zoals dit werken goed voor distributieve en algebraïsche aggregaties, die in stukken kunnen worden berekend en vervolgens gecombineerd kunnen worden voordat ze worden gevisualiseerd (denk aan sommen, aantallen, maxima en gemiddelden). Het laten werken van deze opzet voor holistische aggregaties (die kennis van de volledige distributie vereisen voordat een definitief gefilterd aggregaat kan worden bereikt) heeft zowel exacte als benaderende oplossingen. Dat laat ik over aan de lezer en hun favoriete AI-agent.
Vergelijking met bestaande technieken
Range requests over een zorgvuldig ingedeeld bestand zijn geen nieuw concept. PMTiles verpakt een tileset in één bestand dat clients lezen via range requests over HTTP. Dit werkt omdat de tiles in het bestand zijn geplaatst langs een Hilbert-curve, waardoor tiles voor een bepaald kaartbeeld dicht bij elkaar in het bestand staan en in een paar gecombineerde range requests kunnen worden opgehaald. En natuurlijk bewees het bekende verslag over SQLite-over-HTTP dat dit mechanisme zelfs werkt voor B-trees.
De datapipeline en complexiteit
Mijn favoriete onderdeel is dat deze aanpak de complexiteit volledig "naar links" verschuift, naar de datapipeline. De lay-out wordt vooraf bepaald, dus tegen de tijd dat een gebruiker op een leaderboard klikt of een tijdreeksgrafiek versleept, hoeft de client alleen de juiste rijen op te halen en op te tellen.
Wat de pipeline betreft: voor de meeste klantgerichte dashboards komt een cube van 10MB per klant voort uit een DuckDB GROUP BY GROUPING SETS-statement. Natuurlijk ben ik bevooroordeeld, maar als ik de pipeline zou bouwen, zou ik kiezen voor MotherDuck (een cloud-gehoste DuckDB-database & query-engine) en Flights (een MotherDuck-functie waarmee je Python-jobs voor dit soort taken kunt inplannen). De opzet is vrij eenvoudig met de agent van jouw keuze. Hoe je het ook doet, voor mijn vriend (die een data-engineer is) is het verschuiven van de complexiteit naar de pipeline een perfect voorbeeld van déformation professionnelle.
Eén bestand per klant maakt authenticatie bovendien eenvoudig. Toegangscontrole komt neer op een ondertekende URL (signed URL) voor het bestand van die klant, of een kleine Worker die de sessie controleert.
Kostenanalyse
Gezien het feit dat R2 gratis egress heeft, is de pipeline het enige onderdeel dat daadwerkelijk geld kost. Schrijfacties kosten $4,50 per miljoen (12,5 keer de prijs van leesacties), en je betaalt één schrijfactie per klant per herbouw, ongeacht de bestandsgrootte (een cube van 1MB en een van 40MB kosten hetzelfde om te uploaden).
Neem 10.000 klanten. Elke herbouw vervangt de bestanden, dus de opslagkosten blijven gelijk:
- Cubes van 10MB maken 100GB, ongeveer $1,50 per maand.
- Cubes van 40MB maken 400GB, ongeveer $6 per maand.
De kosten voor herbouw:
- Eén keer per dag elk bestand herbouwen: 300k writes per maand, ongeveer $1,35.
- Elk uur herbouwen: 7,2M writes, ongeveer $32.
- Elke vijf minuten herbouwen voor een maand: 86M writes, ongeveer $389.
Gelukkig vertellen Iceberg snapshot diffs je precies welke klanten nieuwe gegevens hebben, waardoor het eenvoudig is om alleen de cubes met nieuwe activiteit te herbouwen.
Zelfs als we uitgaan van een update elke 5 minuten waarbij elke klant activiteit heeft (wat onwaarschijnlijk is), zijn $389 per maand voor 10.000 klanten waarschijnlijk goedkoper en zeker eenvoudiger dan het opzetten van nieuwe infrastructuur. De economie en de gebruikerservaring zijn hier recentelijk in veranderd: egress-kosten zouden dit idee bijna hebben gedood, maar hebben mensen waarschijnlijk ontmoedigd om op deze manier te experimenteren. Dezelfde opzet op S3 is in totaal slechts ongeveer 20% duurder — ongeveer $20 per maand aan egress bij een miljoen queries, wat meer verkeer is dan de meeste klantdashboards ooit zullen zien.
Mijn andere favoriete onderdeel is de radicaal minimale implementatie: een JavaScript-reader van 18KB en een byte-lay-out die het databasewerk voor je doet. Wat een wereld!
Groetjes,