Úvaha nad horizontálním škálováním databází a těžkostmi s tím spojenými

Škálování databází je velké téma a já rozhodně nejsem takový odborník, abych tady rozebíral kdovíjaké detaily. Zcela jistě znáte termíny jako je sharding, o kterém psal Dagi už před 5 lety, popřípadě znáte termín partitioning, který nám nabízejí některé DB stroje "zadarmo" a jiné "za peníze". Alternativním způsobem horizontálního škálování je škálování pomocí sady replik pro čtení, o kterém lze uvažovat v případě, že máte aplikaci, které řádově méně zapisuje do databáze než z ní čte. Konkrétně se jedná o to, že máte několik databázových strojů v režimu MASTER-SLAVE(S), který se často nasazuje už jen z důvodu hot-backupu (tj. v případě výpadku master databáze je možné velmi rychle z repliky učinit nový master a pokračovat v běhu aplikace).

Jelikož já i moji kolegové jsme šetřiví, napadlo nás, že by nemuselo být od věci repliku, kterou naše Operations nasadili právě z důvodu hot-backupu využít i pro zrychlení / škálování aplikace. Jsme si samozřejmě vědomi, že tento přístup má svoje riziko - v případě výpadku jedné z databází a přepnutím master databáze na záložní repliku půjdou všechny dotazy původně rozdělené na dva stroje na jeden. Pokud bychom tedy uvažovali o tomto druhu škálování bylo by lepší se bavit o dvou replikách nebo by musela být záloha naddimenzovaná oproti master stroji.

Problém, na kterém jsme se však vždy zarazili, byl spojen s tím, že nemáte garantováno, kdy se v replice objeví stejná data jako na masteru. Zpoždění replikace se může pohybovat pouze v milisekundách, ale může nabývat i vyšší hodnoty. Pokud bychom tedy používali MASTER pouze na zápisy a některý ze SLAVE na čtení, budeme mít problém s tím, že data, která v jednom requestu uživatel vytvoří nemusí nutně v dalším requestu vidět. Ať si kdo chce co chce říká o eventuální konzistenci - tohle je z pohledu BFU zcela jasná chyba, kterou nám bude reportovat a požadovat její odstranění.

Napadaly nás různé možnosti kompenzačních technik, ale všechny byly dost pracné - zvlášť v kombinaci s tradiční relační databází, kdy má uživatel často k dispozici plnou škálu funkcí jako je ad-hoc třídění, filtrování, seskupování atp. Tento týden jsem ale dostal nápad, jak by mohlo být možné relativně jednoduše tento problém řešit a otevřít nám tak cestu k využití replik pro škálování. V tomto článku bych se chtěl o něj s Vámi podělit a třeba přijdete na nějaká úskalí, která mě nedošla a nápad půjde do koše. Když na žádná nepřijdete budu mít větší jistotu, že je nápad životaschopný.

Můj nápad je stojí na znalosti přesné timestamp posledního zápisu uživatele a timestamp posledního synchronizovaného záznamu v replice. Zjednodušeně řečeno - jediné riziko čtení dat z repliky spočívá v tom, že tam ještě nemusí být data, která uživatel právě zapsal. Pokud mám jistotu, že tam data již jsou použiji ke čtení repliku, pokud vím, že tam nejsou použiji ke čtení zase master a budu tak dělat až do doby, než se mi data objeví na replikace. Pak přepnu čtení na repliku. Za předpokladu, že uživatel výrazně více čte z databáze než do ní zapisuje bude z master databáze číst jen velmi málo uživatelů po velmi omezenou dobu.

Jak by to mohlo fungovat

Pro zjištění toho, o kolik je replika zpožděná se používá jednoduchý princip - v master databázi se periodicky updatuje v nějaké tabulce řádek s timestamp. Tato aktualizace je samozřejmě také součástí replikace a tudíž, když si z této tabulky na replice timestamp přečteme, víme přesně o kolik je její obrázek světa pozadu - například víme, že replika má data z doby 5 sekund zpět oproti master databázi.

Na druhé straně si v aplikaci udržuji informaci o tom, kdy uživatel provedl poslední zápisovou operaci - opět pomocí timestamp. Potom vím, že když replica_timestamp >= user_write_timestamp mohu bezpečně používat repliku pro čtení, jinak musím pro čtení používat master databázi. Je samozřejmě nutné pracovat s datumem a časem, který vidí databáze (např. pomocí select now() as user_write_timestamp) a nikoliv aplikační server (data se mohou lišit).

Princip jsem zachytil na následujícím activity diagramu:

Jak je z nákresu vidět musíme mít možnost splnit následující předpoklady:

  1. musíme si udržovat user_write_timestamp někde v session uživatele
  2. musíme si dokázat odlišit, jestli v transakci budeme mít zápis nebo ne
  3. musíme přesně vědět o kolik je replika pozadu oproti masteru

Představa o konkrétním nasazení v našem prostředí

Pokud bych chtěl výše uvedený princip aplikovat na druh aplikací, které vytváříme (web aplikace) nad vývojářským stackem, který používáme (Spring), mohl bych technicky implementovat mechanismus takto:

  1. user_write_timestamp udržovat v session uživatele a pomocí servlet filtru jej nastavovat a čistit v nějaké ThreadLocal proměnné (dejme tomu UserDatabaseContext), která je dostupná pomocí statické metody odkudkoliv z aplikace
  2. zápisové transakce detekovat přes výskyt Spring anotace @Transactional pomocí AOP Pointcutu, který opět do nějaké ThreadLocal proměnné uloží příznak, že se jedná o zápis a po ukončení metody opět ThreadLocal vyčistit (je nutné ovšem projít aplikační vrstvu a ověřit, že neexistuje nějaká jednoduchá metoda, která by zapisovala data do databáze bez použití @Transactional anotace - např. když prováděla pouze jediný SQL příkaz)
  3. implementovat proxy nad DataSource, která by při prvním dotazu v rámci requestu (využití existující proměnné UserDatabaseContext) zjistila zpoždění a použitelnost repliky a při volání getConnection metody by buď vracela připojení do master databáze nebo do databáze repliky
Ze všech předchozích nápadů mi tento připadá jako jediný rozumně realizovatelný (myslím v kontextu existující rozsáhlé codebase), jelikož se dá z větší části implementovat jednotně na nízké úrovni aplikace a ponechá stávající aplikační logiku více-méně beze změny.

Problémy, které mne napadají

V aplikaci máme logiku, která dělá časté "infrastrukturální" zápisy do databáze

Například si ke každému uživateli pravidelně ukládáme informaci jestli je připojen on-line - tj. jestli s aplikací aktivně pracuje tak, že si ukládáme do databáze (třeba ne pokaždé, ale dost často) timestamp jeho posledního požadavku na aplikaci. Jedná se sice o zápisovou operaci, ale vůči uživateli je "neviditelná" - on sám neví, že se díky jeho činnosti v databázi něco aktualizovalo a proto ani v UI nečeká žádná data nebo efekty s tím spojené (operace má vliv pouze na ostatní uživatele, kde nám zpoždění tolik nevadí - když není moc velké). Přesto by nám tyto časté zápisy zvyšovaly pravděpodobnost, že uživatel bude číst data z master aplikace místo replik.

Napadá mne řešení si tyto specifické operace označit další anotací @UserInvisibleSideEffect, která by zamezila aktualizaci user_write_timestamp a tudíž by nevynucovala čtení z master databáze.

Máme bezestavovou aplikaci

A tedy není kam ukládat user_write_timestamp. V tuhle chvíli mne napadá leda použít nějakou zástupnou informaci, která by nám dokázala zjistit datum poslední aktualizace datasetu, ke kterému se vztahuje příchozí request. V tomto případě ale musíme provést minimálně jeden hit do master databáze, abychom se k podobnému údaji dostali a proto by se tento přístup vyplatil asi jen tehdy, kdyby nám stačilo zjistit jediné datum poslední aktualizace a otevřeli bychom si tím cestu k velkému množství čtení z repliky. Nebo kdyby zjištění data poslední aktualizace bylo velmi levné oproti následujícím dotazům pro čtení dat. Prostě, aby se nám to vůbec vyplatilo.

Update 10. října 2012

Tento článek posloužil svému účelu - v komentářích se vyskytlo několik zajímavých postřehů, které vedou k závěru, že tento přístup není bezpodmínečně bezpečný a má také svá slabá místa ...