
ليس سراً أن محرك البحث في ووردبريس سيئ للغاية. من الناحية الجمالية، من السهل نسبياً حل هذه المشكلة، لكن من ناحية الأداء، فالأمر مختلف تماماً، حيث لا يوفر أي خيارات للتكوين، بل إن أداءه سيئ للغاية.
عندما يضم موقع ووردبريس آلاف المنشورات، يبدأ محرك البحث المدمج في التعطل. فيتباطأ أداءه إلى درجة يصبح فيها عديم الفائدة عمليًّا. عند إجراء بحث، يقوم ووردبريس بتنفيذ استعلام من نوع LIKE %كلمة% في قاعدة البيانات، والذي يقوم بمسح محتوى جدول wp_posts بالكامل. وهذا يؤدي إلى إنشاء استعلامات SQL قد تستغرق من بضع ثوانٍ إلى وقت طويل جدًّا، مما يتسبب في ارتفاعات حادة في استهلاك الذاكرة تنتهي بتعطيل الخادم.
هذا بالضبط ما كنت أعاني منه بالفعل. مع وجود 6040 منشورًا (باللغة الإسبانية فقط)، كانت أوقات الاستجابة لعمليات البحث التي تعطي عددًا كبيرًا من النتائج جحيمًا حقيقيًّا، حيث كانت تستغرق أكثر من 10 ثوانٍ أو أكثر. وحان الوقت لحل هذه المشكلة مرة واحدة وإلى الأبد.
بعد تفكير طويل، وجدت أن هناك ثلاث طرق على الأقل لحل المشكلة. الطريقة الأولى والأبسط كانت استخدام مكون إضافي مدفوع مثل Relevanssi (إذ إن نسخته المجانية غير كافية) أو خدمة خارجية مثل Algolia، وهو ما استبعدته من البداية لأن ما أسعى إليه هو تسريع عمليات البحث بطريقة أصلية.
من بين الخيارين المتبقيين، جربت إنشاء فهرس FULLTEXT في MySQL باستخدام استعلامات MATCH() AGAINST())، لكنني فشلت فشلاً ذريعاً. على الرغم من ضبط معلمات محرك InnoDB وتحسين الفهارس، كان MySQL لا يزال يتعطل عند معالجة آلاف الكلمات المفتاحية المعقدة، مما أدى إلى أوقات استجابة غير مقبولة وإثقال كاهل الخادم. عندها استبعدت تمامًا فكرة إلقاء العبء الثقيل على عاتق قاعدة البيانات، واخترت الخيار الثالث، وهو إنشاء فهرس ثابت في JSON.
يهدف هذا الدرس التعليمي إلى شرح كيفية حل هذه المشكلة عن طريق استبدال استعلامات MySQL الثقيلة بملف JSON ثابت يعمل كفهرس بحث فائق السرعة.
الخطوة 1: اكتشاف الاستعلامات الثقيلة أو المكررة وإزالتها
قبل تطبيق فهرس JSON، من الضروري تهيئة البيئة أولاً. فالعديد من المكونات الإضافية (مثل أدلة المواقع، وأدوات تصميم الصفحات، أو المكونات الإضافية الخاصة بالبحث الثانوي) تتدخل في عملية البحث في ووردبريس من خلال «الخطافات» (pre_get_posts، posts_where، إلخ)، مما يؤدي إلى إرسال استعلامات إضافية وغير ضرورية إلى قاعدة البيانات.
كيف يمكن رصد هذه الاستعلامات باستخدام «Query Monitor»؟
- قم بتثبيت المكون الإضافي المجاني «Query Monitor» وتفعيله.
- قم بإجراء أي بحث على موقعك الإلكتروني.
- افتح القائمة المنسدلة لـ «Query Monitor» في الشريط العلوي واختر «استعلامات قاعدة البيانات» (Database Queries).
- قم بالتصفية حسب «الاستعلامات البطيئة» أو راجع القائمة بحثًا عن الاستعلامات التي تستغرق وقتًا أطول. وبالمناسبة، قم أيضًا بتدوين الاستعلامات المكررة، وحاول حل هذه المشكلة إن أمكن.
- انظر إلى عمود التفاصيل في الجداول: سترى هناك بالضبط المكون الإضافي أو الملف الذي يقوم بتنفيذ تلك الاستعلام.
في حالتي، اضطررت إلى حل مشكلة عدد قليل من الاستعلامات المكررة الخاصة بالمكوّن الإضافي Name_Directory، وبعض كتل GenerateBlocks في قالب المنشور، لكن أي مكوّن إضافي أو كود مخصص أو عنصر مضاف قد يكون سببًا في توليد استعلامات تؤدي إلى إبطاء الاستجابة.
كيفية تعطيل هذه الـ"هوكات" عبر الكود
بمجرد تحديد اسم الدالة أو الفئة التي تتسبب في بطء الاستعلام، يمكنك تعطيلها في ملف functions.php الخاص بك أو باستخدام مكون إضافي لكتل الكود (مثل Code Snippets أو Code من Perfmatters أو ما شابه):
* ملاحظة: في المقطع 1، يجب عليك إضافة «الهوكات» التي تتسبب في استعلامات بطيئة. إذا لم يكن هذا هو حالتك، فيمكنك حذف هذا الجزء وإضافة المقطع 2 فقط إلى المقتطف.
/**
* 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);
ملاحظة: لإلغاء ربط هوك باستخدام remove_action أو remove_filter، يجب عليك استخدام نفس اسم الحدث بالضبط (pre_get_posts)، ونفس اسم الدالة التي حددتها في Query Monitor، ونفس الأولوية التي أُضيف بها (الافتراضية هي 10).
الخطوة 2: إنشاء ملف JSON الخاص بالبحث وتحديثه باستمرار
لاستبدال MySQL، نحتاج إلى إنشاء ملف ثابت بتنسيق JSON يحتوي على البيانات الأساسية لمقالاتنا (المعرف، والعنوان، والنص العادي). سيتم حفظ هذا الملف في المجلد wp-content/uploads/ وسيتم تحديثه تلقائيًا في كل مرة تقوم فيها بإنشاء مقال أو تعديله أو حذفه.
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));
}
الإعداد الأولي: عند تثبيت هذا المقتطف لأول مرة، يمكنك تشغيل الدالة my_site_rebuild_full_json_index(); مؤقتًا، أو ببساطة حفظ/تحديث أي مشاركة موجودة حتى يتم إنشاء ملف search-es.json لأول مرة. بمجرد الانتهاء من ذلك، قم بزيارة المسار wp-content/uploads/ للتأكد من إنشائه ومعرفة حجمه.
لكي تتصور الحجم التقريبي لملف search-es.json هذا، فقد تم إنشاء ملف بحجم 3,41 ميغابايت لـ 6040 إدخالاً لدي، وهو حجم يمكن التعامل معه بسهولة، حيث لا داعي للقلق ما دام الحجم لا يتجاوز 15 أو 20 ميغابايت (ما يعادل حوالي 30.000 أو 40.000 إدخالًا طويلًا)، فلا داعي للقلق، لأن ملفات JSON الثابتة تُحمَّل مباشرةً إلى ذاكرة التخزين المؤقتة للخادم أو لـ PHP (file_get_contents) دفعة واحدة، ويُقرأ حجم يبلغ بضعة ميغابايت في أقل من جزء من الألف من الثانية؛ ومع ذلك، إذا نما حجم الملف بشكل مفرط، ففي كل مرة يتم فيها تنفيذ عملية بحث، سيستهلك PHP ذروة إضافية غير ضرورية من ذاكرة الوصول العشوائي (RAM) لتحميل ملف JSON ضخم، وفي هذه الحالة يكون من الأفضل التفكير في فهرسة العناوين والمقتطفات فقط بدلاً من المحتوى الكامل، أو الانتقال إلى حل بحث مفهرس في قاعدة بيانات مخصصة.
الخطوة 3: وظيفة مساعدة لتوحيد عمليات البحث (بدون علامات التشكيل)
لضمان حصول عمليات البحث على نتائج بغض النظر عن طريقة كتابة المستخدم (سواء مع أو بدون علامات التشكيل، أو بأحرف كبيرة أو صغيرة)، يتم تعريف دالة تنقية:
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);
}
}
الخطوة 4: اعتراض الاستعلام الرئيسي باستخدام posts_pre_query
من خلال مرشح posts_pre_query، يتم اعتراض استعلام البحث في ووردبريس قبل أن يصل إلى قاعدة البيانات. يتم قراءة ملف JSON، والعثور على المعرّفات التي تتطابق مع مصطلحات البحث، ثم يتم إرجاع تلك المدخلات فقط إلى ووردبريس مع تحديد الترقيم المناسب.
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);
الخطوة 5: التحقق من النتائج
بعد إتمام الخطوات السابقة، قم بإجراء اختبار بحث وافتح «Query Monitor»:
- الوقت الإجمالي لعملية BD: ستلاحظ أن هذا الوقت قد انخفض الآن من عدة ثوانٍ إلى بضعة ميلي ثوانٍ فقط (عادةً ما يتراوح بين 0,03 ثانية و0,2 ثانية).
- استعلامات SQL: ستختفي عبارة LIKE %المصطلح% تمامًا.
- ذاكرة الوصول العشوائي (RAM): سيظل الاستهلاك الأقصى للذاكرة منخفضًا بشكل استثنائي (حوالي 30 ميغابايت).
- الترتيب: سيتم عرض المشاركات بترتيب زمني تنازلي صارم (من الأحدث إلى الأقدم)، مع الحفاظ على الترقيم الأصلي لصفحات ووردبريس.
أنا أكثر من راضٍ عن النتيجة. لم أكتفِ فقط بالتخلص من مشكلة كانت تلازمني منذ سنوات، بل إن النتيجة الآن لا تقل عن كونها مذهلة. في إحدى عمليات البحث الأكثر تعقيدًا، مثل هذه التي تضم 4887 نتيجة لعبارة: "viñeta"، يستجيب محرك البحث بسرعة كبيرة. أما بالنسبة لعمليات البحث التي تحتوي على عدد أقل من النتائج، فيمكن القول إنها تظهر على الفور. يمكنك التحقق من ذلك بالبحث عن أي شيء آخر.






