Návrh databázového schématu: normalizace, klíče a typy prakticky
Jak navrhnout databázové schéma, které vydrží: entity a vztahy, normalizace bez dogmat, cizí klíče, správné datové typy a časté chyby začátečníků.
Špatně navržené schéma se dá opravit, ale bolí to – migrace dat, přepis dotazů, výpadky. Pár rozhodnutí na začátku přitom ušetří hodně práce. Projdeme si návrh na příkladu jednoduchého e-shopu a ukážeme principy, které platí v PostgreSQL i MySQL.
Začněte entitami a vztahy
Než napíšete první CREATE TABLE, sepište si podstatná jména z domény a vztahy mezi nimi:
- Zákazník má více objednávek (1:N).
- Objednávka obsahuje více produktů a produkt je ve více objednávkách (M:N).
- Produkt patří do jedné kategorie (N:1).
Vztah M:N se v relační databázi vždy řeší spojovací tabulkou.
Schéma
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE categories (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES categories(id),
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
PRIMARY KEY (order_id, product_id)
);
CREATE INDEX ON orders (customer_id);
CREATE INDEX ON order_items (product_id);
CREATE INDEX ON products (category_id);
Syntaxe je pro PostgreSQL; v MySQL 8 nahraďte GENERATED ALWAYS AS IDENTITY za AUTO_INCREMENT, TIMESTAMPTZ za DATETIME a TEXT u unikátních sloupců za VARCHAR(255).
Proč je to navržené takhle
Cena v položce objednávky
order_items.unit_price_cents vypadá jako duplicita k products.price_cents. Není. Cena produktu se mění, ale objednávka musí navždy zachovat cenu v okamžiku nákupu. Totéž platí pro doručovací adresu – kopírujte ji do objednávky, neodkazujte na aktuální adresu zákazníka.
Peníze v celých číslech
Nikdy neukládejte částky jako FLOAT nebo DOUBLE – binární plovoucí čárka neumí přesně vyjádřit 0,1. Použijte buď celé číslo v haléřích/centech, nebo NUMERIC(12,2) / DECIMAL(12,2).
Omezení v databázi, ne jen v kódu
NOT NULL, UNIQUE, CHECK a cizí klíče jsou poslední obrana proti chybným datům. Validace v aplikaci se dá obejít – importním skriptem, ručním dotazem, chybou v novém endpointu. Databáze ne.
Indexy na cizí klíče
PostgreSQL index na cizí klíč nevytváří automaticky. Bez něj je pomalý JOIN i mazání nadřazeného řádku (databáze musí zkontrolovat odkazy).
Normalizace bez dogmat
Normalizace znamená, že každá informace je uložena na jednom místě. Prakticky stačí hlídat tři věci:
- Žádné seznamy v jednom sloupci.
tags = 'php,mysql,api'nejde rozumně indexovat ani filtrovat. Udělejte tabulkuproduct_tags. - Žádné opakující se sloupce.
phone1,phone2,phone3jsou signál pro samostatnou tabulku. - Žádné odvozené údaje, které se mohou rozejít. Pokud ukládáte
orders.totalvedle položek, musíte zajistit, že se vždy přepočítá.
Denormalizace je v pořádku, když je vědomá: kvůli výkonu, historii (cena v objednávce) nebo reportingu. Zapište si důvod do komentáře u sloupce.
Volba primárního klíče
| Typ | Výhody | Nevýhody |
|---|---|---|
BIGINT identity |
malý, rychlý, seřazený | odhaluje počet záznamů v URL |
| UUID v4 | lze generovat v aplikaci, nehádatelný | 16 bajtů, náhodné vkládání do indexu |
| UUID v7 | časově seřazený, nehádatelný | podpora generování se teprve rozšiřuje |
Rozumný kompromis: interně BIGINT, navenek (v URL a API) samostatný unikátní sloupec s UUID. Funkce uuidv7() je v PostgreSQL vestavěná až od verze 18; ve starších verzích UUID v7 generujte v aplikaci.
JSON sloupce
PostgreSQL JSONB i MySQL JSON jsou užitečné pro data s proměnlivou strukturou – parametry produktu, odpověď z externího API. Nepoužívejte je ale jako náhradu schématu. Jakmile podle hodnoty v JSONu filtrujete, řadíte nebo na ni odkazujete, patří do vlastního sloupce.
Konvence pojmenování
- Tabulky v množném čísle (
orders) nebo jednotném – hlavně konzistentně. snake_case, bez diakritiky a mezer.- Cizí klíč
<tabulka_jednotne>_id:customer_id. - Časové sloupce s příponou
_at, logické s předponouis_.
Soft delete?
Sloupec deleted_at místo skutečného mazání zní bezpečně, ale každý dotaz pak musí obsahovat WHERE deleted_at IS NULL a unikátní omezení přestanou dávat smysl. Pokud potřebujete historii, zvažte raději archivní tabulku nebo auditní log.
Ověření návrhu
Než schéma nasadíte, zkuste nad ním napsat pět nejčastějších dotazů aplikace (detail objednávky, objednávky zákazníka, tržby za měsíc…). Pokud je některý krkolomný, schéma pravděpodobně neodpovídá tomu, jak data používáte.
Popis domény a návrh tabulek můžete dát ColdHost AI v režimu Programování s modelem Polaris a nechat si schéma oponovat – hledat chybějící omezení a indexy.