Drie plugins, geen schuldige: van 13 naar 1,6 seconden

Het beheer van een WooCommerce-site van een klant voelde al maanden traag. Niet de voorkant, want bezoekers krijgen daar een kant-en-klare pagina uit de cache. Het ging om het werk achter de schermen: producten bewerken, het dashboard openen, even kijken hoe een pagina eruitziet als je bent ingelogd. Elke klik duurde een paar tellen, soms veel meer.

Omslag: Drie plugins, geen schuldige

Het dashboard bleek 13,6 seconden nodig te hebben. Na één kleine ingreep was dat 1,6 seconden. De oorzaak was geen enkele slechte plugin, maar drie plugins die elk iets redelijks deden en samen in een kringetje terechtkwamen.

Eerst de gewone verdachten, en die vielen allemaal af. De automatisch geladen instellingen in de database waren netjes (317 KB, ruim onder de grens waar WordPress voor waarschuwt). De database zelf deed 0,3 seconde. En er draaide een snelle geheugencache (Redis), die volgens de plugin “verbonden” was, zonder fouten.

De plugin Query Monitor liet zien waar de tijd wél zat. Bij elke keer dat het dashboard laadde, deed WordPress 22 verzoeken naar andere servers: controleren of er updates zijn, of er vertalingen zijn, of een licentie nog geldig is. Samen ruim 7 seconden wachten. Zulke controles doet WordPress normaal hooguit een paar keer per dag, want het onthoudt de uitkomst. Hier onthield de site blijkbaar niets.

Het bewijs was eenvoudig. Ik zette via de opdrachtregel een testwaarde in de cache en vroeg hem meteen daarna terug. Weg. Met alle plugins uitgeschakeld bleef hij wél staan. Er maakte dus iets de cache leeg, bij elke start van WordPress.

Daarna volgde ik het spoor tot het moment waarop het gebeurde, met een klein stukje code dat alleen meekijkt. Dat liet de kringloop zien:

1. Het thema (Botiga Pro) wil één keer de adresregels van de site laten herberekenen. Om te onthouden dat het dat gedaan heeft, legt het een geheugensteuntje neer, in de cache. 2. Tijdens dat herberekenen maakt een plugin voor productadressen (Premmerce Permalink Manager) de hele cache leeg. 3. Daarmee is ook het geheugensteuntje van het thema weg. Bij het volgende verzoek denkt het thema: dat heb ik nog niet gedaan. En zo begint het opnieuw.

Zonder die snelle cache was er niets aan de hand geweest: het geheugensteuntje stond dan in de database, en daar komt de plugin niet aan. De cache die het sneller had moeten maken, maakte de kringloop pas mogelijk.

De oplossing is één regel code, in een eigen klein bestand, zonder iets aan het thema, de plugin of de cache te veranderen. Die regel zegt tegen het thema: je geheugensteuntje staat er. Dat is precies wat het thema zelf bedoelt, want het wil de adresregels maar één keer herberekenen.

Het resultaat: het dashboard ging van 13,6 naar 1,6 seconden, met nul verzoeken naar buiten. Een gewone productpagina die vers wordt opgebouwd, ging van 4,9 naar 1,8 seconden.

Wat ik ervan leer

“Verbonden, geen fouten” zegt niet dat een cache werkt. Het zegt dat hij bereikbaar is. Of hij iets onthoudt, meet je door er iets in te zetten en het terug te vragen.

En: bij een probleem tussen plugins is er niet altijd een schuldige. Het thema en de adressenplugin doen elk iets verdedigbaars. Pas de combinatie met een cache maakt er een kringloop van. Iemand anders meldde hetzelfde probleem al eens bij de makers van de adressenplugin, zonder dat de oorzaak werd gevonden.

Onderweg ging ik ook één keer de mist in. Mijn eerste proef om de schuldige aan te wijzen (steeds één plugin overslaan en kijken of de testwaarde bleef staan) wees eerst de adressenplugin aan, en bij een tweede ronde tien willekeurige andere. Op een live site komen er tussendoor gewoon bezoekers, bots en geplande taken langs, en die maakten de cache ook leeg. Een proef die van toeval afhangt, wijst niemand aan. Het meekijkende stukje code wel.

Voor de techneuten: wat er precies gebeurt

Omgeving: WordPress 7.1.2 multisite, WooCommerce, thema Botiga met Botiga Pro, Premmerce Permalink Manager for WooCommerce 2.3.13, Redis Object Cache 3.0.0 (PhpRedis), WP Rocket voor de paginacache.

1. Botiga Pro (inc/modules/templates-builder/v3/src/Frontend/Setup.php):

add_action( 'init', array( $this, 'flush_rewrite_rules' ), 999 );

public function flush_rewrite_rules() {
    if ( ! get_transient( 'botiga_templates_flushed_rules') ) {
        flush_rewrite_rules();
        set_transient( 'botiga_templates_flushed_rules', true, 0 );
    }
}

Een vlag voor “eenmalig” als transient zonder verlooptijd. Met een persistente object cache staat die transient alleen in de cache, niet in wp_options.

2. WordPress stelt een flush die vóór wp_loaded wordt aangevraagd uit: WP_Rewrite::flush_rules() hangt zichzelf aan wp_loaded (sinds 6.4, refresh_rewrite_rules()). Daardoor begint een backtrace vanaf rewrite_rules_array bij wp_loaded en is de aanvrager niet te zien.

3. Premmerce (src/PermalinkListener.php):

add_filter( 'rewrite_rules_array', array( $this, 'addRewriteRules' ), 99 );

public function addRewriteRules( $rules ) {
    // ...
    wp_cache_flush();

Het volledige object cache gaat leeg, en daarmee de transient van stap 1.

Meten. Na een dashboardlading gaf wp transient get update_themes --network *”not set”*. wp cache set en wp cache get in twee aparte processen: weg; met --skip-plugins --skip-themes: bleef staan. De spoorzoeker was een bestand voor --require met WP_CLI::add_wp_hook( 'all', … ), dat meldt op welk moment [ $wp_rewrite, 'flush_rules' ] aan wp_loaded wordt gehangen, met de plugin- en themabestanden in de backtrace. Uitkomst: botiga-pro/…/Frontend/Setup.php:43 set_transient, tussen de hooks transient_botiga_templates_flushed_rules en pre_set_transient_….

Oplossing (mu-plugin):

add_filter( 'pre_transient_botiga_templates_flushed_rules', '__return_true' );

Moeten de adresregels ooit wel opnieuw worden berekend: Instellingen → Permalinks → Opslaan. Let op: verandert Botiga de naam van de transient, dan werkt dit zonder melding niet meer. Controle: in Query Monitor horen de HTTP-aanroepen bij een tweede lading van het dashboard (vrijwel) leeg te zijn.

Eigenlijke fix bij de makers: een eenmalige vlag hoort in een optie, niet in een transient. En wp_cache_flush() in een filter op de adresregels is erg grof.