— Artikel — № 080

080 —Databases

wp_options van 7GB splitsen: een autoload-veldgids

Je wp_options-tabel is 7GB en wordt bij elke request geladen. Hier de SQL om de autoload-rijen te vinden die stilletjes 800ms kosten.

Bovenaanzicht op linnen: SQL-werkblad, wp_options-blauwdruk, manila kaarten, messing plaatje, rode lakzegel.
Hero · gestileerd stilleven№ 080

De Loom van de lead developer kwam binnen om 23:41. Een WooCommerce-site die hij had overgenomen van een ander bureau zat gemiddeld op 2,3 seconden tot first byte, de cache was warm, de queries zagen er prima uit, en het enige wat hij verdacht vond was een wp_options-tabel van 6,8GB op schijf. Hij had de slow query log gecheckt. Niets daarin wees op een dader. Alleen de autoload-rijen waren al meer dan 400MB.

Dit is de meest voorkomende vorm van een wp_options-probleem. Het komt niet bovendrijven als een trage query, want het is geen trage query: het is één snelle query die bij elke request een enorme payload terug naar PHP sleept. Zodra die payload een paar megabytes wordt, verschuift de kost van de database naar maybe_unserialize() en PHP's geheugenallocator, waar bijna geen enkele profiler hem vangt zonder dat je er met je vinger op wijst.

De autoload-kolom goed lezen

De wp_options-tabel heeft een kolom autoload. Bij het booten van WordPress wordt er één query gedraaid die alle rijen waar autoload aanstaat in een geserialiseerde array genaamd alloptions trekt, en die array vervolgens cached voor de request. Die array is wat je PHP-proces door het geheugen sleept nog vóórdat WordPress heeft bepaald welke pagina je hebt opgevraagd.

De kolom gedraagt zich als een boolean, ook al is het type varchar(20). De meeste rijen zijn yes of no. Sommige plugins begonnen rond 2022 met on en off te schrijven, en WordPress 6.6 introduceerde auto-on en auto-off zodat core rijen automatisch kan promoveren en degraderen. Je query moet ze allemaal aankunnen, anders liegt hij tegen je.

De query die de ruis bovenhaalt

Dit is degene die je in je snippet manager wil bewaren:

SELECT
  option_name,
  LENGTH(option_value) AS size_bytes,
  autoload
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on')
ORDER BY size_bytes DESC
LIMIT 40;

Draai hem tegen een recente dump. De top 40 verklaart meestal vrijwel het hele gewicht. Je ziet vier patronen terugkeren:

  • Een plugin die een transient zonder vervaldatum heeft weggeschreven, en die dus nooit is opgeruimd.
  • Een logging-plugin die alle entries in één geserialiseerde array dumpt.
  • Een A/B-test- of analyticstool die een dagelijkse snapshot wegschreef en de oude nooit opruimde.
  • Een losse _transient_timeout_*-rij die zijn tegenhanger is kwijtgeraakt en nu onsterfelijk is.

Als je liever de totalen ziet dan de losse rijen:

SELECT
  autoload,
  COUNT(*) AS rows_,
  ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS mb
FROM wp_options
GROUP BY autoload;

Een gezonde tabel zit meestal onder de 1MB autoload, verdeeld over een paar honderd rijen. Alles boven de 3MB autoload verdient een middag werk.

Rijen verplaatsen zonder de plugin te slopen

De reflex is om autoload op no te zetten bij de boosdoeners en door te gaan. Dat werkt, maar alleen als de plugin de waarde uitleest via get_option(). Sommige plugins roepen wp_load_alloptions() rechtstreeks aan en gaan ervan uit dat hun waarde in de gecachte blob zit. Die gaan bij elke read naar de database zodra je de rij hebt omgezet, wat erger kan zijn dan het oorspronkelijke probleem.

De veiligere volgorde:

  1. Identificeer welke plugin of theme de rij bezit. De prefix van option_name verraadt het meestal (woocommerce_, yoast_, wpseo_, ai1wm_).
  2. Lees de plugin-source om te zien hoe die key wordt opgehaald. Grep de plugin-map op de letterlijke option name.
  3. Gebruikt hij get_option()? Dan kun je met een gerust hart degraderen.
  4. Gebruikt hij wp_load_alloptions()? Open een issue bij de plugin en degradeer pas nadat ze het patchen.

De degradatie zelf is één statement:

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

Flush daarna de object cache zodat de verouderde blob bij de volgende request opnieuw wordt opgebouwd:

wp cache flush

Voor transient-rommel die je direct kunt weggooien dekken de WP-CLI transient docs de standaard schoonmaakacties. Begin met wp transient delete --all; die respecteert externe object caches en raakt de rijen waar het je echt om gaat niet aan.

De tabel eerlijk houden

Heb je de autoload-set eenmaal verkleind, dan staat de tabel zelf nog steeds op 6,8GB op schijf. MySQL geeft standaard geen ruimte terug aan het filesystem wanneer er rijen uit een InnoDB-tabel worden verwijderd. Je hebt twee opties.

De botte:

OPTIMIZE TABLE wp_options;

Dit herbouwt de tabel in place en herschrijft het bestand. Op een live site houdt het een metadata-lock vast voor de hele duur. Op een 7GB-tabel kan dat meerdere minuten zijn, waarin elke request die op wp_options landt moet wachten. Dat is een venster dat je bewust moet kiezen.

De vriendelijkere aanpak is pt-online-schema-change uit Percona Toolkit. Die kopieert de tabel naar een nieuwe terwijl writes via een trigger worden meegespiegeld:

pt-online-schema-change \
  --alter "ENGINE=InnoDB" \
  D=wordpress,t=wp_options \
  --execute

Het duurt langer in wandkloktijd, maar er zijn geen lange locks. De Percona docs behandelen de edge cases rond foreign keys en triggers, die op wp_options out of the box geen van beide van toepassing zijn.

Een wekelijkse check die drift vangt

Het onderhoudspatroon is kort. Eén keer per week, op elke legacy site waar je verantwoordelijk voor bent, draai je de top-40-query en de totalen-query en noteer je de cijfers. Wijkt het aantal MB autoload meer dan dertig procent af van je baseline, kijk dan naar wat er is veranderd. Nieuwe plugin, ander logging-niveau, een back-up die in options is gaan schrijven in plaats van naar een bestand. De meeste regressies komen door iets wat je met opzet hebt geïnstalleerd, dus de audit verdient zichzelf de eerste keer dat hij er één vangt al terug.

Toen we Pier bouwden liepen we tegen exact dit probleem aan op de tweede beta-site. Daarom opent de MySQL editor bij de eerste verbinding met een WordPress-database met een vastgepinde "largest autoloaded options"-query. De version history op elke UPDATE betekent dat een rij degraderen in productie een undo van één klik heeft als een plugin de volgende page load stuk gaat.

Open een SQL-shell tegen een staging-kopie van je traagste WordPress-site en draai bovenstaande top-40-query. De onderkant van je scherm vertelt je welke plugin je morgen moet doorlezen.

— Vragen —

Kan ik die enorme transient-rijen niet gewoon meteen verwijderen?

Begint de option name met _transient_, dan kun je hem weggooien en WordPress maakt hem bij de volgende call opnieuw aan. Voor alles anders: degradeer eerst autoload, kijk één request mee, en beslis dan.

Waarom laadt WordPress alle autoload-rijen vooraf in?

De aanname is dat de meeste ervan tijdens de request gelezen worden, en één SELECT is goedkoper dan tientallen. Die logica breekt zodra de geserialiseerde blob een paar megabytes per request wordt.

Sloopt OPTIMIZE TABLE iets?

Het houdt een metadata-lock op wp_options vast voor de hele duur, op een tabel van 7GB kan dat meerdere minuten zijn. Elke request die die tabel raakt moet wachten. Draai het in een rustig venster.