— Artikel — № 084

084 —Databases

WordPress-audit in SQL: acht snippets uit ons spiekbriefje

De wp_options-tabel is 480MB. wp-admin laadt in negen seconden. Je nam het project vorige dinsdag over. Dit zijn de acht queries die we als eerste draaien.

Foto van bovenaf op linnen: handgeschreven SQL-spiekbrief, wp_options-schema, manilamap, koperen AUDIT-plaatje, stopwatch, lakzegel.
Hero · gestileerd stilleven№ 084

Overgenomen project. Een WordPress-installatie die sinds 2011 draait, in 2019 voor het laatst aangeraakt door een freelancer, en nu staat op een shared host waar wp-admin er negen seconden over doet om het dashboard te renderen. In het overdrachtsdocument staat "komt goed, gewoon patchen." Niets over de database. Niets over de wp_options-tabel van 480MB.

Op elke laptop staat een bestand met de naam wp-audit.sql. Acht fragmenten, allemaal read-only, allemaal veilig om op productie te draaien met een warme koffie binnen handbereik. Ze beantwoorden de drie vragen waar een audit van een legacy WordPress-site in de praktijk mee begint: waar zit het gewicht, wat is een weeskind, en wat hebben zes verschillende ontwikkelaars in het schema achtergelaten.

De autoload-stapel

Bij elke page load van een WordPress-site worden alle rijen uit wp_options opgehaald waar autoload = 'yes'. Een schone install zit ergens onder de 1MB. Sites die wij doorlichten zitten standaard op 40MB, 80MB, soms 200MB. De oorzaak is bijna altijd een plugin die ooit een gigantische geserialiseerde array heeft weggeschreven en die nooit heeft opgeruimd.

Fragment 1 is het kopgetal:

SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS autoload_mb
FROM wp_options
WHERE autoload = 'yes';

Komt daar iets boven de 3 uit, dan vist fragment 2 de boosdoeners op:

SELECT option_name, ROUND(LENGTH(option_value)/1024, 1) AS kb
FROM wp_options
WHERE autoload = 'yes'
ORDER BY LENGTH(option_value) DESC
LIMIT 25;

Wat naar boven komt: een SEO-plugin die een sitemap-blob cachet, een backup-plugin die logs in één option wegschrijft, een page builder die al lang verwijderd is en 18MB heeft achtergelaten. De documentatie van wp_load_alloptions is het herlezen waard zodra je de lijst ziet, want die legt uit waarom een option van 200KB je bij elke request iets kost, niet alleen bij de request die hem schreef.

Transients die nooit afsterven

Transients zijn de armemansvariant van een cache in WordPress. Ze horen te verlopen. Dat doen ze vaak niet, want de cron job die ze opruimt stopt met draaien zodra DISABLE_WP_CRON aanstaat en niemand de moeite neemt om een echte cron op te zetten.

Fragment 3 vindt de verlopen timeouts:

SELECT COUNT(*) AS dead_transients
FROM wp_options
WHERE option_name LIKE '\_transient\_timeout\_%' ESCAPE '\\'
  AND option_value < UNIX_TIMESTAMP();

Op één site (een Magento-storefront met WordPress eraan vastgeplakt voor de blog) gaf dit 412.000 terug. Elke rij op zichzelf was minuscuul. Samen trokken ze elke admin-query door een dikke table scan heen. De pagina over de Transients API beschrijft de lifecycle als je jezelf wilt herinneren waarom de cleanup nooit liep.

Revisions, de stille groeier

WordPress bewaart post revisions voor altijd, tenzij je WP_POST_REVISIONS instelt. Op een site met 12.000 gepubliceerde posts zien we standaard 180.000 revisie-rijen.

Fragment 4 zet ze op volgorde:

SELECT post_parent, COUNT(*) AS revisions
FROM wp_posts
WHERE post_type = 'revision'
GROUP BY post_parent
ORDER BY revisions DESC
LIMIT 20;

Eén post zit doorgaans boven de 600. Het is altijd een homepage waar een redacteur twee jaar lang aan heeft zitten schaven. Goed om te weten voordat je een opruimactie voorstelt, want die redacteur wil ze misschien terug.

Wezen die niemand opmerkte

Plugins worden verwijderd. De rijen die ze in wp_postmeta en wp_usermeta hebben weggeschreven blijven staan. Na tien jaar verloop kan het aantal wezen groter zijn dan het aantal levende rijen.

Fragment 5, verweesde postmeta:

SELECT COUNT(*) AS orphan_postmeta
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;

Fragment 6, verweesde usermeta:

SELECT COUNT(*) AS orphan_usermeta
FROM wp_usermeta um
LEFT JOIN wp_users u ON u.ID = um.user_id
WHERE u.ID IS NULL;

Komt er bij één van beide een getal met zes cijfers uit, dan heb je een flink stuk van je trage queries te pakken. Een Nederlands bureau waar we mee werken gaf ons een site met 2,1M verweesde postmeta-rijen van een CRM-plugin die in 2017 was verwijderd. Het verwijderen ervan bracht het post-edit scherm terug van 7 seconden naar 1,4.

Schemadrift

Twee dingen gaan mis na jaren van migraties: tabellen belanden op verschillende storage engines, en ze belanden op verschillende charsets. Allebei zorgen later voor verrassingen.

Fragment 7 toont de niet-InnoDB-tabellen:

SELECT table_name, engine
FROM information_schema.tables
WHERE table_schema = DATABASE()
  AND engine <> 'InnoDB';

MyISAM-tabellen in een WordPress-installatie in 2026 zijn een verklikker. Meestal is het een eigen analytics-tabel uit 2014 die niemand heeft gemigreerd. Ze ondersteunen geen foreign keys, ze herstellen niet netjes na een crash, en ze locken bij writes de hele tabel. De eigen MySQL-documentatie maakt dat punt al jaren.

Fragment 8 vangt collation-drift op:

SELECT table_collation, COUNT(*) AS tables
FROM information_schema.tables
WHERE table_schema = DATABASE()
GROUP BY table_collation;

Komen er meer dan één rij uit, dan heb je tabellen in utf8mb4_unicode_ci staan en tabellen die nog op utf8_general_ci staan. JOINs over die grens heen forceren impliciete conversies en passeren je indexes geruisloos. Beter rechttrekken voordat je de volgende feature uitrolt, niet erna.

Dit draaien zonder productie aan het schrikken te maken

Alle acht fragmenten zijn read-only. Geen ervan schrijft, lockt of blokkeert. Het blijven echte queries tegen een echte database, dus:

  • Draai ze tegen een read replica als je die hebt. Heb je die niet, draai ze dan in een rustig uur.
  • De queries tegen information_schema kunnen traag zijn op shared hosting waar de metadata enorm is. Schrik niet als fragment 7 er 30 seconden over doet.
  • Houd het bestand in versiebeheer. De fragmenten groeien mee zodra je op nieuwe sites nieuwe failure modes vindt.

Toen we Pier bouwden liepen we op vrijwel elke legacy site die we aanraakten tegen dezelfde lus aan: database open, queries draaien, rijen naar een document, beslissen wat eruit moet. De MySQL editor in Pier zet een chatlaag over deze snippets heen, zodat je gewoon kunt vragen "wat vreet mijn autoload op", het fragment met de rijen terugkrijgt, en een version history-regel overhoudt voor alles waar je op acteert.

Het kleinste dat je vandaag kunt doen: kopieer de autoload-byte-query naar een bestand met de naam wp-audit.sql en draai die op de volgende legacy WordPress-site die op je bureau belandt. Welk getal er ook uitkomt, het vertelt je hoeveel van de rest van het bestand je nodig gaat hebben.

— Vragen —

Zijn deze queries veilig om op een live productiedatabase te draaien?

Ja. Alle acht zijn SELECT-only, geen locks, geen writes. De twee die information_schema raken kunnen traag zijn op shared hosting met overvolle metadata, dus kies een rustig uur.

Hoe zit het met de wp_postmeta-indexes die de wezen-join beïnvloeden?

post_id is in het standaard WordPress-schema geïndexeerd, dus de LEFT JOIN is goedkoop. Heeft een oude plugin die index gedropt, dan doet de query een full scan. Controleer eerst SHOW INDEX FROM wp_postmeta.

Kan ik niet gewoon WP-Optimize installeren en het laten opruimen?

Prima voor een opruimactie in één klik, maar het verbergt wat het verwijdert. Draai eerst de SELECT-kant van deze fragmenten, dan weet je hoe groot en hoe vormgegeven is wat er zo verdwijnt.