
ไม่ใช่ความลับเลยว่าฟังก์ชันการค้นหาของ WordPress นั้นแย่มาก ในด้านความสวยงามการแก้ไขนั้นค่อนข้างง่าย แต่ด้านฟังก์ชันการทำงานนั้นเป็นเรื่องที่ต่างออกไปโดยสิ้นเชิง เพราะมันไม่มีตัวเลือกการตั้งค่าใดๆ เลย – และที่แย่ที่สุดคือประสิทธิภาพของมันนั้นต่ำมาก
เมื่อเว็บไซต์ WordPress มีบทความสะสมถึงหลายพันบทความ ฟังก์ชันการค้นหาที่มาพร้อมกับระบบจะเริ่มทำงานผิดปกติ จนช้าลงจนถึงขั้นที่แทบจะใช้งานไม่ได้เลย เมื่อคุณทำการค้นหา WordPress จะรันคำสั่งLIKE %word%บนฐานข้อมูล ซึ่งจะสแกนเนื้อหาทั้งหมดในตารางwp_posts สิ่งนี้ก่อให้เกิดคำสั่ง SQL ที่อาจใช้เวลาตั้งแต่ไม่กี่วินาทีไปจนถึงนานมาก และก่อให้เกิดการเพิ่มขึ้นของหน่วยความจำอย่างกะทันหัน ซึ่งอาจทำให้เซิร์ฟเวอร์ล่มได้
นี่คือสิ่งที่ผมกำลังประสบอยู่พอดีด้วยจำนวนบทความที่เผยแพร่แล้ว 6,040 บทความ(เป็นภาษาสเปนเท่านั้น) เวลาตอบสนองในการค้นหาที่มีผลลัพธ์จำนวนมากนั้นเป็นฝันร้ายจริงๆ โดยต้องใช้เวลามากกว่า 10 วินาที หรือมากกว่านั้น และในที่สุดก็ถึงวันที่ต้องแก้ไขปัญหานี้ให้จบสิ้นไปเสียที
หลังจากคิดอย่างละเอียดแล้ว ผมก็ตระหนักว่ามีอย่างน้อยสามวิธีที่จะแก้ไขปัญหานี้ วิธีแรกและง่ายที่สุดคือใช้ปลั๊กอินแบบเสียเงิน เช่นRelevanssi(เวอร์ชันฟรีไม่เพียงพอ) หรือบริการภายนอก เช่นAlgolia ซึ่งผมได้ตัดทิ้งทันที เพราะสิ่งที่ผมต้องการคือการเพิ่มความเร็วในการค้นหาแบบในตัวระบบ
จากสองตัวเลือกที่เหลืออยู่ ผมได้ลองสร้างดัชนี FULLTEXT ใน MySQL โดยใช้คำสั่ง MATCH() AGAINST() แต่ล้มเหลวอย่างสิ้นเชิง แม้จะปรับพารามิเตอร์ของเอนจิน InnoDB และปรับแต่งดัชนีแล้ว แต่ MySQL ก็ยังคงทำงานช้าลงเมื่อประมวลผลคำสำคัญที่ซับซ้อนหลายพันคำ ส่งผลให้เวลาตอบสนองที่ยอมรับไม่ได้ และทำให้เซิร์ฟเวอร์ทำงานหนักเกินไป จึงเป็นตอนนั้นเองที่ผมตัดสินใจไม่วางภาระหนักลงบนฐานข้อมูลอีกต่อไป และเลือกใช้วิธีการที่สาม คือการสร้างดัชนีแบบคงที่ (static index) ในรูปแบบ JSON
คู่มือนี้มีวัตถุประสงค์เพื่ออธิบายวิธีแก้ไขปัญหานี้ โดยการแทนที่คำสั่ง MySQL ที่ใช้ทรัพยากรมากด้วยไฟล์ JSON แบบคงที่ ซึ่งทำหน้าที่เป็นดัชนีการค้นหาที่รวดเร็วเป็นพิเศษ
ขั้นตอน 1: ระบุและกำจัดคำสั่งค้นหาที่ทำงานช้าหรือซ้ำซ้อน
ก่อนที่จะนำดัชนี JSON มาใช้ จำเป็นต้องเตรียมระบบให้พร้อมก่อน โดยปลั๊กอินหลายตัว (เช่น ปลั๊กอินไดเรกทอรี ปลั๊กอินเลย์เอาต์ หรือปลั๊กอินค้นหาเสริม) จะเชื่อมต่อกับกระบวนการค้นหาของ WordPressผ่านฮุค(เช่น pre_get_posts, posts_where เป็นต้น) ซึ่งทำให้เกิดการเรียกใช้ฐานข้อมูลเพิ่มเติมและไม่จำเป็น
คุณสามารถระบุคำค้นหาเหล่านี้ได้อย่างไรโดยใช้ Query Monitor?
- ติดตั้งและเปิดใช้งานปลั๊กอินQuery Monitor ที่ใช้ฟรี
- ค้นหาได้ทุกที่บนเว็บไซต์ของคุณ
- เปิดเมนูแบบเลื่อนลง Query Monitor ในแถบด้านบน แล้วเลือก‘Database Queries’
- กรองตาม‘Slow queries’หรือดูผ่านรายชื่อเพื่อระบุคำสั่งที่ใช้เวลานานที่สุดในการทำงาน ขณะที่คุณกำลังทำสิ่งนี้ ให้บันทึกคำสั่งที่ซ้ำกันไว้ และหากทำได้ ก็แก้ไขปัญหาเหล่านั้นด้วย
- ลองดูคอลัมน์ ‘Details’ ในตาราง: ที่นั่นคุณจะเห็นได้อย่างชัดเจนว่าปลั๊กอินหรือไฟล์ใดกำลังดำเนินการคำสั่งนั้น
ในกรณีของผม ผมต้องแก้ไขคำถามที่ซ้ำกันไม่กี่ข้อจากปลั๊กอินName_Directory และบล็อกGenerateBlocksไม่กี่บล็อกในเทมเพลตโพสต์ แต่ปลั๊กอิน รหัสที่กำหนดเอง หรือองค์ประกอบที่เพิ่มเข้าไปใด ๆ ก็อาจสร้างคำถามที่ทำให้ความเร็วในการตอบสนองช้าลงได้
วิธีปิดใช้งาน hook เหล่านั้นผ่านโค้ด
เมื่อระบุชื่อฟังก์ชันหรือคลาสที่ก่อให้เกิดการค้นหาข้อมูลช้าแล้ว คุณสามารถปิดใช้งานมันในไฟล์functions.php หรือใช้ปลั๊กอินสำหรับโค้ดสแนปเพต (เช่นCode Snippets,PerfmattersCodeหรือปลั๊กอินที่คล้ายกัน):
* หมายเหตุ: ในบล็อก 1 คุณควรเพิ่ม hook ที่ก่อให้เกิดการค้นหาช้า หากกรณีนี้ไม่เกี่ยวข้องกับคุณ คุณสามารถลบส่วนนี้ออกและเพิ่มเพียงบล็อก 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 และระดับความสำคัญ (priority) ที่ใช้เมื่อเพิ่มการกระทำนั้น (ค่าเริ่มต้นคือ 10)
ขั้นตอนที่ 2: สร้างและอัปเดตไฟล์ JSON สำหรับการค้นหาให้ทันสมัยอยู่เสมอ
เพื่อแทนที่ MySQL เราต้องสร้างไฟล์แบบคงที่ในรูปแบบ JSON ที่ประกอบด้วยข้อมูลหลักของบทความ (ID, ชื่อ และข้อความธรรมดา) ไฟล์นี้จะถูกเก็บไว้ในโฟลเดอร์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 นี้ สำหรับข้อมูล 6,040 รายการของผมไฟล์ที่สร้างขึ้นมีขนาด 3.41 Mb – ซึ่งเป็นขนาดที่จัดการได้ค่อนข้างดี เพราะยังไม่ต้องกังวลจนกว่าขนาดไฟล์จะเริ่มเกิน 15 หรือ 20 MB (เทียบเท่ากับประมาณ 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`คำค้นหาของ WordPress จะถูกดักจับก่อนที่จะส่งไปยังฐานข้อมูล JSON จะถูกวิเคราะห์เพื่อระบุ ID ที่ตรงกับคำค้นหา และจะส่งเฉพาะโพสต์เหล่านั้นกลับไปยัง WordPress พร้อมกับการกำหนดการแบ่งหน้าให้เหมาะสม
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:
- เวลาทำงานของฐานข้อมูลรวม:คุณจะสังเกตเห็นว่าเวลานี้ได้ลดลงจากหลายวินาทีเหลือเพียงไม่กี่มิลลิวินาที (โดยทั่วไปอยู่ระหว่าง 0.03 วินาที ถึง 0.2 วินาที)
- คำสั่ง SQL:ข้อกำหนดLIKE %term%จะถูกลบออกอย่างสมบูรณ์
- RAM:การใช้หน่วยความจำสูงสุดจะยังคงอยู่ในระดับที่ต่ำมาก (ประมาณ 30 MB)
- การจัดเรียง:บทความจะแสดงตามลำดับเวลาที่ลดลงอย่างเคร่งครัด (จากบทความล่าสุดไปยังบทความเก่าที่สุด) โดยยังคงรักษาระบบการแบ่งหน้าตามค่าเริ่มต้นของ WordPress
ผมรู้สึกพอใจกับผลลัพธ์นี้มากกว่าที่คาดไว้ ไม่เพียงแต่ผมได้แก้ไขปัญหาที่ค้างคามานานหลายปีแล้ว แต่ตอนนี้ผลลัพธ์ที่ได้ก็น่าทึ่งอย่างยิ่ง แม้สำหรับการค้นหาที่ท้าทายที่สุด เช่นการค้นหานี้ที่มีผลลัพธ์ 4,887 รายการสำหรับคำว่า‘viñeta’ เครื่องมือค้นหาก็ตอบสนองได้อย่างรวดเร็วมาก สำหรับการค้นหาที่มีผลลัพธ์น้อยกว่า สามารถกล่าวได้ว่าผลลัพธ์ปรากฏขึ้นทันทีคุณสามารถตรวจสอบด้วยตัวเองได้โดยการค้นหาคำอื่น ๆ








