Článek je volným překladem článku Breta Taylora, vydaného pod CC-BY licencí 27.2.2009 na autorově blogu.
Na úvod
Ve FriendFeedu používáme pro ukládání veškerých dat MySQL. Naše databáze rostla zároveň s počtem uživatelů. V současnosti je v databázi přes 250 milionů záznamů („současnost“ je únor 2009, pozn. překl.) a spousta dalších dat, od komentářů a příznaků „líbí se mi“ až po seznamy přátel.
Během růstu databáze jsme se museli vypořádat s problémy škálovatelnosti, které se objevily spolu s růstem. Udělali jsme to, co se dělává: Použili jsme čtení ze slave databází a memcached pro zvýšení výkonu při čtení, a rozdělili jsme databázi (v originále „database sharding“ – technika, při níž jsou záznamy umisťovány do různých fyzických úložišť podle nějakého předem stanoveného kritéria – pozn. překl.) pro zvýšení výkonu při zápisu. Ale škálování existujících funkcí tak, aby stačily zvýšenému přenosu dat, se ukázalo být tím menším problémem v porovnání s přidáváním nových funkcí.
Konkrétněji: změnit strukturu tabulky nebo přidat indexy do databáze s více než 10–20 miliony řádků zaměstná databázi naplno po dobu několika hodin v kuse. Odstranění starých indexů zabere neméně času, a pokud je neodstraníte, sníží to výkon celého stroje, protože databáze bude dál číst a zapisovat do nepoužitých bloků při každém INSERTu, a bude si tak odsouvat z paměti to potřebné. Existují určité postupy operací, které můžete dělat, abyste se těmto problémům vyhnuli (jako nastavit nový index nejprve na slave stroji a pak prohodit slave a master), ale tyto procedury jsou velmi náročné a náchylné k chybám. Což nás v důsledku nutilo vyhýbat se přidávání takových funkcí, které by vyžadovaly změnu struktury nebo indexů. A protože se od doby, co jsme databázi rozdělili, staly „relační“ funkce jako třeba JOIN naprosto nepoužitelné, rozhodli jsme se porozhlédnout mimo svět relačních databází.
Existuje spousta databázových projektů, navržených tak, aby řešily problém ukládání dat s pružnou strukturou a vytváření indexů za běhu (například CouchDB) (psali jsme o ní na Zdrojáku – pozn. překl.). Bohužel žádný z nich není dostatečně používaný na velkých serverech, abychom se mohli někde inspirovat. V testech, které jsme četli a které jsme si udělali, se žádný z těchto projektů neukázal dostatečně stabilním či otestovaným do té míry, jakou potřebujeme. MySQL funguje. Nepoškozuje data. Replikace funguje. Známe její omezení. Máme rádi MySQL jako úložiště dat, ne kvůli tomu, že je to relační databáze.
Po jistých úvahách jsme se rozhodli implementovat „neschémové“ úložiště nad MySQL namísto vytvoření kompletního nového úložiště. V tomto článku se pokusím popsat nejvýraznější rysy tohoto systému. Zajímá nás, jak se s podobnými problémy vypořádávají jiné velké servery, a doufáme, že některé z našich úvah mohou být užitečné ostatním vývojářům.
Pokud vás naopak zajímá to, jak využít správně všech výhod, které MySQL nabízí, a pokud se chcete naučit správně používat MySQL jako relační databázi, přijďte na školení Akademie Root.cz s tématem Návrh a používání MySQL databáze, kde se dozvíte vše potřebné od návrhu až po samotné využití MySQL ve vašich projektech. Školení je vhodné jak pro důkladné seznámení se základy databází, tak pro využití MySQL u náročnějších projektů.
Přehled
Naše úložiště ukládá neschémové seznamy atributů (jako JSON objekty nebo Pythonské slovníky). Jediná vyžadovaná součást uloženého záznamu je identifikátor
, který tvoří šestnáctibajtový UUID. Zbytek záznamu je z hlediska úložiště nezajímavý. Můžeme tedy snadno změnit „schéma“ prostým uložením nových atributů. (V originále „properties“, překlad „vlastnost“ by nebyl vhodný, proto budu používat „atributy“ – pozn. překl.)
Uložená data indexujeme pomocí indexových záznamů, uložených v oddělených MySQL tabulkách. Pokud chceme indexovat tři atributy v každém záznamu, budeme mít tři MySQL tabulky, pro každý indexovaný atribut jednu. Když budeme chtít přestat užívat index, prostě přestaneme do této tabulky zapisovat. Tabulku pak můžeme smazat. Když potřebujeme nový index, uděláme pro něj novou MySQL tabulku a spustíme asynchronní proces, který index na pozadí naplní, aniž by to mělo vliv na „živou“ službu.
Nakonec jsme skončili s větším počtem tabulek, než jsme měli předtím, ale přidávání a odstraňování indexů je teď snadné. Máme velmi dobře optimalizovaný proces, který vytváří nové indexy (zvaný „Čistič“). Ten dokáže rychle naplnit indexové tabulky, aniž by přitom „zboural“ celý web. Můžeme přidávat nové atributy a indexovat je v řádu dnů, nikoli v řádu týdnů, a nemusíme kvůli tomu prohazovat MySQL master a slave nebo dělat jiné děsivé akce.
Detaily
Naše entity jsou v MySQL uloženy v tabulce, která vypadá nějak takto:
CREATE TABLE entities ( added_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, id BINARY(16) NOT NULL, updated TIMESTAMP NOT NULL, body MEDIUMBLOB, UNIQUE KEY (id), KEY (updated) ) ENGINE=InnoDB;
Sloupec „ added_id
“ je tu proto, že InnoDB ukládá řádky fyzicky v pořadí podle primárního klíče. Primární klíč s AUTO_INCREMENT
zajistí, že nově přidané záznamy jsou na disk zapisovány sekvenčně za starší záznamy, což napomáhá zápisu i čtení (nové záznamy bývají čteny mnohem častěji než starší záznamy, čemuž napomáhá i to, že stránky FriendFeed jsou řazeny v obráceném chronologickém pořadí). Těla záznamů jsou ukládána jako pythonské slovníky, serializované (pickled) a komprimované pomocí zlib.
Indexy jsou ukládány v oddělených tabulkách (v originále sharded – pozn. překl.). Když vytváříme nový index, vytvoříme novou tabulku, která obsahuje atributy, jež chceme indexovat. Kupříkladu typický záznam (entita) ve FriendFeedu může vypadat takto:
{ "id": "71f0c4d2291844cca2df6f486e96e37c", "user_id": "f48b0440ca0c4f66991c4d5f6a078eaf", "feed_id": "f48b0440ca0c4f66991c4d5f6a078eaf", "title": "We just launched a new backend system for FriendFeed!", "link": "http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c", "published": 1235697046, "updated": 1235697046, }
Chceme indexovat atribut „ user_id
“ v těchto záznamech, abychom mohli zobrazit stránku všech záznamů daného uživatele. Naše indexová tabulka bude tedy vypadat nějak takto:
CREATE TABLE index_user_id ( user_id BINARY(16) NOT NULL, entity_id BINARY(16) NOT NULL UNIQUE, PRIMARY KEY (user_id, entity_id) ) ENGINE=InnoDB;
Naše datové úložiště udržuje indexy automaticky, ve své režii. Pokud chceme se záznamy pracovat, můžeme použít nějaký takový kód (v Pythonu):
user_id_index = friendfeed.datastore.Index( table="index_user_id", properties=["user_id"], shard_on="user_id") datastore = friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306", "127.0.0.1:3307"], indexes=[user_id_index]) new_entity = { "id": binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c"), "user_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"), "feed_id": binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"), "title": u"We just launched a new backend system for FriendFeed!", "link": u"http://friendfeed.com/e/71f0c4d2-2918-44cc-a2df-6f486e96e37c", "published": 1235697046, "updated": 1235697046, } datastore.put(new_entity) entity = datastore.get(binascii.a2b_hex("71f0c4d2291844cca2df6f486e96e37c")) entity = user_id_index.get_all(datastore, user_id=binascii.a2b_hex("f48b0440ca0c4f66991c4d5f6a078eaf"))
Třída Index
hledá atribut user_id
ve všech záznamech a automaticky spravuje index v tabulce index_user_id
. Protože je naše databáze dělená, udává argument shard_on
klíč, podle kterého bude rozhodnuto o konkrétním umístění (zde podle čísla user_id
).
Můžete položit dotaz na index pomocí instance této třídy (viz user_id_index.get_all
výše). Kód úložiště udělá potřebný „join“ mezi tabulkou „ index_user_id
“ a tabulkou entit v Pythonu, a to tak, že nejprve požádá o seznam ID záznamů ze všech tabulek index_user_id
ze všech dílčích databází, a pak vyzvedne tyto záznamy z tabulky entities.
Pokud chceme přidat další index, například indexovat odkazy (atribut link
), vytvoříme opět novou tabulku:
CREATE TABLE index_link ( link VARCHAR(735) NOT NULL, entity_id BINARY(16) NOT NULL UNIQUE, PRIMARY KEY (link, entity_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Změníme inicializační kód datového úložiště tak, aby zohlednil nový index:
user_id_index = friendfeed.datastore.Index( table="index_user_id", properties=["user_id"], shard_on="user_id") link_index = friendfeed.datastore.Index( table="index_link", properties=["link"], shard_on="link") datastore = friendfeed.datastore.DataStore( mysql_shards=["127.0.0.1:3306", "127.0.0.1:3307"], indexes=[user_id_index, link_index])
Nakonec asynchronně naplníme index (tedy za normálního provozu) pomocí:
./rundatastorecleaner.py --index=index_link
Konzistence a atomicita
Konzistence je v situaci, kdy je databáze je rozdělena na více strojů a indexy pro entity mohou být uloženy na různých oddílech, často na jiných než entita samotná, docela problém. Co když zkolabuje proces předtím, než jsou zapsány všechny záznamy do všech indexových tabulek?
Vytvořit transakční protokol se stalo výzvou pro ty nejambicióznější vývojáře FriendFeedu, ale chtěli jsme zároveň udržet systém tak jednoduchý jak jen to je možné. Rozhodli jsme se porušit některá pravidla konzistence, jako například:
- Indexy nemusí vždy odrážet aktuální stav záznamů
- Směrodatný je obsah dat v tabulce
entities
V důsledku toho zapisujeme nové záznamy do databáze v těchto krocích:
- Zapíšeme záznam do tabulky „
entities
“ a využíváme přitom vlastností ACID, které nabízí InnoDB - Zapíšeme indexy do všech indexových tabulek ve všech dílech databáze.
Když hledáme informace v indexu, tak víme, že nemusí být přesné (například mohou obsahovat staré hodnoty atributů, pokud při update neproběhl korektně krok 2). Abychom se ujistili, že nevracíme neplatná data, používáme indexy jen jako vodítko k tomu, abychom zjistili, jaké záznamy načíst, ale patřičné filtry aplikujeme znovu na načtené záznamy; nemůžeme totiž důvěřovat indexům jako autoritativnímu zdroji informací. Tedy:
- Přečteme
entity_id
ze všech tabulek podle dotazu - Načteme záznamy z tabulky
entities
podle získaných ID - Vyfiltrujeme (v Pythonu) všechny entity, u nichž hodnoty atributů neodpovídají podmínkám v dotazu.
Abychom se ujistili, že v indexech něco nebude chybět napořád a že nesrovnalosti budou opraveny, prochází proces „Čistič“, který jsem zmiňoval v předchozím textu, neustále tabulkou entit, zapisuje chybějící indexy a čistí staré a neplatné záznamy. Při práci postupuje od posledních změněných záznamů, takže nesrovnalosti v indexech jsou opraveny v praxi velmi rychle (během pár sekund).
Výkon
Optimalizovali jsme naše indexy a výsledkem jsme příjemně překvapeni. Výkon stoupl a latence je stabilní. Se systémem je navíc velmi snadné pracovat. (V originálním článku jsou grafy, ukazující zlepšení stability výkonu, v kontextu tohoto článku na Zdrojáku nebyly nezbytné – pozn. překl.)
O autorovi: Autor textu, Bret Taylor, je spoluzakladatel služby FriendFeed a její bývalý CEO, nyní (po odkoupení FriendFeedu Facebookem) pracuje ve Facebooku jako produktový ředitel.
Poznámka překladatele
Na výše uvedený článek jsem narazil náhodou, když jsem hledal informace o rozumném, snadno použitelném a netradičním ORM systému pro PHP, který by fungoval jako persistentní úložiště objektů. Zvažoval jsem migraci k nějaké „čisté NoSQL“ databázi, ale problém je s tím, že NoSQL databáze nepatří do standardní nabídky webhostingů. Výše uvedený článek mi připadal jako velmi inspirativní, právě pro vývojáře, kteří by rádi zkusili přechod k NoSQL a využili přitom široce dostupné, známé a relativně spolehlivé MySQL databáze. Navíc přináší pohled autora opravdu velkého webu, což je zkušenost pro většinu vývojářů v ČR neznámá. Proto jsem se rozhodl článek přeložit, i když je už téměř rok starý, a nabídnout čtenářům Zdrojáku.
Přehled komentářů