— Artikel — № 034

034 —Databases

Gecrashte MyISAM-tabel herstellen: een incidentverslag

Een WooCommerce wp_options-tabel crashte op shared hosting om 23:41. Negentig minuten, geen SSH, alleen phpMyAdmin en FTP. Het incident in volgorde.

Foto van bovenaf op linnen: papieren reparatielogboek wp_options, phpMyAdmin-print, MyISAM-schema, lakzegel.
Hero · gestileerd stilleven№ 034

Om 23:41 valt er een bericht binnen van een agency lead in Rotterdam waar we vaker mee werken. De WooCommerce-shop van haar klant, een meubelwinkel met zo'n €40k omzet per week, geeft een white screen. Het hostingpaneel is van zo'n goedkope Nederlandse reseller die geen SSH uitdeelt. Als ze eindelijk de PHP error log vindt, staat er:

mysqli_real_connect(): (HY000/144): Table './shop_db/wp_options' is marked as crashed and last (automatic?) repair failed

Dit is MyISAM-corruptie. Het soort dat in 2008 om de andere dinsdag voorkwam en nu alleen nog gebeurt op verouderde sites waar niemand meer naar omkijkt. De volgende negentig minuten waren een doorloop van wat je doet als je geen SSH hebt, mysqld niet kunt herstarten, en je het je niet kunt veroorloven rijen te verliezen. Niets ervan vroeg om iets ingewikkelders dan FTP en phpMyAdmin.

Triage in de eerste vijf minuten

Voordat je iets aanraakt: scheid symptomen van oorzaak. Een white screen in WordPress kan uit minstens vijf hoeken komen: een fatale PHP-fout, een uitgeput geheugenlimiet, een misdragende plugin-update, een database-storing, of DNS dat naar de verkeerde server wijst. De error log in dit incident wees al naar MySQL, maar het eerste instinct van de agency lead was om plugins te deactiveren door wp-content/plugins via FTP te hernoemen. Dat zou niets hebben uitgehaald tegen een gecrashte index, en het had de echte oorzaak kunnen maskeren. Lees eerst de log.

Als wp-config.php WP_DEBUG_LOG uit heeft staan en de host geen PHP error log toont in het paneel, herstellen twee regels bovenaan het bestand de zichtbaarheid:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );

Ververs de falende URL één keer. wp-content/debug.log bevat nu hoe de PHP-fatal er ook uitziet. Verwijder die twee regels zodra je je error-string hebt; ze in productie laten staan logt elke notice die elke plugin uitstoot, en het logbestand groeit snel op een drukke shop.

Het wrak lezen

De zinsnede 'marked as crashed and last (automatic?) repair failed' vertelt je twee dingen. Ten eerste: de eigen myisam-recover-options van de server zijn al afgegaan en hebben opgegeven. Ten tweede: de tabel zit nu vast in een needs-repair-staat, dus elke volgende query geeft dezelfde fout in plaats van bovenop corrupte data te lezen. Dat is een zegen; het betekent dat er geen nieuwe data nog in een corrupte index wordt geschreven terwijl jij onderzoekt.

Bevestig op welke engine de tabel draait. Elke shared host zet phpMyAdmin ergens; open de database en kijk naar de Engine-kolom. Als wp_options MyISAM laat zien, geldt deze post. Is hij door een eerdere developer stilletjes naar InnoDB gemigreerd, dan heb je een ander probleem en zal het draaiboek hieronder je misleiden.

Voordat je iets destructiefs doet, draai eerst een check:

CHECK TABLE wp_options EXTENDED;

Is het resultaat status: OK, dan is de tabel in orde en was de corruptie tijdelijk, wat soms gebeurt na een korte disk-cache flush-fout. Vaker zie je Corrupt, Size of indexfile is: 12345 Should be: 67890, of Found block with too small length at position N. Alle drie betekenen in de praktijk hetzelfde: de index loopt niet meer synchroon met het databestand, en een reparatie is nodig.

De site in onderhoud zetten

Repareer niet terwijl de site verkeer ontvangt. Elke pageview die wp_options raakt terwijl jij werkt, houdt een lock betwist en kan de reparatie halverwege de rebuild onderbreken. Twee minuten met een .htaccess in de document root kopen je een schone werkomgeving:

RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.45$
RewriteCond %{REQUEST_URI} !^/maintenance\.html$
RewriteRule .* /maintenance.html [R=503,L]

ErrorDocument 503 /maintenance.html
Header always set Retry-After "1800"

Vervang 203.0.113.45 door je eigen IP. De 503 met een Retry-After-header is correcte HTTP-semantiek; de crawler van Google behandelt de downtime als gepland en haalt de site niet uit de index. De officiële Google-richtlijn over geplande downtime is sinds 2011 hetzelfde gebleven en blijft het juiste antwoord.

Upload via FTP een maintenance.html van één pagina naast de rewrite-regel. De agency lead in dit incident gebruikte één gestileerde alinea in het Nederlands: "Onze webshop is even offline voor onderhoud. We zijn rond middernacht weer open." In totaal vijf minuten.

Stuur de klant terwijl de onderhoudspagina live staat één kort bericht met uitleg over wat ze zien. "Database-tabel heeft reparatie nodig, site staat op pauze zodat we geen bestellingen verliezen, ETA middernacht" is genoeg. De shop-eigenaar die de 503 ziet voordat jij het vertelt, is een shop-eigenaar wiens vertrouwen je nu samen met de tabel mag heropbouwen.

Eerst repareren via phpMyAdmin

De link 'Repair table' in phpMyAdmin draait onder de motorkap REPAIR TABLE. Veilig om te herhalen, dus probeer hem altijd voordat je iets op bestandsniveau doet:

REPAIR TABLE wp_options;

Drie uitkomsten doen ertoe.

status: OK. De tabel is gerepareerd. Verwijder de .htaccess-onderhoudsblokkade, open de homepage, kijk twee minuten naar de access log, en je bent klaar.

Number of rows changed from 4421 to 4378. Er zijn rijen verloren. Voor wp_options is dat meestal te overleven, omdat de meeste van die rijen autoloaded transient cache-waarden zijn die WordPress bij de volgende request opnieuw opbouwt. Doe een sanity-query tegen de site-URL en het admin-emailadres, zodat je weet dat er niets cruciaals is verdampt:

SELECT option_name, option_value
FROM wp_options
WHERE option_name IN ('siteurl', 'home', 'admin_email', 'blogname', 'template', 'stylesheet');

Cannot create file './shop_db/wp_options.TMD' (Errcode: 28). De tempfs van de host zit vol. Deprimerend gangbaar op goedkope shared hosting. Mail de host, of leeg een te grote error_log in de document root via de File Manager, en draai de reparatie opnieuw.

Voor diepere corruptie, gebruik de uitgebreide vorm:

REPAIR TABLE wp_options EXTENDED;

Dit bouwt de index rij voor rij opnieuw op. Trager dan de standaard-pass en herstelt soms wat de standaard niet kan. Ongeveer vier op de vijf keer eindigt één van deze twee commando's het incident.

Bestandsherstel met myisamchk

Het vijfde geval is het lastige. REPAIR TABLE geeft zo vroeg een fout dat het de .MYI-header niet eens kan lezen, of het blijft in een loop hangen waarbij hetzelfde rij-aantal terugkomt zonder voortgang. Dan ga je naar bestandsniveau.

De engine slaat elke tabel op als drie bestanden in de database-directory:

  • wp_options.frm: het schema
  • wp_options.MYD: de data
  • wp_options.MYI: de index

Op shared hosting staan die ergens als /home/clientuser/var/lib/mysql/shop_db/ of zijn ze verstopt achter een cPanel-abstractie. Kun je rechtstreeks via FTP in de database-directory, haal dan alle drie de bestanden binnen. Zo niet, vraag de host om een hot copy; de meeste doen het als je de tabelnaam noemt en uitlegt dat je rauwe MYD/MYI-toegang nodig hebt. De ticket-formulering die consistent werkt is: "we hebben een bevestigde corruptie op tabel X, kunnen jullie de drie tabelbestanden uit /var/lib/mysql/dbname/ via SCP naar mijn FTP-ruimte zetten zodat we myisamchk lokaal kunnen draaien". De tool bij naam noemen signaleert dat je weet wat je doet en omzeilt de eerste lijn van de scripted support.

Installeer lokaal een MySQL-compatibele toolchain. Op macOS:

brew install mariadb

Draai vervolgens de reparatie tegen het indexbestand, niet tegen het databestand:

myisamchk -r -f --sort_buffer_size=512M wp_options.MYI

-r is recover, -f forceert voorbij kleine fouten, en een ruime sort buffer voorkomt dat de rebuild gaat thrashen op een grote tabel. Mislukt die pass, val terug op de tragere maar vergevingsgezindere safe-recover:

myisamchk -o --safe-recover wp_options.MYI

-o gebruikt de oude recovery-methode, die elke datarij afzonderlijk leest in plaats van de index te vertrouwen. Het kan tabellen redden die -r heeft afgeschreven. Verifieer het resultaat:

myisamchk wp_options.MYI
# Checking MyISAM file: wp_options.MYI
# Data records: 4421   Deleted blocks: 0
# myisamchk: MyISAM file wp_options.MYI is usable

Upload alle drie de bestanden opnieuw. Let op je FTP-client; sommige verbergen uppercase-extensies en uploaden alleen de lowercase .frm, waardoor de database in een slechtere staat achterblijft dan voorheen. Doe na het uploaden een flush via phpMyAdmin, zodat MySQL zijn gecachte file handles dropt en de net geschreven kopieën opnieuw opent:

FLUSH TABLES wp_options;

Waarom deze engine zo voorspelbaar uitvalt

MyISAM stamt uit 1996 en was de standaard storage engine van MySQL tot versie 5.5 in 2010. Hij heeft geen crash recovery, geen transacties, en table-level locking. Als mysqld halverwege een write doodgaat, en op een shared host met twintig luidruchtige buren gaat mysqld vaker dood dan de marketingpagina's van de hosting toegeven, is er geen journal om af te spelen. Het .MYI-indexbestand blijft achter met offsets waar het .MYD-databestand het niet meer mee eens is. MySQL merkt het bij de volgende read en markeert de tabel als crashed.

De corruptie is daarom geen teken van een unieke ramp. Het is de engine die doet wat de engine doet als hij onderbroken wordt. Elke verouderde site die nog op deze storage engine staat, is één mysqld-herstart verwijderd van hetzelfde incident, en daarom telt hardening meer dan het herstel zelf.

Hardening na het herstel

Laat de tabel niet op MyISAM staan. De oorspronkelijke corruptie is bijna altijd terug te voeren op één van drie oorzaken: de mysqld van de shared host crashte, de host had een stroomdip, of twee writes botsten op de table-level lock van de engine tijdens een plugin-update. InnoDB overleeft alle drie. Converteer hem:

ALTER TABLE wp_options ENGINE=InnoDB;

Voor een WordPress-database met veel oude MyISAM-tabellen, genereer de volledige sweep vanuit information_schema:

SELECT CONCAT('ALTER TABLE `', table_name, '` ENGINE=InnoDB;') AS stmt
FROM information_schema.tables
WHERE table_schema = 'shop_db'
  AND engine = 'MyISAM';

Kopieer de output-rijen, plak ze terug in het SQL-tabblad, en draai ze als batch. De meeste WordPress-installaties van voor 2013 hebben nog steeds een paar van zulke tabellen rondhangen, omdat elke mysqldump-restore-cyclus sindsdien de oorspronkelijke engine heeft bewaard. De WordPress developer notes over database engines bevelen InnoDB al meer dan tien jaar standaard aan.

Voeg daarna offsite back-ups toe. De ingebouwde Backup Wizard van cPanel draait hooguit wekelijks, en slaat de back-up op op dezelfde fysieke schijf die je net heeft laten zitten. UpdraftPlus voor WordPress en Akeeba voor Joomla pushen allebei naar S3, Dropbox of Backblaze voor een paar euro per maand. Zet het schema op nachtelijk voor de database en wekelijks voor bestanden. Zonder offsite-kopieën kost de volgende corruptie de klant een getal dat je niet in een excuusmail wilt schrijven.

Het Rotterdamse incident eindigde om 01:09. De extended REPAIR TABLE-pass herstelde de tabel netjes, zonder verlies van rijen; het pad op bestandsniveau bleef in reserve en was nooit nodig. De ALTER TABLE ... ENGINE=InnoDB-sweep draaide in minder dan een minuut op twaalf tabellen. De agency lead stuurde de shop-eigenaar om 01:11 een screenshot van de homepage en ging slapen.

Toen we Pier bouwden, kwamen we precies dit patroon steeds weer tegen: agencies die om middernacht andermans rommel opruimen, met niks anders dan FTP en phpMyAdmin in de hand. De manier waarop we het uiteindelijk hebben opgelost was de MySQL editor een repair-actie geven die eerst CHECK TABLE draait, het verschil in rij-aantal toont voordat je commit, en de .MYD en .MYI in de version history snapshot zodat de reparatie zelf omkeerbaar wordt.

Het kleinste wat je vandaag kunt doen: open phpMyAdmin op één klantsite die je een jaar niet hebt aangeraakt, draai SELECT table_name, engine FROM information_schema.tables WHERE table_schema = DATABASE();, en converteer elke overgebleven MyISAM-tabel naar InnoDB voordat hij om 23:41 op de verjaardag van iemand anders bij je crasht.

— Vragen —

Waarom faalde de auto-recovery van MySQL voordat ik erbij was?

Auto-recovery draait met beperkt geheugen en een strikte timeout. Een grote tabel of een diep corrupte index overschrijdt beide. Een handmatige REPAIR TABLE EXTENDED of myisamchk slaagt vaak waar de automatische pass opgaf.

Kan ik de tabel repareren zonder de site offline te halen?

Nee. Elke pageview die een gecrashte MyISAM-tabel raakt, betwist de lock en kan de reparatie onderbreken. Een 503-onderhoudspagina van tien minuten is goedkoper dan een half opnieuw opgebouwde index en een tweede incident.

Moet ik daarna elke MyISAM-tabel naar InnoDB converteren?

Voor WordPress, Joomla en Drupal in 2026: ja. InnoDB heeft row-level locking en crash recovery. De enige oude reden om MyISAM te houden was full-text search op legacy MySQL, en InnoDB ondersteunt FULLTEXT sinds 5.6.