Cache#
Kdy se čtení vrátí z Redisu místo z databáze, jak se to zneplatní a co udělat,
když potřebujete čerstvá data. Jádro: app/Core/Cache/, konfigurace
config/Shared/cache.neon.
Co se vlastně cachuje#
Ne dotaz, ale HOTOVÁ odpověď
Cachuje se wire payload — to, co z Api presenteru odchází ven (výstup
extractAll() / extractWithReferences()). Ne SQL, ne entity. Zásah tedy
přeskočí celé sestavení dotazu, hydrataci i serializaci, ale zároveň to
znamená, že v cache leží už poskládaná data pro konkrétní volání.
Hranice je Api presenter. Front i administrace čtou přes ni, takže se obě strany trefují do stejné cache.
Klíč nese i publikum volajícího
Payload se liší podle toho, kdo se ptá: interní volání dostane i pole s
Audience::Internal, privilegovaný i pole pro správce. Kdyby klíč publikum nenesl, první
volající by výsledek zafixoval pro všechny — front by nacachoval payload s interními poli
a externí klient by ho dostal. Klíč query cache proto obsahuje i složku publika.
Maskování osobních údajů se dělá až na hotovém payloadu, ne v cache — viz
RBAC v API.
Tři vypínače nad sebou#
cache:
config:
enabled: false # hlavní vypínač
enabledServices: [] # allowlist služeb (FQCN); prázdné = nic
writeOnly: false # zapisuj, ale nečti (měření zahřátí)
queryTtl: 300 # 5 minut
| Vrstva | Co dělá |
|---|---|
enabled: false |
nic se nečte ani nezapisuje, celá cache je mimo hru |
enabledServices |
postupné zapínání po službách — cachuje jen to, co je v seznamu |
writeOnly: true |
zapisuje, ale čte z databáze — na ověření, co by se ukládalo |
enabled: true bez enabledServices necachuje NIC
Platí obojí zároveň. Je to schválně: zapnout cache plošně jedním přepínačem znamená zapnout ji i tam, kde se ještě neověřilo, že se invalidace opravdu provede.
Wildcard * až po ověření všech služeb
enabledServices: ['*'] zapne všechno naráz. Dokud každá služba nemá pokryté
zápisy mimo ORM (raw DBAL), znamená to servírovat stará data — a nikdo si
toho nevšimne, protože stránka se tváří normálně.
Platnost po službách#
Základní platnost je queryTtl (300 s). Pro jednotlivé služby ji lze prodloužit mapou
cache.config.serviceTtl v config/Shared/cache.neon (klíč = plný název třídy služby,
hodnota = sekundy). Služba mimo mapu má queryTtl; ke každé hodnotě se přidává rozptyl ±10 %
(TtlJitter). Hodnotu vrací Config::queryTtlFor().
Výchozí konfigurace dává 3600 s vybraným číselníkovým a strukturním službám: blog
(kategorie, štítky), parametry a skupiny parametrů (inzerce, katalog, e-shop), vazby kategorií
na parametry, kategorie diskuzí a systémové stránky, sekce stránek, menu, položky menu, routy,
měny, jazyky, lokalizace, platební regiony, vlastní assety, nastavení a externí napojení.
Delší platnost patří jen službě, jejíž zápisy všechny zneplatňují cache a jejíž výsledek
nezávisí na čase. Výpisy s časovým filtrem (inzeráty a zprávy diskuzí — topování, články,
novinky, galerie, reklamní plochy, produkty e-shopu) zůstávají na 300 s. Položka v mapě platí
jen pro službu, která je zároveň v enabledServices.
Ruční SQL se u dlouhé platnosti projeví až za hodinu
Změna dat mimo manager (ruční SQL, seed, migrace) cache nezneplatní. U služeb s hodinovou
platností proto změnu uvidíš až za hodinu, dokud cache nesmažeš (klíče app:cache:q:*,
nebo QueryCache::clearOwn()). bin/migrate.php cache po migracích maže sám, nasazení
taktéž. Míří-li dva stroje na stejnou databázi, ale každý má vlastní Redis, zápis z jednoho
cache druhého nezneplatní.
Jak se cache zneplatní#
Klíč nese tagy = FQCN entit, ze kterých se odpověď skládá. Tagy se odvozují automaticky z toho, co dotaz použil:
kořenová entita + includes (relace) + pole ve filtrech + pole v řazení
└────────────── DependencyTagResolver ──────────────┘
Zápis pak jen zvedne verzi tagu (bumpFor() / bumpForClass()). Staré klíče
tím rázem přestanou platit — nic se nemaže, jen se přestanou trefovat.
Proto se nemusí vypisovat, co se má smazat
Uložení článku zvedne verzi tagu Article a všechny odpovědi, které článek
obsahovaly — výpisy, detaily, related boxy — jsou naráz mimo. Bez seznamu
klíčů, který by stejně nikdo neudržel aktuální.
Zápis MIMO ORM tagem nehne
Raw DBAL UPDATE, updateColumn(), databázový trigger — nic z toho nespustí
hook, takže se verze tagu nezvedne a cache dál vrací stará data až do vypršení
TTL. Kdo píše raw SQL, musí bump zavolat sám.
Postup: potřebuji čerstvá data pro jedno volání#
Nejčastější případ. Nesahá se na konfiguraci, jen na to jedno volání:
$payload = $this->bridge->call('api/blog/article/get-all', [
'filters' => $filters,
ApiBridge::PARAM_USE_CACHE => false, // ← tohle volání jde vždy do DB
], null, 'POST');
| Hodnota | Význam |
|---|---|
false |
nečíst z cache ani do ní nezapisovat |
true |
cachovat, i kdyby to volající jinak nedělal |
null / vynechat |
„bez názoru“ — rozhodne konfigurace |
Parametr se z volání odstraní, do Api metody nedoteče
Řídí bridge, ne endpoint. Api metoda ho ve své signatuře nemá a nesmí mít —
jinak skončí Unknown named parameter.
Z manageru: ->useCache()#
Front a admin manager mají totéž na query objektu. useCache() bez argumentu znamená true:
$variant = $this->assetVariantManager->find($id)->useCache(false)->get();
$items = $this->articleManager->findAll($filters)->include(['translations'])->useCache(false)->execute();
| Vrstva | Bez volání useCache() |
Jak to změnit |
|---|---|---|
| front repository | cachuje (podle konfigurace) | ->useCache(false) |
admin ApiRepository |
nečte z cache | jen výslovné ->useCache(true) |
Admin má výchozí false i mimo admin požadavek (CLI harness, admin cron), kde vypínač
celého požadavku ve startup() neběží.
Počítadla zobrazení cache nezneplatní
updateColumns nad views, viewsList, viewsDetail tag nezvedne
(SaveTrait::$nonInvalidatingColumns; manager si seznam může rozšířit, např. hlasy
zpráv diskuze). Bump při každém zobrazení by shodil cache všech výpisů. Kdo takovou
hodnotu čte před zápisem nebo ji počítá, čte s ->useCache(false), jinak zapíše starou.
Manager seznam buď rozšíří (array_merge v konstruktoru, MessageManager diskuze přidává
votesUp/votesDown), nebo ho přepíše deklarací vlastnosti — pak musí výchozí sloupce, které
dál potřebuje, vypsat sám. Tak to dělá AdvertManager (Base) pro reklamní bannery:
views, viewsList, clicks. Zobrazení a kliky čítá registerEvent na každé stránce, a
proto se jimi cache výpisů bannerů netrhá.
Vyčerpaný banner zmizí až po TTL
Počty v cache jsou staré nejvýš queryTtl. Banner, který právě dosáhl max_clicks nebo
max_views (generovaný is_available), se proto ve výpisu ukazuje ještě do vypršení
cache. U reklamy je to přijatelné; kde by nebylo, sloupec do seznamu nedávej.
Příznak nejde nastavit z HTTP požadavku
Cache smí vypnout jen kód uvnitř procesu (ApiBridge volá setQueryCacheEnabled()).
Z parametru požadavku by se z něj stal vypínač cache celého webu, který obejde i
single-flight zámek.
Postup: přidávám čtení, které se má cachovat#
- Ověřit, že všechny zápisy jdou přes manager — tedy že se bump opravdu spustí.
- Dohledat zápisy mimo ORM v témž modulu (raw DBAL,
updateColumn, triggery) a doplnit u nichbumpForClass(). - Zapnout
writeOnly: truea projít stránku — do Redisu se začne zapisovat, čte se pořád z databáze. - Porovnat, co se uložilo, s tím, co má stránka zobrazit.
- Přidat službu do
enabledServices— teprve teď se z cache čte. - Prokliknout scénář zápis → čtení: uložit, hned načíst a ověřit, že je vidět nová hodnota.
Krok 6 nejde přeskočit
Chybějící invalidace se neprojeví chybou, ale starým údajem — a ten vypadá jako pravda. Nejlevnější doba, kdy se to dá odhalit, je hned teď.
Filtr s Closure shodí dotaz
Klíč se skládá z hodnot vlastností filtrů. Closure se deterministicky zahashovat nedá,
takže QueryKeyHelper při ní vyhodí InvalidArgumentException — přeskočit by ji nešlo,
dva filtry lišící se jen callbackem by dostaly stejný klíč a vyměnily si výsledky. Takový
dotaz do cache nepatří: hodnotu předej skalárně, nebo použij NonCacheableFilter.
Vlastní čtecí akce: rememberTagged()#
Hlavní cesta cachuje standardní čtení (get, get-all, get-by) na hranici Api
presenteru. Akce, které standardní nejsou — ceny, počty, agregace, nastavení, připojení —
nemají includes ani filtry, ze kterých by šly tagy odvodit. Pro ně je
QueryCache::rememberTagged():
$payload = $this->queryCache->rememberTagged(
serviceClass: $this->getServiceClass(),
method: 'currentPrices',
keyParams: ['setIds' => $ids, 'currencyId' => $currencyId, 'day' => $day],
tags: [ProductSet::class, ProductSetPrice::class],
producer: fn (): array => $this->buildPrices($ids, $currencyId),
ttlCap: fn (array $payload): ?int => $this->secondsToNextPriceChange($payload),
);
| Parametr | Smysl |
|---|---|
serviceClass |
služba, jejíž řádek v enabledServices cache zapíná |
method, keyParams |
klíč; musí nést všechny vstupy, na kterých výsledek závisí (měna, region, skupina, datum…) |
tags |
FQCN Api entit, které výsledek čte — předává je volající výslovně |
producer |
DB čtení; běží jen při miss nebo vypnuté cache |
useCache |
false = bez cache |
ttlCap |
dostane hotový payload a vrátí, kolik sekund smí nejdéle žít; null = běžné TTL, ≤ 0 = neukládat |
Stejné pojistky jako u hlavní cesty: snímek verzí tagů se bere PŘED čtením z DB, takže zápis mezi snímkem a uložením nechá příští čtení mimo cache a zastaralá data se nikdy neservírují; jedna žádost naplní cache (single-flight); výpadek Redisu cache vypne.
Uživatelsky specifická data do klíče nepatří a chybový výsledek se necachuje
Pokud výsledek závisí na přihlášeném uživateli, musí klíč uživatele nést — nebo se necachuje. Chybový výsledek producer vyhodí jako výjimku, ne vrátí „prázdno": prázdný výsledek by se uložil a vypadal by jako pravda.
Strop TTL řeší změny BEZ zápisu
Naplánovaná cena platí od určitého času a v tu chvíli se nic nezapíše. Payload proto
nese validUntil (nejbližší budoucí published_time) a ttlCap ho nepřekročí; jitter
smí TTL jen zkrátit.
Používají to: ceny sad a konce akcí (ProductSetPresenter, 12 tagů v PRICE_CACHE_TAGS),
počty produktů v kategoriích (ProductCategoryPresenter), řádky nastavení
(SettingManager, tag Setting) a externí připojení (ExternalConnections, viz
Externí připojení). Sklad se necachuje schválně; jen paměť v rámci
požadavku pro okna dovolených (StockReader) a semafory skladů (WarehouseManager).
Datum a čas v klíči#
DateTime nemá PHP property, takže reflexe by z každého data udělala stejný otisk a dva
dotazy lišící se jen datem by sdílely klíč (kalendář akcí tak vracel události prvního
nacachovaného dne). QueryKeyHelper proto vkládá hodnotu výslovně s přesností 5 minut
(DATETIME_BUCKET_SECONDS = 300) plus offset zóny. Celé dny a hodiny zůstanou rozlišené,
filtr „teď" (new DateTime()) nevyrobí nový klíč každou sekundu. Zastarání je nejvýš jeden
interval, tedy totéž co TTL.
Zápis mimo ORM: bumpy, které se doplnily#
Raw zápis, updateColumn() i trigger tag nezvednou, takže je potřeba volat bump sám
(bumpForClass(), u manageru additionalCacheTags). Tyto případy byly dodělané:
| Kde | Co zvedne |
|---|---|
ProductSetManager::softDelete, ProductManager::softDelete, ProductVariantManager::softDelete |
sada, produkt, varianta |
ProductSetManager::recountPrices (jen při změně) |
ceny sad |
PollAnswerManager::registerVotes |
PollAnswer a Poll |
Bazaar CategoryManager |
CategoryParameterRel (přes additionalCacheTags) |
admin ParameterRuleManager (inzerce, katalog, e-shop) |
entity parametrů z cacheTagClasses(), viz Formuláře |
V manageru se bump volá jednou ze dvou metod BaseManager, podle toho, čí data raw zápis mění:
| Metoda | Co zvedne | Kdy |
|---|---|---|
bumpCacheTagsForClass($class, $extra) |
třídu a třídy jejích vlastněných kolekcí (cascade persist/remove, orphan removal) | manager mění vlastní entitu nebo jí podřízené řádky (CustomAssetManager, PageManager po zápisu vazeb kódů) |
bumpCacheTagsExplicit($classes) |
jen vyjmenované třídy | zápis s dopadem na cizí entitu bez vazby na entitu manageru (admin manager, který raw SQL mění cizí Api tabulku) |
bumpCacheTagsExplicit() na vlastní entitu vynechá vlastněné kolekce
Vlastněné kolekce jsou vložené v cachovaném grafu rodiče. Bump jen třídy rodiče nechá v cache staré výpisy jejích podřízených řádků až do TTL.
Stromové operace nemají entitu, additionalCacheTags() se na ně nedostane
additionalCacheTags(object $entity) dostává entitu, takže ho bump bez entity
(přesun nebo přečíslování uzlů v TreeTrait::treeTransaction()) nevolá. Tag, který na
konkrétní entitě nezávisí, vrať z classCacheTags(): array (deklarovaný na
BaseManager s prázdným výchozím). Jinak po přesunu uzlu zůstane cache závislé entity
stará až do TTL.
Ruční SQL (seedy, migrace) cache nezneplatní
Počkej na TTL (5 minut, u služeb z serviceTtl hodinu), nebo zavolej QueryCache::clearOwn(). Při nasazení se Redis
cache maže ve workflow, viz Checklist nasazení.
Které služby zapnout (enabledServices)#
Cache cachuje jen to, co je v cache.config.enabledServices. Serverový local.neon si
seznam vede sám (nesdílí se přes git), takže po nasazení kódu s novou cachovanou akcí je
potřeba doplnit službu i tam. Bez toho aplikace funguje, ale čte z databáze.
| Skupina | Služby (App\UI\Api\…) |
|---|---|
| E-shop | Eshop\Models\Services\{ProductSetService, ProductCategoryService, ParameterService, ParameterGroupService} |
| System | System\Models\Services\{PageSectionService, AssetService, CustomAssetService, PaymentRegionService, SettingService, ExternalConnectionService} |
| Blog, Base | Blog\Models\Services\FlashMessageService, Base\Models\Services\{AdvertPlaceService, AdvertService, PollService, PollPlaceService} |
| Inzerce, katalog | Bazaar\…\CategoryParameterRelService, Comcat\…\CategoryParameterRelService |
SettingService a ExternalConnectionService jsou ty, které se zapomínají: chybí-li, jde
jeden SQL dotaz navíc na každý požadavek.
Nastavení v Redisu nese i neveřejné hodnoty
Do cache jdou řádky nastavení včetně hesla k SMTP, OAuth a tokenu cronu. Redis proto smí
poslouchat jen na 127.0.0.1 (nebo s heslem) a nesmí mít veřejný port.
Co se necachuje schválně#
| Kde | Proč |
|---|---|
| Administrace | politika pro celý požadavek se ve startup() vypne — administrátor musí vidět, co právě uložil |
| Sklad (dostupnost, semafory) | zůstává živý; jen paměť v rámci požadavku |
| Hledání přes Elasticsearch | výsledek se mění bez zápisu do databáze (reindex, změna skóre), takže by cache neměla podle čeho invalidovat |
Dotazy s NonCacheableFilter |
klíč by musel obsahovat celý seznam id |
Administrace vypíná cache i pro frontové managery
Když v admin požadavku běží čtení přes front manager, platí i na něj. Jinak by administrátor viděl v jednom místě čerstvá data a ve druhém stará.
Další cache v systému#
| Cache | Kde | Poznámka |
|---|---|---|
| Route tabulka | app/Core/Cache/Route/RouteTableCache.php |
vlastní vypínač routerEnabled, nezávislý na hlavním |
| Mapy slugů kategorií | app/Core/Cache/Route/SlugMapCache.php |
zapíná se stejným routerEnabled, viz níž |
| Session | Redis | dvě životnosti, viz níž |
| Doctrine, DI kontejner, Latte | temp/cache/ |
soubory, ne Redis |
Mapy slugů (SlugMapCache)#
Překladače kategorií (CategoryNameTranslator v Bazaaru, Comcatu, Blogu, Diskuzi a
ProductCategoryNameTranslator v e-shopu) při prvním nameToId() / idToName() načítaly
všechny kategorie modulu s překlady a stavěly z nich dvě mapy id ⇄ slug — 461
kategorií hydratovaných na každém požadavku. SlugMapCache::load($name, $tags, $producer)
ukládá přímo ty mapy.
| Vlastnost | Detail |
|---|---|
| klíč | app:cache:route:slugmap:<name>, globální (routing nemá uživatelský kontext) |
| zneplatnění | tag-snímek jako u route tabulky (entita + překlad); bump z manageru je zdarma |
| TTL | s jitterem ±10 %, strop staleness pro zápisy mimo manager |
| výpadek Redisu | první PredisException cache vypne do konce požadavku |
| ruční smazání | SlugMapCache::clear($name) |
Navíc: zástupný segment - a nenalezený slug regionu už nejdou při každém odkazu do
databáze (RegionNameTranslator), a kategoriové překladače na - odpovídají bez warmupu.
SlugHelper#
SlugHelper::webalize() je kopie Strings::webalize() pro horké cesty (překladače
routeru). Nette Strings::toAscii() vytváří \Transliterator při každém volání; při
1 383 převodech to bylo 125 ms, se sdíleným transliterátorem a memoizací (strop 20 000
vstupů) 15 ms. Používá ho 15 překladačů.
Výstup musí zůstat bit-identický s Strings::webalize()
Jsou z něj veřejné adresy. Hlídá to SlugHelperTest; po aktualizaci nette/utils ho
spusť a když se algoritmus ve vendoru změní, oprav kopii, ne test.
Relace v Redisu#
Každý návštěvník dostává relaci (formuláře nesou CSRF token). Při autoStart a expiraci 31
dní znamenal každý požadavek bez cookie — tedy každý požadavek robota — klíč v Redisu na
31 dní, takže Redis rychle zaplnily desetitisíce klíčů.
App\Core\Session\TieredTtlSessionHandler (zapojuje DI\SessionTtlExtension, musí být v
konfiguraci za redis) má dvě životnosti:
| Relace | Životnost |
|---|---|
| zapsaná požadavkem bez své cookie (nová) | sessionTtl.newSessionTtl = 7200 s (2 h) |
| prohlížeč se vrátil s cookie | plná doba (redis.sessions.ttl, 31 dní) |
Robot cookie nevrací, takže jeho relace brzy vyprší. Přihlášení vyrobí nové id relace, takže přihlašovací požadavek zapíše s krátkou životností a navazující přesměrování (už s novou cookie) ji prodlouží.
sessionTtl:
newSessionTtl: 7200 # 0 = vypnuto (vždy plná doba)
skipCrawlerSessions: true # relace robota bez cookie se neukládá
Relaci známého robota bez cookie (CrawlerDetector, i prázdný User-Agent) handler
neuloží vůbec: stránka se vykreslí, relace (včetně CSRF tokenů) žije jen v paměti
požadavku. Požadavek s cookie se tak nikdy neposuzuje, takže přihlášený uživatel
s neobvyklým User-Agentem funguje dál. Vypíná se sessionTtl.skipCrawlerSessions: false
(výchozí true).
Staré klíče robotů doexpirují samy
Po nasazení se dosavadních 31denních klíčů nikdo nedotýká; zmizí do 31 dnů.
Týž handler drží souborový záložní režim. Při první výjimce Predisu v požadavku přepne
relace toho požadavku do souborů v temp/sessions; další požadavek zkusí Redis znovu.
Výpadek Redisu tak web neshodí (dřív každý požadavek skončil 500).
Co výpadek stojí
Značka .redis-down pošle do warning.log jeden řádek za hodinu, po návratu Redisu
jde řádek do info.log. Relace zapsané do souborů po návratu Redisu nikdo nečte, takže
přihlášení z doby výpadku propadnou. Query cache a Elasticsearch jsou fail-open.
Katalog překladů se sám neobnoví
Nový nebo změněný jazykový .neon se neprojeví, dokud se nesmaže
temp/cache/translation/. Nette si jinak zregeneruje skoro všechno samo —
tohle ne, a projeví se to vypsáním syrových klíčů místo textů.
Sonda čtení nastavení#
Které klíče z cms_system_settings se za provozu skutečně čtou, neřekne ani kód, ani
databáze: čtenářů je víc (SettingContext::get(), getSettings()[m][k], konstanty presenterů
nastavení). Bez téhle informace nejde klíč bezpečně smazat ani přejmenovat. Sonda
(App\Core\Cache\Probe\SettingReadProbe) čtení eviduje; je ve výchozím stavu vypnutá a
nezávisí na hlavním vypínači cache.
Postup: zapnout sondu a přečíst výsledek#
- Do
config/Shared/local.neonserveru (v něm je sonda vypnutá všude):
- Smaž
temp/cache(nová hodnota konfigurace jde přes DI kontejner). - Nech web běžet. Čím delší a pestřejší provoz (cron, administrace, všechny jazyky), tím spolehlivější data.
- Výsledek vypíše
php bin/settings-usage.php:
| Přepínač | Výstup |
|---|---|
| (žádný) | přehled: klíče z DB s počtem čtení, nečtené klíče, klíče čtené mimo DB, čtenáři celé mapy |
--module=5 |
jen jeden modul |
--unread |
jen klíče z DB, které sonda nezaznamenala |
--json |
strojový výstup |
- Po dosběru sondu vypni a smaž
temp/cache. Data v Redisu po vypnutí sama zmizí (settingsProbeTtl, výchozí 90 dní, obnovuje se při každém zápisu;0= bez TTL).
Nástroj jen čte (databázi i Redis), nic nemaže ani nepřejmenovává. Na stroji s vypnutou sondou, nebo dokud neproběhl žádný požadavek, napíše, že jsou čítače prázdné — a že z toho nelze usuzovat, že se klíče nečtou. Nedostupný Redis hlásí chybou, ne prázdnými daty.
Jak sonda počítá#
- Čtení se sčítají v paměti požadavku a na jeho konci jdou jedním zápisem do Redisu
(
HINCRBYpo polích); u PHP-FPM až po odeslání odpovědi. Vypnutá sonda nedělá nic. - Eviduje tři druhy čtení:
hit(klíč v mapě byl),miss(voláníget(modul, klíč)klíč nenašlo — tichý návratNULL) abulk(čtení celé mapy, podle třídy volajícího). - Výpadek Redisu nic nerozbije: první chyba zápis vypne do konce požadavku.
Sonda vidí jen čtení jednoho klíče přes SettingContext::get()
Klíč čtený přes getSettings()[m][k] nebo přes konstanty presenterů nastavení je ve
výpisu „nečtený“, i když se čte. Před smazáním takový klíč dohledej přes seznam čtenářů
celé mapy (bulk) a prohledáním kódu. Mazat nebo přejmenovávat klíče až po delším sběru z
ostrého provozu.
Zámek, komprese a čítače#
Zbytek voleb v config/Shared/cache.neon. Na hlavním vypínači závisí jen první dvě.
| Volba | Výchozí | Co dělá |
|---|---|---|
lockMs |
3000 | single-flight zámek po missu (viz níž); 0 = vypnuto |
statsEnabled |
true |
čítače zásahů v Redisu; vypnuté nepošlou ani jeden příkaz navíc |
compressData, compressionThreshold, compressionLevel |
true, 256 B, 6 |
DEFLATE pro hodnoty nad prahem; zapsané napevno v definici služby cache.config, ne v parameters |
routerEnabled, routerTtl |
false, 300 |
route tabulka a mapy slugů, viz Routování |
settingsProbeEnabled, settingsProbeTtl |
false, 90 dní |
sonda čtení nastavení |
Single-flight zámek#
Když horký výpis vyprší, nechceme, aby ho z databáze stavělo sto požadavků naráz. Po missu
(a po snímku verzí tagů) se požadavek pokusí o SET {prefix}cache:lock:… 1 NX PX lockMs:
- Kdo zámek získá, čte databázi, uloží payload a zámek smaže.
- Ostatní třikrát po 100 ms zkusí cache (
QueryCache::LOCK_POLL_ATTEMPTS,LOCK_POLL_INTERVAL_US). Payload z čekání prochází stejnou kontrolou verzí jako běžný zásah. - Když se payload neobjeví, čtou databázi, ale neukládají — jinak by se nával zdvojil.
lockMs je strop, jak dlouho zámek přežije spadlého držitele. Výjimka z produceru zámek
uvolní hned, chyba Redisu při zamykání pošle požadavek rovnou do databáze. Route tabulka a
mapy slugů zámek nemají (jeden klíč, levný rebuild).
Postup: změřit, jak cache trefuje#
- Ověř
statsEnabled: true(výchozí). - Prokliknout scénář, který tě zajímá.
- Přečti hashe v Redisu:
redis-cli HGETALL app:cache:stats:_total
redis-cli HGETALL 'app:cache:stats:App\UI\Api\Blog\Models\Services\ArticleService'
| Pole | Význam |
|---|---|
hit |
platný payload z cache |
miss_cold |
klíč chybí, vypršel nebo je nečitelný |
miss_invalidated |
payload byl, ale verze tagů (nebo formátu) nesedí — zneplatnil ho zápis |
set |
uložení po čtení z databáze |
bump |
zvednutí verze tagu; jen v _total, bump běží po commitu bez kontextu služby |
Čítače jsou fail-open: chyba zápisu do nich se spolkne a požadavek ani uložená data neshodí.
Hodně miss_invalidated u služby znamená časté zápisy do jejích tagů, ne chybu cache.
Když se zdá, že cache lže#
Postup od nejlevnějšího
- Zopakovat volání s
PARAM_USE_CACHE => false. Když je výsledek správně, je to invalidace, ne dotaz. - Najít, kudy se ta hodnota zapisuje — jde to přes manager, nebo raw SQL?
- Zkontrolovat, že tag odpovídá entitě, na které se změna stala; odvozuje se z includes a filtrů, takže nečekaná relace může chybět.
- Teprve pak sahat na TTL. TTL je pojistka proti dírám, ne řešení.
Výpadek Redisu cache tiše vypne
První chyba spojení ji pro celý požadavek odstaví a jede se z databáze. Je to záměr — web má fungovat i bez Redisu — ale znamená to, že „cache se neprojevuje“ může znamenat „Redis neběží“.