
Nu este un secret pentru nimeni că motorul de căutare al WordPress este un dezastru. Din punct de vedere estetic, problema se poate rezolva relativ ușor; cu toate acestea, funcționarea sa este cu totul altă poveste, întrucât nu oferă nicio opțiune de configurare, iar performanța sa este, pe deasupra, lamentabilă.
Atunci când un site WordPress acumulează mii de articole, motorul de căutare nativ începe să dea erori. Se încetinește până când devine practic inutil. Atunci când se efectuează o căutare, WordPress execută o interogare de tip LIKE %cuvânt% în baza de date, care scanează întregul conținut al tabelului wp_posts. Acest lucru generează interogări SQL care pot dura de la câteva secunde până la o eternitate și pot provoca vârfuri de memorie care ajung să blocheze serverul.
Exact asta mi se întâmpla deja. Cu 6040 de postări publicate (doar în spaniolă), timpii de răspuns la căutările cu cele mai multe rezultate erau un adevărat coșmar, depășind 10 secunde sau chiar mai mult. Și a venit momentul să rezolv problema odată pentru totdeauna.
După ce m-am gândit îndelung la asta, am constatat că existau cel puțin trei posibilități de a rezolva problema. Prima și cea mai simplă era să apelez la un plugin cu plată, precum Relevanssi (versiunea sa gratuită fiind insuficientă), sau la un serviciu extern, precum Algolia, opțiune pe care am exclus-o de la bun început, deoarece ceea ce îmi doresc este să eficientizez căutările în mod nativ.
Dintre cele două opțiuni rămase, am încercat să creez un index FULLTEXT în MySQL folosind interogări MATCH() AGAINST() și am eșuat lamentabil. În ciuda ajustării parametrilor motorului InnoDB și a optimizării indexurilor, MySQL continua să se blocheze la procesarea a mii de cuvinte-cheie complexe, generând timpi de răspuns inacceptabili și saturând serverul. Atunci am renunțat complet la ideea de a lăsa sarcina grea în seama bazei de date și am optat pentru a treia variantă: construirea unui index static în JSON.
În acest tutorial se încearcă explicarea modului în care se poate rezolva această problemă prin înlocuirea interogărilor grele din MySQL cu un fișier JSON static care funcționează ca un index de căutare extrem de rapid.
Pasul 1: Identificarea și eliminarea interogărilor greoaie sau redundante
Înainte de a implementa indexul JSON, este esențial să pregătiți terenul. Multe pluginuri (cum ar fi cele pentru directoare, layout sau căutări secundare) se conectează la procesul de căutare al WordPress prin intermediul unor hook-uri (pre_get_posts, posts_where etc.), generând interogări suplimentare și inutile către baza de date.
Cum se pot identifica aceste interogări cu ajutorul Query Monitor?
- Instalează și activează pluginul gratuit Query Monitor.
- Efectuează orice căutare pe site-ul tău.
- Deschide meniul derulant al Query Monitor din bara de sus și selectează „Interogări de bază de date” (Database Queries).
- Filtrează după „Interogări lente” sau examinează lista pentru a identifica interogările care durează mai mult. De asemenea, notează și interogările duplicate și, dacă poți, rezolvă și problema aceasta.
- Uită-te la coloana cu detalii din tabele: acolo vei vedea exact ce plugin sau fișier execută acea interogare.
În cazul meu, a trebuit să rezolv câteva interogări duplicate ale pluginului Name_Directory și câteva blocuri GenerateBlocks din șablonul de postare, dar orice plugin, cod personalizat sau element adăugat poate genera interogări care încetinesc răspunsul.
Cum se pot dezactiva aceste hook-uri prin cod
Odată ce ai identificat numele funcției sau clasei care provoacă încetinirea interogării, poți să o dezactivezi în fișierul functions.php sau folosind un plugin de fragmente de cod (cum ar fi Code Snippets, Code de la Perfmatters sau altele similare):
* Notă. În blocul 1 trebuie să adaugi hook-urile care generează interogări lente. Dacă nu este cazul tău, poți elimina această parte și adăuga doar blocul 2 în fragmentul de cod.
/**
* 1. Desactivar funciones invasivas o redundantes en las búsquedas globales de WP
*/
add_action('init', function() {
// Ejemplo: Si Query Monitor muestra que un plugin de directorio se ejecuta en pre_get_posts:
// remove_action('hook_de_wordpress', 'nombre_de_la_funcion_del_plugin', prioridad);
remove_action('pre_get_posts', 'name_directory_search', 10);
remove_filter('posts_where', 'name_directory_insert_sitewide_search_results', 10);
}, 20);
/**
* 2. Bloquear la búsqueda SQL nativa tipo LIKE %palabra% en MySQL
*/
add_filter('posts_search', function($search, $wp_query) {
if (!is_admin() && is_search()) {
return ''; // Neutraliza el LIKE %palabra% en MySQL globalmente en páginas de búsqueda
}
return $search;
}, 10, 2);
Notă: Pentru a dezactiva un hook cu ajutorul funcțiilor `remove_action` sau `remove_filter`, trebuie să folosești exact același nume de eveniment (`pre_get_posts`), același nume al funcției pe care l-ai identificat în Query Monitor și aceeași prioritate cu care a fost adăugat (implicit 10).
Pasul 2: Generarea și actualizarea fișierului JSON de căutare
Pentru a înlocui MySQL, trebuie să creăm un fișier static în format JSON care să conțină datele cheie ale articolelor noastre (ID, titlu și text simplu). Acest fișier va fi salvat în folderul wp-content/uploads/ și va fi actualizat automat de fiecare dată când creezi, editezi sau ștergi un articol.
PHP
/**
* Actualizar el archivo search-es.json automáticamente al guardar o borrar un post
*/
add_action('save_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 3);
add_action('deleted_post', 'mi_sitio_actualizar_indice_busqueda_json', 10, 1);
function mi_sitio_actualizar_indice_busqueda_json($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') return;
// Opcional: Filtrar por idioma si usas un plugin multilingüe como Polylang
if (function_exists('pll_get_post_language') && pll_get_post_language($post_id) !== 'es') {
return;
}
mi_sitio_reconstruir_indice_json_completo();
}
/**
* Función que recorre todas las entradas publicadas y crea el JSON
*/
function mi_sitio_reconstruir_indice_json_completo() {
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'ASC',
'fields' => 'ids', // Solo IDs para optimizar memoria
);
if (function_exists('pll_current_language')) {
$args['lang'] = 'es';
}
$all_post_ids = get_posts($args);
$indice = array();
foreach ($all_post_ids as $id) {
$titulo = get_the_title($id);
$contenido = get_post_field('post_content', $id);
$indice[] = array(
'i' => (int)$id,
't' => (string)$titulo,
'k' => (string)wp_strip_all_tags($contenido),
);
}
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
file_put_contents($json_path, json_encode($indice, JSON_UNESCAPED_UNICODE));
}
Configurare inițială: La prima instalare a acestui fragment de cod, poți rula temporar funcția mi_sitio_reconstruir_indice_json_completo(); sau pur și simplu să salvezi/actualizezi orice articol existent, pentru ca fișierul search-es.json să fie creat pentru prima dată. După ce ați făcut acest lucru, accesați directorul wp-content/uploads/ pentru a vă asigura că fișierul a fost creat și pentru a afla dimensiunea acestuia.
Ca să-ți faci o idee despre dimensiunea aproximativă a acestui fișier search-es.json, pentru cele 6.040 de intrări ale mele s-a generat un fișier de 3,41 Mb, o dimensiune destul de ușor de gestionat, întrucât până când nu începe să depășească 15 sau 20 MB (echivalentul a aproximativ 30.000 sau 40.000 de intrări extinse) nu trebuie să-ți faci griji, deoarece fișierele JSON statice sunt încărcate direct în memoria cache a serverului sau a PHP (file_get_contents) dintr-o singură mișcare, iar o dimensiune de câteva megaocteți este citită în mai puțin de o miime de secundă; totuși, dacă fișierul crește excesiv, de fiecare dată când se execută o căutare, PHP va consuma un vârf suplimentar de memorie RAM, inutil pentru încărcarea unui fișier JSON uriaș; în acest moment, ar fi indicat să se ia în considerare indexarea doar a titlurilor și a extraselor, în loc de întregul conținut, sau migrarea către o soluție de căutare indexată într-o bază de date dedicată.
Pasul 3: Funcție auxiliară pentru standardizarea căutărilor (fără accente)
Pentru a ne asigura că căutările găsesc rezultate indiferent de modul în care scrie utilizatorul (cu sau fără accente, cu majuscule sau minuscule), se definește o funcție de curățare:
PHP
/**
* Función auxiliar para eliminar acentos y pasar a minúsculas
*/
if (!function_exists('mi_sitio_limpiar_acentos')) {
function mi_sitio_limpiar_acentos($cadena) {
$cadena = mb_strtolower($cadena, 'UTF-8');
$string = array(
'á'=>'a', 'à'=>'a', 'ä'=>'a', 'â'=>'a', 'ª'=>'a', 'Á'=>'a', 'À'=>'a', 'Ä'=>'a', 'Â'=>'a',
'é'=>'e', 'è'=>'e', 'ë'=>'e', 'ê'=>'e', 'É'=>'e', 'È'=>'e', 'Ë'=>'e', 'Ê'=>'e',
'í'=>'i', 'ì'=>'i', 'ï'=>'i', 'î'=>'i', 'Í'=>'i', 'Ì'=>'i', 'Ï'=>'i', 'Î'=>'i',
'ó'=>'o', 'ò'=>'o', 'ö'=>'o', 'ô'=>'o', 'Ó'=>'o', 'Ò'=>'o', 'Ö'=>'o', 'Ô'=>'o',
'ú'=>'u', 'ù'=>'u', 'ü'=>'u', 'û'=>'u', 'Ú'=>'u', 'Ù'=>'u', 'Ü'=>'u', 'Û'=>'u',
'ñ'=>'n', 'Ñ'=>'n'
);
return strtr($cadena, $string);
}
}
Pasul 4: Interceptarea interogării principale cu ajutorul funcției ` posts_pre_query`
Prin intermediul filtrului `posts_pre_query`, interogarea de căutare din WordPress este interceptată înainte de a ajunge la baza de date. Se citește fișierul JSON, se identifică ID-urile care corespund termenilor căutați și se returnează către WordPress doar acele intrări, specificând paginarea corespunzătoare.
PHP
/**
* Interceptación de la búsqueda nativa leyendo el archivo JSON
*/
add_filter('posts_pre_query', function($posts, $wp_query) {
if (!is_admin() && $wp_query->is_search() && $wp_query->is_main_query()) {
if (function_exists('pll_current_language')) {
if (pll_current_language() !== 'es') return $posts;
}
$s = $wp_query->get('s');
if (empty($s)) return array();
$upload_dir = wp_upload_dir();
$json_path = $upload_dir['basedir'] . '/search-es.json';
if (!file_exists($json_path)) return $posts;
$indice = json_decode(file_get_contents($json_path), true);
if (!is_array($indice)) return $posts;
$s_clean = mi_sitio_limpiar_acentos(wp_strip_all_tags($s));
$palabras_buscadas = array_filter(explode(' ', $s_clean));
if (empty($palabras_buscadas)) return array();
$matched_ids = array();
// Recorrer el índice acumulando coincidencias
foreach ($indice as $item) {
$titulo_clean = mi_sitio_limpiar_acentos($item['t']);
$texto_clean = mi_sitio_limpiar_acentos($item['k']);
$coinciden = true;
foreach ($palabras_buscadas as $palabra) {
if (mb_strlen($palabra, 'UTF-8') < 2) continue; // Ignorar palabras de 1 letra
if (mb_strpos($titulo_clean, $palabra) === false && mb_strpos($texto_clean, $palabra) === false) {
$coinciden = false;
break;
}
}
if ($coinciden) {
$matched_ids[] = (int)$item['i'];
}
}
// Si no hay resultados, devolver array vacío
if (empty($matched_ids)) {
$wp_query->found_posts = 0;
$wp_query->max_num_pages = 0;
return array();
}
// Paginación
$posts_per_page = (int)get_option('posts_per_page', 10);
$paged = max(1, (int)$wp_query->get('paged'));
$total_encontrados = count($matched_ids);
$wp_query->found_posts = $total_encontrados;
$wp_query->max_num_pages = ceil($total_encontrados / $posts_per_page);
// Recuperar solo los objetos de los posts encontrados, ordenados por fecha
return get_posts(array(
'post__in' => $matched_ids,
'orderby' => 'date',
'order' => 'DESC',
'posts_per_page' => $posts_per_page,
'paged' => $paged,
'post_type' => 'post',
'no_found_rows' => true,
'cache_results' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'suppress_filters' => true,
));
}
return $posts;
}, 10, 2);
Pasul 5: Verificarea rezultatelor
După ce ai parcurs pașii anteriori, efectuează un test de căutare și deschide Query Monitor:
- Durata totală a operațiunilor de baze de date: Vei observa că aceasta a scăzut de la câteva secunde la doar câteva milisecunde (de obicei între 0,03 s și 0,2 s).
- Interogări SQL: Clauza LIKE %termen% va dispărea complet.
- Memorie RAM: Consumul maxim de memorie va rămâne la un nivel extrem de scăzut (în jur de 30 MB).
- Sortare: Articolele vor fi afișate în ordine cronologică strict descendentă (de la cel mai recent la cel mai vechi), respectând paginarea nativă a WordPress.
Sunt mai mult decât mulțumit de rezultat. Nu numai că am rezolvat o problemă cu care mă confruntam de ani de zile, dar acum rezultatul este pur și simplu spectaculos. Chiar și în cazul uneia dintre cele mai solicitante căutări, cum ar fi aceasta cu 4887 de rezultate pentru„viñeta”, motorul de căutare răspunde foarte rapid. În cazul căutărilor cu mai puține rezultate, se poate spune că acestea sunt afișate instantaneu. Poți verifica acest lucru căutând orice altceva.







