069 —Databases
wp_postmeta opschonen: 4,2M rijen weg zonder ACF te slopen
Bij een Nederlands bureau passeerde wp_postmeta de 4,2 miljoen rijen en begon de WordPress-admin uit te vallen. Dit is de prune die ACF heel liet.
Op de Loom-opname stond een WordPress-admin veertien seconden lang naar een spinner te kijken voordat de berichtenlijst eindelijk verscheen. De bureaulead nam het op om 23:41 op een dinsdag, nadat een klant had geklaagd dat de redactie er tijdens de deadlineweek steeds uit gegooid werd. Het ging om een contentzware uitgever op WordPress 6.4 met een afgestelde MariaDB-bak. De CPU op de database-server zat op vier procent te idlen. De bottleneck was wp_postmeta.
SHOW TABLE STATUS LIKE 'wp_postmeta' gaf een rijaantal van boven de 4,2 miljoen op een site met ruwweg 8.400 gepubliceerde posts. Die verhouding, ongeveer vijfhonderd meta-rijen per post, was de smell. Dit artikel loopt door wat we vonden, de ACF-valkuil die de cleanup bijna erger maakte dan de bloat zelf, en de SQL die we uiteindelijk hebben gedraaid op de verouderde site van een Nederlands bureau, vorige maand.
De audit vóór de prune
Het opschonen van wp_postmeta is een van die klussen waarbij het draaien van de cleanup het makkelijke deel is. Het risicovolle deel is exact weten wat je gaat verwijderen. Voordat we iets aanraakten draaiden we drie telqueries op een read replica.
Wezen-meta op posts die niet meer bestaan:
SELECT COUNT(*) AS orphan_rows
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
Dit kwam terug met 612.034 rijen. Twee plugins (de ene was een oude object-cache plugin die in 2022 verwijderd was, de andere een al lang vervangen SEO-plugin) hadden meta geschreven op post-delete hooks die ze bij uninstall niet opruimden. Beide waren al meer dan een jaar van de site af. Hun meta niet.
Meta die bij revisies hoort:
SELECT COUNT(*) AS revision_meta
FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_type = 'revision';
Nog eens 1,84 miljoen rijen. WordPress slaat ACF-waarden by design op bij elke revisie, en deze uitgever had redacteuren die compulsief drafts opsloegen. Vermenigvuldig 8.400 posts met gemiddeld veertien revisies per stuk, vermenigvuldig dat met zestien ACF-velden per template, en je snapt het plaatje.
De derde query was een meta_key-distributiescan, doorgaans dé query die je vertelt welke plugin de boosdoener is:
SELECT meta_key, COUNT(*) AS n
FROM wp_postmeta
GROUP BY meta_key
ORDER BY n DESC
LIMIT 30;
De top van de lijst zag er op hun site ongeveer zo uit:
meta_key | n
----------------------------|--------
_edit_lock | 412,103
_edit_last | 408,776
_wp_old_slug | 287,442
rocket_clean_post | 211,008
_yoast_wpseo_focuskw_text | 198,540
_acf_changed | 91,221
hero_image | 84,610
_hero_image | 84,610
De rocket_clean_post-rijen hoorden bij een plugin die in 2023 was gedeïnstalleerd. De _wp_old_slug-rijen waren opgestapeld door jaren van slug-wijzigingen en waren technisch nog nuttig, dus die lieten we staan. De overige circa 1,7 miljoen rijen waren echte, actuele post-meta. Ruwweg zestig procent van de tabel was terug te winnen. Voordat er ook maar één DELETE-statement liep maakten we een hot copy:
mysqldump --single-transaction --quick \
--triggers --routines \
agency_db wp_postmeta wp_posts \
| gzip > postmeta_backup_2026-05-13.sql.gz
Voor een site van dit formaat duurde de dump elf minuten en leverde een gecomprimeerd bestand van 380 MB op. Goedkope verzekering.
De ACF-val waar de meeste cleanup-scripts in trappen
Advanced Custom Fields slaat ieder veld op als twee rijen in wp_postmeta. Eén bevat de waarde. Eén bevat de verwijzing naar de velddefinitie. De meta_key van de referentie-rij begint met een underscore, en zijn meta_value is de group key van het veld, zoiets als field_5f8a1b2c3d4e5. ACF heeft het paar nodig om het veld op de front-end te renderen.
Concreet, voor één tekstveld genaamd hero_title krijg je:
meta_key | meta_value
--------------|-------------------------
hero_title | "Spring collection"
_hero_title | "field_5f8a1b2c3d4e5"
Repeater- en flexible content-velden vermenigvuldigen dit. Een repeater met zes rijen en vier sub-velden wordt 48 rijen in wp_postmeta, plus een index-counter rij, plus alle underscore-prefixed referenties. Drop één helft van zo'n paar en ACF kan het veld bij het renderen niet meer resolven. De ACF-docs zijn daar expliciet over: de underscore-key is wat de waarde adresseerbaar maakt voor de field group.
Het naïeve cleanup-script dat je op de meeste blogs vindt is dit:
DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
Die query is veilig voor het strikte wezen-geval. Als de parent-post weg is uit wp_posts verdwijnen beide helften van het ACF-paar mee, en ACF heeft sowieso nooit geweten dat de post bestond. Het is niet veilig als je het uitbreidt naar 'verwijder meta waarvan het veld niet in de actieve ACF-group staat'. Genoeg geldige paren leven op posts die je nog wilt houden, alleen met field keys die in de loop der jaren zijn hernoemd of opnieuw gegroepeerd in ACF. We hebben one-liner cleanup-snippets front-end blokken op productie zien slopen, drie dagen na de prune, op het moment dat een redacteur eindelijk een pagina opnieuw opsloeg die stilletjes gecachte HTML had uitgeleverd.
De prune die we hebben gedraaid
Met de back-up op zijn plek bestond de eigenlijke cleanup uit drie gebatchte DELETEs. We draaiden ze in een transactievenster waarin het redactieteam van het bureau veertig minuten paste deed, op zaterdagochtend.
Batch één, wezen-meta waar de parent-post weg is:
DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL
LIMIT 5000;
Deze wrapten we in een shell-loop die het statement opnieuw draaide tot ROW_COUNT() nul teruggaf. Batching is niet optioneel op een tabel van dit formaat. Eén ongebonden DELETE op 600k rijen houdt de table lock lang genoeg vast om elke concurrent INSERT (wat op WordPress letterlijk elke page save, comment of background cron is) te laten timeouten.
while :; do
rows=$(mysql -BN agency_db -e \
"DELETE pm FROM wp_postmeta pm \
LEFT JOIN wp_posts p ON p.ID = pm.post_id \
WHERE p.ID IS NULL LIMIT 5000; \
SELECT ROW_COUNT();")
echo "deleted $rows"
[ "$rows" -eq 0 ] && break
sleep 1
done
Batch twee, meta op revisies. De revisies zelf zijn nuttig, want zo herstellen redacteuren hun werk, en die wilden we niet droppen. Wat we wel wilden droppen was de ACF-meta die WordPress naar elke revisie had geklond, want die meta wordt op de front-end nooit opgevraagd:
DELETE pm FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_type = 'revision'
LIMIT 5000;
Zelfde shell-loop eromheen. Dit was de hoofdmoot, 1,84 miljoen rijen in ongeveer tweeëntwintig minuten op een 4-vCPU MariaDB-instance. We hielden SHOW PROCESSLIST in een andere tab in de gaten. Geen lock-wait timeouts aan de applicatiekant.
Batch drie, de named-key prune voor plugins die al lang gedeïnstalleerd waren. Plugin meta-keys zijn voorspelbaar, wat dit de veiligste van de drie maakt:
DELETE FROM wp_postmeta
WHERE meta_key IN (
'rocket_clean_post',
'rocket_lazyload_excluded',
'old_seo_plugin_score'
)
LIMIT 5000;
Na de laatste batch heroverde een OPTIMIZE TABLE wp_postmeta; ruwweg 1,6 GB aan schijfruimte en herbouwde de indexen. Op InnoDB komt dit neer op ALTER TABLE ... ENGINE=InnoDB, wat een nieuwe kopie van de tabel schrijft en die inwisselt. Reken op het dubbele van de tabelgrootte aan vrije schijfruimte tijdens de operatie, en draai het in het onderhoudsvenster met de site in read-only als je dat kunt regelen. De MySQL-docs leggen het exacte lock-gedrag uit en zijn de moeite van het lezen waard voordat je op een productie-primary op enter drukt.
Eindstand rijaantal: 1.742.803. De admin-berichtenlijst rendeerde in 0,6 seconden. De redactie kreeg om 11:47 weer toegang.
Het onderhoud dat nu nachtelijks draait
De prune is de eenmalige fix. De volgende vraag is hoe de tabel überhaupt zover gekomen is, zodat hij niet binnen achttien maanden weer volloopt. We hebben vier dingen ingericht.
Revisie-cap
WordPress houdt standaard onbeperkt revisies bij. Voor een site met ACF-zware templates is dat een postmeta-versterker. We hebben revisies per post afgetopt op tien in wp-config.php:
define( 'WP_POST_REVISIONS', 10 );
Tien was de voorkeur van het redactieteam van het bureau. Vijf is voor de meeste sites prima. De constante op false zetten schakelt revisies helemaal uit, wat we op een uitgever niet zouden aanraden. De cap geldt alleen voor revisies die na het zetten van de constante aangemaakt worden, dus de historische backlog moet je nog steeds eenmalig prunen.
Wezen-sweep, nachtelijks
Een klein WP-CLI-script draait om 03:00 servertijd en draait de orphan-check opnieuw. Vindt het meer dan duizend wezen, dan pruned het ze in batches van 5.000 rijen en pingt het Slack als het aantal boven de vijftigduizend uitkomt. Vijftigduizend is de ruwe grens voor 'een plugin misdraagt zich', en dat wil je weten op de dag dat het begint, niet bij de volgende kwartaalaudit.
wp db query "DELETE pm FROM wp_postmeta pm \
LEFT JOIN wp_posts p ON p.ID = pm.post_id \
WHERE p.ID IS NULL LIMIT 5000;"
Autoload-audit, wekelijks
Terwijl we in de database zaten merkten we dat wp_options 38 MB aan autoloaded data had, grotendeels van een analytics-plugin die API-responses cachte met autoload = 'yes'. Postmeta-bloat en options-bloat duiken meestal samen op, dus de wekelijkse job draait ook:
SELECT option_name, LENGTH(option_value) AS bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY bytes DESC
LIMIT 20;
Alles boven de 100 KB wordt voor review gevlagd. Autoloaded options worden bij elke request gelezen, dus voor time-to-first-byte telt dit zwaarder dan postmeta-bloat. Twee van de gevlagde rijen op deze site waren JSON-blobs achtergelaten door een font-loader plugin. Die gingen er in hetzelfde onderhoudsvenster uit.
Meta-key drift-alert
De vierde job is een wekelijkse diff van de meta_key-distributie tegenover een baseline-snapshot. Verschijnt er een nieuwe meta_key met meer dan vijftigduizend rijen, dan schrijft het script de key, het aantal en de top drie post-ID's naar een Slack-channel. Zo vang je een vers geïnstalleerde plugin die schrijft waar hij niet hoort, voordat het de audit van volgend jaar wordt.
Hoe we dit aan de Pier-kant auditen
Toen we Pier bouwden kwamen we dit patroon zo vaak tegen op klantsites dat we een postmeta-shape view in de MySQL editor hebben geschreven: rijaantallen gegroepeerd per meta_key, orphan-totalen, het aandeel revisies, de top autoloaded options, allemaal in één paneel. Elke destructieve query die we vanuit Pier draaien schrijft een back-up van de geraakte rijen naar de version history voordat de DELETE afgaat, dus de rollback is één toetsaanslag als de prune ernaast zat.
Als je nog niet naar je wp_postmeta hebt gekeken op de grootste WordPress-site die je beheert, draai de drie telqueries uit de auditsectie vanavond. Die ene read-only check vertelt je of je een postmeta-probleem hebt, en kost minder dan een minuut op de database.
— Vragen —
Sloopt het verwijderen van revisies ACF op de live-post?
Nee. Revisies zijn aparte rijen in wp_posts met post_type='revision'. De live-post en zijn huidige postmeta blijven ongemoeid, en ACF leest bij het renderen van de front-end alleen uit de huidige post.
Hoe groot hoort wp_postmeta normaal te zijn?
Ruwweg tien tot veertig meta-rijen per gepubliceerde post is normaal. Meer dan honderd per post op een site zonder zware ACF-repeaters wijst op wees-data of een plugin die schrijft waar hij niet hoort.
Is OPTIMIZE TABLE veilig op een live site?
Op InnoDB schrijft het een nieuwe kopie van de tabel en wisselt die in, wat ruwweg het dubbele aan schijfruimte vraagt en kort reads locked. Plan het in een onderhoudsvenster voor elke tabel boven een gigabyte.