База данных — сердце любого WordPress сайта. За 16 лет разработки видел проекты, где неоптимизированная БД убивала производительность быстрее, чем все плагины вместе взятые. Один клиентский сайт с 50,000 постами загружался по 8 секунд — после глубокой оптимизации БД время сократилось до 1.2 секунды. Сегодня поделюсь техниками, которые действительно работают.
Анатомия проблемы: где кроется узкое горло
Таблица wp_postmeta — главный виновник
На проектах с большим количеством кастомных полей wp_postmeta превращается в монстра. У одного клиента эта таблица выросла до 2.5 миллионов строк, и каждый запрос к продуктам WooCommerce занимал 4-5 секунд.
Проверка размера таблиц:
sql<code>SELECT
TABLE_NAME AS 'Table',
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS 'Size (MB)',
TABLE_ROWS AS 'Rows'
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database_name'
AND TABLE_NAME LIKE 'wp_%'
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;
</code>Идентификация медленных запросов
Первый шаг — найти проблемные запросы. Query Monitor показывает их в режиме реального времени:
Альтернатива — MySQL Slow Query Log:
text<code># В my.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 2
log_queries_not_using_indexes = 1
</code>После активации анализируем лог:
bash<code><em># Топ 10 медленных запросов</em>
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log
</code>Индексация: правильная стратегия
Понимание EXPLAIN
EXPLAIN — мощнейший инструмент для анализа запросов:
sql<code>EXPLAIN SELECT p.ID, p.post_title, pm.meta_value
FROM wp_posts p
INNER JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'product'
AND p.post_status = 'publish'
AND pm.meta_key = '_price'
ORDER BY pm.meta_value DESC
LIMIT 20;
</code>Критические показатели в EXPLAIN:
| Колонка | Проблема | Решение |
|---|---|---|
| type: ALL | Полное сканирование таблицы | Добавить индекс |
| rows: >10000 | Слишком много строк | Оптимизировать WHERE |
| Extra: Using filesort | Сортировка вне индекса | Индекс для ORDER BY |
| Extra: Using temporary | Временная таблица | Переписать запрос |
Стратегические индексы для WordPress
Базовые индексы для wp_posts:
sql<code><em>-- Индекс для запросов по типу и статусу поста</em>
CREATE INDEX idx_post_type_status ON wp_posts(post_type, post_status);
<em>-- Индекс для сортировки по дате</em>
CREATE INDEX idx_post_date ON wp_posts(post_date);
<em>-- Композитный индекс для архивов</em>
CREATE INDEX idx_type_status_date ON wp_posts(post_type, post_status, post_date);
</code>Критически важные индексы для wp_postmeta:
sql<code><em>-- Базовый индекс (обычно уже есть)</em>
CREATE INDEX meta_key ON wp_postmeta(meta_key);
<em>-- Композитный индекс для частых запросов</em>
CREATE INDEX idx_post_meta_key ON wp_postmeta(post_id, meta_key);
<em>-- Для запросов с сортировкой по значению</em>
CREATE INDEX idx_meta_key_value ON wp_postmeta(meta_key, meta_value(50));
<em>-- Специфичный для WooCommerce цен</em>
CREATE INDEX idx_wc_price ON wp_postmeta(meta_key(20), meta_value(10))
WHERE meta_key = '_price';
</code>Индексы для wp_options (автозагрузка):
sql<code><em>-- Ускорение загрузки опций</em>
CREATE INDEX idx_autoload_option ON wp_options(autoload, option_name);
</code>Реальный кейс: WooCommerce оптимизация
На проекте интернет-магазина с 10,000+ товаров страница каталога загружалась 8 секунд:
Проблемный запрос:
sql<code>SELECT l.key_id, l.order_id, l.licence_key, s.status, pm.meta_value AS 'user_id'
FROM wp_woocommerce_software_licences l
INNER JOIN wp_woocommerce_software_subscriptions s ON s.key_id = l.key_id
INNER JOIN wp_posts p ON p.ID = l.order_id
INNER JOIN wp_postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order' AND pm.meta_value = 279
ORDER BY s.next_payment_date;
</code>EXPLAIN показал полное сканирование таблицы лицензий:
sql<code><em>-- Добавление индекса решило проблему</em>
CREATE INDEX idx_order_id ON wp_woocommerce_software_licences(order_id);
<em>-- Результат: запрос с 8 секунд до 2.1 секунды</em>
</code>Дальнейшая оптимизация через денормализацию:
sql<code><em>-- Добавление product_id напрямую в таблицу лицензий</em>
ALTER TABLE wp_woocommerce_software_licences
ADD COLUMN product_id BIGINT UNSIGNED;
<em>-- Миграция данных</em>
UPDATE wp_woocommerce_software_licences l
INNER JOIN wp_postmeta pm ON pm.meta_key = '_software_product_id'
AND pm.meta_value = l.software_product_id
SET l.product_id = pm.post_id;
<em>-- Теперь запрос без JOIN к postmeta</em>
<em>-- Результат: с 2.1 секунды до 223 миллисекунд!</em>
</code>Очистка базы данных: хирургический подход
Ревизии постов — съедают место
WordPress сохраняет каждую правку. На крупных проектах это тысячи лишних записей:
Анализ ревизий:
sql<code><em>-- Количество ревизий по типам постов</em>
SELECT post_type, COUNT(*) as revisions_count
FROM wp_posts
WHERE post_type = 'revision'
GROUP BY post_type;
<em>-- Топ постов с наибольшим количеством ревизий</em>
SELECT
p.ID,
p.post_title,
COUNT(r.ID) as revision_count
FROM wp_posts p
INNER JOIN wp_posts r ON r.post_parent = p.ID AND r.post_type = 'revision'
GROUP BY p.ID
ORDER BY revision_count DESC
LIMIT 20;
</code>Безопасное удаление старых ревизий:
sql<code><em>-- Оставляем последние 3 ревизии для каждого поста</em>
DELETE r1 FROM wp_posts r1
INNER JOIN wp_posts r2 ON r1.post_parent = r2.post_parent
WHERE r1.post_type = 'revision'
AND r2.post_type = 'revision'
AND r1.post_date < r2.post_date
AND r1.ID NOT IN (
SELECT ID FROM (
SELECT r.ID
FROM wp_posts r
WHERE r.post_type = 'revision'
GROUP BY r.post_parent
HAVING COUNT(*) <= 3
ORDER BY r.post_date DESC
) AS keep_revisions
);
</code>Ограничение ревизий через wp-config.php:
php<code><em>// Максимум 5 ревизий на пост</em>
define('WP_POST_REVISIONS', 5);
<em>// Или полное отключение</em>
define('WP_POST_REVISIONS', false);
</code>Трансиенты — временные данные навсегда
Трансиенты должны автоматически удаляться, но часто остаются:
Анализ просроченных трансиентов:
sql<code><em>-- Просроченные трансиенты</em>
SELECT
option_name,
option_value,
FROM_UNIXTIME(option_value) as expiration_date
FROM wp_options
WHERE option_name LIKE '_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP()
ORDER BY option_value DESC;
<em>-- Общий размер трансиентов</em>
SELECT
COUNT(*) as total_transients,
SUM(LENGTH(option_value)) / 1024 / 1024 as size_mb
FROM wp_options
WHERE option_name LIKE '_transient_%'
OR option_name LIKE '_site_transient_%';
</code>Удаление просроченных трансиентов:
sql<code><em>-- Удаление просроченных</em>
DELETE FROM wp_options
WHERE option_name LIKE '_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP();
<em>-- Удаление соответствующих значений</em>
DELETE FROM wp_options
WHERE option_name LIKE '_transient_%'
AND option_name NOT LIKE '_transient_timeout_%'
AND option_name NOT IN (
SELECT REPLACE(option_name, '_transient_timeout_', '_transient_')
FROM wp_options
WHERE option_name LIKE '_transient_timeout_%'
);
</code>Автоматизация через WP-Cron:
php<code><em>// Ежедневная очистка трансиентов</em>
function cleanup_expired_transients() {
global $wpdb;
$time = time();
<em>// Удаление просроченных</em>
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s
AND option_value < %d",
$wpdb->esc_like('_transient_timeout_') . '%',
$time
)
);
<em>// Удаление орфанных значений</em>
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_%'
AND option_name NOT LIKE '_transient_timeout_%'
AND option_name NOT IN (
SELECT REPLACE(option_name, '_transient_timeout_', '_transient_')
FROM {$wpdb->options}
WHERE option_name LIKE '_transient_timeout_%'
)"
);
}
if (!wp_next_scheduled('cleanup_expired_transients_hook')) {
wp_schedule_event(time(), 'daily', 'cleanup_expired_transients_hook');
}
add_action('cleanup_expired_transients_hook', 'cleanup_expired_transients');
</code>Комментарии: спам и корзина
Спам-комментарии раздувают БД:
sql<code><em>-- Статистика комментариев</em>
SELECT
comment_approved,
COUNT(*) as count
FROM wp_comments
GROUP BY comment_approved;
<em>-- Удаление спама старше 30 дней</em>
DELETE FROM wp_comments
WHERE comment_approved = 'spam'
AND comment_date < DATE_SUB(NOW(), INTERVAL 30 DAY);
<em>-- Удаление комментариев из корзины</em>
DELETE FROM wp_comments
WHERE comment_approved = 'trash';
<em>-- Удаление орфанных мета-данных комментариев</em>
DELETE FROM wp_commentmeta
WHERE comment_id NOT IN (SELECT comment_id FROM wp_comments);
</code>Орфанные метаданные
Метаданные часто остаются после удаления постов:
sql<code><em>-- Поиск орфанных postmeta</em>
SELECT COUNT(*) as orphaned_postmeta
FROM wp_postmeta pm
LEFT JOIN wp_posts p ON pm.post_id = p.ID
WHERE p.ID IS NULL;
<em>-- Удаление орфанных postmeta</em>
DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON pm.post_id = p.ID
WHERE p.ID IS NULL;
<em>-- Орфанные usermeta</em>
DELETE um FROM wp_usermeta um
LEFT JOIN wp_users u ON um.user_id = u.ID
WHERE u.ID IS NULL;
<em>-- Орфанные term relationships</em>
DELETE tr FROM wp_term_relationships tr
LEFT JOIN wp_posts p ON tr.object_id = p.ID
WHERE p.ID IS NULL;
</code>Оптимизация wp_options: проблема автозагрузки
Диагностика autoload
Опции с autoload = yes загружаются на каждой странице:
sql<code><em>-- Размер автозагружаемых опций</em>
SELECT
SUM(LENGTH(option_value)) / 1024 as autoload_size_kb
FROM wp_options
WHERE autoload = 'yes';
<em>-- Топ автозагружаемых опций</em>
SELECT
option_name,
LENGTH(option_value) / 1024 as size_kb
FROM wp_options
WHERE autoload = 'yes'
ORDER BY LENGTH(option_value) DESC
LIMIT 20;
</code>Критический порог: Если autoload > 1MB, это серьезная проблема.
Отключение autoload для больших опций:
sql<code><em>-- Отключение autoload для конкретной опции</em>
UPDATE wp_options
SET autoload = 'no'
WHERE option_name = 'large_plugin_settings';
<em>-- Массовое отключение для плагина</em>
UPDATE wp_options
SET autoload = 'no'
WHERE option_name LIKE 'some_plugin_%'
AND LENGTH(option_value) > 10000;
</code>Программное управление через PHP:
php<code><em>// Проверка и оптимизация autoload</em>
function optimize_autoload_options() {
global $wpdb;
$large_options = $wpdb->get_results(
"SELECT option_name, LENGTH(option_value) as size
FROM {$wpdb->options}
WHERE autoload = 'yes'
AND LENGTH(option_value) > 50000
ORDER BY size DESC"
);
foreach ($large_options as $option) {
<em>// Спрашиваем у разработчика, можно ли отключить</em>
if (apply_filters('can_disable_autoload_' . $option->option_name, true)) {
update_option($option->option_name, get_option($option->option_name), 'no');
error_log("Disabled autoload for {$option->option_name} (size: {$option->size} bytes)");
}
}
}
</code>Оптимизация структуры таблиц
OPTIMIZE TABLE
Дефрагментация таблиц возвращает производительность:
sql<code><em>-- Проверка фрагментации</em>
SELECT
TABLE_NAME,
ROUND(DATA_LENGTH / 1024 / 1024, 2) as data_mb,
ROUND(DATA_FREE / 1024 / 1024, 2) as free_mb,
ROUND((DATA_FREE / DATA_LENGTH) * 100, 2) as fragmentation_percent
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database_name'
AND DATA_FREE > 0
ORDER BY DATA_FREE DESC;
<em>-- Оптимизация фрагментированных таблиц</em>
OPTIMIZE TABLE wp_posts, wp_postmeta, wp_options, wp_comments;
</code>Автоматизация оптимизации:
php<code><em>// Еженедельная оптимизация таблиц</em>
function auto_optimize_database_tables() {
global $wpdb;
$tables = $wpdb->get_col("SHOW TABLES LIKE '{$wpdb->prefix}%'");
foreach ($tables as $table) {
$wpdb->query("OPTIMIZE TABLE $table");
}
error_log("Database tables optimized: " . count($tables) . " tables processed");
}
if (!wp_next_scheduled('auto_optimize_tables_hook')) {
wp_schedule_event(time(), 'weekly', 'auto_optimize_tables_hook');
}
add_action('auto_optimize_tables_hook', 'auto_optimize_database_tables');
</code>Конвертация в InnoDB
MyISAM устарел — InnoDB обеспечивает лучшую производительность и надежность:
sql<code><em>-- Проверка типа таблиц</em>
SELECT
TABLE_NAME,
ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database_name'
AND TABLE_NAME LIKE 'wp_%';
<em>-- Конвертация в InnoDB</em>
ALTER TABLE wp_posts ENGINE=InnoDB;
ALTER TABLE wp_postmeta ENGINE=InnoDB;
ALTER TABLE wp_options ENGINE=InnoDB;
ALTER TABLE wp_comments ENGINE=InnoDB;
ALTER TABLE wp_commentmeta ENGINE=InnoDB;
ALTER TABLE wp_users ENGINE=InnoDB;
ALTER TABLE wp_usermeta ENGINE=InnoDB;
</code>Продвинутые техники оптимизации
Партицирование больших таблиц
Для таблиц с миллионами записей партицирование критично:
sql<code><em>-- Партицирование wp_posts по годам</em>
ALTER TABLE wp_posts
PARTITION BY RANGE (YEAR(post_date)) (
PARTITION p2020 VALUES LESS THAN (2021),
PARTITION p2021 VALUES LESS THAN (2022),
PARTITION p2022 VALUES LESS THAN (2023),
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION pfuture VALUES LESS THAN MAXVALUE
);
<em>-- Запросы автоматически используют нужные партиции</em>
SELECT * FROM wp_posts
WHERE post_date >= '2024-01-01'
AND post_date < '2025-01-01';
<em>-- MySQL будет сканировать только партицию p2024</em>
</code>Создание summary таблиц
Для сложных агрегаций создаем предварительно рассчитанные таблицы:
sql<code><em>-- Таблица статистики постов</em>
CREATE TABLE wp_post_statistics (
post_id BIGINT UNSIGNED PRIMARY KEY,
view_count INT DEFAULT 0,
comment_count INT DEFAULT 0,
like_count INT DEFAULT 0,
last_updated DATETIME,
INDEX idx_views (view_count),
INDEX idx_updated (last_updated)
) ENGINE=InnoDB;
<em>-- Триггер автоматического обновления</em>
DELIMITER <em>//</em>
CREATE TRIGGER update_post_stats
AFTER INSERT ON wp_comments
FOR EACH ROW
BEGIN
UPDATE wp_post_statistics
SET comment_count = comment_count + 1,
last_updated = NOW()
WHERE post_id = NEW.comment_post_ID;
END<em>//</em>
DELIMITER ;
</code>Денормализация для производительности
Иногда нарушение нормальных форм оправдано производительностью:
sql<code><em>-- Добавление author_name прямо в wp_posts</em>
ALTER TABLE wp_posts
ADD COLUMN author_display_name VARCHAR(250);
<em>-- Заполнение данных</em>
UPDATE wp_posts p
INNER JOIN wp_users u ON p.post_author = u.ID
SET p.author_display_name = u.display_name;
<em>-- Теперь запросы без JOIN к wp_users</em>
SELECT ID, post_title, author_display_name
FROM wp_posts
WHERE post_status = 'publish'
ORDER BY post_date DESC
LIMIT 10;
<em>-- Вместо JOIN это экономит 20-30% времени на больших таблицах</em>
</code>Мониторинг производительности БД
Performance Schema
MySQL Performance Schema — встроенный инструмент мониторинга:
sql<code><em>-- Включение Performance Schema</em>
<em>-- В my.cnf:</em>
[mysqld]
performance_schema = ON
<em>-- Топ медленных запросов</em>
SELECT
DIGEST_TEXT as query,
COUNT_STAR as exec_count,
AVG_TIMER_WAIT / 1000000000000 as avg_time_sec,
MAX_TIMER_WAIT / 1000000000000 as max_time_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 20;
<em>-- Таблицы с наибольшей активностью</em>
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
COUNT_STAR as operations,
SUM_TIMER_WAIT / 1000000000000 as total_time_sec
FROM performance_schema.table_io_waits_summary_by_table
WHERE OBJECT_SCHEMA = 'your_database_name'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;
</code>Логирование через PHP
Собственная система мониторинга запросов:
php<code><em>// Логирование медленных запросов</em>
function log_slow_database_queries() {
global $wpdb;
add_filter('query', function($query) use ($wpdb) {
$start = microtime(true);
register_shutdown_function(function() use ($query, $start, $wpdb) {
$execution_time = microtime(true) - $start;
if ($execution_time > 0.5) { <em>// 500ms threshold</em>
error_log(sprintf(
"SLOW QUERY (%.4fs): %s",
$execution_time,
$query
));
<em>// Сохранение в кастомную таблицу для анализа</em>
$wpdb->insert('wp_slow_query_log', array(
'query' => $query,
'execution_time' => $execution_time,
'url' => $_SERVER['REQUEST_URI'],
'timestamp' => current_time('mysql')
));
}
});
return $query;
});
}
add_action('init', 'log_slow_database_queries');
</code>Практические рекомендации
Чеклист оптимизации
Еженедельно:
- Очистка ревизий, автодрафтов, спама
- Удаление просроченных трансиентов
- OPTIMIZE TABLE для активных таблиц
Ежемесячно:
- Аудит autoload опций
- Проверка индексов через EXPLAIN
- Удаление орфанных метаданных
- Анализ логов медленных запросов
Ежеквартально:
- Полная ревизия структуры БД
- Рефакторинг критичных запросов
- Оценка необходимости денормализации
- Backup и тестирование восстановления
Инструменты автоматизации
php<code><em>// Комплексная функция оптимизации</em>
function comprehensive_database_optimization() {
global $wpdb;
$report = array(
'timestamp' => current_time('mysql'),
'operations' => array()
);
<em>// 1. Удаление старых ревизий (>30 дней, оставляем 3 последних)</em>
$deleted_revisions = $wpdb->query(
"DELETE FROM {$wpdb->posts}
WHERE post_type = 'revision'
AND post_modified < DATE_SUB(NOW(), INTERVAL 30 DAY)
AND ID NOT IN (
SELECT ID FROM (
SELECT ID FROM {$wpdb->posts}
WHERE post_type = 'revision'
ORDER BY post_modified DESC
LIMIT 3
) AS keep
)"
);
$report['operations']['deleted_revisions'] = $deleted_revisions;
<em>// 2. Очистка трансиентов</em>
$deleted_transients = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s
AND option_value < %d",
$wpdb->esc_like('_transient_timeout_') . '%',
time()
)
);
$report['operations']['deleted_transients'] = $deleted_transients;
<em>// 3. Удаление орфанных метаданных</em>
$deleted_postmeta = $wpdb->query(
"DELETE pm FROM {$wpdb->postmeta} pm
LEFT JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE p.ID IS NULL"
);
$report['operations']['deleted_orphaned_postmeta'] = $deleted_postmeta;
<em>// 4. Оптимизация таблиц</em>
$tables = array('posts', 'postmeta', 'options', 'comments', 'commentmeta');
foreach ($tables as $table) {
$wpdb->query("OPTIMIZE TABLE {$wpdb->prefix}{$table}");
}
$report['operations']['optimized_tables'] = count($tables);
<em>// 5. Анализ autoload</em>
$autoload_size = $wpdb->get_var(
"SELECT SUM(LENGTH(option_value)) / 1024
FROM {$wpdb->options}
WHERE autoload = 'yes'"
);
$report['autoload_size_kb'] = round($autoload_size, 2);
<em>// Сохранение отчета</em>
update_option('last_db_optimization_report', $report);
return $report;
}
<em>// Запуск через WP-CLI</em>
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('db optimize-full', function() {
$report = comprehensive_database_optimization();
WP_CLI::success("Database optimization completed!");
WP_CLI::line("Deleted revisions: " . $report['operations']['deleted_revisions']);
WP_CLI::line("Deleted transients: " . $report['operations']['deleted_transients']);
WP_CLI::line("Deleted orphaned postmeta: " . $report['operations']['deleted_orphaned_postmeta']);
WP_CLI::line("Autoload size: " . $report['autoload_size_kb'] . " KB");
});
}
</code>Глубокая оптимизация базы данных — это не разовая задача, а постоянный процесс. На одном проекте с 200,000 постами еженедельная очистка и правильные индексы сократили время генерации страниц с 4.5 до 0.8 секунды. Начинайте с анализа медленных запросов, добавляйте стратегические индексы, регулярно чистите мусор. Результат не заставит себя ждать.



