Najprv merajte, potom optimalizujte
Najčastejšia chyba pri riešení pomalej databázy je začať pridávaním indexov na všetko, čo vyzerá podozrivo. Výsledkom je databáza s dvadsiatimi indexmi, pomalým zápisom a rovnako pomalým čítaním. Optimalizácia bez merania je hádanie.
Poradie krokov, ktoré funguje:
- Zapnúť slow query log a nechať ho bežať aspoň deň pri reálnej prevádzke.
- Zoradiť zachytené query podľa celkového času, nie podľa najhoršieho jedného behu.
- Najhoršiu query rozobrať cez
EXPLAIN. - Nasadiť jednu zmenu a zmerať dopad.
- Opakovať, kým sa to oplatí.
Zapnutie slow query logu
-- bez reštartu servera
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5; -- sekundy
SET GLOBAL log_queries_not_using_indexes = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- kontrola nastavenia
SHOW VARIABLES LIKE 'slow_query%';
Hranicu long_query_time nastavte nízko — pol sekundy je rozumný začiatok. Query, ktorá trvá 200 ms, ale vykoná sa tisíckrát za minútu, zaťažuje server viac než tá, čo trvá päť sekúnd raz za hodinu.
log_queries_not_using_indexes nenechávajte zapnutú dlhodobo. Na vyťaženom serveri vygeneruje gigabajty logov za pár hodín. Zapnite ju na analýzu a potom vypnite.
Vyhodnotenie logu
Surový log sa nečíta dobre. Nástroj mysqldumpslow je súčasťou inštalácie MySQL a výsledky zoskupí podľa tvaru query:
# desať query s najvyšším súčtom času
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# zoradenie podľa počtu volaní
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
Zoskupenie je podstatné: rovnaká query s tisíckou rôznych parametrov sa zobrazí ako jeden riadok so súčtom. Práve ten súčet hovorí, kde je skutočný problém.
EXPLAIN: čo MySQL naozaj robí
EXPLAIN ukáže plán vykonania. Postavený pred SELECT vypíše, ktoré indexy sa použijú a koľko riadkov MySQL očakáva, že bude musieť prejsť:
EXPLAIN SELECT o.id, o.suma, z.email
FROM objednavky o
JOIN zakaznici z ON z.id = o.zakaznik_id
WHERE o.stav = 'nova'
AND o.vytvorene_at >= '2026-07-01'
ORDER BY o.vytvorene_at DESC
LIMIT 50;
Vo výstupe sledujte tri stĺpce:
type— spôsob prístupu k tabuľke. HodnotaALLznamená prechod celej tabuľky a je to takmer vždy chyba. Chcete vidieťref,rangealeboconst.rows— odhad počtu prechádzaných riadkov. Ak query vracia 50 riadkov a tu je 800 000, index chýba alebo sa nepoužil.Extra—Using filesortaUsing temporaryznamenajú, že MySQL musí triediť alebo vytvárať dočasnú tabuľku v pamäti (alebo na disku). Pri veľkých výsledkoch je to drahé.
Pre skutočné čísla namiesto odhadov použite EXPLAIN ANALYZE, ktorý query naozaj spustí a vypíše namerané časy jednotlivých krokov.
Indexy: ako ich navrhnúť správne
Index je usporiadaná kópia vybraných stĺpcov s odkazom na riadok. Vďaka tomu MySQL nájde záznam bez prechádzania celej tabuľky. Základné pravidlá:
Indexujte to, podľa čoho filtrujete a triedite
Kandidáti sú stĺpce v WHERE, JOIN, ORDER BY a GROUP BY. Naopak, indexovať stĺpec, ktorý sa len vypisuje, nemá zmysel.
Poradie stĺpcov v zloženom indexe rozhoduje
Zložený index (stav, vytvorene_at) sa dá použiť pre podmienku na stav samotný aj pre kombináciu oboch stĺpcov. Pre podmienku len na vytvorene_at sa použiť nedá. Platí pravidlo najľavejšieho prefixu:
CREATE INDEX idx_objednavky_stav_datum
ON objednavky (stav, vytvorene_at);
-- index sa použije
SELECT * FROM objednavky WHERE stav = 'nova';
SELECT * FROM objednavky WHERE stav = 'nova' AND vytvorene_at >= '2026-07-01';
-- index sa nepoužije
SELECT * FROM objednavky WHERE vytvorene_at >= '2026-07-01';
Praktický dôsledok: namiesto troch samostatných indexov často stačí jeden dobre poskladaný zložený. Do popredia dávajte stĺpce s presnou zhodou (=), rozsahové podmienky nakoniec.
Pokrývajúci index ako zrýchlenie zadarmo
Ak index obsahuje všetky stĺpce, ktoré query potrebuje, MySQL vôbec nemusí siahať do tabuľky. V EXPLAIN to uvidíte ako Using index:
CREATE INDEX idx_objednavky_prehlad
ON objednavky (stav, vytvorene_at, suma);
SELECT stav, vytvorene_at, suma
FROM objednavky
WHERE stav = 'nova'
ORDER BY vytvorene_at DESC
LIMIT 100;
Kedy index nepomôže
- Funkcia nad stĺpcom —
WHERE YEAR(vytvorene_at) = 2026index zahodí. Prepíšte na rozsah:WHERE vytvorene_at >= '2026-01-01' AND vytvorene_at < '2027-01-01'. LIKEso žolíkom na začiatku —LIKE '%text%'sa nedá indexovať. Na vyhľadávanie v texte použite fulltextový index alebo samostatný vyhľadávač.- Nízka selektivita — index na stĺpci s dvomi hodnotami (áno/nie) je pre MySQL väčšinou nepoužiteľný, lebo aj tak prejde polovicu tabuľky.
- Rozdielne kódovanie — JOIN medzi stĺpcami s odlišným
COLLATEalebo typom (VARCHAR vs. INT) index vypne. Toto je častý skrytý problém pri starších databázach.
INSERT a UPDATE musí MySQL aktualizovať všetky indexy tabuľky. Na tabuľke s vysokou frekvenciou zápisu je desať indexov citeľná brzda. Nepoužívané indexy zmažte.
N+1 problém: najčastejšia príčina v Laraveli
Druhá polovica pomalých aplikácií nemá problém s jednou ťažkou query, ale s tristo rýchlymi. Klasický N+1: načítate zoznam a pre každý záznam sa v šablóne dotiahne súvisiaci objekt samostatnou query.
// zlé — 1 + N query
$objednavky = Objednavka::where('stav', 'nova')->get();
foreach ($objednavky as $objednavka) {
echo $objednavka->zakaznik->email; // query pre každý riadok
}
// dobré — 2 query bez ohľadu na počet riadkov
$objednavky = Objednavka::with('zakaznik')
->where('stav', 'nova')
->get();
Aby sa taká chyba nedostala do produkcie, zapnite si v lokálnom prostredí prísny režim, ktorý na lenivé načítanie vzťahu vyhodí výnimku:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Pri väčších zoznamoch nenačítavajte celé modely, keď potrebujete tri stĺpce. Metóda select() a spracovanie po dávkach cez chunkById() ušetria aj pamäť:
Objednavka::select(['id', 'suma', 'zakaznik_id'])
->where('stav', 'nova')
->chunkById(500, function ($davka) {
// spracovanie 500 riadkov naraz
});
Keď indexy nestačia
Ak je query optimalizovaná a stále trvá dlho, problém je inde. Typické situácie a riešenia:
- Agregácie nad miliónmi riadkov (súčty, prehľady, štatistiky) — predpočítajte ich do samostatnej tabuľky cez plánovanú úlohu. Prehľad za minulý mesiac sa nemení, nie je dôvod ho počítať pri každom zobrazení.
- Opakované čítanie tých istých dát — nasaďte cache. Popisujem to v článku o Redise v Laraveli.
- Ťažké exporty a reporty — presuňte ich do fronty na pozadí, aby nezdržiavali HTTP požiadavku. Postup je v článku o Laravel Queues a Jobs.
- Fulltextové vyhľadávanie —
LIKE '%výraz%'nad veľkou tabuľkou nikdy nebude rýchle. Použite fulltextový index alebo samostatný vyhľadávací engine. - Historické dáta, ktoré nikto nečíta — archivujte staré záznamy do oddelenej tabuľky. Tabuľka s dvoma miliónmi riadkov sa správa inak než tá s dvesto miliónmi.
Nastavenie servera, ktoré stojí za kontrolu
Pri InnoDB má najväčší vplyv veľkosť vyrovnávacej pamäte. Ak je menšia než pracovná množina dát, server číta z disku aj to, čo by mal mať v pamäti:
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- veľkosť dát a indexov podľa tabuliek
SELECT table_name,
ROUND((data_length + index_length) / 1024 / 1024) AS mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 10;
Na vyhradenom databázovom serveri býva rozumné nastaviť innodb_buffer_pool_size na 60 až 70 % dostupnej pamäte. Na zdieľanom serveri, kde beží aj PHP, postupujte opatrnejšie.
Zhrnutie: postup pri pomalej databáze
- Zapnite slow query log s prahom 0,5 s a nechajte ho bežať cez bežnú prevádzku
- Vyhodnoťte ho cez
mysqldumpslow -s t— zaujíma vás súčet času, nie jeden extrém - Najhoršiu query rozoberte cez
EXPLAIN, sledujtetype,rowsaExtra - Navrhnite zložený index podľa pravidla najľavejšieho prefixu, presné zhody vpredu
- Odstráňte funkcie nad stĺpcami vo
WHERE - Skontrolujte N+1 a zapnite
preventLazyLoading()v lokálnom prostredí - Nasaďte jednu zmenu naraz a zmerajte dopad
- Zmažte indexy, ktoré sa nepoužívajú — spomaľujú zápis
- Agregácie predpočítavajte, ťažké reporty presuňte do fronty