106 —Databases
mysqlbinlog row events lezen: een praktische veldgids
Om 23:41 pingt een agency-lead: iemand swapte wp_options.siteurl, de weblogs zijn schoon. Zo lees je mysqlbinlog row events en vind je ze terug.
Om 23:41 dropt een agency-lead een Loom in ons gedeelde kanaal. Twee minuten van iemand die door de WordPress-admin van een Nederlandse retailer scrollt. De site laadt assets vanaf het verkeerde domein. wp_options.siteurl is omgezet naar een door de aanvaller gecontroleerde URL, en de vraag aan de lijn is simpel: welk proces heeft het gedaan, wanneer, en wat kan mysqlbinlog ons erover vertellen.
De site draait al negen jaar. Zes admin-users, drie deploy keys, een Jenkins-bak, een cron worker, en één ontwikkelaar die in maart vertrok en misschien nog een sleutel heeft. Geen van de applicatielogs toont de wijziging. De web access log is schoon. De enige plek waar de wijziging met zekerheid bestaat, is de MySQL binary log.
Dit is de post die ik al lang van plan was te schrijven voor precies dat moment. Een veldgids voor de row-format events die mysqlbinlog wegschrijft, zodat je de rij, het tijdstip, de connectie en idealiter de mens erachter kunt vinden zonder drie uur kwijt te zijn aan het format vanaf nul leren.
De vier events die de wijziging dragen
Als je server draait met binlog_format=ROW (de standaard sinds MySQL 5.7), wordt elke datawijziging weggeschreven als een kleine reeks event-types. Ze komen in paren: één descriptor, één payload.
- TABLE_MAP_EVENT: benoemt de tabel en de kolomtypes van de rijen die zo gaan komen. Zonder dit zijn de payload-events anonieme bytes.
- WRITE_ROWS_EVENT: de rij-image van één of meer
INSERTs. - UPDATE_ROWS_EVENT: zowel de BEFORE- als AFTER-image van één of meer
UPDATEs. - DELETE_ROWS_EVENT: de rij-image van één of meer
DELETEs.
Elk paar zit in een transactie-wrapper. Een GTID-event (of een ANONYMOUS_GTID-event), dan een QUERY-event met BEGIN, dan de rijen, dan een XID-event dat het commit. In de wrapper zitten de timestamps, het server id en het thread id. In de payload zit de data. Je hebt beide helften nodig om één write te begrijpen.
Het commando dat je echt wilt
De mysqlbinlog reference noemt een dozijn flag-varianten. In de praktijk wil je één basisrecept en pas je het venster aan. Ga ervan uit dat de binlog-bestanden in /var/log/mysql/ staan en roteren als mysql-bin.000142:
mysqlbinlog \
--base64-output=DECODE-ROWS \
--verbose \
--start-datetime="2026-06-10 23:30:00" \
--stop-datetime="2026-06-10 23:45:00" \
/var/log/mysql/mysql-bin.000142 \
> /tmp/window.sql
Twee flags doen het meeste werk. --base64-output=DECODE-ROWS zegt tegen mysqlbinlog dat het de ruwe base64-blobs waarin ROW-events zijn opgeslagen niet moet printen; zonder die flag is het bestand onleesbaar. --verbose zorgt dat het naast elk event pseudo-SQL als commentaar uitspuugt, zodat je kunt zien welke rij is gewijzigd en wat de waarden ervoor en erna waren.
Sla --start-datetime niet over op een drukke server. Eén binlog kan een gigabyte zijn, en de pseudo-SQL waarnaar het uitvouwt is veel groter. Als je het venster niet weet, draai dan eerst SHOW BINARY LOGS en versmal per bestand vóór je per tijd versmalt.
De pseudo-SQL lezen
In het venster-bestand zie je blokken zoals dit:
#260610 23:41:18 server id 1 end_log_pos 38421 CRC32 0x9af13c20
# Update_rows: table id 142 flags: STMT_END_F
### UPDATE `wp_db`.`wp_options`
### WHERE
### @1=1 /* INT meta=0 nullable=0 is_null=0 */
### @2='siteurl' /* VARSTRING(192) meta=192 nullable=0 is_null=0 */
### @3='https://klant.example/' /* VARSTRING(255) meta=255 ... */
### @4='yes' /* ENUM(1 byte) meta=63233 ... */
### SET
### @1=1
### @2='siteurl'
### @3='https://wp-content-cdn.click/'
### @4='yes'
Een paar dingen om op te merken.
De kolomnamen zijn @1, @2, @3, @4. mysqlbinlog kent je schema niet. Het TABLE_MAP_EVENT geeft alleen ordinale posities en types, dus die map je zelf terug met een snelle DESCRIBE wp_options;. Hier is @1 dus option_id, @2 is option_name, @3 is option_value en @4 is autoload.
Het WHERE-blok is de BEFORE-image. Standaard schrijft MySQL de volledige rij-image weg, en dat is precies wat je wilt tijdens forensisch werk. Zie je ooit alleen de primary key in WHERE, dan draait je server met binlog_row_image=MINIMAL en is het meeste bewijs verdampt. Check het met SHOW VARIABLES LIKE 'binlog_row_image'; en zet het op FULL voor alles dat klantgericht is.
De timestamp in de header is de commit-tijd, in de lokale tijdzone van de server. Het server id is het MySQL-replicatie-id, wat ertoe doet wanneer je een write achternazit die via een replica binnenkwam in plaats van vanaf je applicatieserver.
De verdachte versmallen
Het row-event vertelt je wat er is veranderd. Om te weten wie, kijk je naar de transactie-wrapper een paar regels hoger:
#260610 23:41:18 server id 1 end_log_pos 38302
# GTID last_committed=812 sequence_number=813 ...
SET @@SESSION.GTID_NEXT= 'a3f1c2d4-...:1042'/*!*/;
#260610 23:41:18 server id 1 end_log_pos 38380
# Query thread_id=4471 exec_time=0 error_code=0
SET TIMESTAMP=1812844878/*!*/;
BEGIN
/*!*/;
Die thread_id=4471 is goud waard. Als je MySQL draait met performance_schema aan (de standaard), en je hebt het incident betrapt binnen de levensduur van de connectie, kun je het correleren met performance_schema.threads en zo de aansluitende user, host en programmanaam zien. In de praktijk is de connectie meestal allang weg tegen de tijd dat iemand de binlogs leest, dus val je terug op de slow query log of de general log, als die liepen.
De exec_time is de wall time van het statement op de bron. Een single-row UPDATE met exec_time=0 vanaf een thread die alleen dat ene statement uitvoerde, is de handtekening van een script, niet van een admin die in phpMyAdmin rondklikt. Dat detail alleen al versmalde ons 23:41-incident van zes admins naar één deploy key, die achttien maanden eerder gekopieerd was naar een ongerelateerde GitHub Action en daarna vergeten.
Wanneer ROW-format niet genoeg is
Twee situaties waarin de row-events je tekort doen.
Ten eerste: binlog_row_image=MINIMAL. Sommige hostingmaatschappijen leveren MySQL-configs die op replicatie-throughput zijn afgestemd in plaats van op auditbaarheid. Het WHERE-blok bij elke update is dan alleen de primary key en het SET-blok alleen de gewijzigde kolommen. Je ziet wel wat de nieuwe waarde is, maar niet wat het verving. Draai je een legacy site voor andermans geld, zet dit dan op FULL.
Ten tweede: DELETE_ROWS_EVENT op een soft-deleted tabel. De rij-image staat in de binlog, dus je kunt de data herstellen, maar je moet de INSERT-statement met de hand reconstrueren uit de @1, @2, @3-toewijzingen. Eén keer oefenen op een staging-dump voordat je het om 02:00 nodig hebt, is de moeite waard.
Voor schemawijzigingen zijn ROW-events stil. Die gaan door als QUERY-events met de letterlijke DDL-string, dus grep -E 'ALTER|DROP|CREATE' window.sql werkt nog steeds als eerste pass wanneer iemand zegt: "de tabel is gewoon verdwenen".
Hoe dit eruitziet in ons eigen werk
Toen we Pier bouwden, was precies dit scenario de reden dat de MySQL editor standaard met een version history op rij-niveau wordt geleverd. We wilden dat de agency-lead om 23:41 door de writes van een enkele rij kon scrollen zonder terminal en zonder SSH-hop. De binlog blijft de canonieke bron van waarheid; Pier maakt het gangbare geval (één rij, laatste dertig dagen) één klik in plaats van drie commando's.
Het kleinste ding voor vandaag: draai SHOW VARIABLES LIKE 'binlog_row_image'; op de productie-database van je drukste legacy site. Geeft het MINIMAL terug, zet het dan vanavond op FULL in my.cnf en herstart de server tijdens je volgende onderhoudsvenster. De volgende keer dat iemand vraagt wie wat heeft veranderd, heb je een antwoord in plaats van een schouderophalen.
— Vragen —
Wat doet --base64-output=DECODE-ROWS precies?
Het zegt tegen mysqlbinlog dat het row-events moet uitspugen als leesbare pseudo-SQL-commentaren in plaats van de ruwe base64-encoded payload. Zonder die flag is het dump-bestand onleesbaar.
Waarom heten de kolommen @1, @2 in de output?
mysqlbinlog ziet alleen ordinale posities en types uit het TABLE_MAP_EVENT, niet het schema. Draai DESCRIBE op de tabel om @1, @2, @3 terug te koppelen aan echte kolomnamen.
Kan ik de gebruiker die een UPDATE draaide identificeren uit alleen de binlog?
Je krijgt thread_id, server_id, GTID en timestamp. De aansluitende user en host leven in performance_schema of de general log, dus je hebt meestal beide bronnen nodig om een mens te benoemen.