In der Tabelle wp_options jeder WordPress-Installation ohne persistenten Object Cache können Zeilen liegen, die keine Funktion des Kerns mehr entfernt. Sie sind kein Sicherheitsproblem und sie stören auch nichts. Sie sind nur da, und sie bleiben da. Dieser Artikel dokumentiert eine Messung an WordPress 7.0.3 gegen eine Wegwerfinstallation unter PHP 8.3.33 und MariaDB 10.11.18. Alle Zeilennummern beziehen sich auf wp-includes/option.php dieser Version.
Der Zustand
Ein Transient mit Ablaufzeit besteht aus zwei Zeilen in wp_options: der Wertzeile _transient_<name> und der Timeout-Zeile _transient_timeout_<name>, die einen Unix-Zeitstempel enthält. Die beiden gehören zusammen, aber die Datenbank weiß nichts davon. Es gibt keinen Fremdschlüssel, keine Bedingung, nichts, was das Paar zusammenhält.
Verwaist ist eine Timeout-Zeile, deren Wertzeile fehlt. In der Tabelle sieht das unspektakulär aus:
| option_name | option_value |
|---|---|
| _transient_timeout_tr_b | 1786454604 |
Eine einzelne Zeile, ein Zeitstempel, sonst nichts. Kein _transient_tr_b daneben. Und keine API-Funktion, die sie noch entfernt — mit genau einer Ausnahme, zu der wir weiter unten kommen.
Wie sie entsteht — vier Wege
Der Request bricht mitten in set_transient() ab
Das ist der wichtigste Weg, weil WordPress den Zustand dabei selbst erzeugt. In set_transient() wird die Timeout-Zeile vor der Wertzeile geschrieben:
1546 if ( false === get_option( $transient_option ) ) {
1547 $autoload = true;
1548 if ( $expiration ) {
1549 $autoload = false;
1550 add_option( $transient_timeout, time() + $expiration, '', false );
1551 }
1552 $result = add_option( $transient_option, $value, '', $autoload );Zwischen Zeile 1550 und Zeile 1552 liegen zwei getrennte Schreibvorgänge ohne Transaktion. Bricht der Request dazwischen ab — fataler Fehler, überschrittene Ausführungszeit, erschöpfter Speicher, abgebrochene Verbindung —, ist die Timeout-Zeile bereits committet, und die Wertzeile kommt nie.
Nachgestellt wurde das mit einem exit im added_option-Hook, ausgelöst genau nach der Timeout-Zeile:
set_transient( 'crash', 'wert', 3600 ) wird aufgerufen…
[Request bricht hier ab, direkt nach der Timeout-Zeile]
Zustand danach: 1 Zeile
_transient_timeout_crash 1786455787
delete_transient( 'crash' ) => false
Zeilen danach: 1Nichts Exotisches ist dafür nötig. Ein PHP-Fatal in einem Plugin, das kurz nach einem set_transient() zuschlägt, reicht.
Der Name wird an varchar(191) abgeschnitten
Die Spalte option_name ist in wp-admin/includes/schema.php:143 als varchar(191) definiert. Beide Zeilen eines Paares müssen da hinein, aber ihre Präfixe sind unterschiedlich lang: _transient_ hat 11 Zeichen, _transient_timeout_ hat 19. Die Timeout-Zeile behält vom eigentlichen Namen also acht Zeichen weniger als die Wertzeile.
Bei einem langen Transient-Namen läuft die Timeout-Zeile deshalb acht Zeichen früher gegen die Grenze. Ab da passen Wert- und Timeout-Zeile nicht mehr zueinander, und das Paar fällt auseinander. Beschrieben ist das in Trac #15058 („Validate option and transient name lengths“, seit Jahren offen) und in Trac #58903 („set_transient() allows invalid transient name“). Beide Tickets behandeln die Ursachenseite — wie Paare entstehen, die nicht zusammengehören —, sagen aber nichts darüber, dass die Reste anschließend liegen bleiben.
Leere oder ungültige Transient-Namen
set_transient() akzeptiert auch leere Strings, null und false als Namen. Es entstehen dann Optionszeilen, die wörtlich _transient_ und _transient_timeout_ heißen, ohne angehängten Namen. Trac #58904 hält fest, dass delete_expired_transients() diese Zeilen nicht bereinigt. Der Melder war über einen Tippfehler im eigenen Plugin darauf gestoßen. Das Ticket ist seit Juli 2023 offen, ohne Kommentar und ohne Patch.
Direkte Eingriffe in die Datenbank
Aufräum-Plugins, Migrationsskripte, ein DELETE von Hand, ein teilweise eingespieltes Backup: Wer Zeilen aus wp_options entfernt, ohne beide Hälften zu berücksichtigen, erzeugt denselben Zustand. Das ist der einzige der vier Wege, der außerhalb von WordPress liegt.
Warum keine API sie beseitigt
delete_transient() bricht an der falschen Bedingung ab
Die entscheidende Stelle sind die Zeilen 1394 bis 1400:
1394 $option_timeout = '_transient_timeout_' . $transient;
1395 $option = '_transient_' . $transient;
1396 $result = delete_option( $option );
1397
1398 if ( $result ) {
1399 delete_option( $option_timeout );
1400 }$option ist die Wertzeile, $option_timeout die Timeout-Zeile. Fehlt die Wertzeile, gibt delete_option() in Zeile 1396 false zurück, die Bedingung in Zeile 1398 schlägt fehl, und Zeile 1399 wird nie ausgeführt. Die Timeout-Zeile bleibt stehen, delete_transient() gibt false zurück — und wird das für denselben Namen bei jedem weiteren Aufruf wieder tun.
Ein Detail, das oft falsch dargestellt wird, auch in unserer eigenen früheren Beschreibung: die Funktion liest den Wert nicht. Sie versucht, ihn zu löschen, und benutzt den Rückgabewert des Löschversuchs als Bedingung. Für die Wirkung ist der Unterschied folgenlos, für eine Ticketbeschreibung nicht — wer den Mechanismus falsch beschreibt, bekommt im Trac eine Rückfrage statt einer Zuweisung.
Gemessen wurde das mit drei Transients, bei einem davon wurde die Wertzeile per SQL entfernt:
| Schritt | Zeilen in wp_options |
|---|---|
| nach dem Setzen von tr_a, tr_b, tr_c | 6 |
| nach dem Entfernen der Wertzeile von tr_b | 5 |
| nach delete_transient() auf alle drei | 1 |
delete_transient( 'tr_a' ) => true
delete_transient( 'tr_b' ) => false
delete_transient( 'tr_c' ) => true
übrig: _transient_timeout_tr_bdelete_site_transient() verhält sich identisch. Dort steht dieselbe Konstruktion in Zeile 2529, mit delete_site_option() statt delete_option(). Der Ablauf wurde einzeln nachgestellt, in der Einzelinstallation über wp_options und nach wp core multisite-convert über wp_sitemeta: sechs Zeilen, dann fünf, dann eine. Übrig blieb _site_transient_timeout_st_b. Der einzige Unterschied ist die Tabelle.
Der Aufräum-Job sieht sie nicht
Naheliegender Einwand: Dafür gibt es doch delete_expired_transients(), das täglich per Cron läuft. Der Grund, warum das nicht hilft, steht in Zeile 1645. Das Statement ist ein Verbund über zwei Aliase derselben Tabelle:
DELETE a, b
FROM wp_options a, wp_options b
WHERE a.option_name LIKE '_transient_%'
AND a.option_name NOT LIKE '_transient_timeout_%'
AND b.option_name = CONCAT(
'_transient_timeout_', SUBSTRING( a.option_name, 12 )
)
AND b.option_value < <jetzt>a ist die Wertzeile, b die Timeout-Zeile. Ein Verbund ohne LEFT liefert nur Treffer, wenn beide Seiten existieren. Eine Waise hat kein a und taucht in der Ergebnismenge nie auf. Das gilt unabhängig vom Zeitstempel — auch eine längst abgelaufene Waise bleibt liegen.
Vier Fälle nebeneinander, damit sichtbar wird, dass die Funktion überhaupt arbeitet:
| Fall | Aufbau | Ergebnis |
|---|---|---|
| paar_abgelaufen | Wert + Timeout, Timeout in der Vergangenheit | entfernt — die Funktion arbeitet |
| paar_gueltig | Wert + Timeout, Timeout in der Zukunft | erhalten — richtig |
| waise_alt | nur Timeout, in der Vergangenheit | bleibt liegen |
| waise_neu | nur Timeout, in der Zukunft | bleibt liegen |
Von sechs Zeilen blieben vier übrig. Derselbe Test über den regulären Weg, mit vorher eingeplantem Hook:
$ wp cron event run delete_expired_transients
Executed the cron event 'delete_expired_transients' in 0.002s.
vorher: 4 Zeilen
nachher: 2 Zeilen (_transient_timeout_waise_alt, _transient_timeout_waise_neu)Der Job räumt verwaiste Timeouts also nicht auf, weder mit abgelaufener noch mit zukünftiger Ablaufzeit. Nebenbei: Eingeplant wird der Hook in wp-admin/admin.php:112, also erst, wenn jemand eine Seite im Administrationsbereich aufruft. Auf einer frisch installierten Testinstallation war er schlicht nicht geplant.
Die eine Ausnahme
Es gibt eine Stelle im Kern, die die Waise doch entfernt — get_transient(), Zeilen 1465 bis 1469:
1464 $timeout = get_option( $transient_timeout );
1465 if ( false !== $timeout && $timeout < time() ) {
1466 delete_option( $transient_option );
1467 delete_option( $transient_timeout );
1468 $value = false;
1469 }Hier wird nicht geprüft, ob die Wertzeile existiert. Beide delete_option() laufen bedingungslos. Gemessen:
| Aufruf | Rückgabe | Timeout-Zeile danach |
|---|---|---|
| get_transient( ‚waise_alt‘ ), abgelaufen | false | weg |
| get_transient( ‚waise_neu‘ ), Zukunft | false | noch da |
Das ist die wichtigste Einschränkung des ganzen Befundes, und sie gehört ausdrücklich dazu: Eine Waise verschwindet, sobald zwei Bedingungen zusammenkommen — der Timeout ist abgelaufen, und irgendetwas ruft get_transient() unter genau diesem Namen auf. Dauerhaft bleibt sie nur, wenn niemand mehr danach fragt. Was genau dann der Fall ist, wenn das Plugin, das den Transient angelegt hat, deinstalliert wurde. Die Zeile überlebt ihren Verursacher.
Bei Site-Transients gibt es dazu einen Sonderfall. get_site_transient() nimmt drei Namen vom Timeout-Nachschlagen aus. Der Kommentar in Zeile 2589 begründet das damit, dass diese Core-Transients gar keinen Timeout haben und die Abfrage deshalb gespart werden kann:
2590 $no_timeout = array( 'update_core', 'update_plugins', 'update_themes' );
2591 $transient_option = '_site_transient_' . $transient;
2592 if ( ! in_array( $transient, $no_timeout, true ) ) {update_core, update_plugins und update_themes laufen also nie durch den Zweig, der aufräumt. Eine Waise unter einem dieser drei Namen heilt nicht mehr — auch nicht durch Lesezugriffe.
Sechzehn Jahre
Am 31. März 2010 meldete ein Entwickler unter Trac #12782, dass delete_transient() die zugehörige _transient_timeout_-Zeile stehen lässt. Er hatte ein Plugin auf Transients umgestellt und wollte beim Deinstallieren einen sauberen Zustand hinterlassen. Am Tag darauf kommentierte dd32 sinngemäß, Timeout-Einträge würden nur beim regulären Ablaufen und Auslesen eines Transients entfernt; beim gezielten Löschen per Funktionsaufruf greife dieser Mechanismus nicht.
Das ist exakt der Mechanismus aus dem vorigen Abschnitt, sechzehn Jahre früher beschrieben.
Das Ticket wurde noch am selben Tag mit Changeset [13911] geschlossen. Die Transient-Funktionen lagen damals noch in wp-includes/functions.php; der Umzug nach option.php kam erst später. Vorher sah der Zweig so aus:
} else {
$option = '_transient_' . $transient;
$result = delete_option( $option );
}Und danach so:
} else {
$option_timeout = '_transient_timeout_' . $transient;
$option = '_transient_' . $transient;
$result = delete_option( $option );
if ( $result )
delete_option( $option_timeout );
}Ein analoger Zweig kam für _site_transient_ dazu. Das Bemerkenswerte daran: Das Löschen der Timeout-Zeile wurde mit diesem Commit überhaupt erst eingeführt — vorher gab es den Zweig gar nicht. Und die Bedingung if ( $result ), die heute den beschriebenen Fall verursacht, wurde nicht später ergänzt, sondern kam im einführenden Commit mit. Sie steht seit dem 1. April 2010 unverändert im Code.
Der Versionsvergleich bestätigt das für die neuere Geschichte. Verglichen wurden option.php aus 4.9, 6.0, 6.6, 6.8 und 7.0.3:
| Funktion | 6.0 → 6.6 | 6.0 → 6.8 | 6.0 → 7.0.3 |
|---|---|---|---|
| delete_transient() | identisch | identisch | identisch |
| delete_site_transient() | identisch | identisch | identisch |
| delete_expired_transients() | identisch | identisch | 2 Zeilen |
| get_transient() | 8 Zeilen | 8 Zeilen | 8 Zeilen |
Die beiden Abweichungen sind ohne Belang: In delete_expired_transients() entfiel ein redundantes is_multisite(), das ohnehin in einem else-Zweig hinter ! is_multisite() stand. In get_transient() kamen eine Docblock-Formulierung und ein vorgezogenes Befüllen des Optionen-Caches hinzu; der Zweig, der abgelaufene Zeilen löscht, blieb unverändert. Auch der Vergleich mit 4.9 zeigt die Konstruktion bereits an Ort und Stelle — dazwischen kamen nur wp_installing() in der Bedingung, geschweifte Klammern und die Ausrichtung der Zuweisungen.
Zur Vollständigkeit gehört, dass die Verbund-Abfrage aus delete_expired_transients() ihren eigenen Ursprung hat: Sie kam mit Trac #41699 in Version 4.9, zusammen mit dem täglichen Cron-Hook. Das Ticket beschreibt nicht den hier untersuchten Fall, sondern erzeugt ihn als Nebenwirkung. Wer über eine Änderung an dieser Abfrage argumentiert, argumentiert gegen den Entwurf aus diesem Ticket.
Die Kombination — die Zeile ist über keine API mehr erreichbar, solange niemand denselben Namen liest — war in keinem bestehenden Ticket beschrieben. Dafür gibt es inzwischen Trac #65863.
Der Spiegelfall: Wertzeile ohne Timeout
Es gibt zwei Waisenarten, und fast alle bestehenden Trac-Tickets behandeln die andere. Der umgekehrte Fall ist eine Wertzeile ohne Timeout-Zeile. Er ist ein Verfallsproblem, kein Aufräumproblem:
| Timeout ohne Wert | Wert ohne Timeout | |
|---|---|---|
| vorhanden | _transient_timeout_x | _transient_x |
| fehlt | _transient_x | _transient_timeout_x |
| get_transient( ‚x‘ ) | false, räumt auf wenn abgelaufen | gibt den Wert für immer zurück |
| delete_transient( ‚x‘ ) | false, löscht nichts | true, löscht die Zeile |
| delete_expired_transients() | ignoriert sie | ignoriert sie |
Gemessen für den Spiegelfall: get_transient( 'inv' ) lieferte den Wert, delete_expired_transients() ließ die Zeile stehen, delete_transient( 'inv' ) gab true zurück und die Zeile war weg. Der Transient läuft dort also nie ab, ist aber über die API entfernbar — genau umgekehrt zu unserem Fall.
Beschrieben ist der Spiegelfall in Trac #33561 und Trac #44977. Trac #52345 behandelt denselben Riss zwischen den beiden Schreibvorgängen in set_transient() wie oben beschrieben, nur in der anderen Richtung: dort scheitert der zweite Schreibvorgang, hier gelingt der erste und der zweite kommt nie.
Object Cache
Läuft ein persistenter Object Cache über ein Drop-in, entstehen keine neuen Zeilen dieser Art. Getestet wurde mit Redis 6.2.22 und dem Drop-in Redis Object Cache 2.8.0: set_transient(), get_transient() und delete_transient() liefen komplett über den Cache, in wp_options entstand keine einzige Zeile. Auch mit angehaltenem Redis und gesetztem WP_REDIS_GRACEFUL blieb das so — der Drop-in bleibt geladen, wp_using_ext_object_cache() bleibt true, und es wird nichts in die Datenbank geschrieben.
Der Altbestand aus der Zeit vor der Umstellung ist dafür schlechter dran als ohne Cache. Gemessen mit drei Altzeilen:
| Vorgang | Zeilen |
|---|---|
| drei Altzeilen angelegt (alt_paar als Paar, alt_waise als Waise) | 3 |
| delete_transient( ‚alt_paar‘ ) → Rückgabe false | 3 |
| delete_expired_transients() ohne $force_db | 3 |
| delete_expired_transients( true ) | 1 |
Drei Dinge stecken darin. Erstens geht delete_transient() bei aktivem Object Cache in Zeile 1391 in den Cache-Zweig und fasst die Optionstabelle nicht mehr an. Damit ist selbst ein vollständiges Transient-Paar aus der Zeit davor über die API nicht mehr löschbar — nicht nur die Waise.
Zweitens kehrt delete_expired_transients() in Zeile 1639 sofort zurück, solange $force_db nicht gesetzt ist. Der tägliche Cron-Job übergibt das Flag nie. Gesetzt wird es nur an zwei Stellen, wp-admin/includes/schema.php:711 und wp-admin/includes/upgrade.php:2525 — also nur bei Installation und Aktualisierung.
Drittens bleibt die Waise auch mit $force_db = true liegen. Es ist dieselbe Verbund-Abfrage. Wenn du einen Object Cache mit Valkey oder Redis betreibst, ist der Altbestand in wp_options also nur noch über direkte SQL-Zugriffe erreichbar.
Wie du nachsiehst
Die Prüfung ist ein Anti-Join: Suche alle Timeout-Zeilen, zu denen keine Wertzeile existiert. Passe das Tabellenpräfix an deine Installation an — wp_ ist nur der Standard, viele Installationen benutzen ein eigenes.
SELECT COUNT(*) AS verwaiste_timeouts
FROM wp_options t
LEFT JOIN wp_options v
ON v.option_name = CONCAT('_transient_', SUBSTRING(t.option_name, 20))
WHERE t.option_name LIKE '\_transient\_timeout\_%'
AND v.option_id IS NULL;Die 20 ist kein Zufall: _transient_timeout_ hat 19 Zeichen, der eigentliche Name beginnt also an Position 20. Die Unterstriche im LIKE-Muster sind maskiert, weil der Unterstrich in LIKE sonst als Platzhalter für ein beliebiges Zeichen gilt.
Der Spiegelfall geht genauso, nur andersherum:
SELECT COUNT(*) AS transients_ohne_timeout
FROM wp_options v
LEFT JOIN wp_options t
ON t.option_name = CONCAT( '_transient_timeout_',
SUBSTRING(v.option_name, 12) )
WHERE v.option_name LIKE '\_transient\_%'
AND v.option_name NOT LIKE '\_transient\_timeout\_%'
AND t.option_id IS NULL;Wenn du die Namen sehen statt nur zählen willst, ersetze COUNT(*) durch t.option_name, t.option_value. Auf der Installation, an der diese Abfrage entstanden ist, liefert sie übrigens null Treffer — der Zustand ist nicht die Regel, sondern die Folge einer langen Plugin-Historie.
Wer das lieber im Backend sehen möchte, statt eine SQL-Konsole zu öffnen: Das Plugin Database Health Check übernimmt dieselbe Prüfung, einschließlich des Object-Cache-Altbestands. Und wenn du ohnehin gerade dabei bist, lohnt sich der Blick auf die übrigen Aufräumarbeiten an der WordPress-Datenbank.
Einordnung
Bleiben wir ehrlich: Das ist ein Aufräumproblem, kein Notfall. Es ist kein Sicherheitsproblem, und es ist keine Funktionsstörung. Eine verwaiste Timeout-Zeile tut nichts. Sie liegt herum.
Auch der Platzbedarf ist überschaubar, und zwar nachrechenbar. Gemessen an den 20 _transient_timeout_-Zeilen dieser Website:
| Kennzahl | Wert |
|---|---|
| kleinste Zeile (option_name + option_value) | 61 Byte |
| Durchschnitt | 63 Byte |
| größte Zeile | 101 Byte |
| option_value | ausnahmslos 10 Zeichen |
Die Struktur erklärt das: option_value ist immer ein Unix-Zeitstempel, also zehn Zeichen, und option_name ist auf varchar(191) begrenzt. Zusammen ergibt das eine harte Obergrenze von 201 Zeichen pro Zeile in diesen beiden Spalten — unabhängig von der Installation. Auch zehntausend solcher Zeilen bleiben damit im niedrigen einstelligen Megabyte-Bereich.
Wichtiger als der Platz ist, was diese Zeilen nicht tun: Sie sind nicht autoloaded. In set_transient(), Zeile 1550, wird die Timeout-Zeile mit autoload = false angelegt. Sie werden also nicht bei jedem Request mitgeladen und kosten dich keine Antwortzeit. Wer an der TTFB seiner WordPress-Installation arbeitet, findet hier nichts zu holen — die lohnenden Ziele sind autoloaded Optionen, nicht diese.
Relevant wird der Zustand bei Installationen mit langer Plugin-Historie. Wo über Jahre Plugins kamen und gingen, sammelt sich der Rest von Transients an, nach denen niemand mehr fragt — und genau die heilen nicht mehr von selbst, weil die Selbstheilung über get_transient() einen Leser unter demselben Namen braucht. Ein Grund zur Panik ist das nicht. Ein Grund, einmal nachzusehen, schon.
Weitere technische Analysen findest du in unserem Wissensbereich.

