Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Cache

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().

cache:
    config:
        serviceTtl:
            'App\UI\Api\System\Models\Services\SettingService': 3600

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#

  1. Ověřit, že všechny zápisy jdou přes manager — tedy že se bump opravdu spustí.
  2. Dohledat zápisy mimo ORM v témž modulu (raw DBAL, updateColumn, triggery) a doplnit u nich bumpForClass().
  3. Zapnout writeOnly: true a projít stránku — do Redisu se začne zapisovat, čte se pořád z databáze.
  4. Porovnat, co se uložilo, s tím, co má stránka zobrazit.
  5. Přidat službu do enabledServices — teprve teď se z cache čte.
  6. 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).

sessionTtl:
    fallbackDir: %tempDir%/sessions   # výchozí; null = bez zálohy (500 jako dřív)

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#

  1. Do config/Shared/local.neon serveru (v něm je sonda vypnutá všude):
cache:
    config:
        settingsProbeEnabled: true
  1. Smaž temp/cache (nová hodnota konfigurace jde přes DI kontejner).
  2. Nech web běžet. Čím delší a pestřejší provoz (cron, administrace, všechny jazyky), tím spolehlivější data.
  3. 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
  1. 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 (HINCRBY po 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ávrat NULL) a bulk (č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:

  1. Kdo zámek získá, čte databázi, uloží payload a zámek smaže.
  2. 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.
  3. 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#

  1. Ověř statsEnabled: true (výchozí).
  2. Prokliknout scénář, který tě zajímá.
  3. 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

  1. Zopakovat volání s PARAM_USE_CACHE => false. Když je výsledek správně, je to invalidace, ne dotaz.
  2. Najít, kudy se ta hodnota zapisuje — jde to přes manager, nebo raw SQL?
  3. 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.
  4. 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ěží“.

Nastavení cookies