Disclaimer: Welkom bij onze TechBlog!
Dit is een artikel in ons TechBlog. Ons Techblog bevat artikelen, geschreven door onze developers, over dingen die ze zijn tegengekomen tijdens hun werkzaamheden. Deze artikelen gaan (meestal) over een technisch onderwerp, en zijn met name bedoeld om te vermaken en (soms) te informeren. Dit artikel is in recordtijd geschreven, maar neemt sindsdien wel wat geheugenruimte in.

vrijdag 24 juli
Auteur: Jeroen Mimpen
Tekstredacteur: Mark Coenradie

Moderne PHP-applicatieservers - deel 3 - De voordelen

Dit is het derde deel in onze reeks over moderne PHP-applicatieservers. Lees eerst deel 1 en deel 2 als je die nog niet gelezen hebt. In de vorige artikelen hadden we het over de groeipijnen, de nadelen en de moeilijkheden. Maar we gebruiken deze moderne applicatieservers natuurlijk met een reden. In dit artikel gaan we het hebben over de voordelen en mogelijkheden.

Moderne PHP-applicatieservers

Zoals we in ons vorige artikel ook al schreven: Er zijn verschillende keuzes voor moderne PHP-applicatieservers. We begonnen op Swoole, en zijn aan het overstappen op FrankenPHP. Maar veel van wat we hier schrijven is op alle PHP-runtimes van toepassing, zoals bijvoorbeeld ook RoadRunner.

Het onderliggende principe is namelijk overal hetzelfde: een langdraaiend PHP-proces, waarbij je de optuigende stappen aan het begin van dat proces eenmalig uitvoert (zoals het optuigen en opwarmen van je framework), voordat je in een oneindige loop begint te wachten en requests gaat verwerken.

Diagram van het moderne PHP-model met een langlopend proces

De voordelen zijn waarschijnlijk al direct duidelijk: je hoeft alle logica rondom het optuigen en opwarmen van je framework maar één keer uit te voeren. Echter brengt deze proces-persistentie meer voordelen met zich mee, maar om die te benutten moesten we wel eerst wat dingen gaan herschrijven. Laten we het eens gaan hebben over onze RedirectService.

Legacy RedirectService

De RedirectService is verantwoordelijk voor onze redirects. In het Hydrazine CMS is het mogelijk om redirects aan te maken (van verschillende types, maar we houden het in dit artikel simpel). Iedere request die bij ons binnenkomt, moet dus eerst een controle ondergaan: is er een redirect voor deze URL?

Deze RedirectService zat al in ons Framework voordat we naar Swoole migreerden. En ook toen was het al belangrijk dat deze snel genoeg was, omdat hij bij ieder request aangeroepen werd. Om de implementatie van deze service snel te krijgen, werkte de interne implementatie met een Redis-HashSet, om zo snel mogelijk een URL te kunnen matchen.

Het is mogelijk dat de HashSet in Redis nog niet bestaat, maar het is ook mogelijk dat de HashSet wel bestaat, maar de URL niet aanwezig is in de HashSet. In beide situaties krijg je hetzelfde resultaat terug vanuit Redis, en je kunt dus niet onderscheiden welk scenario van toepassing is. Er zijn dus twee aanroepen nodig: een aanroep die checkt of de HashSet bestaat (en zo niet, moet deze door ons aangemaakt worden), en een aanroep om te kijken of onze URL erin zit.

Dit is wel zonde - want alhoewel Redis heel snel reageert, zit je nog wel met de latency van de netwerk-roundtrip. Om dit te optimaliseren, hebben we deze aanroepen gecombineerd via Redis-Pipelining, die ons dus beide resultaten teruggeeft in een enkele roundtrip. Door beide resultaten te controleren kunnen we bepalen wat de daadwerkelijke situatie is, en kunnen we, in de happy flow (99% van de tijd), meteen door.

Humoristische afbeelding van de architectuur van de RedirectService v1

Hier gebeurde iets eigenaardigs bij het genereren van de afbeelding. Maar ik vond hem eigenlijk te grappig om niet te gebruiken.

Uiteindelijk was onze RedirectService dus complex, maar wel performant - binnen de limitaties van het traditionele PHP-model. Maar Swoole doorbreekt deze beperkingen en biedt nieuwe opties.

RedirectServiceV2

Meestal is het meest trage en tragische aspect van PHP-code niet de PHP-code zelf, maar de tijd die je aan het wachten bent op externe resultaten, zoals die van MySQL en Redis. Deze diensten draaien ook geregeld op een andere server, waardoor het signaal en de data vaak ook nog een tijdje moeten reizen, wat dit probleem verergert. Maar zelfs bij lokaal draaiende diensten is het probleem nog aanwezig, omdat de dienst zelf nog de data op moet zoeken en versturen, en het PHP-proces moet deze ontvangen data vervolgens nog omzetten (meestal deserializen of decoden) naar bruikbare data.

Met de beschikbaarheid van Swoole of FrankenPHP ontstaat er opeens de mogelijkheid tot persistent geheugen. We laden vooraf het framework al in, voordat we requests gaan verwerken. Waarom zouden we ook niet alvast onze redirect-lijst in het (RAM) geheugen inladen? We hebben deze data immers ieder request nodig, waardoor het dus minimaal één Redis en/of MySQL aanroep per request scheelt. Deze aanpak is dus sneller en je verlaagt bovendien de druk op je cache en/of database. Een goede leidraad is vaak: hoe meer je op je applicatieservers kunt doen, hoe beter, want deze kun je eenvoudiger schalen.

En dat is precies wat versie 2 van onze RedirectService doet: bij het starten van het framework, laadt die eenmalig alle redirect-regels in, en gebruikt deze bij ieder request. Hierdoor is de RedirectService conceptueel een stuk simpeler geworden (er zijn bijvoorbeeld geen complexe Redis-HashSet-Pipelining-operaties meer nodig) en het is ook nog eens sneller.

Al snel boden meer kandidaten zich aan om op deze manier herschreven te worden, zoals bijvoorbeeld onze WhitelistService die ook bij ieder request werd aangeroepen. Ze ondergingen allemaal hetzelfde lot, waarbij eenmalig bij het opstarten de data werd ingeladen, en ze alleen nog maar hun daadwerkelijke logica hoefden uit te voeren tijdens de requests zelf.

Verschillende services die allemaal herschreven willen worden naar V2

Na de RedirectService volgden nog vele andere services.

Tradeoffs

De V2 van de RedirectService lijkt in alle opzichten beter, want hij is zowel sneller als simpeler dan de vorige versie. Toch is ook hier weer sprake van een aantal tradeoffs die we moesten maken. Eén daarvan is het geheugengebruik. We laden bij het starten van het framework alle redirect-regels in het RAM in. Dat kunnen honderden, of zelfs duizenden regels zijn. En dit herhalen we voor iedere worker, omdat die onderling geen geheugen delen. Iedere worker heeft dus een kopie van deze lijst van redirect-regels. Het resultaat is dus dat ons geheugengebruik veel hoger ligt. De tradeoff hier is dus geheugen voor performance. Dat was destijds een acceptabele situatie, maar misschien verandert onze calculus hierover, met de huidige geheugenprijzen (dit artikel was in maart 2026 geschreven).

Een andere tradeoff is de complexiteit. De RedirectService zelf is dan wel simpeler geworden, maar het Hydrazine Framework heeft wel een hoop extra complexiteit mogen introduceren om alles goed werkend te maken. Misschien merkte je het in het vorige hoofdstuk al op, waarin ik schreef dat onze aangepaste services nu eenmalig, bij het opstarten van het framework, hun data inladen. Daarvoor hebben we een worker-warm-up-mechanisme gebouwd, waar opwarmbare services kunnen subscriben en ze tijdens de warm-up eenmalig hun data kunnen inladen. Maar dat is natuurlijk een oververeenvoudiging van de werkelijke situatie. Want de meeste data is niet statisch. Wanneer we bijvoorbeeld een redirect-regel aanpassen in ons CMS, moet deze wijziging wel gepropageerd worden naar iedere worker.

Er moest dus een mechanisme gebouwd worden waarmee je kon communiceren met iedere worker, om data just-in-time te verversen. Uit noodzaak moet iedere worker dit bij ieder request checken, wat betekent dat deze communicatielaag geoptimaliseerd moet werken, want anders eindig je straks met een situatie die verdacht veel lijkt op het traditionele PHP-model, waarbij je vooraf aan ieder request steeds weer al je data in moet laden. Dit purge-mechanisme moet dus zo snel, optimaal en gericht mogelijk de nodige data verversen.

Dit alles zit verwerkt in al onze (cache)lagen. Wanneer een service zijn data ophaalt, doet hij dat eerst vanuit het RAM. Als de data daar niet aanwezig is, probeert hij de lokale Redis. Als ook daar de data ontbreekt, probeert hij de globale gedeelde Redis. En als zelfs daar de data niet te vinden is, moet het gevraagd worden aan onze database, waar de data natuurlijk altijd aanwezig is.

Op de 'terugweg' wordt deze data daarna neergezet in alle voorgaande cachelagen. Als het bijvoorbeeld niet in de lokale Redis zat, maar wel in de globale, wordt het daarna in zowel het RAM als in de lokale Redis neergezet. We hebben voor deze complexe en gelaagde structuur gekozen, wederom vanwege de complexiteit vs performance tradeoff. Iedere worker start 'los' op, en moet bij het opstarten veel data inladen. Op die manier voorkom je een stampede-effect op je database, zeker wanneer je bijvoorbeeld een deployment doet en alle workers gestart worden.

Workers die tegelijk de database bestormen in een stampede-effect

Dit stampede-effect, waarbij alle workers tegelijk onze arme database bestormen, is wat we proberen te voorkomen.

Conclusie

Deze artikelen hebben een klein kijkje gegeven in de manier waarop we snelle applicaties opleveren (voor de klanten die graag PHP willen). De mogelijkheden die we nu met PHP hebben dankzij deze moderne applicatieservers zijn ongelooflijk gaaf. Toch is er nog iets wat me 'dwars' zit aan dit geheel. Hierover ga ik schrijven in het volgende artikel.