PLAYGRND ima tipičan problem sportskog proizvoda: podaci su prirodno relacijski, ali korisničke stranice često traže agregate.
Raw sloj mora ostati normaliziran. Utakmice, nastupi igrača, golovi, kartoni i događaji imaju smisla kao odvojeni zapisi. To je dobro za ingestion, provjeru, ispravke i auditability. Ali nije uvijek dobro za hot path.
Ako svaka stranica sezone, igrača ili featured player lista stalno ponovno računa isto iz matches, match_players i match_events, onda sustav plaća cijenu svaki put kad korisnik otvori stranicu. To je posebno vidljivo na SSR proizvodu: frontend zove Go API, Go čita Postgres, a korisnik čeka odgovor koji je u velikom dijelu mogao biti već pripremljen.
Zato smo nedavno optimizirali PLAYGRND Go API i Postgres sloj za nekoliko ponavljanih putanja:
- sezonske tablice
- liste strijelaca
- player season stats
- featured players
- globalne upcoming i finished liste utakmica
Cilj nije bio “cacheati sve”. Cilj je bio maknuti skupe ponavljane agregacije s mjesta gdje ne moraju živjeti.
Princip
Raw podaci ostaju source of truth.
To je najvažnija granica. Izvedeni podaci ne smiju postati paralelna istina koju nitko ne zna obnoviti. Ako se raw zapis ispravi, derived state mora imati jasan recompute mehanizam. Ako derived state još ne postoji, endpoint ne smije puknuti.
Za PLAYGRND je prirodna podjela jednostavna:
- aktivne sezone se i dalje mijenjaju
- završene sezone su uglavnom stabilne
- agregati za završene sezone mogu se izračunati i spremiti
- cache je dobar za česte snapshot odgovore, ali ne smije biti jedini izvor
Zato smo išli u dva sloja.
Prvi sloj su persistirane izvedene tablice u Postgresu za podatke koji su deterministički i stabilni. Drugi sloj su Redis snapshoti za sezonske odgovore koji se često čitaju i mogu se brzo obnoviti.
Iza toga ostaje fallback: kad derived rows ne postoje, endpoint se vraća na raw match_players i match_events putanju. To je sporije, ali proizvod ostaje dostupan i korektan.
Implementacija
Promjena je pokrila nekoliko dijelova backend sloja.
Dodani su i učvršćeni Postgres indeksi za hot pathove: globalne liste utakmica, sezonske tablice, strijelce i player-heavy upite. To je osnova. Ako query planner nema dobre indekse, cache samo skriva problem do prvog miss-a.
Za završene sezone uvedene su persistirane tablice za standings, scorers i player season stats. Te tablice ne zamjenjuju raw podatke. One su izvedeni pogled na stanje koje se rijetko mijenja i koje se može ponovno izračunati.
Sezonski endpointovi dobili su Redis snapshot cache. To skida ponovljeni rad s Go API-ja i Postgresa za odgovore koji se često traže u istom obliku.
Recompute je namjerno kontroliran. Interna maintenance putanja može pokrenuti refresh i recompute kad znamo da se raw podaci promijenili ili kad se radi backfill. Za player season stats recompute je transakcijski: postojeći derived rows se brišu i novi se upisuju u istoj transakciji. Time se izbjegava poluobnovljeno stanje u kojem dio sezone čita staru, a dio novu sliku.
Dodali smo i read-only EXPLAIN ANALYZE helper za hot pathove. To nije stvar koju želiš vrtjeti stalno na produkciji, ali je korisna kao ops alat kad želiš provjeriti stvarni query plan u uvjetima koji sliče produkciji.
Backfill i mjerenja
Prije produkcijskog backfilla napravljen je backup baze. To je dosadan korak dok ga ne preskočiš, a onda postane najvažniji korak koji nisi napravio.
Backfill je nakon toga napunio derived state za završene sezone:
- 33 fully scraped sezone
- 8,479 derived player stat rows
- 5,331 scorer rows
- 0 missing seasons koje imaju raw player podatke
- 3 prazne 2019-2020 sezone ostale su bez derived player rows jer nemaju raw player rows
Nakon backfilla provjerili smo hot pathove read-only EXPLAIN ANALYZE helperom:
- global upcoming league matches:
0.165 ms - global finished league matches:
0.530 ms - persisted season table:
0.401 ms - player season stats:
0.945 ms - featured players by goals:
35.483 ms
Ove brojke ne znače da je cijeli proizvod odjednom univerzalno brži. To bi bila loša interpretacija.
Najveća razlika je na agregatnim i player-heavy dijelovima. Featured players je prije backfilla bio oko 77 ms kad je fallback morao čitati raw podatke za većinu sezona. Nakon backfilla pao je na oko 35 ms, jer više ne mora za svaku završenu sezonu ponovno izvlačiti isti agregat iz raw tablica.
Za većinu listova hot path je sada sub-millisecond. To je dobar ishod, ali važniji ishod je stabilnost: endpointi više ne ovise o skupom raw fallbacku kad za to nema potrebe, a i dalje imaju fallback kad derived state fali.
Što smo naučili
Cache nije arhitektura. Cache je alat.
Ako se cache stavi preko nejasnog modela, dobije se brži nered. Ako se prvo jasno odvoji source of truth od izvedenog stanja, cache postaje puno sigurniji.
Za PLAYGRND je pravilo ispalo praktično:
- ne cacheati sve naslijepo
- cacheati ono što je stabilno ili skupo
- završene sezone tretirati kao dobar kandidat za derived state
- aktivne sezone držati kraće i opreznije
- svaki derived state mora imati recompute
- fallback mora postojati, čak i ako je sporiji
EXPLAIN ANALYZEkoristiti kao mjerni alat, ne kao stalni runtime trošak- napraviti backup prije većeg produkcijskog backfilla
Ova vrsta optimizacije nije glamurozna. Nema velike nove UI površine. Ali mijenja karakter proizvoda. Stranice koje se često otvaraju postaju predvidljivije, agregati prestaju biti stalni runtime trošak, a backend dobije jasniju razliku između raw zapisa, izvedenog stanja i cachea.
To je posebno važno za proizvod kao PLAYGRND, gdje javni sportski zapis mora biti brz, ali i dalje provjerljiv. Brzina vrijedi samo ako sustav zna odakle je podatak došao i kako ga može ponovno izračunati.