— Artikel — № 066

066 —Databases

wp_posts opschonen: 600 dode CPT-rijen veilig wissen

Een staging-kopie, een kerkhof van 600 CPT-rijen, en vier foreign key-valluiken die een snelle DELETE veranderen in een week kapotte admin-schermen.

Bovenaanzicht van papieren SQL-werkblad, ER-blauwdruk, mapje, messing sleutel en plaatje, lakzegel op linnen.
Hero · gestileerd stilleven№ 066

Een Nederlands bureau waar we mee samenwerken stuurde vorige donderdag een staging-dump door met één regel in het bericht: "600 stuks. Geen enkele rendert nog ergens. Mogen we ze gewoon weggooien?" De 600 in kwestie waren rijen in wp_posts met post_type = 'event_legacy', een custom post type dat in 2021 was geregistreerd, twee jaar geleden uitgefaseerd, en sinds de laatste themarebuild niet meer in de codebase voorkwam. De rijen stonden er nog. De postmeta ook. De term relationships, de revisies, de zwevende comments, alles stond er nog. De kortste route naar een schone wp_posts-opschoning is niet DELETE FROM wp_posts WHERE post_type = 'event_legacy';. Dat commando laat vier andere tabellen achter met pointers naar rijen die niet meer bestaan.

Hieronder staat de exacte volgorde die we op een kopie van de database van het bureau hebben gedraaid. We gaan uit van een wp_-tabelprefix en InnoDB. Pas beide aan voordat je gaat plakken.

De schade in kaart brengen voor je DELETE draait

De eerste klus is de omvang vaststellen. Niet "hoeveel wp_posts-rijen" maar "hoeveel rijen er in elke tabel staan die naar die wp_posts-rijen wijzen." Op een typische WordPress-install zijn dat vijf tabellen: wp_posts zelf, wp_postmeta, wp_term_relationships, wp_comments, en wp_commentmeta. Revisies en autosaves staan binnen wp_posts met post_type = 'revision' en een post_parent die naar de bovenliggende rij wijst, dus die hebben een aparte sweep nodig.

SELECT post_type, post_status, COUNT(*) AS n
FROM wp_posts
WHERE post_type = 'event_legacy'
GROUP BY post_type, post_status;

SELECT COUNT(*) AS meta_rows
FROM wp_postmeta
WHERE post_id IN (SELECT ID FROM wp_posts WHERE post_type = 'event_legacy');

SELECT COUNT(*) AS term_links
FROM wp_term_relationships
WHERE object_id IN (SELECT ID FROM wp_posts WHERE post_type = 'event_legacy');

SELECT COUNT(*) AS revisions
FROM wp_posts
WHERE post_type = 'revision'
  AND post_parent IN (SELECT ID FROM wp_posts WHERE post_type = 'event_legacy');

SELECT COUNT(*) AS comments
FROM wp_comments
WHERE comment_post_ID IN (SELECT ID FROM wp_posts WHERE post_type = 'event_legacy');

Bij de site van het bureau kwamen de antwoorden binnen: 612 in wp_posts, 7.344 in wp_postmeta (gemiddeld twaalf meta keys per rij, inclusief de overhead van ACF), 1.820 in wp_term_relationships verspreid over drie taxonomieën die ook al waren uitgefaseerd, 0 comments, en 488 revisies. Totaal aangeraakte rijen: 10.264. Zonder de cascading deletes zouden 9.652 daarvan zwevers zijn geworden zodra wp_posts zijn rijen liet vallen.

Voor je iets verwijdert is ook dit nuttig: dump een handvol representatieve meta keys.

SELECT meta_key, COUNT(*) AS n
FROM wp_postmeta
WHERE post_id IN (SELECT ID FROM wp_posts WHERE post_type = 'event_legacy')
GROUP BY meta_key
ORDER BY n DESC
LIMIT 30;

Soms is een CPT met naam afgevoerd, maar zijn de meta keys hergebruikt voor een nieuwer type. Zie je _event_start-rijen die alleen aan event_legacy-posts hangen, prima. Hangen ze aan zowel event_legacy als event, dan is de meta key nog in gebruik en wil je alleen de rijen schrappen waarvan de post_id in de gedoemde set valt.

De vier tabellen die stilletjes breken

WordPress gebruikt geen foreign key-constraints. De $wpdb-abstractie behandelt elke relatie als een zachte pointer en vertrouwt erop dat de applicatie ze eerlijk houdt. Dat vertrouwen werkt prima tot je de applicatie omzeilt en in SQL gaat DELETEen. De vier pointers die zwevend achterblijven zijn:

  • wp_postmeta.post_id wijst naar wp_posts.ID. Zwevende meta-rijen zijn onschadelijk voor de front-end, maar ze duiken op in elke SELECT die de admin over postmeta draait en ze stapelen voor eeuwig op.
  • wp_term_relationships.object_id wijst naar wp_posts.ID. Zwevende rijen hier blazen wp_term_taxonomy.count op bij elke term, wat de bron is van de categorie- en tagtellingen in de admin. De getallen blijven fout staan tot wp_update_term_count_now() draait of je ze met de hand corrigeert.
  • wp_comments.comment_post_ID wijst naar wp_posts.ID. Zwevende comments zijn onzichtbaar in de admin-queue, maar ze blijven opduiken in elke plugin die zijn eigen comment-query draait.
  • wp_posts.post_parent op revisies, autosaves en bijlagen wijst naar de bovenliggende rij. Zwevende revisies kun je niet vanuit de admin opruimen, want er is geen bovenliggend scherm meer om te openen.

De volgorde van de deletes is belangrijk, omdat de lookups in stap één afhangen van het feit dat de bovenliggende rijen er nog staan. Verwijder eerst wp_posts en de IN (SELECT ID FROM wp_posts ...)-subqueries in de afhankelijke deletes geven een lege set terug, waardoor al het andere zwevend achterblijft. De oplossing is de volgorde omdraaien: eerst de afhankelijken, daarna de ouders.

De opschoning, op volgorde

Voordat hier ook maar iets van draait: maak een hot backup. mysqldump --single-transaction --quick wp_database > backup.sql op een transactioneel InnoDB-schema geeft je een consistente snapshot zonder writes te locken. Controleer dat het bestand niet leeg is en de tabelheaders bevat die je verwacht. Open daarna een transactie, zodat de hele reeks atomisch is.

START TRANSACTION;

-- 1. Cache the doomed IDs so every step uses the same set
CREATE TEMPORARY TABLE doomed_ids AS
SELECT ID FROM wp_posts WHERE post_type = 'event_legacy';

-- 2. Revisions and autosaves first (children of the parent rows)
DELETE FROM wp_posts
WHERE post_type IN ('revision', 'auto-draft')
  AND post_parent IN (SELECT ID FROM doomed_ids);

-- 3. Postmeta
DELETE FROM wp_postmeta
WHERE post_id IN (SELECT ID FROM doomed_ids);

-- 4. Term relationships (record the term_taxonomy_ids first, we need them)
CREATE TEMPORARY TABLE touched_tt AS
SELECT DISTINCT term_taxonomy_id
FROM wp_term_relationships
WHERE object_id IN (SELECT ID FROM doomed_ids);

DELETE FROM wp_term_relationships
WHERE object_id IN (SELECT ID FROM doomed_ids);

-- 5. Commentmeta then comments
DELETE FROM wp_commentmeta
WHERE comment_id IN (
  SELECT comment_ID FROM wp_comments
  WHERE comment_post_ID IN (SELECT ID FROM doomed_ids)
);

DELETE FROM wp_comments
WHERE comment_post_ID IN (SELECT ID FROM doomed_ids);

-- 6. The parent rows
DELETE FROM wp_posts
WHERE ID IN (SELECT ID FROM doomed_ids);

-- 7. Recount the term taxonomy counts we just invalidated
UPDATE wp_term_taxonomy tt
SET count = (
  SELECT COUNT(*) FROM wp_term_relationships tr
  WHERE tr.term_taxonomy_id = tt.term_taxonomy_id
)
WHERE tt.term_taxonomy_id IN (SELECT term_taxonomy_id FROM touched_tt);

COMMIT;

Twee dingen zijn het opmerken waard. Ten eerste de temp table. MySQL evalueert gecorreleerde subqueries op elke rij van een DELETE opnieuw, dus dezelfde SELECT ID FROM wp_posts WHERE post_type = 'event_legacy' in vijf statements stoppen zou vijf volledige scans betekenen tegen een tabel die onderwijl krimpt. De temp table cachet de set eenmaal. Ten tweede de hertelling in stap zeven. Sla je die over, dan blijft wp_term_taxonomy.count opgeblazen met hoeveel term relationships je net hebt verwijderd. De categoriewidget blijft "Events (612)" tonen tot een post in die categorie via de admin wordt opgeslagen.

Als de tabel te groot is om in één keer te verwijderen

Op de database van het bureau bevatte de oudertabel 1,4 miljoen rijen en draaide de opschoning in minder dan een seconde. Op een wp_posts van 60 miljoen rijen zal dezelfde DELETE een lange transactie open houden, de binlog opblazen, en replicatielag zichtbaar maken voor iedereen die meekijkt. Batch het dan. Het patroon dat werkt zonder de logica hierboven te veranderen, is om een LIMIT aan stap zes toe te voegen en te loopen tot ROW_COUNT() nul teruggeeft, waarbij je doomed_ids bovenaan elke batch opnieuw vult.

Controleren dat verder niets regresseerde

Draai dezelfde vijf count-queries van de audit nog een keer. Ze zouden over de hele linie nul moeten teruggeven, behalve wp_posts die nul rijen van post_type = 'event_legacy' zou moeten geven en het oorspronkelijke totaal van elk ander type. Voer daarna twee sanity checks uit die de audit niet ving:

-- Orphan postmeta with no parent row anywhere
SELECT COUNT(*) FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;

-- Orphan term_relationships with no parent row
SELECT COUNT(*) FROM wp_term_relationships tr
LEFT JOIN wp_posts p ON p.ID = tr.object_id
WHERE p.ID IS NULL;

Geeft een van die twee een getal anders dan nul, dan heb je zwevers van een eerder voorval, niet van dit. Goed om van te weten, niet om in paniek over te raken. Een aparte opschoning met hetzelfde patroon ruimt het op. De WordPress-documentatie voor wp_delete_post() somt precies op welke side effects de applicatielaag normaal afhandelt, een bruikbare checklist voor elke toekomstige uitfasering van een custom type.

Daarna: flush de object cache (wp cache flush als WP-CLI op de machine staat), genereer de sitemap opnieuw (Yoast, RankMath en de core-sitemap cachen allemaal de lijst met post types), en herbouw elke persistente zoekindex. ElasticPress, Algolia en Relevanssi houden allemaal hun eigen kopieën van wp_posts-rijen en blijven de verwijderde rijen in de zoekresultaten teruggeven tot ze opnieuw zijn geïndexeerd. Zet tot slot een 410 Gone-regel in .htaccess als de oude CPT-permalinks ooit door Google zijn geïndexeerd, zodat de crawler ze niet meer opvraagt.

Het bonnetje

Toen we Pier bouwden kwamen we dit patroon precies zo tegen op drie van de eerste tien verouderde sites die we openden, en de volgorde van de stappen was telkens het ding dat niemand had opgeschreven. Daarom zit de reeks audit, dan temp table, dan cascade nu als bewaarde query in de MySQL-editor die je tegen elk post_type kunt draaien, en elke stap landt als rij in de versiegeschiedenis, zodat de undo één klik is als iets stroomafwaarts klaagt.

Het kleinste wat je vandaag kunt doen: draai de vijf audit-queries tegen je eigen staging-kopie en kijk hoeveel zwevende rijen je al hebt liggen van opschoningen die niet in de juiste volgorde zijn gegaan.

— Vragen —

Waarom niet wp_delete_post() in een loop draaien in plaats van SQL?

Werkt prima op kleine sets, maar bij 600 rijen vuurt het voor elke delete hooks af, blaast het de object cache op, en draait het synchrone indexupdates. SQL met de cascade in de juiste volgorde is sneller en voorspelbaarder.

Werkt dit op WordPress Multisite?

Ja, maar draai het apart tegen de wp_N_posts, wp_N_postmeta en wp_N_term_relationships van elke site. Multisite geeft elke blog zijn eigen geprefixte kopie van de contenttabellen.

Wat met transients en rewrite rules die naar de CPT verwezen?

Flush rewrites met wp rewrite flush, verwijder daarna transients in wp_options die op de oude post_type keyen. De meeste rewrite caches regenereren bij de volgende admin-pagina.