LW IT Solutions
« Blog-Übersicht /WordPress-Plugins & Tricks/Tutorials / Tutorial: Eine langsame meta_query finden und in...
Diesen Artikel in anderen Sprachen lesen:

Tutorial: Eine langsame meta_query finden und in einen Taxonomie-Filter umbauen

Tutorial: Eine langsame meta_query finden und in einen Taxonomie-Filter umbauen
Inhalt
  1. Was WP_Query aus dem Array baut
  2. Die Abfrage finden statt sie zu erraten
  3. Die EXPLAIN-Ausgabe lesen
  4. Den Filter in eine Taxonomie verlegen
  5. Die vorhandenen Werte einmalig übertragen
  6. Wann eine Taxonomie die falsche Antwort ist
  7. Fragen und Antworten
  8. Quellen

Ein Filter, der als meta_query geschrieben ist, sieht in PHP harmlos aus. Drei Bedingungen, je ein Array, dazwischen eine Verknüpfung. Was bei MySQL ankommt, ist etwas ganz anderes: drei zusätzliche Verbindungen auf eine Tabelle, die jedes Zusatzfeld jedes Beitrags der Installation enthält und deren entscheidende Spalte überhaupt keinen Index trägt.

Der folgende Umbau hat vier Schritte – das SQL lesen, es messen, den Filter in eine Taxonomie verlegen, die Werte einmalig übertragen. Er lohnt sich für genau eine Art von Feld, und der letzte Abschnitt sagt, für welche.

Zwei Verbindungsketten im Vergleich: oben drei Postmeta-Verbindungen mit rund 480 000 betrachteten Zeilen, unten zwei Taxonomie-Verbindungen mit rund 2 100 betrachteten Zeilen
Derselbe Filter, zwei Datenmodelle. Nicht die Zahl der Verbindungen ändert sich, sondern das, was MySQL über einen Index erreichen kann.

Was WP_Query aus dem Array baut

Eine Abfrage mit drei Bedingungen auf Zusatzfeldern ist kurz geschrieben und lang ausgeführt.

$q = new WP_Query( [
    'post_type'  => 'produkt',
    'meta_query' => [
        'relation' => 'AND',
        [ 'key' => 'farbe',     'value' => 'blau' ],
        [ 'key' => 'material',  'value' => 'eiche' ],
        [ 'key' => 'verfuegbar','value' => '1' ],
    ],
] );

Jede Klausel wird zu einer eigenen Verbindung mit eigenem Alias, denn eine einzelne Zeile in wp_postmeta trägt immer nur einen Schlüssel. Drei Bedingungen brauchen deshalb drei Kopien der Tabelle.

SELECT wp_posts.ID FROM wp_posts
  INNER JOIN wp_postmeta ON wp_posts.ID = wp_postmeta.post_id
  INNER JOIN wp_postmeta AS mt1 ON wp_posts.ID = mt1.post_id
  INNER JOIN wp_postmeta AS mt2 ON wp_posts.ID = mt2.post_id
WHERE wp_posts.post_type = 'produkt'
  AND ( wp_postmeta.meta_key = 'farbe'      AND wp_postmeta.meta_value = 'blau' )
  AND ( mt1.meta_key        = 'material'    AND mt1.meta_value        = 'eiche' )
  AND ( mt2.meta_key        = 'verfuegbar'  AND mt2.meta_value        = '1' )
GROUP BY wp_posts.ID ORDER BY wp_posts.post_date DESC

Das Entscheidende steht in der jeweils letzten Zeile jeder Bedingung. wp_postmeta bringt einen Index auf post_id mit und einen auf meta_key, aber keinen auf meta_value – die Spalte ist ein LONGTEXT und lässt sich nicht vollständig indizieren. MySQL findet also alle Zeilen mit dem Schlüssel farbe schnell und muss danach jede einzelne davon ansehen, um den Wert blau zu prüfen.

Wie teuer das wird, hängt an einer Zahl, die niemand beobachtet: wie viele Zeilen sich den Schlüssel teilen. Ein Feld, das an jedem Produkt hängt, hat so viele Zeilen wie es Produkte gibt – und drei solche Felder vervielfachen sich in der Verbindung.

Die Abfrage finden statt sie zu erraten

Bevor etwas umgebaut wird, gehört die Abfrage auf den Tisch. Zwei Zeilen in der wp-config.php lassen WordPress jede abgesetzte Anweisung mitschreiben.

define( 'SAVEQUERIES', true );

Danach lassen sich die langsamsten Anweisungen eines Seitenaufrufs auflisten – samt dem Aufrufpfad, der sie erzeugt hat, und der ist meist die interessantere Hälfte.

add_action( 'shutdown', function () {
    global $wpdb;
    if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) { return; }
    $langsam = array_filter( $wpdb->queries, fn( $q ) => $q[1] > 0.05 );
    usort( $langsam, fn( $a, $b ) => $b[1] <=> $a[1] );
    foreach ( array_slice( $langsam, 0, 5 ) as $q ) {
        error_log( sprintf( "%.3f s  %s\n   %s", $q[1], $q[2], substr( $q[0], 0, 400 ) ) );
    }
} );

Auf einer laufenden Seite liefert dieselbe Information das Protokoll langsamer Abfragen, ganz ohne Eingriff im Code – und das ist die bessere Quelle, weil es auch Aufrufe erfasst, die niemand beobachtet.

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.2;
SET GLOBAL log_queries_not_using_indexes = 'ON';

Die EXPLAIN-Ausgabe lesen

Liegt die Anweisung vor, sagt ein Befehl, was MySQL damit vorhat.

EXPLAIN SELECT wp_posts.ID FROM wp_posts
  INNER JOIN wp_postmeta ON wp_posts.ID = wp_postmeta.post_id
  ...

Drei Spalten tragen den Befund. rows schätzt, wie viele Zeilen je Tabelle betrachtet werden, und das Produkt über alle Tabellen ist ungefähr der Aufwand. key nennt den benutzten Index – hier meta_key, niemals meta_value. Und in Extra stehen die beiden Anmerkungen, auf die sich reagieren lohnt: Using temporary und Using filesort.

Die zweite davon hat eine eigene Ursache. Eine Sortierung nach einem Zusatzfeld macht aus dem Sortieren einen Textvergleich über eine nicht indizierte Spalte – deshalb kostet ein ergänztes 'orderby' => 'meta_value_num' oft mehr als alle drei Filter zusammen.

Beobachtung Bedeutung
key = meta_key Der Schlüssel wird über einen Index gefunden, der Wert nicht
rows im Zehntausenderbereich Das Feld hängt an fast jedem Beitrag – der Filter engt nichts ein
Using filesort Sortiert wird nach der Verbindung, über das ganze Ergebnis
Using temporary GROUP BY braucht eine Zwischentabelle, meist wegen der mehrfachen Verbindungen

Den Filter in eine Taxonomie verlegen

Dass eine Taxonomie schneller ist, liegt nicht daran, dass WordPress dabei besonders klug vorginge. Es liegt daran, dass wp_term_relationships je Zeile zwei ganze Zahlen speichert und einen Primärschlüssel über genau diese beiden Spalten trägt – die Verbindung läuft also aus beiden Richtungen über einen Index.

add_action( 'init', function () {
    register_taxonomy( 'farbe', 'produkt', [
        'hierarchical'      => false,
        'public'            => false,
        'show_ui'           => true,
        'show_admin_column' => true,
        'query_var'         => false,
        'rewrite'           => false,
    ] );
} );

Zwei Einstellungen verdienen einen Moment. public => false hält Archivseiten und Adressen aus dem Auftritt heraus, und genau das will ein reines Filtermerkmal. Und show_admin_column kostet nichts und macht die Werte in der Beitragsliste sichtbar – dort fällt eine misslungene Übertragung zuerst auf.

Die Abfrage danach beschreibt denselben Filter mit anderem Vokabular.

$q = new WP_Query( [
    'post_type' => 'produkt',
    'tax_query' => [
        'relation' => 'AND',
        [ 'taxonomy' => 'farbe',    'field' => 'slug', 'terms' => 'blau' ],
        [ 'taxonomy' => 'material', 'field' => 'slug', 'terms' => 'eiche' ],
    ],
] );

Ein Wort zur dritten Bedingung der ursprünglichen Abfrage: Ein Ja-Nein-Feld wie verfuegbar gehört ebenfalls nicht in eine Taxonomie. Zwei Begriffe auf zehntausend Produkte bedeuten, dass ein Begriff neuntausend Zuordnungen trägt, und ein Index über eine Spalte mit zwei verschiedenen Werten ist nahezu wertlos. Ein solches Kennzeichen ist in post_status besser aufgehoben, in einer eigenen Statustaxonomie neben anderen Zuständen – oder es bleibt als einzige verbleibende Klausel im Postmeta: Eine Verbindung ist kein Problem, drei sind es.

Die vorhandenen Werte einmalig übertragen

Die Taxonomie existiert, die Beiträge wissen noch nichts davon. Ein einmaliger Lauf über WP-CLI überträgt die Werte und lässt sich vorher prüfen.

wp eval '
$ids = get_posts( [ "post_type" => "produkt", "posts_per_page" => -1,
                    "fields" => "ids", "post_status" => "any" ] );
$n = 0;
foreach ( $ids as $id ) {
    $wert = get_post_meta( $id, "farbe", true );
    if ( $wert === "" ) { continue; }
    wp_set_object_terms( $id, sanitize_title( $wert ), "farbe", false );
    $n++;
}
echo "$n Beitraege\n";
'

Drei Einzelheiten entscheiden, ob dieser Lauf wiederholbar ist. post_status => any erfasst auch Entwürfe und geplante Beiträge – genau die, die später als fehlend auffallen. Das false in wp_set_object_terms ersetzt die Zuordnung, statt sie zu ergänzen, sodass ein zweiter Lauf denselben Zustand erzeugt und keine wachsende Liste. Und sanitize_title sorgt dafür, dass der Begriff über seinen Slug gefunden wird, sodass aus Blau und blau ein Begriff wird statt zwei.

Die Postmeta-Zeilen bleiben am besten eine Weile stehen. Läuft der neue Weg einige Wochen, verschwinden sie mit einer Anweisung – und erst dann, denn dieser Schritt ist der ohne Rückweg.

wp post meta delete --all --keys=farbe --dry-run

Wann eine Taxonomie die falsche Antwort ist

Der Umbau rechnet sich für Felder mit wenigen verschiedenen Werten, die als Filter dienen. Für drei andere Fälle ist er der falsche Griff, und jeder hat seine eigene bessere Antwort.

Am deutlichsten sind Zahlenbereiche. Ein Preis, ein Datum, ein Gewicht – alles, was mit BETWEEN oder einem Vergleich abgefragt wird, hat keine nennenswerten Begriffe, und eine Taxonomie mit achttausend Begriffen ist schlechter als das ersetzte Postmeta. Ein solches Feld gehört in eine eigene Spalte, in eine eigene Tabelle mit einem Index über genau die abgefragte Kombination.

Die Volltextsuche ist der zweite Fall. Begriffe treffen genau oder gar nicht; eine Suche nach einem Wortteil braucht einen FULLTEXT-Index oder einen eigenen Suchindex, und beides ist keine Taxonomie.

Der dritte Fall sind Werte, die nur an einer Handvoll Beiträge hängen. Ein Feld an zwanzig von vierzigtausend Produkten ist schon über meta_key allein trennscharf, und die Abfrage ist ohne jede Änderung schnell. Das Werkzeug für diese Entscheidung ist eine einzige Zählung, vor jedem Umbau erhoben.

SELECT meta_key, COUNT(*) AS zeilen, COUNT(DISTINCT meta_value) AS werte
FROM wp_postmeta GROUP BY meta_key ORDER BY zeilen DESC LIMIT 20;

Viele Zeilen und wenige verschiedene Werte – das ist der Fall, für den eine Taxonomie gemacht ist. Viele Zeilen und fast ebenso viele verschiedene Werte verlangen nach einer Spalte. Wenige Zeilen bedeuten, dass gar nichts geschehen muss.

Fragen und Antworten

Was passiert, wenn während der Übergangswochen weiter das alte Feld gepflegt wird?

Dann laufen Feld und Taxonomie lautlos auseinander: Die Abfrage liest nur noch die Taxonomie, während eine Eingabemaske, ein Import oder eine Erweiterung womöglich weiter das alte Feld schreibt. Entscheidend ist deshalb, dass in dieser Zeit genau eine der beiden Ablagen führt.

Bleibt das Feld führend, weil die Eingabe noch nicht umgestellt ist, genügt es, den Übertragungslauf unmittelbar vor dem Löschen noch einmal auszuführen. Er ist dafür gebaut, denn das false in wp_set_object_terms ersetzt die Zuordnung, statt sie zu ergänzen. Eine Lücke bleibt: Der Lauf überspringt Beiträge mit leerem Wert, und ein inzwischen geleertes Feld lässt deshalb den alten Begriff stehen.

Ist dagegen schon die Taxonomie führend, weil die Werte im Editor gepflegt werden, darf der Lauf nicht wiederholt werden. Er würde bei jedem Beitrag, der das alte Feld noch trägt, eine neuere Zuordnung mit dem alten Wert überschreiben. In diesem Fall müssen zuerst alle Stellen umgestellt werden, die das Feld noch schreiben, und erst danach verschwinden die Postmeta-Zeilen.

Warum fehlt die neue Taxonomie im Blockeditor?

Weil show_in_rest in der Registrierung fehlt und standardmäßig false ist; der Blockeditor liest Taxonomien über die REST-Schnittstelle und zeigt nur, was dort freigegeben ist. Mit 'show_in_rest' => true erscheint die Auswahl im Blockeditor. Die Begriffe sind dann allerdings auch unter /wp-json/wp/v2/farbe abrufbar, was public => false allein nicht verhindert.

Lohnt sich der Umbau auch für WooCommerce-Produkte?

Nur für einen Teil der Felder, denn WooCommerce hat vieles schon so gebaut, wie der Artikel es empfiehlt. Drei Stellen zeigen das:

  • Globale Eigenschaften, angelegt unter Produkte → Eigenschaften, sind Taxonomien mit dem Präfix pa_, etwa pa_farbe, und lassen sich mit tax_query filtern. Eigenschaften, die nur an einem einzelnen Produkt angelegt werden, liegen dagegen serialisiert in einem einzigen Metafeld und taugen nicht als Filter.
  • Die Taxonomie product_visibility trägt die Sichtbarkeit im Katalog und das Kennzeichen für nicht vorrätige Produkte, also genau eine solche Statustaxonomie, wie sie der Artikel für Ja-Nein-Felder vorschlägt.
  • Preis, Lagerbestand und Bewertung stehen zusätzlich in eigenen Spalten der Tabelle wc_product_meta_lookup; das entspricht der Lösung für Zahlenbereiche aus dem letzten Abschnitt.

Der Umbau betrifft dort also vor allem Felder, die eine Erweiterung oder das Theme zusätzlich als Postmeta anlegt. Welche das sind, zeigt die Zählung aus dem letzten Abschnitt.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in Touch

Briefly describe your project or inquiry for a tailored response. This site is protected by reCAPTCHA.

Kommentar schreiben

Erfahrungen mit anderen Plugins oder Hosting-Umgebungen und Rückfragen zur Einrichtung sind hier willkommen.

Die E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind mit einem Stern versehen.

Artikel & Kategorien

CCTV

Diese Rubrik per RSS verfolgen

Cloud & AI

Alle 17 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Data Privacy

Alle 19 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Digital Analytics

Alle 58 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Digital Marketing

Alle 37 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

IT & Networks

Alle 18 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Music Production

Alle 17 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Raspberry Pi

Diese Rubrik per RSS verfolgen

SaaS & Internet Earning

Diese Rubrik per RSS verfolgen

Smart Home

Alle 18 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

Web Entwicklung

Alle 11 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen

WordPress-Plugins & Tricks

Alle 14 Artikel dieser Rubrik Diese Rubrik per RSS verfolgen