WordPress
WordPress multisite audit: wat we checken voor een offerte
Een overgenomen WordPress multisite landde vorige maand in onze inbox: 38 subsites, 14 GB database, domeinwissel binnen drie weken. Dit is wat we als eerste auditen.

Een overgenomen WordPress multisite landde vorige maand in onze inbox met een vriendelijk briefje: "Kunnen jullie dit vóór de 30e naar een nieuwe server migreren?" Achtendertig subsites, veertien gigabyte database, elf jaar opgestapelde plugin-schuld. Het vorige bureau reageerde niet meer. De hoster had een laatste waarschuwing gestuurd over CPU-gebruik. De marketing lead wilde gewoon dat de blog bleef laden.
We hebben die dag geen offerte uitgebracht. Eerst de audit. Hier is de checklist, in de volgorde waarin we 'm doorlopen.
De eerste dertig minuten op een onbekende multisite
Voordat we ook maar één configuratiebestand aanraken, willen we drie dingen in beeld: de wp-config.php, een phpMyAdmin- of wp-cli-verbinding, en de CPU-grafiek van de hoster over de laatste veertien dagen. De wp-config vertelt ons of dit een echte multisite is of een single install die zich voordoet als multisite (kijk naar define('MULTISITE', true) en de SUBDOMAIN_INSTALL flag). De database-verbinding geeft ons het table prefix, dat belangrijker is dan mensen denken bij overgenomen builds. De CPU-grafiek vertelt of er een vastlopende cron job draait of een brute-force loginpoging die we straks samen met de code erven.
Die CPU-grafiek is de meest overgeslagen stap en de nuttigste. Spikes elke vijftien minuten? Dan kijk je naar WP-Cron die een verouderde Action Scheduler queue afvuurt. Spikes zonder patroon? Dat is bijna altijd xmlrpc.php onder een botnet. We hebben beide ingelopen. De migratie-offerte verschilt duizenden euro's afhankelijk van het antwoord.
De wp_options autoload query
De allernuttigste query op elke overgenomen WordPress install is deze:
SELECT option_name, LENGTH(option_value) AS size
FROM wp_options
WHERE autoload = 'yes'
ORDER BY size DESC
LIMIT 20;Op een gezonde multisite root blijft de totale autoload = 'yes' payload onder 1 MB. In het netwerk dat we vorige maand overnamen, was de bovenste regel een 31 MB geserialiseerde array, achtergelaten door een lang verwijderde social-sharing plugin. Bij elke request, op elke subsite, voor elke bezoeker, werd die 31 MB in het geheugen geladen voordat WordPress ook maar iets deed. De wp_load_alloptions functie laadt elke autoloaded rij bij elke pageboot. Er is geen caching van 'alleen wat ik nodig heb'.
De autoload-bloat is de goedkoopste fix op de audit. Eén UPDATE statement, één cache flush, en de TTFB zakt 300 tot 800 ms over het hele netwerk. We voeren de fix niet uit tijdens de audit zelf. We meten. Het getal gaat de offerte in als een post-migratie winst die we op de staging clone kunnen aantonen.
SELECT SUM(LENGTH(option_value))/1024/1024 AS autoload_mb
FROM wp_options
WHERE autoload = 'yes';Zit dat getal boven de 3 MB, dan heb je een probleem geërfd. Boven de 10 MB heb je een verhaal geërfd. Op multisite draai je 'm tegen de network root en tegen elke per-site options tabel (wp_2_options, wp_3_options, enzovoort). De snelste enumeratie:
wp site list --field=url | while read url; do
size=$(wp --url="$url" db query \
"SELECT ROUND(SUM(LENGTH(option_value))/1024/1024,2) \
FROM wp_options WHERE autoload='yes'" --skip-column-names)
echo "$url autoload_mb=$size"
doneOrphan uploads en de schijfleugen
De rekening van je hoster zegt dat je 84 GB schijf in gebruik hebt. De database backup is 1,2 GB. Dus 82,8 GB is 'bestanden'. De logische aanname: die bestanden zijn uploads. Ongeveer de helft daarvan is dat niet.
Draai twee getallen. Eerst de werkelijke grootte van wp-content/uploads:
du -sh wp-content/uploads
du -sh wp-content/uploads/sites/*Daarna het aantal attachment rows over elke subsite:
wp site list --field=url | while read url; do
count=$(wp --url="$url" post list --post_type=attachment --format=count)
echo "$url attachments=$count"
doneIs het aantal bestanden op schijf flink hoger dan het attachment-aantal in de database, dan heb je orphans. Orphans komen uit drie hoeken: verwijderde attachment-rijen waarbij het bestand niet is opgeruimd, gegenereerde thumbnails van oude afbeeldingsformaten die niet meer bestaan, en backups die door een plugin in wp-content/uploads zijn gedropt terwijl die plugin beter had moeten weten.
We hebben 60 GB aan orphan thumbnails gezien in één netwerk, omdat een eerdere developer veertien custom afbeeldingsformaten had geregistreerd en er zes maanden later twaalf van had verwijderd. De afbeeldingsformaten waren uit de code. De gegenereerde bestanden niet.
Beslisregel: zit de orphan-ratio boven de 30%, dan bevat de migratie-offerte een parallelle rsync pass aangestuurd door een manifest van attachment-IDs, geen platte kopie van de uploads-map. Dat scheelt al snel een dag op de cutover.
wp_blogs en de network-tabel die alles sloopt
Hier is de tabel die iedereen vergeet bij een multisite-migratie: wp_blogs. Hij bevat het canonieke domein en pad voor elke subsite in het netwerk. WordPress leest 'm op elke request, nog voor 'ie bepaalt welke subsite-options-tabel geladen moet worden. Migreer je de database naar een nieuw domein en update je netjes siteurl en home op elke subsite, dan kan de front-end er correct uitzien. De admin breekt op subtiele manieren. wp-admin redirects gaan in een loop. Network admin toont het oude domein. Nieuwe subsites aanmaken faalt. Cookies worden tegen de verkeerde host gezet.
Bij een multisite domeinwissel is wp_options.siteurl updaten de makkelijke helft. Schrijf je niet ook wp_blogs.domain, wp_site.domain, wp_sitemeta en wp_blogmeta opnieuw, dan laadt de network admin maar weigert dienst te doen.
De fix is één search-replace per omgeving, via wp-cli zodat geserialiseerde PHP-arrays de operatie heelhuids overleven:
wp search-replace 'old.example.com' 'new.example.com' \
--network \
--precise \
--skip-columns=guid \
--dry-runAltijd eerst dry-run. Altijd de guid kolom overslaan (GUIDs zijn post-identifiers, geen URLs om te herschrijven, ook al zien ze eruit als URLs). De wp-cli search-replace docs leggen uit waarom. We hebben dat advies precies één keer genegeerd, bij een migratie uit 2017 die in elke reader van het netwerk een feed vol dubbele posts opleverde. Een tweede keer doen we dat niet.
wp_blogmeta kwam in WordPress 5.0 en is nu de opslaglaag voor per-site metadata die de network admin bij elke load uitleest. Verouderde entries hier zijn de op één na meest voorkomende oorzaak van 'de network admin laadt maar ik kan nergens op klikken'. Is je overgenomen netwerk opgezet vóór 5.0 en later geüpgraded, reken dan op een bestaande, inconsistente tabel.
De plugin-wildgroei tellen
Het Network Plugins scherm liegt. Het vertelt je welke plugins network-activated zijn. Het vertelt je niet welke plugins individueel actief zijn op subsite 17 door een editor die in 2021 is vertrokken.
wp plugin list --status=active-network --format=csv > plugins-network.csv
wp site list --field=url | while read url; do
wp --url="$url" plugin list --status=active --format=csv \
| tail -n +2 | sed "s|^|$url,|"
done > plugins-per-site.csvUit die twee bestanden vallen drie categorieën: plugins actief op elke subsite die network-activated zouden moeten zijn (goedkope opruiming); plugins actief op één subsite waarvan het vorige team niet meer wist dat ze bestonden (audit-kandidaat); en plugins die helemaal niet in de WordPress repository staan (custom code die we nu erven). De derde categorie is waar migratie-schattingen ontsporen. Eén custom plugin die op elke pageload op elke subsite de database raakt, kan de post-migratie serverrekening verdrievoudigen. We lezen elk PHP-bestand in mu-plugins en in elke wp-content/plugins/[custom-slug]/. Twintig minuten lezen scheelt een week debuggen.
We kruisen actieve plugin-slugs ook met de WPScan vulnerability database. Het aantal ongepatchte CVEs in een typisch overgenomen netwerk is ongemakkelijk. De offerte moet aangeven welke fixes binnen scope vallen en welke de klant ervoor kiest uit te stellen.
Cron, transients en de dingen die op elke request afgaan
Nog twee queries:
SELECT COUNT(*) FROM wp_options
WHERE option_name LIKE '_transient_%';
SELECT COUNT(*) FROM wp_options
WHERE option_name LIKE '_site_transient_%';Dit horen lage-honderdtal getallen te zijn. Zie je tienduizenden, dan maakt een plugin transients aan zonder expiry of ruimt ze niet op. Action Scheduler, gebruikt door WooCommerce en veel newsletter plugins, kan een wp_actionscheduler_actions tabel met miljoenen rijen achterlaten. Die ene tabel kan groter zijn dan de rest van de database bij elkaar.
Check daarna de WP-Cron schedule:
wp cron event list --fields=hook,next_run_relative,recurrenceZie je een event gepland vijftien minuten in het verleden met 'next run' als 'ago', dan vuurt WP-Cron niet. Of een system cron is gestopt, of de hoster heeft WP-Cron uitgezet zonder externe trigger op te zetten. Hoe dan ook: de migratie-offerte bevat een server-side cron entry voor wp cron event run --due-now op de nieuwe host.
Wat er uiteindelijk in de offerte komt
Hebben we het autoload-getal, de orphan-ratio, de wp_blogs domeinmap, de plugin-telling en de cron-status, dan weten we wat de migratie is. We weten welke subsites uit de cutover kunnen (er is bijna altijd één 'test' subsite die in vier jaar niet is aangeraakt). We weten of we de uploads kunnen rsyncen of dat er een manifest-gedreven kopie nodig is. We weten of de nieuwe server Redis nodig heeft voor object cache. We weten of de klant één plugin-licentie moet laten verlopen voordat we iets aanraken. De offerte is eerlijk omdat de getallen eerlijk zijn.
De eerste query op elke overgenomen WordPress install is de wp_options autoload size. Hij vertelt je wat elke pagina betaalt, voordat WordPress één regel van je theme draait.
Toen we het 38-site netwerk van de opening aanpakten, bleek de autoload payload 31 MB, was de orphan-ratio 47%, en toonde wp_blogs nog acht subsites die via de admin 'verwijderd' waren maar stilletjes verkeer naar oude URLs bedienden. Dat vouwden we in een vierfase legacy migratie die een dag onder deadline klaar was. De audit is het deel dat de rest voorspelbaar maakt.
Open een database-client tegen de WordPress install waar je je het meest zorgen om maakt. Draai de autoload-query bovenaan dit artikel. Komt het resultaat boven 3 MB, dan heb je je eerste dertig-minuten-project voor morgen.
Kern
De eerste query op elke overgenomen WordPress install is de wp_options autoload size. Hij vertelt je wat elke pagina betaalt voordat je theme draait.
FAQ
Wat is een veilige autoload size voor wp_options?
Onder 1 MB op een gezonde install. Tussen 1 en 3 MB is acceptabel. Boven 3 MB wijst op plugin-schuld; boven 10 MB betaalt de site die kosten bij elke request.
Waarom breekt het updaten van siteurl een multisite admin na een domeinwissel?
WordPress leest wp_blogs en wp_site voordat het de options van een subsite laadt. Wijzen die network-tabellen nog naar het oude domein, dan misdragen network admin, login-cookies en het aanmaken van subsites zich allemaal.
Hoe vind ik orphan uploads op een WordPress multisite?
Vergelijk du -sh van wp-content/uploads/sites met het attachment-aantal per subsite uit wp post list. Een groot gat betekent gegenereerde thumbnails of plugin-backups die zonder database-rij op schijf staan.
Is wp-cli search-replace veilig om over een multisite-database te draaien?
Ja, met --network, --precise en --skip-columns=guid, na een dry run. Het verwerkt geserialiseerde PHP-arrays correct, iets wat een rauwe SQL REPLACE stilletjes corrumpeert.