Data Modeling

Vztahy, návrh podle dotazů, UUID vs auto-increment, soft delete.

Z Databází víš, jak databáze funguje. Tahle kapitola je o tom, jak navrhnout vlastní data — jaké tabulky, jaké sloupce, jak je propojit. Špatně navržené schéma tě bude bolet každý den (pomalé dotazy, duplicity, nemožné změny); dobré schéma je skoro neviditelné. A klíčová myšlenka: navrhuj data podle toho, jak je budeš používat.


Pár pojmů na úvod

  • Schéma (schema) = popis, jak tvoje tabulky vypadají (sloupce a jejich typy).
  • Primární klíč (primary key) = sloupec, který jednoznačně identifikuje řádek (typicky id).
  • Cizí klíč (foreign key) = sloupec, který odkazuje na řádek v jiné tabulce (objednávka odkazuje na uživatele, který ji vytvořil).
  • Vztah (relationship) = jak spolu tabulky souvisí (viz níže).

Vztahy mezi tabulkami

Skoro celé modelování je o tom správně vyjádřit, jak věci souvisí. Tři typy vztahů:

  • 1:1 (jedna ku jedné) — jednomu řádku odpovídá nejvýš jeden v druhé tabulce (uživatel ↔ jeho nastavení). Často se dá sloučit do jedné tabulky — ale rozdělení má smysl, když chceš oddělit citlivá data (jiná oprávnění), velké/málo používané sloupce (kvůli výkonu) nebo nepovinnou část záznamu.
  • 1:N (jedna ku mnoha) — nejčastější. Jeden uživatel má mnoho objednávek. Řeší se cizím klíčem na straně „mnoha" (objednávka má user_id).
  • N:M (mnoho ku mnoha) — objednávka má mnoho produktů a produkt je v mnoha objednávkách. Tohle nejde vyjádřit jedním cizím klíčem — potřebuješ spojovací tabulku (order_items) s odkazy na oba.

Nejdůležitější princip: schéma se navrhuje podle dotazů, ne naopak. Než nakreslíš tabulky, zeptej se: Jak se na ta data budu ptát nejčastěji? U relačních databází ti dobrý návrh + indexy pokryjí skoro cokoli. U NoSQL je to ještě zásadnější — tam si data často uložíš přímo ve tvaru, v jakém je budeš číst (protože JOINy nemáš).

Z Databází: nejdřív normalizuj (každý údaj jednou, žádné duplicity), a denormalizuj (záměrně zkopíruj data pro rychlejší čtení) teprve, když máš změřený důvod.


Volba klíče — UUID vs auto-increment

Jaké id dát řádkům? Dvě běžné volby:

Auto-increment (1, 2, 3…)UUID (8f3a1c2e-…)
VelikostMaléVětší
GenerováníMusí ho přidělit databázeVyrobíš ho kdekoli (i v appce předem)
Prozradí počet?✅ ano (/users/1000 napoví, kolik máš uživatelů)❌ ne
Uhodnutelné✅ ano (riziko IDOR)❌ ne

Pro interní data je auto-increment v pohodě. Kde nechceš prozradit počty ani umožnit hádání ID (veřejné URL, distribuované generování), sáhni po UUID.

⚠️ Pozor na náhodné UUID (UUIDv4) jako primární klíč: zápisy padají na náhodná místa B-tree indexu → fragmentace a pomalejší zápis na velkých tabulkách. Pokud chceš UUID jako PK, použij časově řazené UUIDv7 / ULID (mají v sobě čas, takže rostou popořadě jako auto-increment).


Pár praktických rozhodnutí

  • Integrita patří do schématu, ne do aplikace. Pravidla jako „tohle pole je povinné", „tahle hodnota musí být unikátní" nebo „zůstatek nesmí být záporný" vynuť přímo v databázi (NOT NULL, UNIQUE, CHECK, cizí klíče) — databáze je ohlídá vždy, aplikace na to může zapomenout (souvisí s ACID „Consistency" z Databází). U cizích klíčů zvol ON DELETE chování: CASCADE (smaž i navázané), RESTRICT (nedovol smazat), nebo SET NULL — špatná volba je častý zdroj nechtěného mazání i osiřelých řádků.
  • Stavy a číselníky. Pro „stav objednávky" apod. měj jasno, jak ho modeluješ (enum / číselníková tabulka), ať nemáš v datech volný text „paid" / „Paid" / „PAYED".
  • Soft delete vs hard delete. Hard delete řádek opravdu smaže. Soft delete ho jen označí (deleted_at) a schová z dotazů — data zůstanou (pro audit, obnovu, statistiky). Pozor: pak musíš na „smazané" myslet v každém dotazu (filtrovat WHERE deleted_at IS NULL).
  • Indexovací strategie. Indexuj sloupce, podle kterých filtruješ, spojuješ (JOIN) a řadíš. Neindexuj všechno (zpomaluje zápisy, viz Databáze).
  • Evoluce schématu. Schéma se bude měnit — dělej to bezpečně přes migrace a expand-contract, ať při nasazení nespadne stará verze kódu (viz Infra & DevOps).

Failure modes — jak špatné modelování bolí

  • Špatně zvolené vztahy → N:M nacpané bez spojovací tabulky → duplicity nebo nemožné dotazy.
  • Návrh bez ohledu na dotazy → schéma vypadá hezky, ale nejčastější dotaz je pomalý a nejde indexovat.
  • Zapomenutý filtr u soft delete → „smazané" záznamy se objevují tam, kde nemají.
  • Přehnaná denormalizace → data zkopírovaná všude, při změně se rozejdou.
  • Měnit schéma natvrdo v produkci → spadne stará verze kódu (proto expand-contract).

🛠️ Cvičení

  1. Navrhni schéma. Pro blog s uživateli, články a komentáři navrhni tabulky, klíče a vztahy. Který vztah je 1:N a kde bys potřeboval spojovací tabulku?
  2. N:M. Studenti a kurzy — student chodí na víc kurzů, kurz má víc studentů. Navrhni spojovací tabulku a řekni, co všechno do ní patří.
  3. UUID, nebo auto-increment? Pro interní id objednávky a pro veřejný identifikátor v URL profilu rozhodni a zdůvodni.
  4. Podle dotazů. Máš nejčastější dotaz „vrať posledních 20 objednávek uživatele seřazených podle data". Jaké sloupce a indexy z toho plynou?
  5. Soft delete. Zavedeš soft delete u uživatelů. Vyjmenuj tři místa v kódu, kde na to musíš nově myslet.
Náčrt řešení — rozbal, až si cvičení zkusíš sám
  1. Navrhni schémausers, posts (s user_id → autor), comments (s user_id i post_id); vztahy uživatel→články a článek→komentáře jsou 1:N přes cizí klíč na straně „mnoha". Pointa: spojovací tabulku tu nepotřebuješ — vznikla by až kdyby šlo o N:M (např. články ↔ štítky).
  2. N:M — spojovací tabulka enrollments s odkazy na student_id i course_id; rozloží N:M na dva vztahy 1:N. Na co pozor: nepatří do ní jen ty dva cizí klíče, ale i údaje o samotném vztahu (datum zápisu, známka) — a dej na dvojici (student_id, course_id) UNIQUE, ať se student nezapíše dvakrát.
  3. UUID, nebo auto-increment? — interní id objednávky klidně auto-increment (malé, rychlé), veřejný identifikátor v URL profilu radši UUID, ať neprozradíš počet uživatelů a nejde hádat (IDOR). Pozor: jako PK použij časově řazené UUIDv7/ULID, ne náhodné UUIDv4 — to fragmentuje B-tree a zpomaluje zápisy.
  4. Podle dotazů — filtruješ podle user_id, řadíš podle created_at a bereš jen 20, takže nasaď composite index (user_id, created_at). Pointa: jeden správně poskládaný index pokryje filtr i řazení naráz; dva samostatné indexy by řazení nezvládly tak dobře.
  5. Soft delete — myslet na to musíš (a) ve filtru čtecích dotazů (WHERE deleted_at IS NULL), (b) v UNIQUE constraintech (smazaný e-mail by jinak blokoval nový stejný), (c) u cizích klíčů / navázaných dat (objednávky „smazaného" uživatele). Na co pozor: na filtr se nejsnáz zapomene → „smazaní" se objeví tam, kde nemají.

🧠 Otázky & odpovědi

Co znamená navrhovat data podle toho, jak je budeš používat?

Že schéma se odvíjí od dotazů, ne naopak. Než nakreslíš tabulky, zeptej se, jak se na data budeš ptát nejčastěji, a navrhni je tak, aby ten dotaz byl rychlý a šel indexovat. U relačních databází to zvládneš návrhem + indexy; u NoSQL je to ještě důležitější — tam si data často uložíš přímo ve tvaru, v jakém je budeš číst, protože JOINy nemáš.

Jak vyjádříš vztah mnoho-ku-mnoha (N:M)?

Jedním cizím klíčem to nejde (objednávka má mnoho produktů a produkt je v mnoha objednávkách). Potřebuješ spojovací tabulku (např. order_items), která má odkazy na obě strany — na objednávku i na produkt — a klidně i další údaje (množství, cena v okamžiku objednání). Tím rozložíš N:M na dva vztahy 1:N.

Kdy zvolit UUID místo auto-increment ID?

Auto-increment (1, 2, 3…) je malé a uspořádané, ale prozradí počet a je uhodnutelné (riziko IDOR). UUID je větší, ale neprozradí počty, nejde hádat a vyrobíš ho kdekoli (i předem v aplikaci nebo distribuovaně). Pro interní data stačí auto-increment; pro veřejné identifikátory v URL nebo distribuované generování sáhni po UUID.

Jaký je rozdíl mezi soft delete a hard delete?

Hard delete řádek opravdu smaže. Soft delete ho jen označí (deleted_at) a schová z dotazů, ale data zůstanou — kvůli auditu, možné obnově nebo statistikám. Cena soft delete: musíš na „smazané" záznamy myslet v každém dotazu (filtrovat je), jinak se objeví tam, kde nemají.

Které sloupce indexovat?

Ty, podle kterých filtruješ (WHERE), spojuješ (JOIN) a řadíš (ORDER BY). Tam index zrychlí dotaz z procházení celé tabulky na rychlé vyhledání. Neindexuj naopak všechno — každý index zabírá místo a zpomaluje zápisy. Vodítkem je opět to, jak se na data ptáš.