Cache s časovou platnosťou (TTL) je kompromis: krátke TTL znamená častú záťaž databázy, dlhé TTL znamená, že redaktor po úprave dlho čaká, kým ju uvidí. Ak však presne viete, kedy sa dáta menia, môžete cache zahodiť práve vtedy. Článok opisuje, ako to riešime na porovnávacej stránke na jednom z našich portálov, čo kód robí a čo nerobí, a kedy by sme rovnaký prístup neodporučili. Je určený vývojárom a architektom, ktorí riešia drahé čítanie nad zriedka menenými dátami.

Východisko: drahé čítanie, zriedkavé zápisy

Stránka skladá dataset z viacerých zdrojov: hodnotenia, agregované počty nad tabuľkami s tisícmi riadkov, prehľad súvisiacich ponúk a históriu zmien. Bez cache to je až deväť databázových dotazov na jedno zobrazenie. Hotový dataset sa uloží ako jeden súbor a z toho istého súboru čítajú aj ďalšie časti webu: detailové stránky, kategórie, karty na úvodnej stránke aj podklady pre newsletter. V rámci jednej požiadavky sa súbor prečíta najviac raz, ďalšie volania dostanú výsledok z pamäte.

Tieto dáta sa menia len nasadením novej verzie alebo uložením v našej administrácii. Neexistuje tretí zapisovateľ, ktorého by sme nepoznali. Preto sa pri nich dá použiť invalidácia udalosťou.

Kto mení dáta, maže cache

Formuláre administrácie, ktoré menia údaje porovnania (hodnotenie, doplnkové údaje, záznamy zmien, priradenie a aktivácia položiek katalógu, založenie alebo zmazanie ponuky a zápis jej kontroly), po úspešnom zápise zmažú súbor cache. Ďalšie zobrazenie verejnej stránky dataset postaví nanovo z databázy a uloží. Po nasadení novej verzie, keď sa dáta mohli zmeniť skriptom mimo administrácie, sa cache obnoví tlačidlom na dashboarde: zmaže sa a hneď znovu naplní, aby na dotazy nečakal prvý návštevník. TTL 24 hodín zostáva ako poistka pre zabudnutý reload alebo zápis, ktorý invalidáciu nevolá.

Že poistka nie je zbytočná, sme si overili pri písaní tohto článku. Jeden modul administrácie menil údaje, ktoré sa v porovnaní zobrazujú, no cache mazal len pri zmazaní záznamu, nie pri bežnej úprave. Čerstvosť tam dovtedy držal len TTL alebo ručné tlačidlo. Chýbajúce volania sme doplnili. Invalidácia udalosťou je taká spoľahlivá ako zoznam miest, ktoré ju volajú, a ten sa pri raste systému ľahko rozíde so skutočnosťou. Mazanie patrí až za úspešný commit. Ak by prebehlo pred ním, súbežná požiadavka by si cache stihla postaviť zo starého stavu.

Prečo mazať a nie prepisovať

Alternatívou k mazaniu (invalidate-on-write) je write-through: zapisovateľ po zmene v databáze hneď uloží do cache aj novú hodnotu. Výhodou je, že po zápise nevznikne výpadok cache ani nával dotazov. Nevýhodou je, že zapisovateľ musí vedieť zostaviť celú odvodenú hodnotu. Pri našom datasete by to znamenalo spúšťať všetky agregácie v každom formulári administrácie a duplikovať logiku skladania. Navyše pri dvoch súbežných zápisoch môžu nové hodnoty doraziť do cache v inom poradí, než sa zapísali do databázy. Preto autori článku „Scaling Memcache at Facebook“ (NSDI 2013) pri zápise hodnotu v cache mažú, nie aktualizujú. Mazanie sa dá bezpečne zopakovať a nezáleží na poradí.

Verzia dátového kontraktu a verzované kľúče

Uložený dataset nesie číslo verzie svojej štruktúry. Keď sa zmení kód, ktorý ho skladá alebo číta, verzia sa zvýši a súbor so starou verziou sa ignoruje bez ohľadu na vek. Bez toho by nový kód po nasadení čítal dáta v starom tvare a padal na chýbajúcich kľúčoch. Príbuzná technika sú verzované kľúče: verzia alebo menný priestor je súčasťou kľúča, takže zvýšením verzie sa naraz „zneplatní“ celá skupina záznamov bez ich mazania. Dokumentácia memcached ju opisuje ako náhradu chýbajúcich menných priestorov. Pri jednom súbore stačí verzia v obsahu. Pri tisícoch kľúčov je verzia v kľúči lacnejšia ako hromadné mazanie, no staré záznamy treba nechať vypršať alebo upratať.

Keď databáza zlyhá

Ak niektorý čiastkový dotaz zlyhá, dataset dostane príznak „nekompletný“ a do cache sa nezapíše. Ak existuje predchádzajúci úspešný súbor v rovnakej verzii, použije sa ten s príznakom „starší“ a stránka to návštevníkovi otvorene povie aj s dátumom poslednej úspešnej aktualizácie. Výpadok sa tak neprejaví ako nuly tam, kde mali byť čísla. Je to obdoba direktívy stale-if-error z RFC 5861.

Má to však háčik: po invalidácii starý súbor neexistuje. Ak databáza zlyhá práve v okamihu prvého zobrazenia po zmazaní, posledná úspešná verzia nie je k dispozícii a stránka zobrazí neúplné údaje s upozornením. To isté platí pre tlačidlo obnovy, ktoré najprv maže a až potom stavia. Robustnejšie by bolo postaviť nový dataset vedľa a starý nahradiť až po úspechu.

Súbežnosť: čo kód rieši a čo nie

Zápis súboru prebieha s výhradným zámkom, čitatelia však zámok neberú a na Linuxe sú zámky súborov v PHP štandardne len odporúčacie. Čitateľ teda teoreticky môže prečítať rozpísaný súbor. Kód s tým počíta nepriamo: neúplný JSON sa nedá dekódovať, takže sa to správa ako chýbajúca cache a dataset sa načíta z databázy. Výsledkom sú zbytočné dotazy, nie chybné čísla. Čistejšie riešenie je zapísať do dočasného súboru v tom istom adresári a premenovať ho na cieľový názov. Na systémoch POSIX funkcia rename podľa dokumentácie nahradí cieľ atomicky, ak sú oba súbory na tom istom súborovom systéme.

Nával pri prvom zobrazení po zmazaní (thundering herd) kód nerieši: každá požiadavka, ktorá príde, kým súbor neexistuje, spustí vlastné dotazy a súbor zapíše. Pri našej návštevnosti, cene prestavby a tlačidle, ktoré cache po nasadení zahreje vopred, je to prijateľný kompromis. Riešiť by sa to oplatilo, keby prestavba trvala sekundy, návštevnosť bola vysoká alebo databáza bežala na hrane kapacity. Možnosti sú neblokujúci zámok, pri ktorom prestavuje len jeden proces a ostatní dostanú starú kópiu, alebo „leases“ ako v spomínanom článku o memcache.

Tretí, najmenej viditeľný problém je zápis starých dát po invalidácii (stale set): požiadavka A začne skladať dataset, medzitým redaktor uloží zmenu a cache zmaže, a A potom zapíše výsledok zo starého stavu. Kód tomu nebráni. Okno trvá tak dlho ako prestavba a stará hodnota vydrží najviac do ďalšej invalidácie alebo do uplynutia TTL. Spoľahlivou obranou je napríklad počítadlo generácií: pred zostavením si zapamätať generáciu a jej kontrolu pri publikovaní spojiť so zápisom atomicky. Samostatná kontrola pred zápisom necháva ďalšie okno pre súbeh. Prestavba zapisovateľom po commite alebo druhé, oneskorené zmazanie riziko obmedzia, samy však nezaručia, že pomalý čitateľ neskôr nezapíše starú verziu.

Stale-while-revalidate ako alternatíva

RFC 5861 (informačné RFC z roku 2010) definuje pre HTTP cache direktívu stale-while-revalidate: po uplynutí platnosti smie cache ešte istý čas vracať starú odpoveď a medzitým ju na pozadí obnoví. Na úrovni aplikácie to znamená neodstraňovať súbor, ale označiť ho ako neaktuálny, a jeden proces ho prestaví, kým ostatní čítajú pôvodný. Rieši to nával aj chýbajúcu záložnú kópiu pri výpadku. Cenou je, že zmenu prvý čitateľ po uložení ešte nevidí. Pri redakčnom systéme, kde redaktor chce svoju úpravu vidieť hneď, je preto rozumná kombinácia: okamžitá prestavba z administrácie a stale-while-revalidate ako cesta pre verejné požiadavky.

Kedy invalidácia udalosťou nestačí

  • Viac zapisovateľov mimo vašej kontroly: importy, iné služby, ručné SQL. Každý by musel invalidáciu volať, inak rozhoduje len TTL. Spoľahlivejšie je odvodiť invalidáciu z databázy (napríklad zo záznamu zmien cez change data capture) než z aplikačného kódu.
  • Viac webových serverov s lokálnou súborovou cache. Zmazanie súboru na jednom serveri ostatné nevidia, preto treba zdieľanú cache alebo rozosielanie invalidácií.
  • Distribuované systémy s replikami. Invalidačná správa sa môže stratiť alebo prísť skôr, než replika dobehne zápis, a cache sa potom postaví zo starých dát. Bez TTL ako poistky taká chyba vydrží neobmedzene.
  • Dáta, ktoré sa menia stále alebo ich menia návštevníci. Cache by bola väčšinu času prázdna. Preto počty komentárov v našom datasete nie sú. Stránka si ich číta priamo jedným lacným dotazom.

Kontrolný zoznam pred nasadením

  • Spíšte všetky cesty zápisu do dotknutých tabuliek a pri každej overte, či volá invalidáciu.
  • Invalidujte až po úspešnom commite, nie pred ním.
  • Ponechajte TTL ako poistku a stav cache (kedy vznikla, dokedy platí, či sedí verzia) zobrazte v administrácii.
  • Nekompletný výsledok neukladajte a čitateľovi povedzte, že údaje sú staršie alebo neúplné.
  • Cache ukladajte mimo dosahu webu alebo do adresára, ktorý server zvonka blokuje.
  • Neznámu hodnotu zobrazte ako neoverenú, nedopočítavajte ju.
  • Rozhodnite vedome, či riešite atomický zápis, nával po zmazaní a zápis starých dát, alebo ich prijímate ako kompromis.

Invalidácia udalosťou je dobrá voľba, keď poznáte všetkých zapisovateľov a zápisy sú zriedkavé v pomere k čítaniu. Redakčná zmena je potom viditeľná pri ďalšom čítaní bez čakania na TTL, ak každá cesta zápisu invalidáciu volá a súbežná prestavba je ošetrená. TTL zostáva poistkou a pri viacerých zapisovateľoch alebo serveroch zložitosť rýchlo rastie. V takom prípade začnite pri stale-while-revalidate alebo zdieľanej cache s invalidáciou z databázy.