Kapitola 16 · Vzory · Výkonnostní aspekty DDD v Symfony

Výkonnostní aspekty DDD v Symfony

Výkonnostní aspekty Domain-Driven Design v Symfony s Doctrine ORM – řešení N+1 problému, optimalizace agregátů, implementace read modelu přes CQRS, správné používání UUID a cachování doménových objektů.

Autor M. Katuščák
Doba čtení ≈ 30 min
Náročnost náročná
Publikováno · Aktualizováno ·
Obsah kapitoly

16.01 Výkon v kontextu DDD

Pověst pomalého DDD se opírá o anekdoty místo měření. Výkonnostní problémy přicházejí ze špatné implementace: příliš velkých agregátů, nevhodného lazy loadingu, absence read modelu. Doménový model rychlou aplikaci nevylučuje.

Výkon se stává kritickým ve třech scénářích: aplikace s desítkami propojených agregátů, velké agregáty s kolekcemi tisíců položek a systémy s vysokou frekvencí čtení a požadavky na odezvu v desítkách milisekund.

16.02 N+1 problém a lazy loading v Doctrine

N+1 je typický anti-vzor, který produkuje každý ORM bez explicitní fetch strategie. Aplikace provede 1 dotaz pro načtení seznamu entit a poté pro každou entitu další dotaz pro načtení asociovaných dat. Celkem tedy N+1 SQL dotazů místo 1–2 dotazů.

Pro kolekce (OneToMany, ManyToMany) Doctrine ve výchozím stavu používá lazy loading: kolekce zůstává neinicializovaná, dokud k ní kód poprvé nepřistoupí. V situacích, kdy kolekci vůbec nepoužijeme, je to výhoda. Při iteraci přes mnoho agregátů to ale plodí výše popsaný N+1 problém.

Řešení 1: EXTRA_LAZY kolekce

Doctrine nabízí strategii EXTRA_LAZY pro kolekce. Na rozdíl od standardního lazy loadingu, který načte celou kolekci při prvním přístupu, EXTRA_LAZY umožňuje provádět operace jako count(), contains() nebo slice() přímými SQL dotazy bez načtení celé kolekce do paměti.

Řešení 2: JOIN FETCH v DQL pro eager loading

Pokud víme předem, že budeme iterovat přes kolekce, je efektivnější použít DQL s klauzulí JOIN FETCH. Doctrine pak načte agregát včetně asociovaných objektů v jediném SQL dotazu s LEFT JOIN nebo INNER JOIN.

Při použití JOIN FETCH s paginací (setMaxResults(), setFirstResult()) Doctrine vypíše varování a provede paginaci v paměti (in-memory pagination), ne na úrovni SQL. Řešením je stránkovat přes identifikátory a teprve pak načíst data, nebo použít nativní SQL s vlastním mapováním výsledků.

16.03 Agregát a výkon: správné určení hranic

Agregát drží konzistenční hranici: invarianty platí uvnitř jednoho agregátu. Pokud hranici nakreslíte příliš široce, agregát při každém načtení tahá z databáze rozsáhlý objektový graf, i když potřebujete jen malou část dat.

Řešení: rozdělení agregátu a specializované repozitářní metody

Prvním krokem je kriticky přezkoumat, zda OrderItem skutečně musí být součástí agregátu Order, nebo zda jde o samostatný agregát s odkazem na OrderId. V e-commerce doméně bývá správné mít Order jako kořen agregátu s přímým přístupem pouze k metadatům (číslo, datum, stav, celková cena). OrderItem pak tvoří samostatný agregát odkazující na OrderId.

Pravidlo zní: hranice agregátu vede přes doménové invarianty, výkonnostní požadavky se řeší jinde. Když výkon tlačí proti doménovému modelu, odpovědí je read model (viz sekci CQRS), ne porušení doménové integrity.

16.04 Optimalizace read modelu (CQRS)

Oddělení write side (operace přes agregáty) od read side (dotazy do prezentace) je hlavní páka pro výkonnostní problémy v DDD. Read side doménové objekty nepotřebuje – vrací rovnou strukturu dat pro UI nebo API klienta.

Doctrine NativeQuery pro komplexní reportovací dotazy

DQL pokrývá většinu dotazů, ale pro složité reportovací dotazy (agregace, window funkce, CTE) nestačí. Doctrine umožňuje spouštět nativní SQL dotazy s vlastním mapováním výsledků přes ResultSetMapping.

16.05 UUID vs. integer primární klíče

Agregát musí znát svoji identitu už před uložením do databáze. AggregateId se generuje v doménovém kódu bez databázové sekvence nebo auto-increment hodnoty. Pro distribuované systémy, event sourcing a paralelní vytváření agregátů to není volba, ale podmínka.

ULID jako kompromis

ULID (Universally Unique Lexicographically Sortable Identifier) a UUID verze 6/7 (ordered UUID) řeší problém fragmentace indexů tím, že jsou monotónně rostoucí. Nové hodnoty jsou vždy větší než předchozí a vkládají se na konec B-tree indexu. Chování je stejné jako u auto-increment integeru, ale se zachováním globální unikátnosti bez centrálního generátoru.

16.06 Doctrine Identity Map a Unit of Work

Doctrine ORM implementuje vzor Identity Map (Martin Fowler, Patterns of Enterprise Application Architecture). Každý spravovaný objekt (managed entity) je v jednom EntityManageru uložen v paměti pod svým identifikátorem. Pokud načtete tentýž agregát dvakrát, Doctrine vrátí tentýž PHP objekt z paměti bez opakovaného SQL dotazu.

Problém s batch zpracováním

Identity Map počítá s typickým web requestem: jednotky až desítky agregátů. Hromadné zpracování (import, migrace, reporty) sype do Identity Map tisíce objektů, které tam zůstávají po celou dobu běhu. Spotřeba paměti roste (memory leak) a dirty checking se zpomaluje, protože Doctrine musí procházet stále větší množinu spravovaných objektů.

16.07 Caching v DDD architektuře

Caching v DDD má jednu vstupní otázku: co cachovat? Pravidlo: cache patří na výsledky, které jsou výpočetně nebo I/O nákladné a v čase se nemění (nebo se mění předvídatelně). Doménová logika do cache klíče nepatří – cache slouží infrastruktuře, ne doménovým rozhodnutím.

Query cache a result cache v Doctrine

Doctrine nabízí dvě úrovně cachování SQL dotazů:

  • Query cache: cachuje přeložený DQL → SQL. DQL parsing je relativně nákladný; query cache eliminuje opakované parsování pro identické DQL dotazy. Výsledky se nemění.
  • Result cache: cachuje výsledky SQL dotazu. Musí být explicitně nakonfigurován a invalidován při změnách dat. Vhodný pro read-heavy dotazy s řízenou dobou platnosti.

Cache invalidace při doménových událostech

Účinným přístupem pro invalidaci cache v DDD je naslouchání doménovým událostem. Když agregát změní stav (publikuje doménovou událost), Event Listener invaliduje příslušné cache záznamy. Cache invalidace se tím stává součástí doménového toku, nikoli ad-hoc voláním rozptýleným po kódu.

16.08 Bulk operace a hromadné zpracování

Standardní DDD postup – načti agregát, aplikuj doménovou logiku, zavolej flush() – funguje pro zpracování jednotlivých agregátů. Pro hromadné operace (import tisíců záznamů, hromadná aktualizace stavů, migrace dat) je tento přístup neefektivní. Každý cyklus načítá a spravuje jeden agregát, dirty checking zpracovává celou Identity Map. Celkový čas zpracování roste lineárně s počtem záznamů.

DQL bulk UPDATE a DELETE – bypass Identity Map

Pro hromadné aktualizace, kde není potřeba procházet doménovou logiku, nabízí Doctrine možnost provést UPDATE nebo DELETE přímo přes DQL. Tyto operace obcházejí Identity Map a Unit of Work – jsou to přímé SQL příkazy přeložené z DQL. Nevýhoda: po DQL bulk operaci mohou být spravované entity v Identity Map nekonzistentní se stavem v databázi. Je nutné zavolat clear().

Symfony Messenger pro asynchronní hromadné zpracování

Namísto synchronního zpracování tisíců záznamů v jednom PHP procesu je doporučeným přístupem rozdělit práci na menší úlohy zasílané přes Symfony Messenger na asynchronní transport (RabbitMQ, Redis Streams, Amazon SQS). Každá zpráva zpracuje jeden nebo malý batch agregátů. Paměťové nároky a doba zpracování jedné zprávy jsou pak předvídatelné.

16.09 Provozní výkonové vzory

Předchozí sekce řeší výkon na úrovni jednoho dotazu nebo jednoho agregátu. Jakmile aplikace běží 24/7 s reálnou zátěží, narážíte na třídu problémů, které lokální profiling neukáže: souběžnost více klientů, omezení databáze jako sdíleného zdroje a operační omezení Doctrine ve více procesech.

Hot aggregates a optimistic lock thrash

Hot aggregate je agregát, který je modifikován mnoha klienty současně. Klasické příklady: globální Inventory jednoho produktu při rozjezdu kampaně, Tournament agregát s 1000 účastníky, kteří všichni paralelně potvrdí účast, nebo BankAccount firmy s tisíci transakcí denně.

FIG. 17.9-A Optimistic lock thrash: 3 souběžné modifikace, 2 retry

S #[ORM\Version] (optimistický zámek) souběžná modifikace vyhází OptimisticLockException. Při nízké souběžnosti (5 % konfliktů) je retry levný. Při hot aggregate (50–80 % konfliktů) systém degraduje na sériový provoz: worker dělá retry → load → modify → save → conflict → retry. Throughput klesne o řád, latence stoupne.

Tři strategie, podle pořadí preference:

  • Re-design hranic agregátu. Pokud je Inventory hot, není to často jeden agregát, ale N samostatných agregátů per warehouse + sklad pool. Jeden agregát na region/sku/sklad. Konflikty pak nejsou „mezi všemi klienty“, ale „mezi klienty stejné lokace“.
  • Eventual consistency místo strong. Místo „strhni 1 ks z Inventory synchronně“ publikuj ItemReserved(productId, qty) event a agregát ho zpracuje asynchronně přes saga. Konflikty řeší sága přes kompenzaci, ne optimistic lock.
  • CRDT / counter-only agregáty. Pokud doménová operace je čistý increment (view_count, like_count), nepotřebujete celý agregát – stačí Postgres UPDATE counters SET n = n + 1 WHERE id = ?. To není „obvykle DDD“, ale je to validní u skutečně commutative operací.

Partitioning velkých tabulek

PostgreSQL declarative partitioning je standardní řešení pro tabulky s 50M+ řádky, kde aktivně se mění jen poslední část (typicky podle created_at):

  • orders partitioned po měsících – aktivní partition za poslední měsíc drží 1M řádků, vlézá do RAM, indexy malé. Staré partitions (read-only) můžou být na pomalejším disku nebo v archivu.
  • audit_log partitioned po dnechDROP PARTITION po retention period je atomický a nezamyká aktivní tabulku.
  • projection_* tabulky s vysokým write rate.

Pro DDD má partitioning jeden důsledek navíc: agregátní reference přes ID musí být kompozitní (id + partition key, např. created_at). Pokud doména zná jen OrderId, partition lookup vyžaduje plný scan napříč partitions (slow). Standardní řešení: zahrnout created_at (nebo derivovaný měsíc) do hodnotového objektu OrderId, aby ho repozitář uměl použít pro partition pruning.

Read replicy a connection pooling

V CQRS architektuře bývají read modely vhodný kandidát pro read replicy – samostatná databáze (nebo Postgres streaming replica), na kterou jdou všechny queries, zatímco write model zůstává na primary. Důsledky pro DDD kód:

FIG. 17.9-B Routing: write na primary, read na replicu, replikační lag
  • Repozitář write strany drží EntityManagerInterface namapovaný na primary.
  • Query handler read strany drží separátní Connection nebo EntityManager namapovaný na replicu (doctrine.orm.read_entity_manager).
  • Replikační lag (typicky 10–100 ms) znamená, že po save() na primary query na replicu nemusí ihned vidět změnu – stejný „read your writes“ problém jako u eventual consistency. Vzor řešení viz CQRS – eventual consistency v UI.

Connection pooling je ortogonální problém. PHP-FPM model „1 worker = 1 PHP proces = 1 DB connection“ se nasčítá: 100 PHP-FPM workerů × 4 DB pody × 10 read replicas = 4000 connections, což překročí výchozí max_connections = 100 v Postgresu. Standardní řešení: PgBouncer / RDS Proxy mezi aplikací a DB, transaction pooling mode. Pozor: transaction pooling nepodporuje prepared statements (Doctrine používá), takže potřebujete buď session pooling (méně efektivní), nebo PgBouncer ve verzi 1.21+ s prepared_statements = true.

Snapshotting v Event Sourcingu (přehled)

Při Event Sourcingu (kapitola Event Sourcing) je rebuild stavu agregátu lineární s počtem eventů. Pro agregát s 100 eventy je to instant; pro 1000 eventů to začíná být znát; pro 100k+ eventů (long-lived agregát jako UserAccount po letech provozu) je hydration nepoužitelná.

Snapshot je zhuštěný stav agregátu uložený periodicky:

  • Po každých N eventech (typicky 50–100) se uloží Snapshot{aggregateId, version, state}.
  • Při hydration se načte poslední snapshot + jen eventy novější než snapshot version.
  • Tradeoff: rychlejší read, ale snapshot tabulka roste a její struktura je vázaná na konkrétní verzi agregátu (schema evolution problém – viz Event Sourcing – verzování).

Detailní implementace včetně Symfony kódu je v sekci Event Sourcing – Snapshotting. V kontextu výkonu si pamatujte: snapshot není výchozí volba, ale úniková páka pro dlouho žijící agregáty. Většina DDD agregátů má desítky eventů za celý životní cyklus a snapshotting nepotřebuje.

16.10 Profiling DDD aplikací

Úzké místo nepoznáte bez měření. Pro PHP/Symfony jsou v ruce tři vrstvy nástrojů: vývojový profiler, produkční profiling a programatický logger SQL dotazů.

Symfony Profiler (Web Debug Toolbar)

Ve vývojovém prostředí odhaluje N+1 a pomalé dotazy nejdřív Symfony Profiler (aktivní při APP_ENV=dev). Panel Doctrine zobrazuje:

  • Celkový počet SQL dotazů za request – nadměrný počet dotazů signalizuje N+1 problém.
  • Dobu trvání každého dotazu – pomalé dotazy vyžadují indexování nebo přepis.
  • Kompletní SQL s parametry – umožňuje přímé testování v databázovém klientovi.
  • Stack trace pro každý dotaz – identifikuje, která část kódu dotaz vydala.

Doctrine query logging

Pro programatické zachycení SQL dotazů (např. v integračních testech nebo při ladění batch operací) lze Doctrine konfigurovat s vlastním SQL loggerem.

Blackfire.io pro produkční profiling

Pro profiling v produkčním nebo stagingovém prostředí se v PHP používá Blackfire.io. Blackfire zachytí kompletní call graph každého requestu nebo CLI příkazu – s přesným měřením doby trvání, počtu volání a paměťové stopy pro každou funkci. Umožňuje psát výkonnostní testy (Blackfire Builds) jako součást CI/CD pipeline a tím předcházet výkonnostním regresím.

Tři páky výkonu v DDD: hranice agregátů, read model a profiling. Pořadí, ve kterém je řešit, je opačné – nejdřív měřit, pak oddělit read od write přes CQRS, pak doladit hranice agregátů a eliminovat N+1. Pokračováním je kapitola CQRS v Symfony 8 a praktické příklady implementace DDD.

Časté otázky

Zpomaluje DDD aplikaci oproti CRUD?

Samotné DDD výkon nesnižuje – doménové třídy jsou čistý PHP bez runtime režie. Zpomalení nastává, když je špatně navržený agregát (načte víc dat, než je třeba). Další příčinou je chybějící read model v CQRS nebo nesprávné použití Doctrine lazy loadingu, které vede k N+1 dotazům. Při správném návrhu je DDD aplikace srovnatelná s CRUD a lépe optimalizovatelná díky explicitním hranicím. Viz sekci Výkon v kontextu DDD.

Jak v DDD řešit N+1 problém s agregáty?

N+1 vzniká, když se pro načtený rodičovský objekt doplňkově dotazuje na každý vnitřní prvek. Řešení v Doctrine má tři úrovně: fetch="EAGER" u mapování, fetchJoin() v repository metodě, nebo denormalizovaný read model v CQRS. Pro čtení dat do UI bývá read model nejpřímočařejší – eliminuje ORM lazy loading úplně. Pro write operace stačí správný fetch join při načtení agregátu. Rozbor řešení v sekci N+1 problém.

Má velikost agregátu vliv na výkon?

Ano, zásadně. Příliš velký agregát vede k načítání desítek vnitřních entit při každé operaci a k častým konfliktům optimistického zamykání. Správně zvolený agregát drží jen to, co musí být konzistentní v jedné transakci. Když dvě části agregátu nesdílejí invariant, jde zpravidla o dva samostatné agregáty – to zvyšuje paralelismus i rychlost operací. Podrobný rozbor v sekci Agregát a výkon.

Jak optimalizovat read model v CQRS?

Read model se navrhuje přímo pro daný dotaz – denormalizované tabulky odpovídají tvaru UI, nikoli doménovému modelu. Typické optimalizace jsou dedikované indexy pro konkrétní filtry, materializované projekce místo JOIN dotazů nad write modelem nebo replikace read modelu na jiný datový stroj (Elasticsearch, Redis). Read model lze rebuildnout z událostí, takže změna schématu nevyžaduje klasickou migraci. Detailní rozbor v sekci Optimalizace read modelu.

Je lepší UUID, nebo integer primární klíč z pohledu výkonu?

Integer klíč je rychlejší v indexech a zabírá méně místa, ale vyžaduje auto-increment generovaný databází. UUID umožňuje vygenerovat identitu v doméně bez round-tripu do DB, což DDD vyžaduje – agregát dostane ID před persistencí. Výkonový rozdíl je v řádu jednotek procent a v praxi je pohlcen vyšší přehledností doménového kódu. Pro DDD se UUID doporučuje. Srovnání obou variant v sekci UUID vs. integer primární klíče.