Ve světě relačních databází obvykle používáte silnou konzistenci (defacto jen jeden node je aktivní v daný čas) a relativně silné oddělení transakcí. To ale znamená i zásadní nevýhody pro škálovatelnost a výkon, na druhou stranu pro některé situace to ideálně reflektuje reálný svět (ale méně často, než si většinou lide myslí). Azure Cosmos DB není relační (má omezené možnosti transakčního zpracování), ale NoSQL - co se týče konzistence nabízí laditelnost, 5 různých stupňů od silné až po eventuální konzistenci.
Na první pohled se zdá, že systémy musí být silně konzistentní, protože svět a byznys takový je. Určitě? Co když například prodáme jedno sedadlo v letadle dvěma lidem, protože si ho náhodou koupili v úplně stejný čas? Vadí to? Nebo když poslední ledničku na skladě omylem prodáme dvěma lidem, protože systém nereflektoval změnu okamžitě, ale s jistým zpožděním (= nebyl silně konzistentní)?
Pokud má letadlo 200 míst, letecké společnosti už mnoho let nezastaví prodej po dosažení 200 vydaných letenek. Statisticky vědí, že na konkrétním typu letu se s velkou pravděpodobností někdo odhlásí, onemocní či z jiných důvodů let nenastoupí. Tato místa (třeba pět) pak zůstanou neobsazena. Potenciální zájemci měli smůlu a letěli s konkurencí. Letecká společnost je tedy ochotna prodat letenek 205, což ji statisticky umožní mít plně obsazené letadlo, tedy dosáhnout maximální efektivitu a zisk. Může se občas stát, že statistický model selže – možná už se vám to stalo. Společnost nabídne například 100 EUR tomu, kdo se vzdá místa v letadle a poletí o pár hodin později. Je to pakatel v porovnání s tím, kolik strategie overbookingu přináší.
A co ona chybějící lednička? Vybudování silně konzistentního systému, který by situaci předcházel, nemusí dávat smysl. Může znamenat větší náklady (dražší vybavení díky scale-up), horší výkon a tím menší uživatelskou přívětivost, v případě havárie dlouhou dobu výpadku s výraznou ztrátou byznysu. Méně konzistentní systém může být levnější, rychlejší a mít lepší dostupnost. Musím se tedy ptát - kolim mne stojí slabší konzistence byznysově? Odpověď je často je, že vlastně moc ne. Když čas od času prodám ledničku, kterou už nemám, nabídnu zákazníkovi lepší model. On obvykle bude rád souhlasit, ve finále napíše pochvalnou recenzi a mne to stojí jen finanční rozdíl mezi nižším a vyšším modelem.
Jinak řečeno – dočasná nekonzistence může vypadat technicky hrozivě, ale její řešení může být obchodní, snadné a levné (v porovnání se situací s vynucenou konzistencí). Jasně - pokud jde o život nebo velké peníze, potřebujete silnou konzistenci (banka, nemocnice), ale tolerovat slabší konzistenci lze v podstatně více případech, než si techničtí lidé z IT myslí.
A co transakční zpracování, tedy schopnost běžet několik kroků v transakci tak, že se provede buď celá nebo vůbec? V IT jsme si na to zvykli, ale život takhle nefunguje, protože by to bylo příliš pomalé a neefektivní. Fantistická studie z roku 2005 vysvětluje, proč Starbucks nepoužívá dvoufázový commit: http://www.enterpriseintegrationpatterns.com/docs/IEEE_Software_Design_2PC.pdf
Platba, která neprobíhá z ruky do ruky (na začátku by zákazník musel dát peníze na stůl a společně s pokladním na nich drží ruku dokud kafe není připraveno - prevence objednávky a útěku a ochrana, že za peníze dostanu kafe), asynchronní zpracování ve frontě (prázdné kelímky podle objednávek), competing consumers (více baristů) vede k porušení přísného first-in-first-out zpracování (máme tady kafe mimo pořadí, nutnost na výstupu korelovat podle jména zákazníka) a tak podobně. Starbucks by mohl mít totální konzistenci a transakční zpracování, ale jejich kapacita by dramaticky poklesla - byznysově to nedává smysl.
SQL dosahuje vysoké konzistence tím, že v základním nastavení jsou všechny zápisy i čtení do jediného nodu. Pokud v rámci Always On clusteru v jedné lokalitě (vše milisekundu od sebe) přidáte další repliky, tyto jsou řešeny synchronně (tzn. je jim doručen transakční log a po úspěšném zapsání do logu se teprv vrací informace o úspěšném zápisu). Je možné nastavit sekundární repliky do čtecího režimu, takže čtecí operace typu načítání tabulek, reporty či zálohy mohou jít proti sekundární replice. Protože je vše synchronní, je to také konzistentní. V případě Azure SQL se toto děje na pozadí jako služba.
Pokud chcete repliku do ještě do jiného regionu, tam už je řešení eventuálně konzistentní, tedy sekundární regiony budou pozadu. Pokud z nich čtete, musíte počítat s tím, že data nemusí mít konzistenci ve smyslu přečtení poslední hodnoty (je možné, že načtete údaje, které už neplatí). Pokud vám to nevadí, můžete jet proti sekundárnímu regionu reporty, nicméně v případě Azure SQL jde o dva odlišné endpointy (logika kdy zapisuji a kdy čtu musí být v aplikaci).
Vraťme se do učebnic – co je ACID vlastnost transakcí? Atomicity říká, že je nelze roztrhnout v půlce (odečíst peníze z jednoho účtu, ale už nepřipsat na druhý). Consistency znamená, že celý systém je v každé milisekundě svého života v konzistentním stavu. Můžete dělat cokoli, číst, zapisovat, odkudkoli, jakkoli, kdykoli – vždy bude stav konzistentní v tom smyslu, že bude dodržovat omezující podmínky v systému (sloupec, který musí mít hodnotu, nebude NULL) – což samozřejmě nezaručuje logickou konzistenci (aplikace klidně může správně zapsat hlouposti). Transakce jsou Isolated, takže se nemohou vzájemně ovliňovat (to je složitější, než se zdá a u jednoduchých systémů to má mouchy – například udržení konzistence může znamenat zámky na záznamy a vznik stavu, kdy na sebe dvě transakce donekonečna čekají, tzv. deadlock – řešit to lze, ale tak jednoduché to není). Výsledky transakce jsou Durable, takže i kdyby se po jejich skončení stalo něco nečekaného, třeba se restartoval server, zápisy tam musí trvale zůstat.
Vraťme se ještě k požadavku na izolaci, který je velmi obtížný a vede na masivní dopady ve výkonu. Zejména pokud nebudete mít systém na jednom serveru, ale hned na několika v různých lokalitách – natáhnout zabezpečovací mechanismy mezi nimi často snižuje výkonu hluboko pod možnosti jediného serveru (už jen pro dodržení Atomicity s využitím dvoufázového commitu nebo Paxos vás čeká velká latence navíc po přidání druhého serveru v synchronním režimu). Skutečná izolace je na úrovni obvykle označované jako Serializable. To znamená v mnoha implementacích dát zámečky na zápis, ale i na čtení a dokonce i uzamčení celého range (SELECT s nějakou WHERE klauzulí). Dopady na výkon jsou velmi vysoké. Podobných vlastností dosahuje Snapshot izolace, která problém řeší tak, že v zásadě pracujete v rámci transakce s kopií databáze, která se nakonec zpátky promítne do té hlavní. Pokud vám nevadí menší izolace, můžete použít Repeatable read, tedy garanci, že čtení v rámci jedné transakce dopadne pokaždé stejně (že se hodnota nezmění) – nicméně už nedáváte range zámeček, takže se může objevit fantomové čtení (SELECT s WHERE vrátí jiný počet záznamů). Obvykle se systémy “vyrelaxují” ještě víc a použije se Read commited. Nikdo vám negarantuje, že pokud budete během transakce číst dvakrát to samé, dostanete pokaždé stejnou hodnotu (nedávají se read zámky nebo se uvolní hned po každém SELECT, ne až na konci transakce). Dokonce i write zámek se dá vypnout a pak následují čtení uncommited hodnot (dirty read), ale to už je z pohledu ACID opravdu špinavé.
Obvykle tedy ACID trochu ošidíte…a možná ještě víc. ACID se dá docela dobře udělat v jednom serveru, ale na více uzlech po přidání latence dostáváte horší parametry, než na jednom (čili nám to trochu anti-škáluje). Distribuované řešení mnohdy vede (z důvodu vyřešení jinak problematické škálovatelnosti) na Sharding, kdy si DB rozdělíte na řezy a každý je na jiném serveru (ACID uvnitř řezu je OK, ale pokud chcete ACID napříč hodnotami z různých serverů, máte problém – když nic jiného tak výkonnostní). Mimochodem to vede ke strategii mít “řezy” podle logických celků (faktura, zákazník, …) místo klasického relačního přístupu (no a už máte nakročeno k document oriented NoSQL, což je jeden z typů, který rozebereme na jindy). Pokud už takhle slevujete, co to udělat jinak a nasadit systém s vlastnostmi eventuální konzistence?
Kde použít ACID? Tam, kde transakční vlastnosti jsou klíčem a i sebemenší riziko jejich porušení má fatální následky neopravitelého charakteru – lidské životy nebo hodně velké peníze.
Moderní NoSQL databáze často nabízí konfigurací ovlivnit model konzistence. Cosmos DB v tomto vyniká proto, že nabízí laditelnost jak na výchozí úrovni v rámci DB, tak pro jednotlivé operace (v operaci můžete specifikovat požadovaný model konzistence). Často je konzistence změtí špatně pochopitelných parametrů. Cosmos DB dává 5 dobře definovaných a SLAčkem garantovaných modelů konzistence.
Vaše Cosmos DB může fungovat jako silně konzistentní systém. Pokud čtete, vždy dostanete tu poslední hodnotu. Veškeré změny v hodnotách jsou lineární, či chcete-li existuje jeden globální state (to ale nemusí znamenat jeden node). Zápisy jsou potvrzeny až když jsou doreplikovány na nadpoloviční většinu uzlů, čtení probíhá minimálně z nadpoloviční většiny uzlů (to, že jsou tam nějaké nody vás ale netrápí - vše je as a service).
Silná konzistence si ale vybírá i svou daň. Logická nutnost synchronních operací znemožňuje tuto úroveň nasadit napříč regiony, silná konzistence je tedy vždy pouze u Cosmos DB nasazené v jediném regionu. To vám znemožňuje distribuovat data blíže ke globálním uživatelům a také si nemůžete užít fantastického 99,999% na čtení u globálně replikované databáze. A konečně náklady (tedy spotřebované RU jednotky) tu budou největší.
Příklad: hodnota klíče byla v1 a zapsali jste v2 a chvilku po vás někdo jiný v3. V ten okamžik kdokoli odkudkoli bude číst, uvidí jen hodnotu v3.
Co dělat, když chcete globální relativně silné garance a databázi replikovanou přes regiony? Tato úroveň konzistence připouští jisté zpožďování (tedy riziko načtení informace, která není z globálního hlediska v ten okamžik ta nejnovější), ale s exaktně danou mírou "zastaralosti" vyjádřenou v počtech verzí nebo/a v čase. Náklady v RU jednotkách jsou obdobné jako u silné konzistence, ale tady můžete využít více regionů, což přináší vyšší dostupnost a čtení blíž uživatelům (Cosmos DB má jediný endpoint pro vás a čtecí operace sama servíruje z regionu, který je aplikaci nejblíže).
Jaké garance přináší? Existuje zde zákaz cestování v čase v rámci regionu (pokud jednou přečtete v2, podruhé nemůžete přečíst v1), čtete si svoje vlastní zápisy (pokud zapíšu v2 a pak přečtu, uvidím v2 a nikdy v1) a máte konzistentní prefix, tedy vidíte data možná zpožděná, ale ve správném pořadí). Zhruba 20% zákazníků využívá tento model konzistence.
Nejoblíbenější (a také výchozí) model konzistence v Cosmos DB je Session. Je na půli cestě mezi silnou a eventuální konzistencí a v tomto případě jeho garance už jsou méně globální a více vztažené ke konkrétní session. Zjednodušeně řečeno aplikace má session a ta je v zásadě směrována stále na stejný node, čímž se tam dají uhlídat některé konzistence. Nicméně ostatní session chodí klidně na jiné nody a tak už jsou garance menší. Náklady v RU jednotkách jsou citelně nižší, než u dvou předchozích, ale plně eventuální konzistence jde samozřejmě ještě o něco níž. V rámci session čtete svoje vlastní zápisy (zapíšu v2 a vždy přečtu minimálně v2, nikdy v1), v rámci session nikdy nečtete hodnotu starší, než minule (pokud přečtete v2, už nikdy se vám neobjeví v1) a máte konzistentní prefix (vaše čtení může být sice pozadu, ale následuje nějakou časovou linii). Nicméně to všechno není globální vlastnost, ale jen vlastnost vaší session.
Session je ideální kompromis - pro programátora je dobře pochopilná (dává mu rozumné konzistence), má nízké latence na zápis i čtení a finančně vychází velmi dobře.
Postupujeme dále a ubíráme další garance. Vzdáváme se teď garance čtení vlastních zápisů (zapíšete v2, hned na to čtete a ona tam klidně je ještě v1, protože čtení šlo z jiného nodu) i monotonických čtení (přečtete v2, ale příště klidně v1). Co máte garantováno je ale konzistentní prefix. Jak si ho vysvětlit? Představte si hokejový západ a dvě políčka - domácí a hosté. Tak jak se skóre bude měnit, budete to zapisovat. 0:1, 0:2, 1:2, 1:3, 1:4. Díky konzistentnímu prefixu sice možná budete pár minut pozadu, ale hodnoty načtené pro domácí a hosty budou reprezentovat skutečné skóre, které existovalo. Bez této garance se může stát, že načtete pro domácí hodnotu 0 a pro hosty rovnou hodnotu 4 ... 0:4 ale nikdy skóre nebylo.
Chcete-li nejnižší latence a nejnižší spotřebu RUs, musíte se vzdát garancí konzistence. Samozřejmě stále máte durabilitu (o zapsaná data rozhodně nepřijdete), takže eventuální konzistence není nic hrozného nebo špatného. Jen zkrátka pokud do systému přestanete posílat update on se v nějaký okamžik (= eventuálně) dostane do konzistentního stavu.
Kdy to nevadí? Tak například pokud chcete maximální výkon a případné dočasné nekonzistence si vyřešíte aplikačně. Pokud jde o něco jako Facebook, tak příspěvek, který pošle vaše aplikace si uchová v paměti a pokud při načtení "zdi" tam tento chybí (tzn. nepovedlo se konzistenční pravidlo "read your own writes"), doplní to z paměti. To, že jiní uživatelé vidí něco s nějakým zpožděním, Franta už jo a Martin ještě ne, také asi není problém. Pokud Franta něco vidí, pak obnoví stránku a už to nevidí (nemáme "monotonic reads"), tak i to se dá vyřešit aplikačně (aplikace drží příspěvky a z databáze jen aktualizuje a doplňuje). Možná také jde o data, kde je to všechno jedno. Například sbíráte nějaké události z IoT senzorů. Pro operace v reálném čase používáte nějaké proudové řešení, například Event Hub na příjem, Stream Analytics na zpracování a Azure Functions na vykonání logiky. Teprve pak to zapíšeme do Cosmos DB, abychom v rámci hodiny či dne provedli nějaké agregované vyhodnocení. V takovém případě mi nějaké nekonzistence v posledních minutách dat nemusí vůbec vadit.
V případě Cosmos DB také existuje transakční zpracování, ale v omezené míře. Všechno je dané tím, že u distribuované datové sady to nefunguje. Tak například Cosmos DB vám dává transakční zpracování na úrovni jednoho dokumentu. Pokud tedy v JSON dokumentu upravujete něco na začátku a něco na konci, tato záležitost je atomární - povede se buď oboje nebo nic.
Cosmos DB ale umožňuje i transakce mezi dokumenty za předpokladu, že jsou ve stejné partition. Jinak řečeno buď v rámci kolekce partition nepoužíváte vůbec (pro menší databáze ideální) nebo máte takové partition key, že dokumenty nad kterými chcete dělat transakci jsou ve stejné partition (například partition key jsou státy a transakce chcete jen mezi uživateli v rámci státu). Tyto transakce jsou implementovány jako kód - jde o uložené procedury v Javascriptu. Pokud máte kód v notebooku a v rámci transakce potřebujete udělat 3 operace, DB neumí zajistit, že vám do toho nikdo neskočí (a mezi druhou a třetí operací někdo jiný něco zmodifikuje). Pokud je ale kód ve formě uložené procedury, tak běží v DB samotné a ta dokáže zajistit, že do toho nikdo další skočit nemůže.
Další možností je chování podobné transakčnímu řešit aplikačně. Saga pattern je řešení, kdy všechny změny stavu zapisujeme tak jak přicházejí - ideální na to je Cosmos DB. V podstatě tímto způsobem vytváříte aplikační žurnál - podobně, jak to dělají souborové systémy nebo relační databáze. Z tohoto logu jste schopni stav zrekonstruovat (přehrát logy). To můžete dát dohromady s patternem compensating transactions, což jsou v podstatě rollback operace. Pokud například potřebujete převést virtuální peníze mezi hráči (já dám peníze, ty dáš meč), tak to je transakce. Co můžeme udělat je převést peníze v jedné transakci (a zapsat si ji do logu) a pak převést vlastnictví meče. Pokud se to v druhém kroku zadrhne, spustíme kompenzující transakci a peníze zase přičteme zpět.
Konzistence a transakce jsou fascinující témata. Mnoho lidé v IT si zvyklo na ACID vlastnosti a silnou konzistenci databází jako je Microsoft SQL. To má obrovské výhody, ale překvapivě často je možné požadavky na konzistenci trochu snížit. A odměna je pak opravdu sladká, tak jako v celoplanetární databázi s laditelnou konzistencí, různými modely i API, SLA na výkon i latence a dostupnost 99,999% - Azure Cosmos DB.