Překombinované počítadlo aneb přemýšlím nahlas

Posted 02. 12. 2012 / By Petr Soukup / Nezařazené

Na PHP je sympatické, že se ho i začátečník dokáže velmi rychle naučit a hned něco tvořit. Je to trochu jako současné hry, kdy nejdřív projdete tutorialem a máte pocit, jako by jste byli nejlepší hráči na světě - jenže obtížnost se postupně zvyšuje. U jiných jazyků (c#, java, ...) je to trochu jiné. Musíte tam už přesně vědět co děláte, než můžete vytvořit i úplně jednoduchou věc. Proto se na PHP programátory ostatní dívají trochu z vrchu - kazí jim image začátečníci, kteří už sice tvoří reálné projekty, ale spíš je patlají dohromady.

Nejlépe se rozdíl ukáže, pokud na takový web pošlete větší návštěvnost. Jak bude web fungovat při návštěvnosti 300 lidí denně nebo 50 000 je celkem zásadní rozdíl. Zkusím to předvést na úplně základním příkladu, který je na začátku každé učebnice - počítadlo na stránkách. Prkotina, která se dělá hned po Hello world a je to sranda na pár řádků. Nebo ne?

Zadání

Chceme mít na webu počítadlo unikátních návštěv. Bude třeba v levém sloupci každé stránky a bude ukazovat, kolik lidí přišlo od vzniku webu. Ano, je to úplně k ničemu. Berme to jen jako teoretickou ukázku různých přístupů. Budu místy trochu zjednodušovat, protože hlavním cílem není udělat superpočítadlo, ale spíš si ukázat, nad čím vším se vyplatí zamyslet.

Přístup 1: Ukládáme všechny návštěvy

Nejpřímočařejší řešení by bylo ukládat si každou unikátní návštěvu. Přijde návštěvník, kouknu, jestli už v databázi mám jeho IP (s omezením třeba na poslední týden) a pokud ne, tak si ho přidám včetně IP. Pro zjištění stavu počítadla jen sečtu řádky. Jednoduché.

<?php
/** započítání nívštěvy */
if(dibi::query("select count(*) from navstevy where ip=%s",$_SERVER['REMOTE_ADDR']," and tyden=%i",date("YW"))->fetchSingle() == 0){
  dibi::insert("navstevy",array(
     "ip%s"=>$_SERVER['REMOTE_ADDR'],
     "tyden%i"=>date("YW")
))->execute();
}

Jenže co se stane, když nám na web začne chodit 50 000 lidí denně? Databáze návštěv ošklivě poroste a po roce používání bude mít tabulka 18 000 000 záznamů. Práce s takovou tabulkou už nebude nejrychlejší a navíc to celkem znatelně zvedne třeba zálohy webu. Může se klidně stát, že 99% velikosti databáze bude jen seznam návštěv. Při provozu v řádu stovek návštěv denně by tohle řešení ale fungovalo celkem v pohodě a dávalo by nám i možnost generovat nějaké grafíky a podobně.

Přístup 2: Ukládáme stav počítadla

Musíme tedy zajistit, aby nám databáze nerostla. Uděláme si tedy dvě tabulky - jednu s agregovanými počty návštěv po týdnech a druhou s návštěvami. Na konci týdne sečteme návštěvy toho týdne, zapíšeme do týdenní tabulky a seznam návštěv vymažeme. Když budu chtít zjisti stav počítadla, sečtu agregovanou tabulku se seznamem návštěv.

Po roce provozu bude v agregované tabulce 52 řádků a seznamu návštěv se bude držet konstantně kolem 350 000 řádků, což jde. Zkomplikovalo se nám zjišťování stavu počítadla, ale zase pracujeme s mnohem menší tabulkou, takže by to mělo být ve výsledku jednodušší. Z tohoto pohledu je tedy problém hezky vyřešen. Nebo ne?

Na nižší úrovni

Zmenšili jsme sice tabulku, ale co práce s databázi na nižší úrovni, kterou přímo nevidíme? Při každé návštěvě kvůli počítadlu proběhnou tři selecty:

  • agregovaný stav
  • součet návštěv
  • je návštěvník zaznamenán?
A pokud není, tak proběhne ještě jeden insert. Jeden návštěvník projde kolem čtyř stránek denně, to je 200 000 zobrazení stránek a tedy 600 000 selectů pro počítadlo. Insertů bude kolem 50 000, jenže každý změnový dotaz (insert, update, delete, alter) se ukládá do binárního logu databáze. Takže my jsme sice zajistili, aby nerostla tabulka návštěv, jenže pořád dál poroste tajně binární log.

Obě řešení mají navíc nepříjemnou vlastnost, že takřka při každé návštěvě zapisují do databáze. Po každém zápisu do tabulky návštěv se tak musí přepočítat její index. To mu by se dalo vyhnout cachováním hodnoty, kontrolovaným spouštěním přepočtu a podobně. Tím ale stejně nevyřešíme problém s write lock - pokud se například databáze zálohuje, tak se zamknou tabulky pro zápis. Tyhle operace se dělají třeba ve 4 ráno, takže to není problém a nikoho to netrápí - kdo by v tu hodinu něco zapisoval do databáze? Jenže když tam zapisujete návštěvy, tak POŘÁD někdo zapisuje do databáze. Může se pak klidně stát, že Googlebot bude čekat na zámek databáze, kvůli hloupému počítadlu.

Přístup 3: Ukládáme jinam

Protože návštěvy nejsou zrovna relační data a jejich ukládání nám způsobuje problémy, mohlo by se nabízet ukládat je jinam. Například do souboru a nebude problém s binárním logem. Ale na to rovnou zapomeňte. U souboru je problém, že se u něj těžko zajišťuje výhradní přístup. Při velké návštěvnosti se velmi snadno stane, že se soubor občas záhadně vymaže. Řeší se to různými zámky a podobně, ale to jsme zase tam kde jsme byli.

Místo souboru bychom tak mohli spíš použít jiný typ databáze, který je na to vhodnější. To by bylo sice hezké, ale... zbláznili jste se? Kvůli stupidnímu počítadlu budeme udržovat druhou databázi jiného typu? Budeme inicializovat spojení s dvěma databázemi jen kvůli vypsání počtu návštěv?

Přístup 3: Počítáme asynchronně

Problému se zamykáním databáze se dá elegantně vyhnout, pokud budeme návštěvy počítat asynchronně. Můžeme například po načtení stránky AJAXem zavolat skript (nebo přes vložený obrázek, ale pozor na blokování dalšího načítání) a započítat návštěvu. Při načtení stránky tak zjistíme stav počítadla jako dřív, ale návštěvu započítáme až následně.

Rovnou můžeme ale ušetřit ještě více, když si zjednodušíme situaci. Pozná někdo, že občas započítáme někoho dvakrát? Musí být počítadlo přesné? Samozřejmě ne. Můžeme se tak vrátit k asynchronnímu přístupu přes obrázek. Do webu se vloží obrázek, který bude mít velikost 1x1px a povede na nějaký PHP skript. Ten rovnou bez zjišťování duplicit započítá návštěvu. Vrátí 1x1px bílý obrázek a hlavně pošle hlavičku expirace na 7 dní. Nemusíme pak vůbec zjišťovat, zda už jsem dotyčného započítali - prohlížeč se na obrázek zeptá jen jednou a pak bude používat cache. Samozřejmě, že pokud návštěvník zmáčkne CTRL+F5, tak se započítá znovu, ale to nám nevadí a je to přijatelná odchylka.

Trochu nepříjemné ale bude, že musíme mít o jeden HTTP požadavek víc. Ten se navíc musí znova spojit s databázi a všechno inicializovat. Kvůli této režii navíc se pak může ve výsledku vyplatit raději synchronní přístup.

Přístup 4: Počítáme bez databáze

Přístup přes obrázky nabízí ale ještě jednu zajímavou možnost. Pokud si obrázek hezky pojmenujeme, tak si návštěvy ani nemusíme ukládat, protože už je vlastně ukládáme - do přístupového logu. Nejlepší by asi bylo rovnou si přístupy k této adrese ukládat do jiného logu - nebude nám nafukovat log vcelku nepodstatnou informací a pro zjištění počtu návštěv nám stačí jen sečíst řádky. Dokonce bychom si mohli i nastavit formát logu i tak, že bude ukládat pouze adresu (která je vždy stejná) a pak nám pro zjištění počtu návštěv stačí vzít jeho velikost a vydělit nějakou konstantou.

Přístup k logu ale nebude mít přímo z PHP, takže bude potřeba nějaký meziskript na serveru. Ten může rovnou vyřešit i rotaci logů a podobně. Tím se nám čtení už ale trochu komplikuje, takže by se hodnota měla nějak cachovat - pokud bude stav návštěv cachovaný v intervalu 5 minut, tak stejně nikdo nepozná rozdíl a nám to značně sníží zátěž.

Přístup 5: Nepočítáme vůbec

Osobně bych to ale udělal celé úplně jinak. Počítadlo je nesmysl, který nemusí být vůbec přesný, nemá žádný přínos a přitom je vcelku komplikované ho nějak řešit. Většina eshopů ale má Google Analytics a ty už návštěvy počítají, tak proč je nevyužít? Analytics má i vcelku hezké API, přes které se můžou číst data dle parametrů. Přístup k nim se ale musí cachovat výrazněji, protože jde o externí službu a nemůžeme spoléhat, že bude vždy rychle dostupná. Analytics má navíc zhruba hodinu zpoždění, takže musíme rozdíl nějak kompenzovat. Budeme tedy stahovat počet unikátních návštěv a pak k nim ještě připočteme interpolovanou hodnotu, abychom předstírali aktuálnost počítadla a nezastavovalo se. Údaje bude samozřejmě o nějaké drobné lhát, ale kdo to pozná?

<?php
/** POZOR - pro zjednodušení neřeší nedostupnost Analytics apod */
function pocitadlo_stav(){
   if(!apc_exists("pocitadlo_stav")){
       // unikátní návštěvy od začátku do "před hodinou" a cachujeme hodinu
       apc_store("pocitadlo_stav",GA_uip("2000-01-01",strtotime("-1 hour")),60*60);
       apc_store("pocitadlo_cas",time(),60*60);
       apc_store("pocitadlo_prumer",round(GA_uip("-8 days",strtotime("-1 days"))/7),60*60);
   }

// předpokládané návštěvy od poslední kontroly
$odhad = round((24*60*60)/(time()-apc_get("pocitadlo_cas") * apc_get("pocitadlo_prumer")
   return apc_get("pocitadlo_stav") + $odhad ;
}

Další možnosti

Mám tu sepsaný slušný román, jak (ne)dělat počítadlo a přitom jsem vzal jen prvních pár možností, co mě napadly. Vůbec jsem nezkusil využít šikovně session nebo cookies. Hezký přístup taky popisuje Jakub Vrána pomocí APC.

Cílem ale ani nebylo udělat funkční počítadlo, ale spíš jen ukázat, že i taková prkotina, jako je počítadlo, může být zatraceně složitá. Často na to narážím i u našich eshopů - klient (třeba znalý základů PHP apod) požaduje nějakou funkci, která je vlastně úplně jednoduchá. Jenže jakmile zohledníte velkou návštěvnost, balancování eshopů mezi servery, návaznost na zálohy, další vývoj a podobně, tak se situace ošklivě komplikuje.

P.S.: Mám vás tu otravovat s podobnými techničtějšími články nebo to nikoho zajímá a je to španělská vesnice? :)


O blogu
Blog o provozování eshopů a technologickém zázemí.
Aktuálně řeším hlavně cloud, bezpečnost a optimalizaci rychlosti.

Rozjíždím službu pro propojení eshopů s dodavateli.