Databáze

ACID, isolation levels, race conditions, indexy, N+1, sharding.

Databáze je místo, kde tvoje aplikace trvale ukládá data — uživatele, objednávky, zprávy. Když server vypneš a zapneš, data tam pořád jsou. Skoro každý vážný backendový problém je nakonec problém s daty: dvě věci se přepíšou navzájem, dotaz je pomalý, někde se ztratil zápis. Proto je tahle kapitola jedna z nejdůležitějších v celé učebnici. Vezmeme to od úplných základů.


Pár pojmů na úvod

  • Databáze = program (běží jako vlastní server), který se stará o ukládání a vyhledávání dat.
  • Tabulka = jako list v Excelu: má sloupce (např. jmeno, email) a řádky (jednotlivé záznamy).
  • Řádek (row) = jeden záznam, třeba jeden konkrétní uživatel.
  • Dotaz (query) = příkaz pro databázi, čím se ptáš nebo co měníš. Píše se v jazyce SQL (např. SELECT * FROM users WHERE id = 5 = „dej mi uživatele s ID 5").
  • Schéma (schema) = popis, jak tvoje tabulky vypadají (jaké mají sloupce a typy).
  • Transakce = skupina operací, které mají proběhnout jako jeden celek — buď všechny, nebo žádná. (Klíčový pojem, vrátíme se k němu hned u ACID.)

SQL vs NoSQL — dva přístupy k ukládání

Databáze se dělí na dvě velké rodiny. Není to souboj dobra a zla, jen různé nástroje na různé úkoly.

  • SQL (relační databáze) — data jsou v tabulkách, které se navzájem propojují odkazy (objednávka odkazuje na uživatele, který ji vytvořil). Slovo „relační" znamená právě tyhle vztahy mezi tabulkami. Příklady: PostgreSQL, MySQL. Mají pevné schéma a silné záruky (ACID, viz níže).
  • NoSQL — všechno ostatní, co se nedrží tabulkového modelu. Ukládá třeba celé „dokumenty" (kus JSONu), nebo jen dvojice klíč → hodnota. Flexibilnější schéma, snazší rozložení na hodně serverů, ale často slabší záruky.
SQL (Postgres, MySQL)NoSQL
Jak ukládáTabulky propojené vztahyDokumenty / klíč-hodnota / sloupce / grafy
SchémaPevné (změna = migrace)Flexibilní
ZárukySilné (ACID)Často slabší (eventual consistency — kopie se srovnají „nakonec", viz Distribuované systémy)
ŠkálováníHlavně silnější stroj + kopie pro čteníStavěné na rozložení na víc strojů

Pravidlo pro začátek: Začni s PostgreSQL. Zvládne 90 % všeho, co budeš potřebovat. Po NoSQL sáhni, až narazíš na konkrétní důvod (extrémní škála, nebo data, co se do tabulek nehodí). Volit NoSQL „protože je to moderní" vede k pozdějšímu utrpení.

Druhy NoSQL, ať máš přehled (zatím stačí vědět, že existují): klíč-hodnota (Redis, DynamoDB), dokumentové (MongoDB), wide-column (Cassandra), grafové (Neo4j).


ACID — čtyři záruky, na které se můžeš spolehnout

Když relační databáze řekne, že je „ACID", slibuje ti tyhle čtyři věci. Nejlíp se to chápe na bankovním převodu (strhni 100 Kč z účtu A, přičti je na účet B):

  • A — Atomicity (nedělitelnost): transakce proběhne celá, nebo vůbec. Nemůže se stát, že se z účtu A strhne, ale na účet B nepřičte. Žádné polovičaté stavy.
  • C — Consistency (konzistence): transakce zachová pravidla, která databázi sám deklaruješ — cizí klíče, UNIQUE, CHECK (např. CHECK (zustatek >= 0)). Pozor: tohle ti nehlídá business pravidla sama od sebe — když constraint nenapíšeš (nebo pravidlo závisí na souběhu), musíš si konzistenci ohlídat ty (proto je dole celá sekce o race conditions a o WHERE sklad > 0).
  • I — Isolation (izolace): když běží víc transakcí naráz, navzájem si „nelezou do zelí" (kolik přesně, určuje isolation level — hned níže).
  • D — Durability (trvanlivost): co se jednou potvrdí (commitne), přežije i pád serveru (je bezpečně zapsáno na disk).

Transakce a izolace — kde vzniká spousta záhadných bugů

Když dvě transakce sahají na stejná data ve stejnou chvíli, můžou si navzájem zamotat výsledek. Tomu, když výsledek závisí na náhodném pořadí dvou souběžných operací, se říká race condition („závod" o to, kdo bude první). Je to jeden z nejčastějších a nejzákeřnějších zdrojů bugů.

Isolation level je nastavení, kterým říkáš, jak moc se mají souběžné transakce navzájem ignorovat. Vyšší izolace = míň anomálií, ale za cenu víc konfliktů — a ty se řeší buď čekáním (zámky), nebo v MVCC databázích (Postgres) abortem transakce (serialization failure). Pozor: na nejvyšší úrovni (SERIALIZABLE) ti tedy databáze transakci může odmítnout a ty ji musíš zopakovat — počítej v kódu s retry smyčkou na serialization errors. Klasický trade-off.

Úroveň izolaceDirty readNon-repeatable readPhantom read
Read Uncommittedmožnýmožnýmožný
Read Committed (default v Postgresu)nemožnýmožný
Repeatable Readnenemožný
Serializablenenene

Tabulka je podle SQL standardu. Pozor — konkrétní databáze bývají přísnější: třeba Postgres na úrovni Repeatable Read (snapshot isolation) phantom readům reálně zabraňuje, i když standard je dovoluje. Ber tabulku jako „nejhorší, co úroveň smí připustit", ne jako přesné chování tvé databáze.

Co ty tři „anomálie" znamenají:

  • Dirty read — přečteš data, která jiná transakce ještě nepotvrdila (a třeba je za chvíli zruší).
  • Non-repeatable read — přečteš řádek, pak ho mezitím někdo jiný změní a potvrdí, a když ho přečteš znovu ve stejné transakci, má jinou hodnotu.
  • Phantom read — stejný dotaz vrátí podruhé jiný počet řádků (někdo mezitím přidal/smazal).

⚠️ Pozor na past: výchozí úroveň Read Committed NEřeší lost update (přečti hodnotu → spočítej → zapiš zpět, mezitím to změní někdo jiný). Lost update není žádná ze tří anomálií v tabulce — takže tě před ním default izolace neochrání. Izolační úrovně řeší jeden druh problémů; application-level race conditions (níže) si musíš ohlídat sám (lock / atomický update / verze).

Race condition na příkladu (tohle musíš pochopit)

Dvě objednávky chtějí poslední kus na skladě ve stejnou chvíli:

Obě přečetly „1 kus skladem" dřív, než kterákoli stihla zapsat, takže obě prodaly. Tři způsoby, jak to spravit (umět je = umět myslet jako backend engineer):

  1. Pessimistic lockSELECT ... FOR UPDATE: první transakce řádek zamkne, druhá počká.
  2. Optimistic lock — přidáš sloupec s verzí; při zápisu ověříš, že se mezitím nezměnil.
  3. Atomický updateUPDATE ... SET sklad = sklad - 1 WHERE sklad > 0: databáze udělá kontrolu i odečet v jednom kroku, takže se dva nemůžou „protnout".

MVCC a zámky — jak izolace funguje uvnitř

Možná tě napadlo: když se transakce nesmí navzájem „vidět", musí na sebe pořád čekat? Ne — a tady je trik, kterým to moderní databáze (Postgres, MySQL InnoDB) řeší: MVCC (Multi-Version Concurrency Control, řízení souběhu přes více verzí).

Místo aby databáze řádek při změně přepsala, vytvoří jeho novou verzi a starou si chvíli nechá. Díky tomu:

  • Běžné čtení nezdržuje zapisovatele a naopak — kdo čte (obyčejný SELECT), vidí konzistentní „fotku" (snapshot) dat z okamžiku začátku své transakce, i když je mezitím někdo mění. (Výjimka: SELECT ... FOR UPDATE si řádek schválně zamkne, takže zapisovatele počká — viz zámky níže.)
  • Staré nepotřebné verze databáze časem uklidí (v Postgresu se tomu říká vacuum).

⚠️ Detail pro provoz (na první čtení klidně přeskoč): cena MVCC je, že stará data zabírají místo, dokud je vacuum neuklidí (bloat). A pozor — dlouhá otevřená transakce (i jen čtecí!) blokuje vacuum v úklidu verzí, takže tabulka bobtná. Klasický produkční incident je „idle in transaction" spojení, které drží transakci otevřenou. Další důvod, proč držet transakce krátké.

Když to nestačí (dva chtějí měnit stejný řádek), nastoupí zámky (locks):

  • Řádkový vs tabulkový zámek — zamknout jeden řádek je mnohem lepší než celou tabulku (míň čekání).
  • Sdílený (čtecí) vs výhradní (zápisový) — víc čtenářů může číst zároveň (sdílený), ale zapisovat smí jen jeden (výhradní).

Pravidlo: čím míň a čím kratší zámky, tím víc souběhu. Dlouhá transakce držící zámky brzdí všechny ostatní (a zvyšuje riziko deadlocku — viz failure modes).


Indexy — proč je dotaz pomalý a jak ho zrychlit

Představ si telefonní seznam bez abecedního řazení — abys našel jedno jméno, musíš projít všechny řádky odshora dolů. To je databáze bez indexu: u milionu řádků hrůza.

Index je pomocná struktura (databáze ji drží seřazenou), díky které najde řádek rychle, místo aby procházela celou tabulku. Je to jako rejstřík na konci knihy.

  • Bez indexu databáze dělá „sequential scan" — projde celou tabulku řádek po řádku.
  • Cena indexu: zabírá místo a zpomaluje zápisy (každý nový/změněný řádek musí aktualizovat i index). Proto neindexuj všechno naslepo.
  • Composite index přes dva sloupce (a, b) pomůže při hledání podle a i podle a a b zároveň, ale ne když hledáš jen podle b. (Pravidlo „leftmost prefix" — index funguje zleva.)
  • EXPLAIN ANALYZE je příkaz, který ti databáze odpoví, jak dotaz provedla — jestli použila index, nebo projížděla celou tabulku. Tvůj nejlepší nástroj na zpomalené dotazy.

Co se děje uvnitř — WAL a storage engine

Nemusíš umět napsat databázi, ale dvě věci pod kapotou vysvětlí spoustu jejího chování:

WAL (Write-Ahead Log) — jak databáze přežije pád. Než databáze změní samotná data, nejdřív si změnu zapíše do logu na disk (jako bys napřed napsal do deníku, co se chystáš udělat, a teprve pak to udělal). Díky tomu, když server zrovna při zápisu spadne, databáze po restartu z logu dohraje, co nestihla. To je ta záruka Durability z ACID. Bonus: u Postgresu se z WALu posílají změny na read repliky (streaming replikace) a dělá se z něj i obnova k libovolnému bodu v čase (viz zálohy níže). (Jiné databáze replikují jinak — princip „log změn" je ale univerzální.)

B-tree vs LSM — dva způsoby, jak držet data na disku. Většina relačních databází (Postgres, MySQL) používá B-tree — vyvážené pro čtení i úpravy, skvělé pro běžné aplikace. Databáze laděné na obří objem zápisů (Cassandra, RocksDB) používají LSM-tree — zápisy jen rychle připisují a uklízí se až později. Jako junior si stačí pamatovat: B-tree = vyvážené (default), LSM = optimalizované na zápis.


Normalizace vs denormalizace — duplikovat data, nebo ne?

  • Normalizace = každý údaj je v databázi jen jednou, ostatní na něj odkazují. Žádná duplicita, čisté, ale čtení často potřebuje pospojovat víc tabulek (JOIN).
  • Denormalizace = záměrně si data zkopíruješ na víc míst, aby šlo čtení rychleji (nemusíš spojovat). Cena: když se údaj změní, musíš ho ohlídat na všech kopiích.

Trade-off rychlost čtení vs. jednoduchost zápisu. Nejdřív normalizuj (čistší a bezpečnější), denormalizuj teprve, když máš změřený důvod, že je čtení pomalé.


Škálování databáze (v pořadí, jak se to dělá)

Když databáze nestíhá, řeš to v tomhle pořadí — odshora je to nejlevnější a nejjednodušší:

  1. Indexy a optimalizace dotazů — nejlevnější, udělej vždy první.
  2. Silnější stroj (víc CPU/RAM) — jednoduché, ale má strop.
  3. Read replicas — kopie databáze jen pro čtení. Pozor na replication lag: kopie je o kousek pozadu, takže hned po zápisu z ní můžeš přečíst ještě starou hodnotu (viz failure modes).
  4. Caching — častá data drž jinde, ať se databáze tolik neptá (Caching).
  5. Sharding — rozdělíš data na víc databází podle nějakého klíče. Nejsložitější, nech až nakonec: dotazy přes víc shardů jsou peklo a špatně zvolený klíč vytvoří přetížené místo (hot spot).

Partitioning vs sharding — nepleť si. Partitioning je rozdělení jedné tabulky na části uvnitř jedné databáze (např. objednávky po měsících), aby byly dotazy a údržba rychlejší. Sharding je rozdělení dat na víc samostatných databází/serverů. Partitioning je levný a běžný; sharding je ten těžký krok.


CDC a zálohy

CDC (Change Data Capture) — jak zachytit změny v databázi. Někdy potřebuješ, aby se o každé změně v databázi dozvěděl i někdo jiný — vyhledávač (Search), cache, datový sklad nebo jiná služba. CDC čte WAL (ten log změn) a posílá změny ven v reálném čase, aniž bys musel databázi otravovat dotazy. Je to spolehlivější bratranec outboxu.

Zálohy — a hlavně obnova. Pravidelná záloha je samozřejmost, ale klíčové pravidlo zní: záloha, kterou neumíš obnovit, není záloha. Obnovu pravidelně zkoušej, jinak zjistíš, že nefunguje, až v nejhorší chvíli. Díky WALu umí databáze i point-in-time recovery — obnovu ke konkrétnímu okamžiku (např. „těsně před tím, než to někdo smazal").


Connection pooling — častý a snadno přehlédnutelný zabiják výkonu

Připojit se k databázi něco stojí (navázat spojení, ověřit se) — viz handshake v Foundations. Databáze navíc snese jen omezený počet současných spojení. Když otevřeš nové spojení na každý request, brzy ten limit vyčerpáš a dostaneš too many connections.

Řešení je connection pool: sada už otevřených spojení, která se recyklují mezi requesty. Cenu za navázání zaplatíš jen jednou. Skoro každá appka by pool měla mít.


N+1 problém — nejčastější past s ORM

ORM je knihovna, která ti dovolí pracovat s databází přes objekty v kódu místo psaní SQL. Šikovné, ale schová před tebou, kolik dotazů se opravdu pošle. Klasická past:

Načti 100 objednávek          → 1 dotaz
Pro každou načti zákazníka    → 100 dotazů   ← N+1

Místo 1–2 dotazů jich uděláš 101. Při pár záznamech to nepoznáš, pod zátěží to položí databázi. Řešení: načíst related data najednou (eager loading / JOIN / WHERE id IN (...)). Hlavně to uměj poznat — zapni si log SQL a uvidíš ten samý dotaz opakovaně.


Failure modes — jak to v praxi „praská"

  • Replication lag → uživatel něco uloží a hned to nevidí, protože čte z kopie, která je pozadu.
  • Lost update → dvě transakce se přepíšou navzájem (ten race condition výše) bez locku nebo verze.
  • Deadlock → dvě transakce čekají každá na to, co drží ta druhá → zaseknutí; databáze jednu z nich „zabije" (deadlock detected). Obrana: zamykej zdroje vždy ve stejném pořadí (víc v Concurrency).
  • Connection exhaustion → došla spojení, protože chybí pool (nebo „neuklízí" použitá spojení).

🛠️ Cvičení

Cvičení používají psql (textový klient pro PostgreSQL) — pokud ho nemáš, nainstaluj si Postgres lokálně nebo přes Docker. Sáhnout si na reálnou databázi je nejrychlejší způsob, jak to pochopit.

  1. Zaviň race condition. Otevři dvě okna psql a ve dvou transakcích simuluj „poslední kus skladem": v obou přečti sklad, v obou UPDATE. Pak to oprav třemi způsoby: SELECT ... FOR UPDATE, verzovacím sloupcem (optimistic), a atomickým UPDATE ... WHERE sklad > 0.
  2. Přečti EXPLAIN ANALYZE. Vytvoř tabulku s milionem řádků, pusť dotaz s WHERE na neindexovaný sloupec → uvidíš Seq Scan. Přidej index, spusť znovu → uvidíš Index Scan. Porovnej časy.
  3. Composite index. Vytvoř index (a, b) a EXPLAINem ověř, na kterém z těchto se použije: WHERE a=1, WHERE a=1 AND b=2, WHERE b=2. Vysvětli proč.
  4. Najdi N+1. V libovolném ORM načti 50 záznamů a pro každý jeho related entitu. Zapni log SQL, spočítej dotazy, pak je sniž na 1–2 (eager loading / IN).
  5. Vyzkoušej izolaci. Předveď non-repeatable read: v Read Committed přečti řádek, v druhé transakci ho změň a commitni, znovu přečti. Pak totéž zkus v Repeatable Read a porovnej.
Náčrt řešení — rozbal, až si cvičení zkusíš sám
  1. Zaviň race condition — bez ochrany obě transakce přečtou sklad = 1 a obě zapíšou 0, takže prodáš dvakrát; FOR UPDATE zamkne řádek (druhá počká), optimistic ověří verzi, atomický UPDATE ... WHERE sklad > 0 udělá kontrolu i odečet v jednom kroku. Pozor: samotná vyšší izolace tě tu nezachrání — Read Committed lost update neřeší, ochranu si musíš dát sám.
  2. Přečti EXPLAIN ANALYZE — bez indexu uvidíš Seq Scan (projde všech milion řádků), po CREATE INDEX Index Scan a čas spadne o řády. Na co pozor: po vytvoření indexu na velké tabulce spusť ANALYZE, jinak nemá databáze čerstvé statistiky a může index ignorovat.
  3. Composite index — index (a, b) se použije na WHERE a=1 i WHERE a=1 AND b=2, ale ne na WHERE b=2 (pravidlo leftmost prefix — index je seřazený nejdřív podle a). Pointa: hledání jen podle pravého sloupce index nepokryje, na to bys potřeboval samostatný index na b.
  4. Najdi N+1 — v logu uvidíš 1 dotaz na seznam a pak N stejných dotazů na related entitu (1+N celkem); oprav eager loadingem nebo WHERE id IN (...), ať to spadne na 1–2 dotazy. Pozor: při pár záznamech to nepoznáš, problém propukne až pod zátěží — proto si log SQL zapni schválně.
  5. Vyzkoušej izolaci — v Read Committed druhé čtení vrátí novou (commitnutou) hodnotu = non-repeatable read; v Repeatable Read vidíš pořád původní „fotku" z začátku transakce. Na co pozor: izolaci nastav na začátku transakce (BEGIN ISOLATION LEVEL ...), ne až po prvním čtení, jinak měříš něco jiného.

🧠 Otázky & odpovědi

Co je lost update a jak mu zabráníš?

Dvě transakce přečtou stejnou hodnotu, obě ji upraví a zapíšou — druhý zápis přepíše první, jako by se nikdy nestal. Obrany: pessimistic lock (SELECT ... FOR UPDATE zamkne řádek, druhý počká), optimistic lock (verzovací sloupec, při zápisu ověříš, že se mezitím nezměnil), nebo atomický update (UPDATE ... SET sklad = sklad - 1 WHERE sklad > 0 — databáze to vyřeší v jednom kroku).

Proč může být dotaz pomalý, i když má sloupec index?

Index se nemusí dát použít: dotaz filtruje na funkci sloupce (WHERE lower(email)=… bez funkčního indexu), porušuje pravidlo leftmost prefix u composite indexu, vrací tolik řádků, že je stejně rychlejší projít celou tabulku, nebo databáze nemá aktuální statistiky. Vždy se podívej do EXPLAIN ANALYZE — ukáže, jestli a jaký index se reálně použil.

Kdy sáhnout po shardingu a proč až jako poslední možnost?

Až když jeden stroj neuveze zápisy ani po indexech, cache a read replikách. Sharding je nejdražší krok: dotazy a transakce přes víc shardů jsou složité, špatně zvolený klíč vytvoří přetížené místo (hot spot) a pozdější přerozdělení dat je bolestivé. Pořadí: indexy → silnější stroj → cache → read replicas → teprve pak sharding.

Read replika vrací stará data hned po zápisu. Proč a co s tím?

Kopie pro čtení se aktualizuje obvykle asynchronně, takže je za primární databází kousek pozadu (replication lag). Když čteš hned po zápisu z repliky, můžeš ještě vidět starou hodnotu. Řešení: to, co uživatel právě uložil, čti z primární databáze (read-your-writes), nebo lag toleruj tam, kde malé zpoždění nevadí.

Proč N+1 problém vzniká a jak ho poznáš?

Načteš N záznamů jedním dotazem a pak pro každý spustíš další dotaz na související data → místo 1–2 dotazů jich máš 1+N. ORM to schová, takže to nevidíš, dokud to pod zátěží nezačne brzdit. Poznáš to zapnutím logu SQL (uvidíš tu stejnou query mnohokrát). Oprava: načti related data najednou (eager loading / JOIN / WHERE id IN (...)).

Co je MVCC a jak pomáhá, aby transakce nečekaly?

MVCC (Multi-Version Concurrency Control) znamená, že databáze řádek při změně nepřepíše, ale vytvoří jeho novou verzi a starou si chvíli nechá. Díky tomu běžné čtení vidí konzistentní „fotku" dat z okamžiku začátku své transakce a nezdržuje zapisovatele ani naopak — nemusí na sebe čekat. Staré verze databáze časem uklidí. Když dva chtějí měnit stejný řádek, nastoupí zámky.

Co je WAL a proč díky němu databáze přežije pád?

WAL (Write-Ahead Log) znamená, že databáze nejdřív zapíše chystanou změnu do logu na disk a teprve pak změní samotná data (jako bys napřed napsal do deníku, co uděláš). Když server spadne uprostřed zápisu, databáze po restartu z logu dohraje, co nestihla — to je záruka Durability z ACID. U Postgresu se z WALu navíc posílají změny na read repliky a dělá se obnova ke konkrétnímu okamžiku v čase.