meta_query w WP_Query: jak każda klauzula staje się złączeniem i ile to kosztuje

Spis treści
Strona filtrująca wpisy po trzech polach własnych to w PHP jedna tablica, a w SQL cztery złączenia. Tablica czyta się jak filtr; SQL czyta się jak tabela odwiedzana ponownie dla każdego warunku – bo dokładnie tym jest.
Godne uwagi nie jest to, że działa wolno; większość witryn nigdy nie urośnie na tyle, by to rzucało się w oczy. Godne uwagi jest to, że koszt pozostaje niewidoczny w kodzie, który go wywołuje, i że rośnie od dwóch rzeczy naraz: od liczby klauzul i od rozmiaru tabeli, w którą nikt nie zagląda.

Co WP_Query buduje z tablicy
Każda klauzula w meta_query dostaje własny alias na wp_postmeta i własny warunek złączenia. Dwie klauzule, dwa aliasy; pięć klauzul, pięć. Tabelę łączy się z samą sobą tyle razy, ile jest warunków, a każde z tych złączeń ciągnie za sobą pełny ciężar tabeli.
SELECT wp_posts.ID FROM wp_posts
INNER JOIN wp_postmeta pm1 ON wp_posts.ID = pm1.post_id
INNER JOIN wp_postmeta pm2 ON wp_posts.ID = pm2.post_id
INNER JOIN wp_postmeta pm3 ON wp_posts.ID = pm3.post_id
WHERE post_type = 'post' AND post_status = 'publish'
AND ( pm1.meta_key = '_region' AND pm1.meta_value = 'polnoc' )
AND ( pm2.meta_key = '_poziom' AND pm2.meta_value = 'b' )
AND ( pm3.meta_key = '_aktywny' AND pm3.meta_value = '1' )
ORDER BY wp_posts.post_date DESC
Nie ma w tym nic błędnego. To dosłowne tłumaczenie tego, o co poproszono, i baza odpowie poprawnie – po odczytaniu na średniej witrynie rzędu pół miliona wierszy, żeby zwrócić czterdzieści.
Indeks, którego nie ma
wp_postmeta niesie trzy indeksy: klucz główny na meta_id, jeden na post_id i jeden na pierwszych 191 znakach meta_key. Na meta_value nie ma żadnego i sensownie być nie może: kolumna jest typu LONGTEXT, a indeks na kolumnie tekstowej nieograniczonej długości to albo przedrostek, albo nic.
Każdą klauzulę obsługuje się więc w dwóch ruchach. Złączenie znajduje wiersze wpisu po post_id – szybko, z indeksu, dokładnie do tego indeks służy. Potem wartość porównuje się wewnątrz każdego z tych wierszy, jeden po drugim, bez pomocy skądkolwiek. Nakład jest proporcjonalny do liczby wierszy meta na wpis, a ta na witrynie z kreatorem stron i kilkoma rozszerzeniami rzadko spada poniżej trzydziestu.
Klauzule kosztujące więcej niż pozostałe
| Klauzula | Co musi za to zrobić baza |
|---|---|
'compare' => '=' |
przypadek podstawowy: jedno złączenie, jedno porównanie napisów na wiersz |
'compare' => 'LIKE' |
to samo z dopasowaniem wzorca; wiodący znak wieloznaczny wyklucza każdy skrót |
'type' => 'NUMERIC' |
otacza kolumnę CAST() i zabiera ostatnią szansę na użycie indeksu |
'compare' => 'NOT EXISTS' |
LEFT JOIN wraz z testem IS NULL – każdy niepasujący wiersz musi najpierw powstać |
'relation' => 'OR' |
klauzule przestają się nawzajem zawężać; zbiór pośredni to suma |
'orderby' => 'meta_value' |
tabela tymczasowa i sortowanie po kolumnie tekstowej |
Ostatni wiersz zamienia wolną stronę w przekroczenie czasu. Sortowania po wartości meta nie da się obsłużyć z żadnego indeksu; baza buduje więc cały wynik pośredni i sortuje go – a ponieważ meta_value jest typu LONGTEXT, sortowanie to odbywa się na dysku, a nie w pamięci, gdy tylko zbiór urośnie.
NOT EXISTS zasługuje na osobne ostrzeżenie. Czyta się jak tanie przeciwieństwo EXISTS, a jest przeciwieństwem taniości: wykazanie nieobecności oznacza odwiedzenie każdego kandydata – i właśnie tego indeks skrócić nie potrafi.
Gdzie ten filtr należy zamiast tego
Wartość, po której się filtruje, nie jest własnością wpisu w tym sensie, w jakim jest nią podtytuł. Jest przyporządkowaniem, a dla przyporządkowań WordPress ma już strukturę z właściwymi indeksami: wp_term_relationships jest zaindeksowana na obu kolumnach, więc filtr po terminie to jedno złączenie obsłużone w całości z indeksów, całkiem bez porównywania tekstu.
Wynikająca stąd reguła jest krótka. To, po czym się filtruje albo sortuje, należy do taksonomii – albo, gdy wartości są naprawdę liczbowe i ciągłe, do kolumny własnej tabeli. To, co odczytuje się dopiero po znalezieniu wpisu, może zostać w metadanych, gdzie kosztuje dokładnie jeden dostęp po post_id i nic poza tym.
Mierzyć zamiast zgadywać
Zapytanie zbudowane przez WP_Query jest dostępne przed wykonaniem: w $query->request leży gotowy SQL, a przepuszczony przez EXPLAIN odpowiada na jedyne dwa pytania, które się liczą. Kolumna rows to oszacowanie, ile wierszy odczyta każdy krok; kolumna Extra nazywa kosztowne kształty wprost, a Using temporary; Using filesort razem są podpisem sortowania, które nie rośnie razem z witryną.
wp db query "EXPLAIN SELECT wp_posts.ID FROM wp_posts INNER JOIN ..."
id select_type table type key rows Extra
1 SIMPLE wp_posts ref type_st. 4800 Using temporary; Using filesort
1 SIMPLE pm1 ref post_id 40 Using where
1 SIMPLE pm2 ref post_id 40 Using where
1 SIMPLE pm3 ref post_id 40 Using where
Trzy wiersze z Using where po dostępie przez post_id to dokładnie opisany wyżej obraz: indeks znalazł wiersze, warunek zastosowano potem. Liczba w rows, pomnożona przez złączenia, jest wielkością rosnącą razem z witryną – i warto ją znać tego dnia, w którym powstaje zapytanie, a nie tego, w którym strona przestaje się wczytywać.