Možná někoho napadne, co je to za bláznivý nápad, proč by si někdo psal novou databázi. Není to zbytečná práce? Proč nesáhnout po něčem existujícím? Databází máme přehršel. Na druhou stranu, proč ne. Uplatnění si určitě najde
Nejprve bych trochu poupravil clickbaitový titulek.
Doufám, že teď už to nevypadá šíleně. Jde tedy vlastně jen o jakousi nadstavbu nad databází implementující Key-Value úložiště – pro tento low-level přístup já nazývám spíš jako databázový engine.
Knihovna DocDB spadá někam na úroveň „middle-level“ databáze, protože i přes mnoho užitečných funkcí, které nabízí, doprogramování další věcí se nevyhneme. Což má výhody a nevýhody. Například nevýhodou je, že zde není žádný query language ani query executor, dotazy se samy neoptimalizují, to si musíte zařídit v C++ podle toho, co v databázi potřebujete vyhledat. Na druhou stranu to přináší obrovskou vyjadřující svobodu, vše se programuje v jednom jazyce za masivního používání šablon.
Když už mluvíme o šablonách, tak DocDB je navržena jako header-only knihovna, právě proto, že drtivá většina tříd a struktur jsou šablony.
Napsat si databázi pro C++ nebylo rozhodnutí ze dne na den jako myšlenka šíleného programátora. Když se podíváte na github a najdete nejstarší větev, zjistíte, že první commit do této (již mrtvé větve) byl učiněn v červenci roku 2019. Pokud by se někomu chtělo probírat se historií, nenajde jeden projekt, ale asi čtyři projekty, jako kdyby někdo vývoj v určitém bodě zastavil, všechno smazal a začal znova. A skutečně tak to bylo. V jedné větvi dokonce najdete stabilní verzi která byla nasazena v reálné produkci. Tak i tato větev je dnes mrtvá.
Produkční nasazení jedné z prvních verzí vzniklo pro server zajišťující jistou mobilní hru pro nejmenovanou celostátní televizi. Aplikační server, který se interně jmenoval „game server“ měl klasické uspořádání ve formě load balancer → aplikační servery → databáze
. Jako databáze byla zvolena CouchDB, a to z důvodu toho, že herní stav každého hráče byl koncipován jako dokument. Časem se však ukázalo, že i přesto, že CouchDB byla instalována v clusteru o 16 datových uzlech a každý aplikační server měl vlastní agregační uzel, nebyla databáze schopna zvládat některé špičkové zátěže tak, jak bych si představoval. V rámci mé implementace konektoru k této databázi v C++ (jednalo se v zásadě jen o C++ wrapper nad REST API této databáze) vznikl jednoduchý systém pro cachování indexů na straně aplikačního serveru s cílem ulehčit samotné databázi pro některé časově kritické dotazy. Tady se využilo faktu, že CouchDB lze použít i jako message broker a dále pak znalosti, jak se v CouchDB sestavují indexy. Index byl realizován v aplikačním serveru jako jednoduchá mapa klíčů a hodnot.
Cachování indexů na straně aplikačních serverů výrazně pomohlo výkonu, ale časem se objevil jiný problém. Jak přibývalo hráčů, odehraných her a dalších informací, indexy začaly bobtnat a jak jsem předeslal, implementace byla realizována pomocí mapy v paměti, rostla tak i paměťová náročnost aplikačních serverů. Proto jsem postupně paměťové mapy nahradil LevelDB. Tato databáze je snadno dostupná v knihovně libleveldb a kromě pár úprav v programu nevyžaduje žádnou zásadní instalaci a samotný update aplikačních serverů v produkci proběhl naprosto bez problémů.
Integrace do samotného aplikačního serveru si vyžádalo nějakou organizaci uložených dat, což vedlo ke vzniku knihovny DocDB, motivací bylo i možnost využití této knihovny i v některých budoucích projektech, které by nebyly navázané na CouchDB, jinými slovy, aby knihovna nesloužila jen ke cachování indexů, ale aby mohla spravovat i samotné ukládání a načítání dokumentů.
Celá věc se bohužel vyvinula jinak, soutěž byla zastavena a s tím se zastavil nejen vývoj a vylepšování serverů ale i práce na DocDB. Projekt zůstal nedopsaný a chyběla motivace v něm pokračovat. Občas jsem se k tomu vrátil s novými nápady, ale vždycky jsem toho po čase zanechal.
A současný stav? Zapojil jsem se do komunity kolem protokolu NOSTR[3] a jako proof of concept jsem se pokusil naprogramovat NOSTR relay. O vlastním protokolu mám v plánu napsat delší článek, takže jej nebudu rozebírat. Samotná relay není nic jiného než dokumentově orientovaná databáze. A to byl impuls dokončit DocDB – i když šlo spíš o kompletní přepis, protože původní kód vznikal v C++14 a časem v C++ 17. Mým požadavkem bylo mít novou knihovnu aspoň v C++20.
LevelDB je key-value orientovaná databáze, která k ukládání dat používá levely a immutabilní mapy. Immutabilní znamená, že každý zápis do databáze vede na vytvoření nové revize databáze která obsahuje změny vůči předchozí revizi. Pokud je změn hodně, jsou změny sloučeny do některého z levelů, přičemž se postupuje od levelu 0 do levelu 7, který je poslední. V každém level (kromě levelu 0) jsou klíče seřazeny, takže nalezení záznamu by mělo být velice rychlé. Díky téhle vlastností je rychlé i slučování levelů, které má složitost O(n). Level 0 je realizován v paměti pomocí map a navíc je zálohován na disku pomocí neseřazeného log souboru. Ostatní levely jsou realizovány v souborech na disku. Při přístupu k levelu se pak soubory cachují v paměti, přičemž velikost cache si mohu zvolit v konfiguraci. Slučování levelů probíhá na pozadí. Spouští se vždy, když daný level dosáhne určité velikosti.
Z hlediska programátorského rozhraní je používání LevelDB velice jednoduché. Najdeme zde vlastně tři operace. Get(klíč)
Put(klíč, hodnota)
a Delete(klíč)
. Pro procházení databáze máme k dispozici databázové iterátory, kterými lze klíče hledat a iterovat nahoru i dolu.
Mezi pokročilé nástroje pak patří batchové zápisy, které garantují atomický zápis do databáze … a bez nichž se v DocDB neobejdeme. A stejně důležité je schopnost vytvářet dočasné snapshoty, přitom jejich vytvoření je rychlé, protože se využívá vrstvení revizí databáze, takže snapshot není nic jiného, než zamknutá revize, která se drží v paměti (a na disku) tak dlouho, dokud existuje reference, která na ní ukazuje.
Jak jsem předeslal, DocDB je dokumentově orientovaná databáze. Co je to dokument? Tohle je hlavním důvodem, proč je DocDB plná šablon. Dokument je totiž cokoliv, o čem prohlásíte, že je dokument. Prakticky se jedná o definici typu, který následně vkládáte jako argument do většiny tříd nabízené knihovnou.
Dokument se definuje pomocí struktury, která musí vyhovovat konceptu[4] docdb::DocumentDef
struct MyDocumentDef { using Type = MyDocument; template<typename InputIter> static MyDocument from_binary(InputIter b, InputIter e); template<typename OutputIter> static OutputIter to_binary(const MyDocument &doc, OutputIter out); };
Protože leveldb ukládá klíče a hodnoty jako binární string, je třeba definovat funkce pro serializaci dokument do binární podoby a pro deserializaci z binární podoby zpět do objektu dokumentu. Vstupní a výstupní iterátory jsou standardní C++ iterátory které pracují s bajty (unsigned char). OutputIter bývá zpravidla instanciován jako std::back_inserter(c)
.
Deklarace šablon je zde nutností, na různých místech se může objevit iterátory různých typů, ať jde o serializaci do stringu, vektoru, nebo o deserializaci se stringu, vektoru nebo string_view. Serializační a deserializační funkce ví pouze to, že iterátor pracuje s bajty
Knihovna DocDB nabízí tři předem definované dokumenty, které můžeme použít a vyhneme se nutnosti definovat vlastní
docdb::Row
, který umožňuje uložit více hodnot z omezené sady podporovaných typů ve formě řádky tabulky. Typ Row
se používá k implementaci klíčů, takže se k němu ještě dostanemeVrátím se k počáteční motivaci, kdy bylo potřeba do leveldb ukládat indexy. Ano, správně, jde o množné číslo, indexů bylo víc. A jistě by se nabízelo pro každý index použít jednu instanci leveldb::DB
objektu. Není to ale dobrý nápad
V rámci jedné instance databáze ( docdb::Database
) je možné globální keyspace rozdělit až na 255 keyspaců a v nich realizovat stejné množství kolekcí. Termínem kolekce se zde rozumí nejen tabulky s daty, ale i indexy a další objekty (viz dále). Tohle číslo odpovídá tomu, že identifikátor keyspace má osm bitů – jeden bajt – a dále pak skutečnost, že jeden keyspace je vyhrazen. Ten má ID=255. V tomto keyspace se nachází tabulka přidělených keyspaces
Z hlediska programátorského rozhraní byla zvolena jiná organizace. Bylo by nepohodlné, kdyby si programátor musel pamatovat, které keyspace má přidělené které kolekci. Místo toho každou kolekci identifikujeme jménem. Kolekce organizujeme podobně jako soubory na disku a není zde omezení na to, co smí a nesmí jméno kolekce obsahovat. Tak můžeme roztřídit kolekce do jmenných prostorů (například „customes.by_id“). Mapování kolekce na keyspace id (zkráceně KID) řeší právě objekt docdb::Database
a tyto informace drží v KID=255.
Domnívám se, že limit na 255 kolekcí je dostačující. Pokud opravdu někomu nestačí, pak by se měl zamyslet nad strukturou své databáze. I zde má možnost vytvořit dvě databáze, zvlášť pokud obsahují nezávislé, nepropojené tabulky. .
Příklad: inicializace storage
docdb::PDatabase db = docdb::Database::create(...); docdb::Storage<MyDocumentDef> tabulka_1(db, "tabulka_1"); docdb::Indexer<docdb::Storage<MyDocumentDef>, IndexFn> tabulka_1_index(tabulka_1, "tabulka_1_index");
Kód pracuje tak, že po inicializace databáze vytvoří nebo připojí kolekci tabulka_1
jako storage (viz dále). Pokud kolekce neexistuje, je založena prázdná, pokud existuje, pak je otevřena. Přístup ke kolekci se pak děje přes objekt uložený v proměnné tabulka_1
. Stejným způsobem se inicializuje index, ten máme k dispozici v proměnné tabulka_1_index
.
┌─────────────────┐ ┌───────────────┐ │docdb::PDatabase │----->│docdb::Database│ └─────────────────┘ └───────────────┘ ▲ ┌──────────────┐ │ ┌───────┐ │docdb::Storage│ ----------> └──┤Storage│ ─────────── updates ─────┐
└──────────────┘ └───────┘ │ ▲ │ ┌──────────────────────┐. │ ┌────────────┐ │ │docdb::Indexer<unique>│-----------> ├─┤Unique index│ ◄─────┤ └──────────────────────┘ │ └────────────┘ │ │ │ ┌─────────────────────┐ │ ┌─────┐ │ │docdb::Indexer<multi>│ -----------> ├─┤Index│ ◄─────┤ └─────────────────────┘ │ └─────┘ │ │ │ ┌─────────────────────┐ │ ┌──────────────────────┐ │ │docdb::Indexer<multi>│ -----------> ├─┤Materializovaný pohled│ ◄──┤
└─────────────────────┘ │ └──────────────────────┘ │ │ ▲ │ │ │ ┌───────────────┐ │ ┌─────────────────┐ │ └─┤Materializovaný│ ◄───┤ │docdb::Aggregator│ -----------> │ │souhrn │ │ └─────────────────┘ │ └───────────────┘ │ │ │ ┌───────────────────────────┐ │ ┌──────────────────────┐ │ │docdb::IncrementalAgregator│------> └─┤Materializovaný souhrn│ ◄──┘ └───────────────────────────┘ └──────────────────────┘
Programátor má k dispozici několik tříd, které spolupracují. Na schématu jsem se snažil naznačit právě i vztahy. Vše začíná na objektu docdb::Database
který obsahuje společný stav celé databáze, drží také instanci leveldb::DB
. Tento objekt je alokován dynamicky pomocí std::make_shared
. Programátor k němu přistupuje přes chytrý ukazatel docdb::PDatabase
. Zároveň každý další objekt z knihovny DocDB
drží jednu referenci, tím je zajištěno, že společná část „nikam sama neodejde“.
Hlavním úložištěm dokumentů je Storage
který si alokuje jeden keyspace. Tam budeme ukládat dokumenty. Na Storage se odkazují indexery. Ty si také alokují keyspace, vždy jeden pro každou instanci. Zároveň ale úložiště posílá aktualizace o všech změnách v úložišti všem indexerům. Tímto způsobem je zajištěna automatická indexace všeho, co se uloží do úložiště.
Indexace probíhá sekvenčně, tedy indexery jsou postupně volány pro každý vložený dokument. Tady by se sice nabízela paralelizace pro zrychlení výkonu, ale nakonec jsem se rozhodl, že nechám paralelizaci na programátorovi. Dokumenty lze vkládat a měnit paralelně a tím docílit paralelní indexace. Jediné místo, kde dochází k serializaci je na úrovni LevelDB
V programu zpravidla držíme celou naši databázi pohromadě, měla by vznikat naráz a zanikat naráz. Přidávání indexů za běhu v zásadě není možné, musel by tam být zámek a to zvyšuje komplexitu celého řešení. Doporučuji tedy pro instanci „celé databáze“ použít strukturu
struct MojeDatabaze { Storage dokumenty; Indexer1 index1, Indexer2 index2, MyView view, Aggregator aggreg1; MojeDatabaze(docdb::PDatabase db) :dokumenty(db, "dokument") ,index1(dokumenty,"index1") ,index2(dokumenty,"index2") ,view(dokumeny, "pohled1") ,aggreg1(view, "aggreg1") {} } MojeDatabaze mojeDatabaze(db); mojeDatabaze.dokumenty.put(doc); for (const auto &row: mojeDatabaze.index1.select(key)) { //.... }
Jistě si teď někdo všiml, že v tomto příkladě nejsou žádné šablony, ani formát dokumentu, ani informace o tom co se indexuje. To protože použité typy nejsou přímo typy pocházející z DocDB
. Ta pravá magie začíná až s použitím klíčového slova using
using Storage = docdb::Storage<MujDokumentDef>; using Indexer1 = docdb::Indexer<Storage, Indexer1Function, docdb::IndexType::unique, docdb::RowDocument>; ....
Schéma databáze je defacto definováno pomocí vyjadřovacích prostředků jazyka C++. Překladač pak může sestavit kód na míru schématu naší databáze a optimalizovat kód k maximálnímu výkonu.
Aby článek nebyl dlouhý, představím ještě typy kolekcí, a podrobně si je rozebereme a příště. Nějaké typy už byly představené nepřímo ve schématu, takže si je popišme všechny včetně hlavních vlastností
Tahle kolekce představuje úložiště. V názvosloví relačních databází by se jednalo o tabulku. Storage však není organizováno po řádcích a sloupcích. Do úložiště ukládáme dokumenty. Ale pokud dokumentem je řádka s pevným formátem, pak to může připomínat tabulku. Dokumenty však mohou být rozvětvenější, strukturovanější. Na rozdíl od relačních databází, zde může mít každý dokument jiný význam. Není problém míchat zákazníky, košíky, zboží a kategorie v jednom úložišti. Postačí, když každý dokument bude mít nějakou identifikaci typu na stejném místě ve své binární reprezentaci. Indexery pak mohou být napsané tak, aby dokumenty třídily do skupin, například indexer který indexuje zákazníky podle zákaznického ID prostě zpracovává jen zákazníky a jiné typy dokumentů ignoruje
Důležité je, že změny v úložišti lze provádět po celých dokumentech. Změna jednoho bajtu v dokumentu vyžaduje vložit upravený dokument celý.
Cokoliv je zapsáno do úložiště, to je svázáno s unikátím ID: Označuje se DocID. Toto ID začíná na čísle 1 a s každou aktualizací roste nahoru. I změna existujícího dokumentu (jeho nahrazení novou verzí) sváže zápis s novým ID. Tedy pozor, přidělené ID nelze chápat jako automatické číslo v relační databázi. Tam totiž mohu řádku změnit a ponechat si stejné ID. V tomto případě DocID spíš představuje revizi zápisu. Pokud chci evidovat dokumenty podle nějakého ID musím si ho přidat do dokumentu a generovat si ho vlastní kódem.
Úložiště funguje jako „insert only“ databáze. Zapsané dokumenty nelze měnit, ale lze zapsat nový dokument s tím, že se zároveň zapisuje i DocID dokumentu, který je přepsán. Starý dokument pak zmizí z indexu a je nahrazen novým dokumentem. Staré revize zůstávají v úložišti (takže je možné sledovat historii změn), ale je také možné (ručně) provést operaci „compact“ a staré revize smazat.
Indexery jsou definované pomocí indexační funkce. Tu specifikuje programátor a její náplní je převést dokument na množinu dvojic klíč=hodnota
. Funkce přitom může generovat libovolně velkou množinu těchto dvojic, a může samozřejmě generovat i prázdnou množinu. Pokud množina obsahuje více dvojic, pak klíčové části dvojic musí být unikátní v rámci této množiny. V indexu pak dokument hledáme pod každým generovaným klíčem a kromě přiřazené hodnoty získáme i DocID dokumentu, který dvojici generoval. Pokud funkce pro určitý dokument negeneruje žádnou dvojici, pak tento dokument nebude v tom konkrétním indexu přítomen. Toho lze využít pro roztřídění dokumentů podle typu.
Klíč je typu docdb::Key
. Detaily o této třídě rozeberu v dalším článku. Prozatím postačí vědět, že tento typ umožňuje vytvářet vícesloupcové klíče, přičemž každý sloupec může být typu string
, unsigned int
, double
nebo bool
(a nějaké další okrajové typy). Klíče jsou řazeny alphanumericky vzestupně (čísla jsou řazeny tak jak jdou čísla po sobě).
Typem hodnoty je opět dokument, tedy typ, ke kterému existuje DocumentDef definice. Samozřejmě, že to nemusí být stejný typ jako máme v úložišti, pro hodnoty v indexech si můžeme definovat jiný typ dokumentu. Většinou si ale vystačíme s RowDocument
, typicky pro uložení čísel, IDček a podobně.
Nutnou podmínkou pro indexační funkci je, že musí být deterministická. Její výsledek musí záviset pouze na dokumentu, který indexuje. Programátor nemá nástroje na to ovlivnit, ve kterém konkrétním případě se tato funkce volá. Typicky se volá při vložení dokumentu, ale i při přepsání nebo smazání dokumentu – kdy se volá i pro dokument, který byl smazán nebo nahrazen. Je potřeba, aby tato funkce generovala determinicky stejnou množinu klíčů pro všechny situace.
Indexy se dají použít pro hledání dokumentů, protože k nalezenému klíči je přiřazen zdrojový DocID a ten lze získat jako výsledek hledání. Index lze použít také jako materializovaný pohled, kdy využejem hodnoty ( value
) k uložení dalších hodnot, například části původního dokumentu, nebo i spočítané hednoty. Tyto hodnoty lze následně získat přímo z pohledu bez nutnosti načítat data z úložiště. Další využití hodnoty v indexu může být přidání relevance výsledku například pro následné řazení. Využití indexu jako materializovaného pohledu může být výhodné zejména pro časově kritické hledání. Index může být sestaven na míru danému hledání, a typicky postačí jeden lookup do takového indexu na místě, kde by třeba v relační databázi bylo potřeba kombinovat několik indexů.
Indexy se dále dělí na dvě varianty
Následuje příklad deklarace indexeru. Vytváří se dva indexy, primární index obsahuje pouze ID zákazníka. Další index indexuje víc položek ze zákazníka jako jméno, společnost, město, telefon atd… Jedná se jen o demonstrační příklad, jak uložit do klíče položku enum IndexedField
spolu s hodnotou. Při hledání v tomto indexu pak musíme nejprve specifikovat, jakou položku hledáme a pak hodnotu hledané položky. Bylo by samozřejmě možné to řešit i tak, že pro každou položku vytvořímé nový index (pokud máme volné keyspaces, pak takové řešení dokonce generuje o trochu menší objem indexovaných dat). V těchto indexech se nepoužívá přidružená hodnota, pro hodnoty se používá StringDocument
a obsah je vždy prázdný
enum class IndexedField { name, company, city, country, phone, email, subsdate, website }; struct PrimaryIndexFn { static constexpr int revision = 1; template<typename Emit> void operator()(Emit emit, const Customer &row) const { emit(row.id,{}); } }; struct OtherIndexFn { static constexpr int revision = 1; template<typename Emit> void operator()(Emit emit, const Customer &row) const { emit({IndexedField::name, row.fname, row.lname},{}); emit({IndexedField::company, row.company},{}); emit({IndexedField::city, row.city},{}); emit({IndexedField::phone, row.phone1},{}); emit({IndexedField::phone, row.phone2},{}); emit({IndexedField::email, row.email},{}); emit({IndexedField::subsdate, row.subsdate},{}); emit({IndexedField::website, row.website},{}); } }; using Storage = docdb::Storage<CustomerDocument>; using PrimaryIndex = docdb::Indexer<Storage,PrimaryIndexFn, docdb::IndexType::unique,docdb::StringDocument>; using OtherIndex = docdb::Indexer<Storage, OtherIndexFn, docdb::IndexType::multi, docdb::StringDocument>;
Knihovna DocDB nabízí několik nástrojů pro agregaci dat v indexech. Při agregaci se vždy využívá struktura klíče a dále pak přítomnost hodnoty pro každý klíč, tedy jde o použití indexu jako materializovaného pohledu. Například pokud budeme zaznamenávat žáky na škole a jejich prospěch jako číslo známky od 1 do 5, pak klíč bude identifikovat žáka, předmět a třeba úlohu za kterou je hodnocen, hodnotou bude přímo jeho známka. Agregovat můžeme známky do průměrné známky. Můžeme agregovat přes žáky a pro každého žáka spočítat průměrnou známku. Můžeme ale agregovat přes žáky a předměty, nebo, pokud žák může mít za některé úlohy více známek, můžeme agregovat i přes všechny tři sloupce klíče a získat průměrnou známku za každou úlohu.
Klíč Hodnota --------------------------- Žák,Předmět,Úloha Známka Možné agregace Žák Průměr Žák,Předmět Průměr Žák,Předmět,Úloha Průměr
V tomto uspořádání nelze provést agregaci přes úlohy, nebo přes úlohy v předmětech. To by vyžadovalo nový index s jinou strukturou klíče
Na straně hodnot ale můžeme generovat i víc než jednu hodnotu, například počet známek, průměr, nejhorší, nelepší známku, atd. Agregační funkce je samozřejmě třída, která může poskytovat naprosto libovolnou agregaci nad vybranou skupinou dat.
Z hlediska implementace máme několik způsobů, jak provádět agregaci
Aggregator≺Keys≻::RecordSet
Aggregator≺Keys≻::Materialized
IncrementalAggregator≺T≻
Data lze vyhledávat pouze v indexech. V indexech lze hledat dotazem na konkrétní klíč. V takovém případě je výsledkem buď nalezená hodnota (a případně DocID), nebo odpověď, že klíč nebyl nalezen. V neunikátních indexech (s duplicitními klíči) je nutné ke klíči dodat i konkrétní DocID a pak je získána hodnota pro danou kombinaci klíč a docid, pokud existuje.
Častěji ale hledáme záznamy pro určitý rozsah klíčů. Přitom asi nejčastěji se hledají všechny výskyty jednoho klíče v indexu s duplicitními kliči, pak získáme všechny dokumenty, které patří pod daný klíč. Případně lze hledat i prefix. To je podobné jako u agregací. Můžeme tedy v indexu obsahující třísloupcové klíče hledat podle prvních dvou klíčů a získáme všechny dokumenty které mají první dva sloupce shodné s hledaným klíčem.
Vyhledávání zajišťují metody definované v rozhraní indexeru.
auto rc1 = <index>.select(key); auto rc2 = <index>.select_between(key1, key2); auto rc3 = <index>.select_from(key, docdb::Direction::forward);
Výsledkem hledání je vždy RecordSet. Ten lze použít jako stream výsledků. Definuje iterátor ( InputIterator
), takže lze snadno použít range-for procházení nalezených výsledků
for (const auto &result: index.select(key)) { auto key = result.key; auto value = result.value; auto docid = result.id; .... }
Použití iterátoru je pohodlné ale je potřeba mít stále na paměti, že jde o stream výsledků. Iterátor není implementován jako ukazatel na výsledek. Používá se zde LevelDB iterátor, ve kterém se data načítají přímo z LDB souborů. Je však možné nechat čtení restartovat pomocí metody rewind()
(pokud je k dispozici objekt recordsetu)
Pokud je třeba výsledek agregovat, pak recordset předáme třídě Aggregator
spolu s agregační funkcí. Agregované výsledky lze též procházet pomocí for
.
Často se stane, že vyhledávací požadavek zasahuje více indexů. Pak lze postupovat tak, že se vybere jeden index, který sestaví seznam kandidátů a tyto kandidáty se následně filtrují podle dalších podmínek. Přitom se snažíme vybrat index s nejmenší mohutností. Logiku výběru vhodného indexu si musí programátor naprogramovat sám, stejně tak filtraci nalezených dokumentů. Knihovna nabízí pouze funkci odhadu mohutnosti indexu.
Druhým způsobem je nechat vyhledat kandidáty pomocí vícero indexů. Například v projeku NOSTR relay může klient dost široce specifikovat filtry, podle kterých chce hledat uložené dokumenty. Je to přitom implementováno tak, že na každý filtr existuje index. Knihovna pak nabízí třídu RecordsetCalculator
. Tato třída implementuje zásobníkovou kalkulačku pro výpočet množinových operací jako je průnik, sjednocení, doplněk atd…
//(index1 OR index2) AND NOT index3 RecordsetCalculator<RowDocument> calc; calc.push(index1.select(key1)); .push(index2.select(key2)); .OR(); .push(index3.select(key3)); .NOT(); .AND() .list(storage, [&](DocID id, const Row &value, const FoundRecord<Doc> &doc) { print_result(id,value,doc); });
Při práci s hodnotami můžeme definovat agregační funkci jako parametr operací OR and AND. ( OR(aggrfn)
)
Výsledkem hledání v indexech je vždycky pouze DocID. K získání dokumentu musíme do úložiště (storage). Objekt Storage nabízí funkci find(DocID).
auto fndres = storage.find(docid); if (fndres) { DocID previous_id = fndres->previous_id; const auto &document = fndres->document; }
Funkce find()
nevrací přímo dokument, ale objekt, který se chová jako chytrý ukazatel. Je potřeba nejprve zjistit, zda má hodnotu, tím ověříme, že dokument existuje. Pokud existuje, pak přes ukazatel můžeme získat nejen dokument ale i ID dokumentu, který byl nahrazen. Tímto způsobem můžeme procházet historii změn pozpátku do historie.
Kromě hledání podle konkrétního ID můžeme dále úložiště procházet a to pomocí metod select_all()
nebo select_from
. Výsledkem je opět RecordSet, v tomto případě ale obsahuje položky previous_id
, document
a id
for (const auto &row: storage.select_all()) { auto id = row.id; auto prev_id = row.previous_id; const RowDocument &doc = row.document; ... }
Při použití DocDB na serverech bývá požadavek na konzistenci zapsaných dat v dynamicky se měnícím prostředí. Typicky programátor potřebuje garantovat atomické zápisy a také atomické čtení. DocDB k tomu poskytuje dva nástroje (které využívají stejné funkce v LevelDB). Batche a Snapshoty
Batch umožňuje provést atomický zápis vícero záznamů. DocDB používá batch při zápisu dokumentu a všech indexů, které s dokumentem souvisí. Neměla by tedy nastat situace při které by byl v databázi viděn vložený dokument ovšem ještě bez vytvořených indexů.
Batche lze použít i při zápisu vícero dokumentů společně. Rozhraní pro zápis dokumentu umožňuje dodat vlastní objekt v podobě docdb::Batch
. Ten pak musíme nechat ručně commitnout pomocí db->commit_batch(batch)
a tím je zápis dokončen. I když se batch chová jako transakce, tak jsou zde odlišnosti. Na rozdíl od transakce, všechny zápisy pomocí batche nejsou nikde viditelné, nevidí je ani ten, kdo zápisy provádí. Při používání batchů je také třeba zajistit, aby dokumenty nevytvářely kolizní duplicitní klíče, jinak zápis končí výjimkou. Některé kolekce totiž během zápisu mají výhradní zámek na zapisované klíče a při vícenásobném požadavku na stejný klíč se většinou detekuje deadlock a tím se zápis zmaří právě vyhozením výjimky.
docdb::Batch b; storage.put(b,doc1); storage.put(b,doc2); db->commit_batch(b);
Batche v DocDB nabízí další funkce. Každý batch má přiděleno unikátní sériové číslo. Každý batch také může mít zaregistrovaný observer, který je zavolán při událostech before_commit
, after_commit
a after_rollback
Snapshot umožňuje atomicky zmrazit aktuální stav databáze. Využívá se samozřejmě implementace snapshotů v LevelDB. Každý druh kolekce, tedy storage, indexer, agregator, má i „view“ variantu ( StorageView
, IndexView
…), což jsou instance, které poskytují read-only přístup ke kolekcím. Těch lze vytvořit víc a každá může být svázána s jiným snapshotem. Pro atomické vytvoření snapshotu přes několik kolekcí je definovaná funkce docdb::make_snapshot
auto [index1_view, storage_view] = docdb::make_snapshot(index1,storage);
Tímto způsobem obdržíme snapshot výše uvedených kolekcí v proměnných index1_view
a storage_view
. Tyto objekty umožňují vykonávat dotazy nad zmraženým stavem databáze přes stejné rozhraní, jako původní kolekce. Ve vícevláknových aplikacích je vytváření snapshotů nad indexy víceméně nutnost.
Mým původním záměrem bylo představit DocDB víc technicky, ale nakonec byl příspěvek mnohem delší než jsem byl ochoten připustit. Rozhodl jsem se tedy představit jen základní vlastnosti databáze a její organizaci – přitom popis je pořád hodně povrchní. Detaily bych se tedy zabýval až dalšími články.
Dobrý den, nevím jestli přeci jen trochu toho hledání by se nevyplatilo víc, než psát vlastní projekt. Napadlo mě třeba řešit to pomocí Cephs, tedy https://github.com/OpenMPDK/KVCeph a pro zájemce ještě https://ceph.com/assets/pdfs/CawthonKeyValueStore.pdf
Přesně tento "Časem se však ukázalo, že i přesto, že CouchDB byla instalována v clusteru o 16 datových uzlech a každý aplikační server měl vlastní agregační uzel, nebyla databáze schopna zvládat některé špičkové zátěže tak, jak bych si představoval. "
Okej, byla použita dokumentová databáze, ne key-value databáze.
V téhle fázi projektu komplet vyměnit základní technologii bylo moc náročné a vždycky se musíte dívat i s optikou "Mám běžící produkci, jak to udělat, aby nasazení nové verze proběhlo co s nejmenšími problémy".
Druhá věc je, netuším, jak je dobrá ta zmíněná databáze v indexaci. Ten problém o CouchDB byl, že každý uzel dostával plnou dardu requestů, protože v indexech se hledá formou map-reduce, tedy každý uzel poskytne své výsledky a ty se na agregačním uzlu zmergují. Byla tam ale relativně velká režije na samotné zpracování requestu, a to platí i pro requesty, které vracely prázdný výsledek.
Spíš by se vyplatilo, kdyby se indexace prováděla na separátní uzly, které by byly distribuované přes load balancer, aby se rozložila zátěž.
No a není toto "každý uzel dostával plnou dardu requestů" trošku chybka v návrhu architektury aplikace, takže to pak vůbec neškáluje :D :D :D ??
Jo je. Blbý je, když na to příjdeš až v produkci. I když předpokládám, že tady by na to lidi přišli rychle a hned o začátku navrhli lepší řešení.
No urcite je blbe ak prides na nieco ked uz mas projekt na produkcii ale tomu sa hovori refaktoring a ano niekedy je narocny a rozsiahly. Vsetko zalezi od toho kolko usilia a penazi tomu vies venovat. Nie som si ale isty ci vyvoj vlastnej object db je lacnejsie riesenie (s ohladom na dalsi vyvoj)
Tak pokud jsi četl celý článek, tak víš, že to nebylo cílem, cílem bylo jen přesunout kritické indexy co nejblíže k aplikačním serverům. Jiný přístup, který se běžně používá a který samozřejmě znám, je replikace main databáze na aplikační servery, takže ty si chodí pro indexy právě tam. Replikace není nic jiného než rozesílání updatů z hlavní databáze do těch replik, což je přesně to co se tady dělo. Mimochodem, naslouchání updatů z main db tam bylo od začátku, v zásadě to byl hlavní mechanismu jak se rozesílaly herní stavy mezi jednotlivé uzly. Takže zase tolik práce v tom nebylo, stačilo jen na místo, kam přicházela tahle data strčit nějakou mapu.
Využívalo se toho, že hráč, který hraje hru, si bude chodit pro zadání jednotlivých úkolů na server v řádově několika sekund až minut, takže v paměti byla cache pro všechny aktuálně hrající hráči, takže ve výsledku aplikační servery nechodily na databázi skoro vůbec, jen když se stalo, že některý stav vypadl z cache. Do databáze se posílaly jen updaty herních stavů.
Další možnou refaktorizací by pak už byl jen clusterizace hračů, jako že hra nepotřebovala kombinovat stavy mezi hráči - ne na realtime úrovni. Kromě finále, kde se tedy hrálo hromadně a kde byla potřeba jakási interakce mezi hrajícími a kde zase nehrálo tolik hráčů. Clusterizace by znamenala, že by vzniklo víc "datacenter", a každý hráč by měl přidělený vlastní centrum, kde by hrál, což by ale přineslo komplikace na non-realtime interakci, třeba vyhodnocování pořadí ve hře, statistiky a rozdávání cen a bodů.
Všechno to bylo backlogu, kdyby to televize v Covidové době nezrušila.
Cepth je hrozny moloch, co potrebuje XY masin len na to aby sa rozbehol, navyse je prudko platformovo zavisli.
Pekny projekt. Podobnych je viac, no v kazdom pripade zaujimave rienie s templeitami.
Uvazoval ste oddlit KV-databazu, aby ju bolo mozne vymenit? Napriklad za FoundationDb a tym ziskat horizontalnu skalovatelnost?
Ano uvažoval. Mám to v backlogu. Už proto, že jsem dostal námitku, proč nepoužiju RocksDB, že prý je lepší, než LevelDB
Ked som si kedysi vyberal databazu na svoj projekt (ktory som nedokoncil) tak som tetsoval obe LevelDB aj RocksDB, a mne to vyslo, ze je to prakticky to iste (su to forky), v niecom sa lisili, ale celkovo to bolo na jedno kopyto.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 51 057×
Přečteno 23 933×
Přečteno 22 867×
Přečteno 20 947×
Přečteno 17 755×