— Artikel — № 006

006 —Databases

WordPress MySQL queries: spiekbriefje voor legacy beheer

De handvol SQL-queries die een verouderde WordPress-site redden op dinsdagavond: URL-paar, autoload-audit, revisies opschonen, wachtwoord, transients.

Open houten kaartenbaklade op eiken bureau, messing labelhouder, waaiervormige indexkaarten, rood verzegeld papieren label.
Hero · gestileerd stilleven№ 006

Een klant mailt om 17:40 op een dinsdag. Ze hebben WooCommerce geüpdatet op een build uit 2018, en nu geeft wp-admin een wit scherm. Het error log is leeg. SSH geeft je een shell, maar alleen tot public_html. Je hebt phpMyAdmin, tien minuten, en de agency lead stelt al vragen in een ander kanaal. Het enige dat dit op tijd gaat oplossen is de MySQL-console.

Dit is het moment waarop de MySQL-queries die je écht uit je hoofd kent hun werk doen. Niet de elegante statements uit een cursus database-ontwerp. De handvol one-liners die dingen oplossen op een legacy WordPress-site die draait op 5.8 met PHP 7.4 achter een Litespeed-cache, met zeven jaar aan post-revisies en een postmeta-tabel zo groot als een klein meer.

Wat hieronder volgt is de werkset. Draai ze eerst op een kopie als de tabelgrootte je nerveus maakt, en maak een back-up van de rij die je gaat aanpassen. Altijd.

Het site-URL-paar

Na elke migratie bepalen twee rijen in wp_options of de site überhaupt laadt.

SELECT option_id, option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home');

Ze updaten lijkt triviaal:

UPDATE wp_options
SET option_value = 'https://new.example.com'
WHERE option_name IN ('siteurl', 'home');

Dat is het niet. Dit fixt de homepage en de login-redirect. Het fixt niet de URL's die hard-coded in post-content staan, widget-JSON, Customizer-mods of geserialiseerde plugin-instellingen. WordPress slaat veel state op als PHP-geserialiseerde strings, en zo'n string bevat zijn eigen byte-lengte. Een naïeve SQL search-and-replace breekt de length-prefix en corrumpeert de rij in stilte.

Voor de rest van de URL's gebruik je het search-replace commando van WP-CLI, dat eerst deserialiseert voordat het vervangt:

wp search-replace 'https://old.example.com' 'https://new.example.com' \
  --skip-columns=guid --all-tables-with-prefix

Is WP-CLI niet geïnstalleerd en laat de host je het niet installeren, dan is het Search Replace DB-script van interconnect/it de veilige terugvaloptie. Draai nooit een rauwe UPDATE ... SET ... REPLACE() over alle tabellen. Eens per week, ergens, doet iemand dat en zit zijn zaterdag een back-up terug te zetten.

Noodluik bij een vergrendelde admin

Als een plugin-update de admin sloopt en je niet bij /wp-admin komt om iets te deactiveren, is de brute-force-fix om de lijst met actieve plugins direct in de database leeg te halen.

SELECT option_value
FROM wp_options
WHERE option_name = 'active_plugins'\G

Kopieer die string ergens veilig op. Daarna:

UPDATE wp_options
SET option_value = 'a:0:{}'
WHERE option_name = 'active_plugins';

De waarde a:0:{} is een PHP-geserialiseerde lege array. De site laadt met nul plugins, je logt in, je reactiveert ze één voor één tot je de boosdoener vindt. Plak de opgeslagen waarde terug zodra je klaar bent, minus de kapotte plugin.

De autoload-audit

Elke rij in wp_options met autoload = 'yes' wordt op iedere request geladen. Op een gezonde site is dat zo'n 200KB. Op een vijf jaar oude WordPress met een kerkhof aan gedeactiveerde plugins kan dat oplopen tot 8MB, en je TTFB bestaat dan vrijwel volledig uit MySQL die option-blobs leest.

SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY size_bytes DESC
LIMIT 20;

En het kopgetal:

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

Veelvoorkomende boosdoeners die we tegenkomen op legacy WordPress-sites: rank_math_404_logs, jetpack_sync_*, woocommerce_admin_notes, oude elementor_*-revisiecaches, en elke optie die eindigt op _log of _queue. De meeste hebben geen enkele reden om te autoloaden. Zet ze uit:

UPDATE wp_options
SET autoload = 'no'
WHERE option_name = 'rank_math_404_logs';

Heeft de plugin de optie écht nodig, dan roept hij get_option() wel aan zodra dat moet. De kosten verschuiven van iedere request naar de paar requests die de waarde daadwerkelijk gebruiken.

Revisies opruimen

Post-revisies zijn handig totdat ze het niet meer zijn. Een redactionele site die sinds 2018 live staat met drie auteurs en ongelimiteerde revisies bestaat in onze ervaring voor 60 tot 85 procent uit revisierijen in wp_posts. Tel ze:

SELECT post_status, post_type, COUNT(*) AS n
FROM wp_posts
GROUP BY post_status, post_type
ORDER BY n DESC;

Verwijder revisies ouder dan 90 dagen:

DELETE FROM wp_posts
WHERE post_type = 'revision'
  AND post_modified < DATE_SUB(NOW(), INTERVAL 90 DAY);

Dat laat verweesde rijen in wp_postmeta achter die wijzen naar posts die niet meer bestaan. Ruim die op:

DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON pm.post_id = p.ID
WHERE p.ID IS NULL;

Beperk daarna toekomstige revisies in wp-config.php zodat de tabel niet weer aangroeit:

define( 'WP_POST_REVISIONS', 5 );

Wachtwoord resetten, rollen opzoeken, transients opvegen

Drie kleine MySQL-queries die je vaker gebruikt dan de grote.

Een admin-wachtwoord resetten zonder mailtoegang:

UPDATE wp_users
SET user_pass = MD5('temporary-pw-change-me')
WHERE user_login = 'admin';

WordPress accepteert een plain MD5-hash bij het inloggen en zet die bij succes in stilte om naar phpass. Dit is gedocumenteerd gedrag, geen achterdeur. Verander het wachtwoord direct daarna via de admin-UI.

Lijst elke gebruiker met administratorrol op een site die je net hebt overgenomen:

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered;

Staat er een admin-account tussen dat je niet herkent, zeker als het is geregistreerd tussen 02:00 en 05:00 UTC, dan ben je nu met security bezig, niet met onderhoud.

Veeg verlopen transients op (de meeste plugins ruimen die van zichzelf nooit op):

DELETE FROM wp_options
WHERE option_name LIKE '\_transient\_%'
   OR option_name LIKE '\_site\_transient\_%';

Zoek de grootste tabellen in de database via information_schema:

SELECT table_name,
       ROUND((data_length + index_length)/1024/1024, 1) AS mb,
       table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY mb DESC
LIMIT 10;

Negen van de tien keer is het antwoord wp_postmeta, wp_options of wp_actionscheduler_logs. De tiende keer is het een plugin waar je nog nooit van hebt gehoord die elke pageview wegschrijft naar een tabel zonder index, en daar begint het gesprek met de klant of die plugin überhaupt nog op de site thuishoort.

Het spiekbriefje eerlijk houden

Geen van deze MySQL-queries is slim. Het zijn de queries die je pakt als een legacy site in brand staat en de enige tool die je vertrouwt al op de server stond toen de site in 2014 live ging. De vaardigheid zit in weten welke je draait en in welke volgorde, niet in het uit het hoofd kennen van obscure JOIN-trucs.

Toen we Pier bouwden liepen we hier bij onze eigen klanten precies tegenaan: mensen die deze queries vanuit phpMyAdmin draaiden en maar hoopten dat ze de WHERE-clause goed hadden getypt. Hoe we het uiteindelijk oplosten: elke UPDATE en DELETE loopt door de ingebouwde MySQL-editor met een preview op rij-niveau, en van elke aangeraakte rij houdt de version history een snapshot bij, zodat een verkeerde WHERE-clause een revert van twee klikken is in plaats van een zaterdag back-ups terugzetten.

Het kleinste wat je vandaag kunt doen: open de database van de legacy site die je het meest onderhoudt, draai de autoload-audit-query en screenshot de top vijf rijen. Je vindt er bijna altijd een plugin tussen die je was vergeten en die op elke request rustig telemetrie in option-storage zit te lekken.

— Vragen —

Kan ik deze queries draaien op een live WordPress-productiesite?

SELECT-statements zijn veilig. Voor UPDATE en DELETE maak je eerst een back-up van de betreffende rijen, draai je binnen een transactie als je engine dat ondersteunt, en zorg je dat je een werkende database-dump hebt van binnen het laatste uur.

Waarom fixt het updaten van siteurl en home niet alle URL's na een migratie?

WordPress slaat plugin-instellingen, widgets en Customizer-mods op als PHP-geserialiseerde strings met een byte-lengte-prefix. Een rauwe SQL-replace breekt die prefix en corrumpeert de rij. Gebruik in plaats daarvan WP-CLI search-replace.

Is het veilig om alle post-revisies te verwijderen?

Revisies ouder dan 90 dagen verwijderen was veilig op elke site die we hebben aangeraakt. Redacteuren verliezen de mogelijkheid om specifieke oudere drafts terug te halen, maar de huidige post-content en recente bewerkingsgeschiedenis blijven intact.