Кеширование в WordPress без плагинов: Transients API и Object Cache

Каждый запрос к WordPress — это обращение к базе данных, сборка шаблона, подключение плагинов. При высокой посещаемости сервер начинает тормозить. Кеширование позволяет сохранить результат тяжёлой операции и отдавать его повторно без лишних вычислений. WordPress предоставляет для этого два встроенных механизма: Transients API и Object Cache.

Transients API: кеширование с TTL

Transients — это временные данные с ограниченным сроком жизни. По умолчанию они хранятся в таблице wp_options. Три функции составляют весь API:

// Сохранение транзиента
set_transient( 'weather_data', $weather, 3600 ); // 3600 секунд = 1 час

// Получение транзиента
$weather = get_transient( 'weather_data' );
// Вернёт false, если транзиент истёк или не существует

// Удаление транзиента
delete_transient( 'weather_data' );

// Для сетевых транзиентов (мультисайт)
set_site_transient( 'global_stats', $stats, DAY_IN_SECONDS );
get_site_transient( 'global_stats' );
delete_site_transient( 'global_stats' );

WordPress предоставляет удобные константы для времени: MINUTE_IN_SECONDS (60), HOUR_IN_SECONDS (3600), DAY_IN_SECONDS (86400), WEEK_IN_SECONDS (604800).

Практический пример: кеширование внешнего API

Типичная задача — получить данные с внешнего API и показать на сайте. Без кеширования каждый посетитель будет ждать ответа от стороннего сервера. С кешированием запрос к API выполняется раз в час:

function get_github_repos( $username ) {
    // Ключ кеша уникален для каждого пользователя
    $cache_key = 'github_repos_' . sanitize_key( $username );

    // Пробуем получить из кеша
    $repos = get_transient( $cache_key );

    if ( false !== $repos ) {
        // Данные в кеше — возвращаем без запроса к API
        return $repos;
    }

    // Кеш пуст или истёк — делаем запрос
    $response = wp_remote_get(
        "https://api.github.com/users/{$username}/repos?sort=updated&per_page=10",
        array(
            'timeout' => 10,
            'headers' => array(
                'Accept' => 'application/vnd.github.v3+json',
            ),
        )
    );

    // Обработка ошибок
    if ( is_wp_error( $response ) ) {
        error_log( 'GitHub API error: ' . $response->get_error_message() );

        // Пробуем отдать устаревший кеш (stale cache)
        $stale = get_option( '_stale_' . $cache_key );
        return $stale ?: array();
    }

    $code = wp_remote_retrieve_response_code( $response );
    if ( 200 !== $code ) {
        error_log( "GitHub API returned HTTP {$code}" );
        return array();
    }

    $body  = wp_remote_retrieve_body( $response );
    $repos = json_decode( $body, true );

    if ( ! is_array( $repos ) ) {
        return array();
    }

    // Сохраняем в кеш на 2 часа
    set_transient( $cache_key, $repos, 2 * HOUR_IN_SECONDS );

    // Сохраняем резервную копию без TTL
    update_option( '_stale_' . $cache_key, $repos, false );

    return $repos;
}

// Использование в шаблоне
$repos = get_github_repos( 'developer' );

if ( $repos ) {
    echo '<ul class="github-repos">';
    foreach ( $repos as $repo ) {
        printf(
            '<li><a href="%s">%s</a> — %s (%d stars)</li>',
            esc_url( $repo['html_url'] ),
            esc_html( $repo['name'] ),
            esc_html( $repo['description'] ?? '' ),
            absint( $repo['stargazers_count'] )
        );
    }
    echo '</ul>';
}

Паттерн «stale cache» — хранение устаревших данных как запасной вариант — полезен, когда внешний API недоступен. Лучше показать данные часовой давности, чем пустую страницу.

Кеширование тяжёлых WP_Query

Сложные запросы к базе данных — ещё один кандидат для кеширования. Популярные записи, статистика, навигация — всё это можно закешировать:

function get_popular_posts_cached( $count = 5 ) {
    $cache_key = 'popular_posts_' . $count;
    $posts     = get_transient( $cache_key );

    if ( false !== $posts ) {
        return $posts;
    }

    $query = new WP_Query( array(
        'posts_per_page' => $count,
        'meta_key'       => 'post_views_count',
        'orderby'        => 'meta_value_num',
        'order'          => 'DESC',
        'post_status'    => 'publish',
        'no_found_rows'  => true,           // ускоряет запрос
        'fields'         => 'ids',           // только ID
    ) );

    $posts = array();
    foreach ( $query->posts as $post_id ) {
        $posts[] = array(
            'id'        => $post_id,
            'title'     => get_the_title( $post_id ),
            'url'       => get_permalink( $post_id ),
            'thumbnail' => get_the_post_thumbnail_url( $post_id, 'thumbnail' ),
            'views'     => get_post_meta( $post_id, 'post_views_count', true ),
        );
    }

    // Кешируем на 6 часов
    set_transient( $cache_key, $posts, 6 * HOUR_IN_SECONDS );

    return $posts;
}

// Сброс кеша при публикации новой записи
add_action( 'publish_post', 'clear_popular_posts_cache' );
add_action( 'delete_post', 'clear_popular_posts_cache' );

function clear_popular_posts_cache() {
    delete_transient( 'popular_posts_5' );
    delete_transient( 'popular_posts_10' );
}

Параметры no_found_rows и fields => 'ids' значительно ускоряют запрос: первый отключает подсчёт общего количества записей (не нужен для пагинации), второй возвращает только ID вместо полных объектов.

Object Cache: кеширование в памяти

Object Cache — это внутренний кеш WordPress, который по умолчанию живёт только в рамках одного HTTP-запроса. Данные хранятся в оперативной памяти PHP-процесса:

// Сохранение в object cache
wp_cache_set( 'my_data', $data, 'my_plugin', 3600 );

// Получение
$data = wp_cache_get( 'my_data', 'my_plugin' );
// Вернёт false, если ключ не найден

// Проверка с получением
$data = wp_cache_get( 'my_data', 'my_plugin', false, $found );
if ( ! $found ) {
    // Ключа нет в кеше (отличает false-значение от отсутствия)
    $data = expensive_computation();
    wp_cache_set( 'my_data', $data, 'my_plugin', 3600 );
}

// Удаление
wp_cache_delete( 'my_data', 'my_plugin' );

// Добавление (не перезапишет существующий)
wp_cache_add( 'my_data', $data, 'my_plugin', 3600 );

// Инкремент / декремент
wp_cache_incr( 'page_views', 1, 'stats' );
wp_cache_decr( 'remaining_quota', 1, 'api' );

Второй параметр — группа. Она позволяет организовать кеш и избежать коллизий имён. Используйте имя плагина или темы как группу.

Persistent Object Cache: Redis и Memcached

По умолчанию Object Cache не переживает HTTP-запрос — это ограничивает его полезность. Но если подключить persistent object cache через Redis или Memcached, кеш сохраняется между запросами и разделяется между всеми процессами PHP:

// wp-config.php — настройка Redis
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_DATABASE', 0 );          // номер БД Redis
define( 'WP_REDIS_PREFIX', 'wp_mysite_' ); // префикс ключей

// Для авторизации (если настроен пароль)
define( 'WP_REDIS_PASSWORD', 'your_redis_password' );

// Таймаут подключения
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );

Для подключения нужен drop-in файл wp-content/object-cache.php. Его устанавливают плагины:

  • Redis Object Cache — для Redis. Устанавливает drop-in автоматически.
  • Memcached Object Cache — для Memcached.
  • LiteSpeed Cache — включает свой object cache.

После подключения persistent object cache все вызовы wp_cache_set/get и транзиенты начинают использовать Redis/Memcached вместо БД. Транзиенты перестают записываться в wp_options — это снижает нагрузку на MySQL.

Transients vs Object Cache: когда что использовать

Разберём разницу на конкретных сценариях:

// TRANSIENTS — когда нужен TTL и данные должны пережить запрос
// даже без Redis/Memcached (хранятся в wp_options)

// Результат внешнего API
set_transient( 'exchange_rates', $rates, HOUR_IN_SECONDS );

// Тяжёлый запрос к БД
set_transient( 'monthly_stats', $stats, DAY_IN_SECONDS );

// OBJECT CACHE — когда данные нужны только в текущем запросе
// или у вас есть persistent object cache

// Кеширование результата функции внутри одного запроса
function get_user_permissions( $user_id ) {
    $cache_key = 'permissions_' . $user_id;
    $perms     = wp_cache_get( $cache_key, 'my_plugin' );

    if ( false !== $perms ) {
        return $perms;
    }

    // Тяжёлое вычисление
    $perms = calculate_complex_permissions( $user_id );
    wp_cache_set( $cache_key, $perms, 'my_plugin' );

    return $perms;
}

// Кеширование результатов в цикле
function get_author_stats( $author_ids ) {
    $stats = array();
    foreach ( $author_ids as $id ) {
        $cached = wp_cache_get( "author_stats_{$id}", 'stats' );
        if ( false !== $cached ) {
            $stats[ $id ] = $cached;
            continue;
        }

        $data = fetch_author_data( $id );
        wp_cache_set( "author_stats_{$id}", $data, 'stats', HOUR_IN_SECONDS );
        $stats[ $id ] = $data;
    }
    return $stats;
}

Фрагментное кеширование HTML

Иногда полезно кешировать не данные, а готовый HTML-фрагмент. Например, сайдбар, меню навигации или блок «Популярные записи»:

function render_cached_sidebar() {
    $cache_key = 'sidebar_html_' . get_locale();
    $html      = get_transient( $cache_key );

    if ( false !== $html ) {
        echo $html;
        return;
    }

    ob_start();
    // Здесь тяжёлый рендеринг
    dynamic_sidebar( 'sidebar-main' );
    $html = ob_get_clean();

    set_transient( $cache_key, $html, 30 * MINUTE_IN_SECONDS );
    echo $html;
}

// Сброс кеша сайдбара при обновлении виджетов
add_action( 'update_option_sidebars_widgets', function() {
    delete_transient( 'sidebar_html_ru_RU' );
    delete_transient( 'sidebar_html_en_US' );
} );

Стратегии инвалидации кеша

Кеширование бесполезно без правильной инвалидации. Основные подходы:

  • TTL (Time To Live) — кеш автоматически истекает через заданное время. Самый простой подход, подходит для данных, которые не критично обновлять мгновенно.
  • Event-based — сброс кеша при конкретном действии. Используйте хуки save_post, delete_post, switch_theme, update_option для сброса связанного кеша.
  • Версионирование — вместо удаления кеша меняйте ключ. Например, menu_v2 вместо menu_v1. Старый ключ истечёт по TTL.
// Event-based инвалидация
add_action( 'save_post', 'invalidate_related_caches', 10, 2 );

function invalidate_related_caches( $post_id, $post ) {
    if ( 'publish' !== $post->post_status ) {
        return;
    }

    // Сбрасываем кеш категорий этой записи
    $categories = wp_get_post_categories( $post_id );
    foreach ( $categories as $cat_id ) {
        delete_transient( 'category_posts_' . $cat_id );
    }

    // Сбрасываем общие кеши
    delete_transient( 'recent_posts' );
    delete_transient( 'popular_posts_5' );

    // Для object cache — очистка группы (если поддерживается)
    if ( function_exists( 'wp_cache_flush_group' ) ) {
        wp_cache_flush_group( 'my_plugin_posts' );
    }
}

Мониторинг и отладка кеша

Чтобы понять, работает ли кеширование, используйте плагин Query Monitor. Он показывает количество SQL-запросов, время выполнения и попадания в object cache. Без кеширования типичная WordPress-страница делает 50-100 SQL-запросов. С правильным кешированием — 10-20.

Для Redis полезна команда redis-cli monitor — она показывает все операции в реальном времени. Команда redis-cli info memory покажет, сколько памяти занимает кеш.

Кеширование — один из самых эффективных способов ускорить WordPress без смены хостинга. Transients API доступен из коробки и не требует дополнительного ПО. Object Cache с Redis выводит производительность на новый уровень. Начните с кеширования самых тяжёлых запросов и внешних API-вызовов — результат будет заметен сразу.