Zapytania N+1 w filtrach WordPressa — jak je znaleźć i naprawić
Filtr budujący menu z taksonomii potrafi kosztować kilkaset zapytań SQL na jedno wyświetlenie strony. Jak to zmierzyć, dlaczego cache obiektów nie pomaga i jak przepisać filtr, żeby liczba zapytań przestała rosnąć z liczbą kategorii.

Filtr wp_nav_menu_objects, który dobudowuje menu z kategorii rekurencyjnie, wykonuje osobne zapytanie dla każdego poziomu drzewa i drugie dla każdej pozycji — tylko po to, żeby sprawdzić, czy ma dzieci. Przy stu kategoriach to kilkaset zapytań SQL przy każdym wyświetleniu dowolnej strony witryny. Poprawka: pobrać wszystkie terminy jednym zapytaniem, złożyć drzewo w PHP i wynik odłożyć w transiencie.
Ten kod znalazłem w motywie na koncie, które diagnozowałem z powodu przekroczeń limitu CPU. Z tamtym problemem nie miał nic wspólnego i to jest osobna historia. Warto go było naprawić z własnego powodu: strona generowała się zauważalnie dłużej, niż powinna.
Skąd wiadomo, że to N+1
N+1 to jedno zapytanie po listę i po jednym dodatkowym dla każdego elementu z tej listy. Rośnie liniowo z liczbą danych, więc na instalacji deweloperskiej z pięcioma kategoriami jest niewidoczne, a na produkcji ze stoma zjada sekundę.
Najszybszy pomiar bez wtyczek — stała w wp-config.php:
define('POMIAR_ZAPYTAN', true);
i jedna funkcja w functions.php:
add_action('shutdown', function (): void {
if (!defined('POMIAR_ZAPYTAN') || !POMIAR_ZAPYTAN) {
return;
}
error_log(sprintf(
'%s — %d zapytań, %s s',
sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '-')),
get_num_queries(),
timer_stop(0, 3)
));
});
Wynik ląduje w error_log obok wp-config.php albo w logu skonfigurowanym przez WP_DEBUG_LOG. Odśwież kilka podstron i porównaj liczby. Zdrowa strona na WordPressie z kilkoma wtyczkami mieści się w przedziale 30–80 zapytań. Trzysta na każdej podstronie oznacza, że coś liczy w pętli.
Dwie uwagi do tego licznika. Po włączeniu stałej loguje każde żądanie, także boty, więc zostawiony na dobę sam urośnie w problem — zapal go na kilka minut i zgaś.
I rzecz ważniejsza: mierz wyjście anonimowe, nie własną sesję. Odruch podpowiada bramkę na current_user_can('manage_options'), ale zalogowany administrator ma pasek administracyjny, sprawdzanie uprawnień, powiadomienia o aktualizacjach i omija cache stron — to dwadzieścia parę zapytań ponad to, co widzi zwykły odwiedzający. Do porównania „przed i po" różnica się skraca, ale liczby bezwzględne przestają się odnosić do przedziału wyżej. Otwórz stronę w oknie prywatnym.
Gdy już wiesz, że jest źle, do znalezienia miejsca służy Query Monitor. Zakładka „Queries" grupuje zapytania po funkcji wywołującej i po komponencie, więc filtr motywu wychodzi na wierzch bez czytania kodu. Wtyczkę instalujesz na czas diagnozy i zdejmujesz po niej — sama dokłada kilkanaście zapytań.
Filtr menu: co robił kod
Motyw miał w menu jedną pozycję wskazującą na kategorię z własnej taksonomii, a pod nią miało się rozwijać całe drzewo podkategorii. Autor rozwiązał to filtrem, który dla każdego węzła pyta bazę o jego dzieci:
add_filter('wp_nav_menu_objects', 'motyw_dobuduj_kategorie');
function motyw_dobuduj_kategorie(array $pozycje): array {
foreach ($pozycje as $pozycja) {
if ($pozycja->object !== 'oferta_kategoria') {
continue;
}
$pozycje = array_merge($pozycje, motyw_galezie((int) $pozycja->object_id, $pozycja->ID));
}
return $pozycje;
}
function motyw_galezie(int $rodzic, int $rodzicMenu): array {
$dzieci = get_terms([
'taxonomy' => 'oferta_kategoria',
'parent' => $rodzic,
'hide_empty' => false,
]);
$wynik = [];
foreach ($dzieci as $dziecko) {
$wnuki = get_terms([
'taxonomy' => 'oferta_kategoria',
'parent' => $dziecko->term_id,
'hide_empty' => false,
'fields' => 'ids',
]);
$pozycja = motyw_pozycja_menu($dziecko, $rodzicMenu);
$pozycja->classes[] = $wnuki ? 'menu-item-has-children' : '';
$pozycja->url = get_term_link($dziecko);
$wynik[] = $pozycja;
$wynik = array_merge($wynik, motyw_galezie($dziecko->term_id, $pozycja->ID));
}
return $wynik;
}
To wierny przedruk oryginału, razem z $pozycje = array_merge(…) w środku foreach po tej samej tablicy. Działa, bo PHP iteruje po kopii zrobionej przy wejściu w pętlę, ale wygląda na błąd i czyta się jak błąd. Zostawiam, bo tekst jest o zapytaniach, a nie o tym, co jeszcze dałoby się w tym kodzie posprzątać.
Każde get_terms() z parametrem parent to osobne zapytanie, bo zawęża wynik w SQL-u. Rachunek przy stu kategoriach w trzech poziomach wygląda tak:
- jedno zapytanie na każdy węzeł, żeby pobrać jego dzieci — sto jeden razem z korzeniem
- jedno zapytanie na każdą pozycję, żeby stwierdzić, czy ma dzieci — kolejne sto
get_term_link()dla każdej pozycji, które przy taksonomii hierarchicznej dochodzi po przodkach aż do korzenia
Drugi punkt boli najbardziej, bo odpowiedź na pytanie „czy ten termin ma dzieci" siedzi już w wyniku pierwszego zapytania o poziom niżej. Pytanie zadaje się dwa razy.
I rzecz, która decyduje o skali: filtr wp_nav_menu_objects odpala się przy każdym wp_nav_menu(), czyli na każdej podstronie i przy każdym menu na niej. Nagłówek i stopka to już dwa przebiegi.
Dlaczego cache obiektów tego nie łapie
Odruchowa odpowiedź brzmi „przecież WordPress cachuje terminy". Cachuje, tylko ten cache żyje w obrębie jednego żądania PHP. Domyślna implementacja WP_Object_Cache trzyma dane w tablicy w pamięci procesu i znika razem z nim.
Pomaga to przy powtórzonych odczytach tego samego terminu w jednym żądaniu. Nie pomaga tutaj, bo każde get_terms() ma inny zestaw argumentów, a więc inny klucz cache. Pierwsze wywołanie każdego z nich i tak idzie do bazy.
Cache przeżywa żądanie dopiero z persystentnym backendem: Redis albo Memcached z odpowiednią wtyczką drop-in. Na hostingu współdzielonym zwykle nie ma ani jednego, ani drugiego. Zakładaj, że przy każdym wejściu użytkownika kod startuje z pustym cache.
Poprawka: drzewo składane w PHP
get_terms() bez parametru parent zwraca całą taksonomię jednym zapytaniem i przy okazji wrzuca wszystkie terminy do cache obiektów. Relacje rodzic–dziecko są w polu parent każdego z nich, więc drzewo składa się w PHP bez dotykania bazy:
function motyw_drzewo_kategorii(): array {
$klucz = 'motyw_drzewo_oferta_kategoria';
$drzewo = get_transient($klucz);
if ($drzewo !== false) {
return $drzewo;
}
$terminy = get_terms([
'taxonomy' => 'oferta_kategoria',
'hide_empty' => false,
'pad_counts' => true,
]);
if (is_wp_error($terminy)) {
return [];
}
$drzewo = [];
foreach ($terminy as $termin) {
if ((int) $termin->count === 0) {
continue;
}
$drzewo[$termin->parent][] = [
'id' => $termin->term_id,
'nazwa' => $termin->name,
'url' => get_term_link($termin),
];
}
set_transient($klucz, $drzewo, DAY_IN_SECONDS);
return $drzewo;
}
Tablica jest indeksowana identyfikatorem rodzica, więc dzieci dowolnego węzła to $drzewo[$id] ?? [], a pytanie „czy ma dzieci" to isset($drzewo[$id]). Oba bez zapytania.
get_term_link() w pętli nie wraca po nic do bazy, bo get_terms() zdążyło już zapełnić cache terminów w tym żądaniu. Po co tu pad_counts i dlaczego bez niego filtr po count jest błędny — dwie sekcje niżej.
Rekurencja zostaje, ale chodzi po tablicy w pamięci:
function motyw_galezie(array $drzewo, int $rodzic, int $rodzicMenu): array {
$wynik = [];
foreach ($drzewo[$rodzic] ?? [] as $dziecko) {
$pozycja = motyw_pozycja_menu($dziecko, $rodzicMenu);
if (isset($drzewo[$dziecko['id']])) {
$pozycja->classes[] = 'menu-item-has-children';
}
$wynik[] = $pozycja;
foreach (motyw_galezie($drzewo, $dziecko['id'], $pozycja->ID) as $wnuk) {
$wynik[] = $wnuk;
}
}
return $wynik;
}
Dwieście zapytań schodzi do kilku: jedno po terminy, jedno do dwóch od pad_counts, plus odczyt i zapis transientu. Każde następne wyświetlenie strony kosztuje już tylko sam odczyt transientu.
To jest dobre miejsce, żeby nie zaokrąglić w swoją stronę. Transient nie kosztuje zera zapytań. set_transient() z czasem wygaśnięcia zapisuje dwie opcje — wartość i _transient_timeout_… — obie z autoload ustawionym na „nie". Nie jadą więc w zbiorczym zapytaniu o opcje autoładowane, tylko get_transient() pobiera po kolei timeout i wartość. To dwa zapytania na żądanie zamiast dwustu.
Można zejść do zera, zapisując transient bez czasu wygaśnięcia — wtedy opcja jest autoładowana i przyjeżdża razem z resztą. Cena jest taka, że drzewo ładuje się przy każdym żądaniu, także przy tych, w których menu nikomu nie jest potrzebne, i znika zabezpieczenie na wypadek nieunieważnionej zmiany. Przy stu kategoriach ta zamiana się nie opłaca.
Wersja sprzed poprawki dokładała do rekurencji array_merge($wynik, …), co na każdym poziomie kopiuje całą zebraną dotąd tablicę. Przy stu pozycjach to koszt pomijalny obok dwustu zapytań, ale w kodzie, który właśnie przepisujesz pod wydajność, zostawianie tego wygląda niepoważnie. Dopisywanie po jednym elemencie kosztuje tyle samo linii.
Czego walker wymaga od dokładanej pozycji
motyw_pozycja_menu() chowa w moich przykładach składanie obiektu pozycji i to jest miejsce, w którym najłatwiej się przejechać. Pozycje wstawiane przez ten filtr nie przeszły przez _wp_menu_item_classes_by_context(), bo ta funkcja odpala się w wp_nav_menu() wcześniej, przed wp_nav_menu_objects. Wszystko, co ona normalnie dokłada, musisz dołożyć sam.
Trzy pola, o których zapomina się najczęściej:
$pozycja->current = false;
$pozycja->current_item_ancestor = false;
$pozycja->current_item_parent = false;
Walker_Nav_Menu::start_el() czyta $menu_item->current bez sprawdzania, czy pole istnieje. Brak tych trzech linii nie psuje menu wizualnie i dlatego przechodzi niezauważony — za to sypie ostrzeżeniem na każdą pozycję przy każdym renderze. W opisywanym motywie dawało to około czterech tysięcy wpisów do error_log na dobę.
Poza tym pozycja potrzebuje kompletu pól, po których chodzi walker: ID, db_id, menu_item_parent, object, object_id, type, title, url, classes, target, attr_title, xfn i description. Najprościej wziąć (object) [] i wypełnić wszystkie, zamiast liczyć na to, że walker sobie poradzi z brakami.
Uwaga przy przepisywaniu: ta funkcja zmienia typ argumentu. W wersji sprzed poprawki dostawała obiekt WP_Term prosto z get_terms(), w wersji po — tablicę z sześcioma polami, którą składa motyw_drzewo_kategorii(). Nazwa została ta sama, więc łatwo zostawić starą implementację i dostać błąd przy pierwszym $termin->name. Przepisz ją razem z resztą albo nazwij inaczej.
I tu domyka się historia z wpisu o limicie CPU: sześć tysięcy warningów na dobę, które wyglądały tam na najgorętszy trop, brało się w większości właśnie z tego. Trop był fałszywy co do przyczyny przeciążenia i prawdziwy co do tego, że w motywie siedzi błąd.
pad_counts to jedna linia, bez której logika się wywraca
Oryginał miał hide_empty => true i puste gałęzie odpadały same, w SQL-u. Po przejściu na hide_empty => false filtrowanie przenosi się do PHP i tu czeka pułapka: pole count liczy domyślnie wyłącznie wpisy przypisane wprost do danego terminu. Kategoria nadrzędna, która sama nie ma żadnego wpisu, ale jej podkategorie mają komplet, dostaje count === 0.
Odfiltrowanie po takim liczniku wycina ją z menu razem z całą gałęzią pod spodem. Na instalacji deweloperskiej, gdzie wpisy siedzą byle gdzie, tego nie widać — na produkcji z uporządkowaną strukturą znika pół nawigacji.
pad_counts => true każe WordPressowi zsumować liczniki rekurencyjnie w dół drzewa, więc rodzic dostaje sumę swoich potomków. Dopiero wtedy count znaczy to samo co przy hide_empty => true i dopiero wtedy wolno po nim filtrować.
Sprawdź, czy taksonomia ma w ogóle przyjazne adresy
get_term_link() przy taksonomii zarejestrowanej z 'rewrite' => false zwraca adres z parametrem zapytania, w rodzaju /?oferta_kategoria=szkolenia. To nie jest błąd tej funkcji — taksonomia po prostu nie ma własnej struktury adresów.
Kod, który na wyniku get_term_link() szuka potem fragmentu ścieżki przez strpos(), nigdy się w takim układzie nie dopasuje i po cichu leci gałęzią awaryjną. Zapytań to nie kosztuje, bo cache jest już pełny, ale to sto wywołań funkcji, której wynik ląduje w koszu, i mylący trop dla następnej osoby czytającej ten plik. Zajrzyj do register_taxonomy(), zanim zaczniesz budować ścieżki z tego, co zwraca.
Unieważnianie transientu
Transient bez unieważniania to nowy błąd w miejsce starego: redaktor zmienia nazwę kategorii i przez dobę nie widzi efektu. Drzewo zmienia się przy dwóch rodzajach zdarzeń — przy edycji samych terminów i przy zmianie tego, co jest do nich przypisane, bo od przypisań zależą liczniki:
add_action('created_oferta_kategoria', 'motyw_wyczysc_drzewo');
add_action('edited_oferta_kategoria', 'motyw_wyczysc_drzewo');
add_action('delete_oferta_kategoria', 'motyw_wyczysc_drzewo');
add_action('set_object_terms', function ($id, $terminy, $tt, $taksonomia): void {
if ($taksonomia === 'oferta_kategoria') {
motyw_wyczysc_drzewo();
}
}, 10, 4);
add_action('transition_post_status', function ($nowy, $stary, $post): void {
if ($nowy !== $stary && $post->post_type === 'oferta') {
motyw_wyczysc_drzewo();
}
}, 10, 3);
add_action('before_delete_post', function ($id, $post): void {
if ($post->post_type === 'oferta') {
motyw_wyczysc_drzewo();
}
}, 10, 2);
function motyw_wyczysc_drzewo(): void {
delete_transient('motyw_drzewo_oferta_kategoria');
}
Trzy pierwsze to dynamiczne akcje — w nazwie siedzi slug taksonomii, więc dla category będzie edited_category.
Kuszące jest zastąpienie trzech ostatnich jednym save_post bez warunku i sam tak najpierw napisałem. To kasuje transient przy każdej rewizji, przy autozapisie co minutę i przy zapisie dowolnej strony w całym serwisie, także takiej, która z tą taksonomią nie ma nic wspólnego. Na serwisie, w którym ktoś pisze, cache przestaje wtedy istnieć w praktyce, a ty masz złudzenie, że działa.
set_object_terms łapie dokładnie to zdarzenie, które zmienia drzewo: podpięcie albo odpięcie wpisu od kategorii. transition_post_status łapie publikację, przeniesienie do kosza i przywrócenie, bo każde z nich rusza licznikiem. before_delete_post domyka trwałe usunięcie.
Czas życia ustawiony na DAY_IN_SECONDS jest tu tylko zabezpieczeniem na wypadek zdarzenia, którego nie przewidziałem. Przy sprawnym unieważnianiu transient i tak znika wcześniej.
Jedno takie zdarzenie warto wymienić, bo żaden z powyższych hooków go nie łapie: w transiencie leżą gotowe adresy z get_term_link(). Ani zmiana struktury bezpośrednich odnośników w ustawieniach, ani zmiana sluga w register_taxonomy() nie rusza terminów ani przypisań, więc menu przez dobę prowadzi pod stare adresy.
Pewne wyjście to nie przechowywać adresów: trzymaj w transiencie same slugi i składaj adres przy renderze. Dopięcie się do permalink_structure_changed zamyka tylko połowę problemu, bo ta akcja odpala się przy zapisie ustawień odnośników, a zmiana sluga taksonomii dzieje się w kodzie i żadnego zdarzenia w bazie nie zostawia.
Drugi filtr: permalinki
Ten sam motyw miał w permalinkach produktów placeholder podmieniany na slug kategorii:
add_filter('post_type_link', 'motyw_permalink_produktu', 10, 2);
function motyw_permalink_produktu(string $odnosnik, WP_Post $post): string {
$terminy = wp_get_object_terms($post->ID, 'oferta_kategoria');
if (empty($terminy) || is_wp_error($terminy)) {
return $odnosnik;
}
return str_replace('%oferta_kategoria%', $terminy[0]->slug, $odnosnik);
}
wp_get_object_terms() odpytuje bazę przy każdym wywołaniu i nie sprawdza po drodze cache obiektów. Listing dwudziestu produktów to dwadzieścia zapytań na same odnośniki — a post_type_link odpala się także przy generowaniu mapy witryny i kanałów RSS, gdzie pozycji bywają setki.
Zamiennik to get_the_terms(). Ta funkcja pyta najpierw cache obiektów i dopiero przy pudle schodzi do bazy, a wynik odkłada dla tego wpisu.
Wdrożona poprawka na tym się skończyła: podmiana jednej funkcji na drugą i tyle. To wystarczyło, żeby zapytania zniknęły. Poniżej wersja, którą napisałbym dziś — różni się od wdrożonej trzema rzeczami i żadna z nich nie dotyczy wydajności:
add_filter('post_type_link', 'motyw_permalink_produktu', 10, 2);
function motyw_permalink_produktu(string $odnosnik, WP_Post $post): string {
if (!str_contains($odnosnik, '%oferta_kategoria%')) {
return $odnosnik;
}
$terminy = get_the_terms($post, 'oferta_kategoria');
if (empty($terminy) || is_wp_error($terminy)) {
return str_replace('%oferta_kategoria%', 'bez-kategorii', $odnosnik);
}
return str_replace('%oferta_kategoria%', reset($terminy)->slug, $odnosnik);
}
Wczesny return. Filtr post_type_link dostaje odnośniki wszystkich typów treści, także tych, które żadnego placeholdera nie mają. Sprawdzenie str_contains() kosztuje ułamek mikrosekundy i wycina większość wywołań przed dotknięciem taksonomii. W oryginale każdy odnośnik strony i wpisu przechodził przez pełne zapytanie, którego wynik i tak trafiał do str_replace() bez efektu.
Zapasowy slug. Wersja z oryginału zwracała przy braku terminu adres z niepodmienionym %oferta_kategoria% w środku. Taki odnośnik trafia do HTML-u i do mapy witryny.
reset() zamiast [0]. get_the_terms() zwraca tablicę, której klucze nie muszą zaczynać się od zera — przy odczycie z cache bywają zachowane identyfikatory terminów. $terminy[0] daje wtedy null i ostrzeżenie.
Ten ostatni punkt ma wersję, która wygląda na naprawioną, a nie jest. Kod, w którym potrzebny jest drugi termin, a pierwszy służy za zapas, kusi żeby napisać:
$termin = isset($terminy[1]) ? $terminy[1] : $terminy[0];
isset() na [1] zabezpiecza jeden indeks i zostawia drugi gołym dostępem. Gdy klucze nie będą listą od zera, [1] nie istnieje, warunek grzecznie przechodzi do [0] — którego też nie ma — i ostrzeżenie leci mimo wszystko. Założenie o kształcie kluczy zostało, tylko przesunięte o jedną linię. array_values() na wejściu albo reset() rozwiązuje to u źródła.
Jak sprawdzić, że poprawka zadziałała
Zapal licznik z pierwszej sekcji drugi raz i porównaj wpisy z tymi sprzed zmiany. Pomiar przed i po nie wymaga ciągłego działania, tylko dwóch krótkich okien na tej samej podstronie i przy tej samej liczbie kategorii. Spodziewasz się trzech rzeczy:
Pierwsze wyświetlenie po wyczyszczeniu transientu ma o tyle zapytań mniej, ile węzłów liczyło drzewo razy dwa. Menu zostaje przy kilku.
Kolejne wyświetlenia schodzą do dwóch po stronie menu — timeout i wartość transientu z wp_options.
Liczba przestaje zależeć od liczby kategorii. To jest właściwy test. Dodaj w panelu dziesięć kategorii i odśwież stronę — wcześniej licznik rósł razem z nimi, po poprawce stoi w miejscu. Dopóki rośnie, gdzieś w kodzie została pętla pytająca bazę.
Gdzie jeszcze szukać tego wzorca
Trzy miejsca, w których ten sam błąd powtarza się w motywach najczęściej:
Pętla po wpisach z get_post_meta() bez przygotowania cache. Sama funkcja korzysta z cache, ale zapełnia go dopiero przy pierwszym odczycie danego wpisu, a WP_Query z update_post_meta_cache ustawionym na false odbiera jej to przygotowanie. Sprawdź argumenty zapytania, zanim zaczniesz przepisywać pętlę.
get_field() z ACF w pętli po elementach powtarzalnych. Każde wywołanie to odczyt metadanych, a przy polach typu relacja także pobranie obiektu wpisu. Wariant get_fields() dla całego wpisu naraz bywa o rząd wielkości tańszy.
Liczniki w listingach. wp_count_posts() albo własne COUNT(*) wywoływane dla każdej kategorii na liście. To dokładnie ten sam kształt co sprawdzanie dzieci w menu: pytanie zadawane per element, gdy jedno zapytanie grupujące załatwia całość.
Pytania, które padają przy takiej poprawce
Czy nie prościej wrzucić wtyczkę cache'ującą stronę? Cache stron ukryje objaw dla niezalogowanych i nie zmieni nic dla redaktora, dla koszyka ani dla żądań, które omijają cache. Kod wykonujący trzysta zapytań na żądanie dalej je wykonuje, po prostu rzadziej. Wtyczka jest warstwą na wierzchu poprawnego kodu.
Czy transient nie zaśmieci bazy?
Transient bez persystentnego cache obiektów ląduje w tabeli wp_options. Jeden wpis o rozmiarze kilku kilobajtów to żaden koszt, ale ten rozmiar zależy wyłącznie od tego, co do niego włożysz. Przykład wyżej odkłada sześć pól na termin i przy stu kategoriach mieści się w kilku kilobajtach. Wrzucenie tam pełnych obiektów WP_Term daje przy tej samej liczbie kategorii kilkadziesiąt kilobajtów, a przy tysiącu terminów sama deserializacja przy każdym żądaniu potrafi kosztować więcej niż zapytanie, przed którym transient miał chronić. Trzymaj w nim to, czego naprawdę używa szablon.
Druga pułapka to transienty z unikalnym kluczem na każdą kombinację parametrów — tych potrafią urosnąć tysiące i po wygaśnięciu nikt ich nie sprząta.
Zapytania liczne kontra zapytania kosztowne — jak je odróżnić? Z samej liczby się nie da i to jest ważne rozróżnienie. Query Monitor podaje czas każdego zapytania i sumę dla całego żądania — dwieście zapytań po 0,1 ms to 20 ms i przepisywanie filtra się za to nie zwróci. Sprawdź sumę, zanim zaczniesz. Druga rzecz warta sprawdzenia to koszt po stronie PHP: przy setkach pozycji menu samo składanie obiektów potrafi ważyć więcej niż baza, a wtedy cache na poziomie transientu daje więcej niż optymalizacja zapytań.
Czy hide_empty => false nie spowalnia zapytania?
Samo hide_empty => false je upraszcza, bo hide_empty => true dokłada złączenie z tabelą relacji i warunek na liczbę wpisów. Przy budowaniu drzewa i tak potrzebujesz pełnej listy, żeby poznać relacje rodzic–dziecko.
Koszt wraca przez pad_counts => true, bez którego filtrowanie po count jest błędne. WordPress odpala wtedy _pad_term_counts(), czyli dodatkowy odczyt tabeli relacji i statusów wpisów. To jedno czy dwa zapytania na całą taksonomię, nie jedno na termin — czyli dokładnie ta zamiana, o którą w tym tekście chodzi.
Pomiar przed poprawką jest tu ważniejszy od samej poprawki. Kod, który wygląda na kosztowny, bywa niewidoczny w profilu, a kilkaset zapytań po 0,1 ms nie jest problemem wartym przepisywania filtra. Zacznij od get_num_queries() i od czasów w Query Monitorze, a dopiero potem otwieraj functions.php.