Kardinalnost u InfluxDB-u: ID-jevi korisnika i uređaja ne idu u tagove
ID-jevi u tagovima povećavaju indeks InfluxDB-a sa svakim novim posjetiocem. Uz ID-jeve u poljima i dnevni sažetak u MySQL-u, godina za preko 900 oglasa vraća se za manje od sekunde.
Dizajnirao sam analitički sloj za marketplace oglasa. Primao je preko dva miliona upisa dnevno, slao podatke na dashboard koji se osvježavao uživo i na upit nad cijelom godinom historije odgovarao za manje od sekunde.
Prva odluka o šemi u tom sistemu pokazala se kao najvažnija. Za svaki identifikator svodila se na jedno pitanje: ide li ova vrijednost u tag ili u polje?
Svaki identifikator stavio sam u polje, uključujući ID-jeve oglasa, korisnika i uređaja, i nijedan nije postao tag. U nastavku objašnjavam zašto, šta je to koštalo i čime se to nadoknadilo.
Šema koja izgleda ispravno
Evo događaja onako kako bi ga većina ljudi prvo napisala: neko je na nekom uređaju pogledao oglas.
1listing_event,listing_id=48213,user_id=u_9f2c,device_id=d_71ab,event=view count=1i 1717430400000Lijepo se čita. Sve po čemu biste mogli filtrirati nalazi se među tagovima, a jedino polje je ono što brojite. Većina primjera koje ćete naći ima ovaj oblik, s host ili region u tagovima.
Problem je što host i region uzimaju vrijednosti iz malih, fiksnih skupova. ID-jeva oglasa, korisnika i uređaja stalno je sve više.
Svaka kombinacija vrijednosti tagova je jedna serija
InfluxDB indeksira tagove, a polja ne. Cijena zavisi od toga šta je u tom indeksu, a u njemu su serije.
Serija je measurement zajedno s jednom tačnom kombinacijom vrijednosti tagova. Svaka različita kombinacija koju je baza ikad vidjela posebna je serija i ima svoj unos u indeksu. Polja s tim nemaju veze. U jednu seriju možete upisati milion različitih vrijednosti polja i ona ostaje jedna serija.
Zato je broj serija ograničen proizvodom broja različitih vrijednosti svakog taga:
1series(listing_event) <= |listing_id| x |user_id| x |device_id| x |event|U praksi nećete doći do punog proizvoda, jer ne dira svaki korisnik svaki oglas, ali to i nije potrebno da bi nastao problem. Kad su korisnik i uređaj među tagovima, svaki novi posjetilac pravi nove serije, a platforma za oglašavanje i postoji zbog novih posjetilaca.
Taj broj se zove kardinalnost serija i može samo rasti. Tag s malim, fiksnim skupom vrijednosti dodaje poznat broj serija. Identifikator dodaje nove kako saobraćaj raste, pa kad su identifikatori u tagovima, indeks raste sa saobraćajem umjesto sa šemom.
Indeks koji raste troši sve više memorije i usporava upise, a upiti koji su nekad bili trenutni počinju čitati mnogo veći dio indeksa nego što bi trebali. Na početku sve radi, a pukne kad saobraćaj krene, u najgorem mogućem trenutku. Ovakve instalacije najčešće propadnu baš tako.
Šema koju sam koristio
Identifikatori idu u polja. Tagovi su za vrijednosti iz malog, poznatog skupa po kojima filtrirate:
1import { InfluxDB, Point } from '@influxdata/influxdb-client'2 3const writeApi = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN })4 .getWriteApi(INFLUX_ORG, INFLUX_BUCKET, 'ms')5 6export function recordEvent(e) {7 const point = new Point('listing_event')8 // tags: a closed set, known before the first write9 .tag('event', e.type)10 // fields: anything that grows with traffic11 .stringField('listing_id', String(e.listingId))12 .stringField('user_id', e.userId)13 .stringField('device_id', e.deviceId)14 .stringField('session_id', e.sessionId)15 .stringField('page_url', e.pageUrl)16 .intField('count', 1)17 .timestamp(e.at)18 19 writeApi.writePoint(point)20}Isti događaj u line protocolu:
1listing_event,event=view listing_id="48213",user_id="u_9f2c",device_id="d_71ab",count=1i 1717430400000Na svakog kandidata za tag primjenjujem isti kratki test. Mogu li zapisati svaku vrijednost koju će ikad imati? Ako mogu, može biti tag. Ako se lista povećava svaki put kad se neko registruje, objavi oglas za firmu ili otvori sajt na novom telefonu, to je polje.
Vrsta događaja prolazi. Taj skup određujemo mi, u kodu, i saobraćaj ga ne mijenja. ID oglasa pada, jer je svaki novi oglas nova vrijednost, a firma upravo želi što više novih oglasa.
Cijena: u time-series bazi nema grupisanja po oglasu
Kad su ID-jevi oglasa u poljima, u time-series bazi ne možete efikasno grupisati po oglasu. U InfluxQL-u GROUP BY prima tagove i vrijeme, ali ne i polja. Drugi načini postavljanja upita mogu grupisati po bilo kojoj koloni, ali tek kad pročitaju redove, pa skeniraju cijeli vremenski prozor umjesto da odmah odu na seriju. Izvještaj po oglasu za cijelu godinu najgori je slučaj za takav posao.
Dakle, očigledan prigovor stoji. Pitanje koje se najčešće postavlja, „kako mi ide oglas?“, upravo je ono na koje je s ovom šemom skupo odgovoriti iz time-series baze. To je bilo prihvatljivo, jer time-series baza nikad nije ni trebala odgovarati na njega.
Dnevni sažetak u MySQL-u odgovara na pitanja po oglasu
Dizajn radi zahvaljujući drugoj odluci, donesenoj zajedno s prvom. Sirovi događaji ostaju u time-series bazi sedam dana. Jednom dnevno sažetak po oglasu upisuje se u relacionu bazu, a dashboard i fakture čitaju taj sažetak. Sirovi podaci idu u object storage kao arhiva.
Time-series baza ima jedan zadatak: prima upise i vraća podatke za nedavne vremenske prozore. Nikad ne mora odgovarati na pitanje grupisano po oglasu za dug period, a to je jedina vrsta pitanja koju identifikatori u poljima usporavaju.
Sažetak se upisuje u običnu MySQL tabelu:
1CREATE TABLE listing_daily (2 listing_id BIGINT UNSIGNED NOT NULL,3 day DATE NOT NULL,4 event VARCHAR(64) NOT NULL,5 total INT UNSIGNED NOT NULL,6 uniques INT UNSIGNED NOT NULL,7 PRIMARY KEY (listing_id, day, event)8);Dnevni cron job čita jedan dan sirovih događaja, gdje je skeniranje jeftino jer je prozor mali, i upisuje jedan red po oglasu, danu i događaju. Može se napisati, na primjer, ovako:
1async function rollUp(day) {2 const flux = `3 from(bucket: "${INFLUX_BUCKET}")4 |> range(start: ${day.start}, stop: ${day.stop})5 |> filter(fn: (r) => r._measurement == "listing_event")6 |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")7 |> keep(columns: ["_time", "event", "listing_id", "session_id", "page_url"])8 `9 10 const buckets = new Map()11 for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {12 const row = tableMeta.toObject(values)13 const key = `${row.listing_id}|${row.event}`14 const b = buckets.get(key) ?? { total: 0, seen: new Set() }15 b.total += 116 b.seen.add(`${row.session_id}|${row.page_url}`)17 buckets.set(key, b)18 }19 20 const rows = [...buckets].map(([key, b]) => {21 const [listingId, event] = key.split('|')22 return [listingId, day.date, event, b.total, b.seen.size]23 })24 25 await mysql.query(26 `INSERT INTO listing_daily (listing_id, day, event, total, uniques)27 VALUES ?28 ON DUPLICATE KEY UPDATE total = VALUES(total), uniques = VALUES(uniques)`,29 [rows],30 )31}Job odvojeno čuva ukupan broj događaja i broj jedinstvenih. Jedinstveni se broje po sesiji i URL-u stranice, pa se neko ko traži u dva grada broji dvaput, jer je tražio dvije različite stvari. Ukupan broj je veći i ljepše izgleda u izvještaju, a broj jedinstvenih govori oglašivaču koliko je različitih ljudi vidjelo njegove oglase. Oba broja čuvamo u tabeli, pa nijedan ne treba skrivati.
Zbog upserta se job može bez problema pokrenuti ponovo. Ako se isti dan sažme dvaput, drugo pokretanje prepiše prvo umjesto da ga udvostruči.
I sesija i URL stranice su u sirovoj bazi polja. Brojanje jedinstvenih ih treba, a obje vrijednosti rastu sa saobraćajem.
Godišnji izvještaj za više od 900 oglasa vraća se za manje od sekunde
Godišnji izvještaj ne čita nijedan sirovi red, nego 365 sažetih redova po oglasu:
1SELECT d.listing_id, d.event, SUM(d.total) AS total2FROM listing_daily d3JOIN listings l ON l.id = d.listing_id4WHERE l.account_id = ?5 AND d.day >= CURDATE() - INTERVAL 1 YEAR6GROUP BY d.listing_id, d.event;Mjerio sam najgori slučaj. Za klijente s više od 900 oglasa godina dnevnih sažetaka je otprilike 330.000 redova, koje jedan upit agregira, a rezultat stiže za manje od jedne sekunde. „Manje od sekunde“ ne znači ništa kod klijenta s tri oglasa, pa vrijedi mjeriti one najveće.
| Podaci | Gdje se čuvaju | Šta ih čita |
|---|---|---|
| Sirovi događaji, identifikatori u poljima | time-series baza, 7 dana | dashboard uživo, dnevni sažetak |
| Jedan red po oglasu, danu i događaju | relaciona baza | godišnji izvještaj, fakture |
| Sirova arhiva | object storage | niko, osim ako nešto treba ponovo obraditi |
Ista podjela bila je važna i za račun za hosting, o čemu pišem u posebnom tekstu. Indeks je ostao mali, jer u njemu ništa nije raslo sa saobraćajem.
Duga historija jednog entiteta ide u relacionu bazu
Time-series baza dobro odgovara na pitanje „šta se nedavno desilo, po nekoliko fiksnih presjeka“, a loše na „šta se desilo s ovim jednim entitetom u dužem periodu“. Drugo je relaciono pitanje i ima relacioni odgovor: tabelu sažetaka s primarnim ključem.
Kad pitanja o dugim periodima idu u relacionu bazu, odluka o tagovima je laka. Tagovi pokrivaju fiksne presjeke. Identifikatori pripadaju pitanjima o dugim periodima, pa im ne treba indeks u bazi koja na ta pitanja ne odgovara.
Kad vidim identifikatore u tagovima, razlog je rijetko to što neko nije znao bolje. Češće je time-series baza jedina baza u dizajnu, pa mora odgovarati na svako pitanje, i na ono s kojim se najlošije snalazi, a cijenu plaća indeks, koji raste sa saobraćajem.
Šta bih uradio drugačije
Ponovo bih razmotrio prozor od sedam dana. Izabran je rano i nikad nije provjeren. Gotovo sigurno je bio dobar, ali nikad nisam pogledao da li je ikome trebalo ponovo obraditi period duži od sedmice, niti koliko bi koštalo trideset dana. Dizajn sažetaka zavisi od te brojke, a ja sam je izabrao i krenuo dalje.
Redovno bih pregledao vrste događaja. Analitika je za četiri godine pratila oko 180 vrsta događaja, bez redovne provjere koje od njih još iko gleda. Neke su se sigurno prestale koristiti, a i dalje su se upisivale. Test za tagove iznad drži indeks malim, ali ne sprječava da zatvoreni skup raste, a iz ovog skupa niko nije uklanjao vrste događaja.
Zapisao bih i test za tagove, i to prije prvog upisa. Šemu je jeftino promijeniti prvog dana, a skupo kad serije već postoje. Odluka stane u jedno pitanje, a to pitanje vrijedi zapisati tamo gdje će ga vidjeti sljedeća osoba koja dodaje tag.
Blog
Ako radite na nečemu sličnom, javite nam se s detaljima.
Prvi poziv traje 30 minuta i ni na šta vas ne obavezuje.