Sepsal jsem si závazné konvence pro návrh MySQL schémat a u každého pravidla si vynutil odpověď na otázku proč. Ta odpověď je totiž polovina celé hodnoty: pravidlo, které se neumí obhájit, je dogma, a to první nespokojený kolega právem shodí ze stolu.
Vším, co následuje, se vine jedna myšlenka: databáze je poslední stráž integrity. Aplikace validuje kvůli uživateli: hezké hlášky, v jeho jazyce, na jeho cestě kódem. Databáze garantuje. Napříč importem, migrací, ručním SQL v konzoli, druhou aplikací, buggy kódem a tím kolegou, co si do produkce pouští UPDATE bez WHERE.
Dohody se v datech neudrží. Udrží se jen to, co schéma vynucuje.
Vše níže platí pro MySQL 8.0.16 a novější (kvůli vynucovaným CHECK constraintům) a pro InnoDB.
Nultý předpoklad: strict mód
Bez striktního sql_mode nemá zbytek článku smysl, protože
všechna pravidla pod ním degradují ze záruky na snahu s tichým
fallbackem:
| bez strict módu (tiše) | se strict módem (nahlas) |
|---|---|
ENUM mimo seznam → uloží '' |
error Data truncated |
| přetečený INT UNSIGNED → clamp na maximum | error out of range |
| delší vstup do VARCHAR(20) → ořízne | error |
'0000-00-00' → uloží se |
error |
chybějící NOT NULL sloupec → 0 nebo '' |
error |
Nastavte to na serveru, ne na spojení, které vám kdokoli může přepsat:
STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
A hlavně od začátku. Zapnout strict nad databází, kde už leží oříznutá a nulová data, znamená pády až při prvním UPDATE dotčeného řádku. Tedy za půl roku, v pátek večer.
1. Tabulka se jmenuje post, ne posts
Ano, tímhle se dá rozpoutat hádka na celé odpoledne a tábor množného
čísla má na své straně zvyk i pár frameworků. Tak si to pojďme odbýt
hned. Množné je o chlup víc SQL-safe, SELECT * FROM posts se
čte jako věta. Proti tomu stojí dva důvody, které přežijí i vaše
preference:
- Jednotné číslo mapuje 1:1. S množným se jméno musí celou
cestu překládat: třída
Post, tabulkaposts, a v každé vrstvě jeden převodník navíc. - A drží rodinu tabulek pohromadě. Ve výpisu schématu stojí
postv čele svých příbuznýchpost_slugapost_tag, kdežtopostsse od nich abecedně odtrhne a skončí až za nimi. U tří tabulek je to detail, u tří set je to rozdíl mezi orientací a hledáním.
Zbytek pravidel o jménech už kontroverzní není. Všechna stojí na tom, že jméno v databázi je veřejné rozhraní, ne interní detail. Opírá se o něj váš kód, ale taky uložené dotazy, reporty, exporty, integrace a ten Excel, co si nad databází postavilo obchodní oddělení. Přejmenovat sloupec je proto zásah stejného kalibru jako změna signatury veřejné metody v knihovně: rozbijete i konzumenty, o kterých nevíte. Proto se pojmenovává to, co vydrží.
snake_case, malá písmena. Nic, co by vyžadovalo quotování.blogIdse v Postgresu musí psát v uvozovkách, protože neuvozené identifikátory sklápí na malá písmena, takže vámblogIdablogidklidně začnou žít vedle sebe jako dvě různé věci.- PK vždy
id, FK<jednotné číslo>_id, self-referenceparent_id. Když na jednu tabulku míří víc cizích klíčů, jméno nese roli, ne cíl:author_id,reporter_id. Kam to ukazuje, stejně říká FK constraint. - Modulový prefix až tehdy, když to začne bolet
(
shop_order,shop_product). Uvnitř modulu pak FK prefix nepotřebuje, tabulka namespace už nese.
A vydrží význam, ne implementace. Hranice mezi nimi je jemnější, než
se zdá: jednotka je význam, typ je implementace.
weight_grams a timeout_seconds ano, protože holé
číslo by bylo dvojznačné. amount_int a data_json
ne, ty jen převyprávějí to, co už stojí ve schématu.
2. NOT NULL a DEFAULT, který nelže
Nullable jen tehdy, když je absence hodnoty fakt o světě, ne technický stav. U čísel a dat se to rozhoduje samo, prázdná hodnota tam neexistuje a NULL je jediná pravda o absenci.
Zajímavé to začne být u řetězců, protože tam existuje
'' a vy máte najednou stavy dva. Rozhodněte se pro jeden a
napište to do schématu. Ve většině případů je odpověď
NOT NULL DEFAULT '', aplikace pak testuje !== '' a je
hotovo:
`meta_title` varchar(160) NOT NULL DEFAULT '',
Ta dvojznačnost je totiž ještě horší, než vypadá: v PHP jsou
null i '' obě falsy, takže rozdíl, který
v databázi pracně držíte, aplikace většinou stejně nevidí. Ten druhý
stav je tedy nejen nadbytečný, ale ještě neviditelný.
Výjimka existuje, jen je vzácnější, než si lidi myslí: když NULL a
'' opravdu znamenají různé věci, třeba '' jako
„vědomě prázdné, nedědit z rodiče“ proti NULL jako „nevyplněno“.
Pak nullable, ale ten význam povinně do COMMENT, jinak to za rok
nikdo nerozklíčuje.
A proč se NULL vůbec vyplatí šetřit? Protože se chová jinak, než čekáte:
WHERE col <> 'x'nevrátí NULL řádky. Nejčastější tichý bug vůbec.col NOT IN (poddotaz)vrátí s jediným NULL v poddotazu prázdno.UNIQUEbere NULL hodnoty jako navzájem různé, takže jich projde libovolně mnoho.GROUP BYje naopak slučuje do jedné skupiny.
Tolik k NULL. Druhá polovina tohohle pravidla je DEFAULT, a
ten je stejný případ: je to sémantické tvrzení, ne záplata.
Default jen tehdy, když je hodnota doménově smysluplná v okamžiku vzniku řádku. Povinné pole bez hodnoty nechte spadnout.
Nejčastější chyba je vyplnit default proto, ať INSERT nespadne.
Tím vkládáte lež k nerozeznání od skutečné hodnoty a rušíte přesně
tu ochranu, kterou vám dal strict mód. status DEFAULT 'draft'
ano, objednávka fakt začíná draftem. price DEFAULT 0
u povinné ceny ne, to je nula, o které nikdo nepozná, jestli je to dar,
nebo chyba importu.
3. _at je okamžik,
_date je den
Dva sufixy, dva typy, a rozdíl mezi nimi není kosmetický.
- Okamžik v životě řádku: sufix
_at, typdatetime.created_at,published_at,deleted_at. Klidně i v budoucnosti,expires_atje pořád okamžik. Sufix se řídí typem, ne minulostí. - Kalendářní den nezávislý na zóně:
{podstatné jméno}_date, typdate.birth_date,invoice_date,due_date.
Kalendářní den uložený jako datetime je bug čekající na spuštění, a stojí za to si rozmyslet proč. K datu se přilepí půlnoc. Jakmile pak aplikace začne převádět časové zóny, což se dřív nebo později stane, půlnoc se posune o hodinu dozadu: z 15. 3. 00:00 je rázem 14. 3. 23:00. Datum narození je najednou o den dřív a nikdo dlouho nepřijde na to proč, protože ta hodina v datu není vidět. Kalendářní den zónu nemá, tak mu ji nedávejte.
Hranice není vždycky ostrá a nevadí to: valid_from a
valid_to sufix nemají a je to v pořádku. Volba typu pro ně
platí stejná, jen se rozhoduje podle domény. Předplatné běží od
okamžiku, tedy datetime. Kupón platí od dne, tedy date.
Meta-sloupce mají mít napříč schématem jeden slovník: všude
created_at, nikdy míchat create_time a
date_added. A zrcadlově: stejné jméno nesmí ve dvou
tabulkách znamenat dvě různé věci. Když je updated_at
v jedné tabulce „kdykoli se řádku někdo dotkl“ a ve druhé „kdy
obchodník naposledy sáhl na ceník“, tak dotaz, který obě tabulky spojí a
seřadí podle data, vrátí nesmysl. A nikdo si toho nevšimne, protože
jméno slibovalo, že jde o totéž.
4. TIMESTAMP nikdy
Dva důvody, každý stačí sám o sobě: rok 2038 a skrytá konverze
podle zóny spojení, kvůli které se to, co uložíte, a to, co dostanete
zpátky, liší podle toho, kdo se ptá. DATETIME nekonvertuje nic,
uloží co dostane a vrátí co uložil. Disciplína kolem zón tím ale
nekončí, jen se přesouvá k vám: konvence nepředepisuje UTC ani lokální
čas, obojí je legitimní, předepisuje konzistenci. Celá databáze
v jedné zóně a konverze až při zobrazení.
DEFAULT CURRENT_TIMESTAMP i NOW() totiž vkládají
čas v zóně session, takže když se serverová
default_time_zone rozejde se zónou historických dat, nové
řádky tiše dostanou jinou zónu než staré a nic to nenahlásí.
5. „Změnilo se X“ není „něco se změnilo“
Dva sloupce, které vypadají stejně a nejsou. Rozhodovací otázka zní: co má ten sloupec vlastně měřit?
modified_atměří doménovou změnu, tedy „datum poslední úpravy z pohledu čtenáře“. Nesmí s ním hnout přepnutí příznaku ani přepočet počitadla zhlédnutí.ON UPDATE CURRENT_TIMESTAMPje tu proto špatně a sloupec musí udržovat trigger, který sleduje jen vyjmenované sloupce.updated_atměří, že se něco změnilo. Je to row version, token proti souběžné editaci. Tady jeON UPDATE CURRENT_TIMESTAMPnaopak přesně správně a trigger by byl horší: musel by vyjmenovat všechny editovatelné sloupce a při přidání jedenáctého tiše zastarat. Ochrana proti přepsání cizí editace by mlčky přestala krýt nová pole.
Trigger pro ten první případ hlídá jediný sloupec,
content, a všechno podstatné je v komentářích:
DELIMITER ;;
-- BEFORE, protože jen v něm má SET NEW.* nějaký efekt
CREATE TRIGGER `page_before_update_touch_modified_at`
BEFORE UPDATE ON `page` FOR EACH ROW
BEGIN
-- COLLATE bin na obou stranách: bez něj se oprava diakritiky nepočítá jako změna (pravidlo 11)
IF NEW.content COLLATE utf8mb4_0900_bin <> OLD.content COLLATE utf8mb4_0900_bin
-- <=> je NULL-safe rovnost; zajistí, že trigger nepřebije datum zadané ručně (migrace, import)
AND NEW.modified_at <=> OLD.modified_at THEN
SET NEW.modified_at = CURRENT_TIMESTAMP;
END IF;
END;;
DELIMITER ;
Z toho plyne obecnější povinnost: každý magicky udržovaný sloupec
potřebuje COMMENT, který pojmenuje, co ho plní. U
updated_at obzvlášť („concurrency token,
ON UPDATE je záměr“), jinak ho příští čtenář v dobré
víře „opraví“ na trigger a tiše rozbije optimistic locking.
6. Boolean je přídavné jméno
Chvíli jsem zvažoval povinný prefix is_, než mi došlo, že
si tím odporuju: maďarskou notaci typu str_title zakazuju
s tím, že typ patří do schématu a ne do jména, a is_ přitom
nekóduje nic jiného než typ. Opačný extrém ale neobstojí taky. Zkuste
vymyslet adjektivum k food_supplement nebo spam —
doména o nich mluví podstatnými jmény a nutit je do adjektiv znamená
vymýšlet slova, která nikdo neřekne. Pravidlo tedy není tvar jména, ale
postup:
- Ověřte, že to vůbec má být boolean. Stav, u kterého vás
zajímá i okamžik, patří do timestampu (
read_atnese obojí zadarmo). Víc než dva stavy patří do výčtu. A hodnotu, kterou spočítáte z jiného sloupce, ukládat nemusíte vůbec: jestli je kategorie podkategorií, poznáte podle toho, že má vyplněnéparent_id. - Zkuste přídavné jméno nebo příčestí:
published,visible,pinned. Vždy pozitivní polarita, protožeWHERE NOT disabledje hlavolam. - Když adjektivum nesedí, podívejte se na věc jinak:
use_avatar→avatar_enabled,hide_price→price_visible,noindex→indexable. - Jinak
is_. To není selhání, to je legitimní východisko. Oprávnění pak vždycan_.
Proč postup, a ne tvar? Protože hledání adjektiva je diagnostické. Prošel jsem si stovku booleanů napříč deseti databázemi a u každého se snažil najít jméno bez prefixu. Vypadlo z toho sedm sloupců, které vůbec neměly být boolean, dva odvoditelné (a tedy ke smazání) a hrst případů obrácené polarity. Mechanický prefix by tohle všechno zabetonoval.
Druhý argument je, že hlavní povrch není SQL, ale šablona:
{if $user->avatarsVisible} čte se jako věta
{if $user->isShowAvatars} ???
isXxx je napříč ekosystémem tvar metody, takže
property s tímhle jménem je vizuální falešný přítel a zavírá dveře
k rozdělení na property active a getter
isActive().
Sloupec je pak vždycky tinyint(1) NOT NULL s defaultem, jehož
hodnota se řídí sémantikou bezpečného stavu: enabled klidně
DEFAULT 1. Jméno se kvůli estetice nuly neotáčí.
7. VARCHAR(255) je kargo-kult
To číslo pochází z historických limitů: jednobajtový délkový prefix fungoval do 255 bytů a starý limit klíče byl 767 B. V utf8mb4 neplatí ani jedno. 255 znaků je až 1020 bytů (a prefix je stejně dvoubajtový) a limit klíče je dnes 3072 B.
Dnes 255 neoptimalizuje vůbec nic. Je to jen číslo, které jsme viděli v cizí tabulce.
Délka je deklarace domény a strict mód ji vynucuje. Nejlepší jsou
čísla, která obhájíte normou: email varchar(254) podle RFC,
country char(2) podle ISO 3166,
variable_symbol varchar(10), protože delší ho banka nevezme.
Jenže u spousty sloupců žádná norma neexistuje a musím se přiznat,
že slug varchar(100) je taky kargo kult, jen o generaci mladší.
Sto znaků nevychází z ničeho než z pocitu, že delší adresa je nesmysl.
Rozdíl proti 255 je jediný, zato podstatný: u té stovky víte, proč
jste ji zvolili. Je to strop, který jste si vědomě určili, ne číslo
opsané z cizí tabulky. Účelem stropu je dát datům mez a odstřihnout
odpad, ne trefit magickou konstantu.
Pozor na TEXT: je to 64 KB bytů, ne znaků. Na HTML
obsah článků raději rovnou MEDIUMTEXT, 64 KB umí být
překvapivě těsných.
A když je množina hodnot malá a pevná, ENUM chyba není.
Zabírá jeden bajt v každém indexu, povolené hodnoty přečte admin z
information_schema a postaví z nich dropdown, a přidání
hodnoty na konec je ALGORITHM=INSTANT. Cenu má jinde:
ORDER BY řadí podle interního pořadí, ne podle textu, a
WHERE status = 1 porovnává s pozicí, ne s hodnotou.
A přejmenování je „rozšiř, přesyp, ukliď“, jak jsem popisoval
dřív.
8. Peníze jsou DECIMAL
FLOAT a DOUBLE jsou ban pro všechno, co se
počítá nebo přesně porovnává. Binární aproximace, chyby se v
SUM() sčítají, = 0.3 nefunguje. Float nechte na
měření, kde je aproximace podstatou věci: senzor, souřadnice.
Zajímavější spor je DECIMAL versus celočíselné haléře. V PHP je
držet částku jako int vynásobený stem naprosto rozumné, protože jazyk
přesný desetinný typ nemá. Jenže co je dobré v aplikaci, není
automaticky dobré ve sloupci: v databázi haléře vynášejí interpretaci
ven ze schématu a jednou to někdo vypíše bez dělení stem. Ideální dělba
práce je proto DECIMAL ve schématu a celá čísla nebo money
objekt v PHP, s konverzí na jednom místě. Driver vám DECIMAL vrací jako
string právě proto, že přesný typ v PHP neexistuje; nepřetypovávejte ho
na float, tím zahodíte přesně to, co jste si sloupcem koupili.
Scale volte podle domény, ne z generické opatrnosti:
DECIMAL(9,2) na částky, (9,4) na jednotkové ceny a
sazby, (12,6) na kurzy. Nezápornost řešte přes CHECK, ne
UNSIGNED, to je u DECIMAL od 8.0.17 deprecated.
A past, na kterou vás neupozorní ani strict mód: vložení hodnoty
s vyšší přesností se tiše zaokrouhlí, jen s warningem.
Zaokrouhlování je doménové rozhodnutí, dělejte ho vědomě. Mimochodem,
ROUND v MySQL je half away from zero, bankovní zaokrouhlování
tu neexistuje.
9. ON DELETE podle povahy vztahu
Cizí klíče explicitně a s oběma klauzulemi.
ON UPDATE CASCADE jako výchozí. U umělých klíčů se
prakticky nikdy neuplatní, protože id se nemění, takže vás
nic nestojí. Zato ve chvíli, kdy na přečíslování opravdu dojde (ruční
slučování dat, srovnání id mezi prostředími), provede ho atomicky, místo
aby vás dohnalo k SET FOREIGN_KEY_CHECKS=0. To je teprve
nebezpečí, protože s vypnutou kontrolou se odkazy neaktualizují a vzniknou
sirotci.
ON DELETE má jednu jedinou rozhodovací otázku: je dítě
součástí rodiče, nebo jen nezávislá věc, která na něj
ukazuje?
| vztah | pravidlo | příklad |
|---|---|---|
| kompozice | CASCADE |
order_item → customer_order, pivoty |
| asociace | RESTRICT |
post.author_id → user |
| odpojení se smyslem | SET NULL |
log.user_id po anonymizaci |
SET NULL je nejhorší možný default. Reference se vypaří,
dítě zůstane viset a navíc vás to nutí do nullable sloupce. Použijte ho
jen tam, kde osiřelé dítě dál dává smysl a to NULL něco
znamená.
Dvě pasti vás pak dohoní jinde. Kaskádové mazání nespouští triggery, protože to InnoDB řeší pod SQL vrstvou. Invariant držený triggerem se tedy kaskádou tiše obejde. A kaskáda je jen pro hard delete: při soft delete žádný reálný DELETE nepřijde a celá vaše pečlivě promyšlená kaskáda je mrtvý kód.
10. CHECK na invarianty, ne na politiku
Začněme tím, že CHECK constrainty v MySQL existují a vy je chcete. Do verze 8.0.16 je server tiše parsoval a ignoroval, což je nejspíš důvod, proč je půlka lidí dodnes považuje za výsadu Postgresu a druhá půlka jim nevěří. Dnes se vynucují, takže jde o plnohodnotný nástroj, jak dostat doménové pravidlo z aplikace do schématu.
Otázka pak zní, co do nich patří. Osa je tahle: doménový invariant
versus business politika. Invariant je nadčasová pravda
(price >= 0, konec není před začátkem) a patří do
schématu. Politika je měnitelné pravidlo („objednávka nad 10 000 jde na
schválení“), to se za rok změní a CHECK by znamenal migraci.
Nejlepší využití je to, co jiný mechanismus zapsat neumí: vztahy mezi
sloupci téhož řádku a podmíněná povinnost, což je implikace
A -> B zapsaná jako NOT A OR B:
CONSTRAINT `chk_order_shipped_needs_date`
CHECK (`status` <> 'shipped' OR `shipped_at` IS NOT NULL),
Všimněte si jména. Constrainty pojmenovávejte podle pravidla, ne podle sloupců, protože tohle jméno uvidí uživatel v chybové hlášce. Test: přečtete jméno v logu a víte, co se stalo, aniž byste otevřeli schéma.
Co CHECK naopak neumí: cokoli přes víc řádků (nemá poddotazy), cokoli
nedeterministického (CURDATE() je zakázaný, takže ani
birthdate <= CURDATE()), a nesmí odkazovat na
AUTO_INCREMENT sloupec. A nepřidávejte redundantní CHECK tam,
kde totéž zaručí levnější mechanismus. CHECK (age >= 0)
nad TINYINT UNSIGNED je jen šum plus cena na každý zápis.
Titulek sliboval deset a tady měl článek správně skončit. Jenže tři pravidla, co zbývají, patří k těm, jejichž porušení bolí nejdéle, a vyhodit je kvůli kulatému číslu v nadpisu by bylo přesně to rozhodnutí, jaké tady celou dobu kritizuju: dát na vzhled místo na důvod. Takže jedenáct. A dvanáct. A když už jsme u toho, třináct.
11. Collation: 0900, jinak vám CHECK jen tak leží
utf8mb4 vždy, nikdy utf8 (to je tříbajtový
alias, který rozbije emoji). To asi víte. Míň se ví, že collation
patří do rodiny utf8mb4_0900_*, tedy UCA 9.0. Legacy
utf8mb4_czech_ci, unicode_ci (UCA 4.0.0!),
general_ci ani utf8mb4_bin nemají v novém schématu
důvod existovat.
A teď to důležité, protože tohle se nedozvíte z dokumentace, ale
z provozu. Porovnání i REGEXP v CHECKu respektují collation
sloupce. Pod výchozím _ai_ci tenhle constraint klidně pustí
'CS' i 'cs_CZ':
CHECK (`lang` REGEXP '^[a-z]{2}$')
Ta část regulárního výrazu, která rozlišuje velikost písmen, tam
nedělá vůbec nic. Formátový a členský CHECK má smysl jen nad sloupcem s
_bin, ascii_bin nebo _as_cs
collation.
Stejná past kousne v triggeru z pravidla 5. Bez toho
COLLATE utf8mb4_0900_bin by pod výchozí collation nebyla oprava
„reditel“ na „ředitel“ považovaná za změnu. Doplnění diakritiky,
tedy naprosto typická česká editace, by datem poslední úpravy ani
nehnulo.
12. Ascii sloupec vrátí 500 místo 404
Skoro v každém schématu se najde pár sloupců, ve kterých z definice
leží jen ascii: slug článku, kód kupónu, přihlašovací jméno, API
token. CHARACTER SET ascii se u nich nabízí jako chytrá volba,
protože v klíči rezervuje jeden bajt na znak místo čtyř. Jenže
charset se nevolí podle toho, co ve sloupci leží, ale podle toho, s čím
se porovnává. A tyhle sloupce mají ještě jednu společnou vlastnost:
jsou to přesně ty, podle kterých se vyhledává, takže hodnota v podmínce
nepochází z vaší tabulky, ale zvenčí. A tam vám kdokoli pošle
cokoli.
-- slug varchar(100) CHARACTER SET ascii
SELECT * FROM post WHERE slug = 'muj-clanek'; -- projde
SELECT * FROM post WHERE slug = 'můj-článek'; -- ERROR 1267 Illegal mix of collations
Spojení je utf8mb4, takže hodnota zvenčí dorazí jako utf8mb4. Dokud jsou v ní jen ascii znaky, MySQL ji tiše převede a porovná. Jakmile obsahuje jediný znak navíc, převod možný není a dotaz skončí chybou, ne prázdným výsledkem. V tom je ten rozdíl: hodnota, která v tabulce není, se normálně prostě nenajde, kdežto tenhle dotaz se vůbec nezeptá. Aplikace tak nedostane šanci vrátit 404 a návštěvník dostane pětistovku, protože do adresy zaklikal háček. Testy to nechytí, ty zkoušejí existující a neexistující hodnoty, ne hodnoty s diakritikou.
Ověřil jsem si to na MySQL 8.4 a nedělá to _bin, ale
charset ascii: padá každý řetězcový operátor od
= po CONCAT, a rozhoduje obsah toho řetězce, ne data
v tabulce, takže spadne i prázdná tabulka. Obejít to jde přes
CONVERT(? USING ascii), jenže musí být na každém dotazu
a první zapomenuté místo past vrací.
Otázka proto nezní „jsou ta data ascii?“, ale „může se do
porovnání dostat neascii hodnota?“. Ascii si nechte na sloupce, které
plní výhradně aplikace a nikdo se podle nich zvenčí neptá
(lang, country, ip_address). Všechno
ostatní dostane utf8mb4_0900_bin, který umí totéž, jen zabere
v indexu víc místa. Chybná ascii volba je výpadek, chybná utf8mb4 volba
pár bajtů navíc.
13. Primární klíč se propaguje
INT UNSIGNED AUTO_INCREMENT jako výchozí primární klíč,
BIGINT u tabulek, do kterých se jen sype.
Proč se zrovna u primárního klíče vyplatí šetřit byty způsobem, který by jinde byl předčasná optimalizace? Protože v InnoDB je PK fyzicky součástí každého sekundárního indexu a musí být typově identický v každém cizím klíči, který na něj míří. Jeho velikost neplatíte jednou, ale (1 + počet indexů + počet odkazů)krát.
Dvě věci se přitom běžně přehlédnou:
AUTO_INCREMENTse řídí maximem, ne počtem řádků. Nikdy nerecykluje a díry po rollbacích neboINSERT IGNOREuž nikdo nezaplní. Fronta nebo log vyčerpáINTs pár miliony živých řádků. Dívejte se na to, kolik se za život tabulky vloží, ne kolik jich tam bude ležet.- Riziko je asymetrické. Poddimenzování je produkční incident:
INSERT padá a
ALTERnaBIGINTje dlouhá těžká operace v nejhorší možnou chvíli. Předimenzování stojí čtyři byty na řádek. Když váháte, berteBIGINT.
UNSIGNED má smysl na INT, kde zdvojnásobení na
4,29 miliardy často rozhodne, že BIGINT nepotřebujete. Na
BIGINT už ne: signed rozsah je absurdní sám o sobě a horní
polovina je z PHP stejně nedosažitelná.
Zbytek už je hygiena
Věci, které si vlastní pravidlo nezaslouží, ale sežerou vám odpoledne, když je neuděláte:
- Index a constraint vždycky pojmenujte sami. MySQL jinak vyrobí
post_ibfk_1apost_chk_1, tedy jména číslovaná podle pořadí vzniku. Stačí, aby někdo constraint zahodil a přidal znovu, nebo aby tabulka jinde vznikla z dumpu místo z migrací, a číslování se rozejde.DROP FOREIGN KEY post_ibfk_2pak na jednom prostředí projde a na druhém spadne, nebo zahodí něco úplně jiného. - Jména hybridně: indexy bez prefixu, constrainty s prefixem
(
fk_post_blog,chk_post_lang_format). Rozdíl má důvod: jméno constraintu je součást chybové hlášky, takže patří tam, kde ho čtenář potká, kdežto jméno indexu čte jen ten, kdo ladí dotazy. - Index technický, nebo výkonnostní? Ten pod cizím klíčem a
UNIQUEna přirozeném klíči existuje od začátku, to nejsou optimalizace, ale constrainty. Ostatní vznikají až podle reálného dotazu, zEXPLAINa slow logu. Podle toho je i pojmenujte: technický podle sloupců, protože jeho obsah je jeho účel, výkonnostní podle dotazu, který obsluhuje (post_listing). - Index, který žádný dotaz nepoužívá, je čistá cena. Zpomaluje každý zápis a žere buffer pool.
- Leftmost prefix: index
(a, b, c)obslouží podmínky naa,a+bia+b+c, ale ne nabsamotné. Z toho recept: nejdřív sloupce porovnávané na rovnost, potom nejvýš jeden pro rozsah nebo řazení. Za rozsahovým sloupcem už index pro filtrování nepokračuje. - PK je v InnoDB implicitně na konci každého sekundárního indexu. Takže ho tam neopakujte, covering index vám často vyjde zadarmo, a je to hlavní důvod pravidla 13.
- Sloupec s malou kardinalitou neindexujte samostatně, boolean vám nic nevyfiltruje. Jako složka kompozitu na správné pozici je ale úplně v pořádku.
- Index
(a)vedle(a, b)je zbytečný, leftmost prefix ho pokrývá. - FK sloupec musí typem přesně kopírovat cílový PK včetně
UNSIGNED, jinak ten cizí klíč vůbec nevytvoříte.
Kdy tohle všechno porušit
Neporušitelné jsou jen nulté předpoklady (strict mód, utf8mb4, explicitní cizí klíče) a fakta o MySQL, protože co nejde vytvořit, to nejde. Všechno ostatní smíte porušit s důvodem, který by obstál jako zápis rozhodnutí: konkrétní, zapsaný, a přežil by otázku „proč to tady takhle je?“ za dva roky. „Nelíbí se mi to“ není důvod. „Tahle tabulka má 500 milionů řádků a rebuild nepřežijeme“ je.
Jen ať je ta odchylka vidět tam, kde ji čtenář potká, tedy v
COMMENT sloupce nebo tabulky. Ne v commit message, ne
v Confluence a rozhodně ne ve vaší hlavě. SHOW CREATE TABLE je
jediná dokumentace, která cestuje spolu s daty.
Konvence bez únikové cesty se totiž buď obchází tajně, nebo blokuje práci 🙂
A proč vlastně tuhle energii sypat do schématu, když v aplikaci se to napíše rychleji? Protože aplikaci jednou přepíšete. Data ne.
Komentáře
Michal #1
To psal Sonnet 3.5?
Jinak se vším tak nějak souhlasím :)
Napište komentář