HTTP-кэширование в PHP: методы и примеры для оптимизации производительности

Раздел: Администрирование и оптимизация -> Оптимизация производительности

HTTP-кэширование в PHP: основные подходы и реализация

Как добиться максимальной производительности с помощью HTTP-кэширования через заголовки ответа?

Основной и наиболее эффективный способ управлять кэшированием на стороне клиента и промежуточных серверов (proxy, CDN) - отправлять корректные HTTP-заголовки из PHP-скрипта. Для этого используются заголовки Cache-Control, ETag и Last-Modified.

Пример базовой реализации

Ниже представлен класс, который инкапсулирует логику отправки заголовков и проверки условий If-None-Match и If-Modified-Since:


<?php
class HttpCache {
    private $maxAge = 3600; // секунды
    private $etag = null;
    private $lastModified = null;

    public function setMaxAge($seconds) {
        $this->maxAge = $seconds;
    }

    public function setEtag($value) {
        $this->etag = $value;
    }

    public function setLastModified($timestamp) {
        $this->lastModified = gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
    }

    public function applyHeaders() {
        header('Cache-Control: public, max-age=' . $this->maxAge);
        if ($this->etag) {
            header('ETag: ' . $this->etag);
        }
        if ($this->lastModified) {
            header('Last-Modified: ' . $this->lastModified);
        }
    }

    public function checkNotModified() {
        $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH']) : null;
        $lastModified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : null;

        if ($etag && $this->etag && $etag === $this->etag) {
            header('HTTP/1.1 304 Not Modified');
            exit;
        }

        if ($lastModified && $this->lastModified && $lastModified === $this->lastModified) {
            header('HTTP/1.1 304 Not Modified');
            exit;
        }
    }
}
?>
  

Php http cache (http-кэширование в php)

Применение в контроллере:


<?php
$cache = new HttpCache();
$cache->setMaxAge(86400); // на сутки
$cache->setEtag(md5_file(__DIR__ . '/data.json'));
$cache->setLastModified(filemtime(__DIR__ . '/data.json'));
$cache->applyHeaders();
$cache->checkNotModified();

// если не 304, выводим содержимое
readfile(__DIR__ . '/data.json');
?>
  

Wp content object cache php (кэш объектов в wp-content (wordpress))

Такой подход позволяет браузеру или прокси повторно использовать ответ без обращения к серверу, если ресурс не изменился.

Типичные ошибки

  • Забывают передать ETag вместе с Cache-Control. Тогда проверка If-None-Match не сработает, и клиент будет запрашивать контент повторно.
  • Используют Last-Modified с точностью до секунды, но клиент может послать If-Modified-Since с другой форматировкой. Стоит придерживаться формата RFC 1123.
  • Отправляют заголовки после вывода тела ответа - это вызовет предупреждение и заголовки не будут работать.

Решение: всегда вызывать header() до любого вывода (echo, print, HTML), либо использовать буферизацию вывода.

Как настроить кэширование статических ресурсов через .htaccess?

Для статических файлов (CSS, JS, изображения) проще задать заголовки через конфигурацию веб-сервера. В Apache это делается в .htaccess или httpd.conf:


<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css "access plus 1 month"
    ExpiresByType application/javascript "access plus 1 month"
    ExpiresByType image/jpeg "access plus 1 year"
    ExpiresByType image/png "access plus 1 year"
</IfModule>

<IfModule mod_headers.c>
    <FilesMatch "\.(css|js|woff2)$">
        Header set Cache-Control "public, max-age=2592000, immutable"
    </FilesMatch>
</IfModule>
  

Bitrix php cache (кэширование в bitrix (php))

Эти директивы добавляют Expires и Cache-Control к статике, полностью снимая нагрузку с PHP.

Частые проблемы

  • Модули mod_expires или mod_headers не включены - сервер игнорирует директивы. Проверить можно через phpinfo() или консоль: apache2ctl -M.
  • Указание неверного MIME-типа для файлов. Например, современные браузеры могут использовать application/javascript для JS.
  • Директивы не переопределяются в дочерних конфигурациях - нужно убедиться, что AllowOverride All разрешено для каталога.

Как использовать Nginx для кэширования PHP-ответов?

Nginx может выступать в роли реверс-прокси, кэшируя ответы PHP-FPM. Настройка в блоке server:


location ~ \.php$ {
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_cache phpcache;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
    fastcgi_cache_valid 200 1h;
    fastcgi_no_cache $cookie_nocache;
    fastcgi_cache_bypass $cookie_nocache;
}
  

Cache index php (кэширование индекса в php)

Параметры кэша задаются в секции http:


fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=phpcache:10m inactive=30m max_size=1g;
  

Cache php id (кэширование по id в php)

Этот способ подходит для динамических страниц, которые меняются редко (лендинги, новости). Кэш очищается при обновлении контента (например, через запрос с Cookie: nocache=1).

Ошибки при настройке

  • Нет директории для кэша - Nginx не создаёт её автоматически. Нужно создать вручную и выдать права www-data.
  • Забывают добавить fastcgi_cache_use_stale для защиты при высокой нагрузке.
  • Кэш не отключается для административных разделов - можно использовать $cookie_nocache или проверку IP.

Как внедрить кэширование с помощью готовых библиотек?

Для более сложных сценариев (раздельное кэширование фрагментов, инвалидация по тегам) применяются библиотеки. Например, php-cache/integration-tests предоставляет единый интерфейс PSR-6 и PSR-16. Установка через Composer:


composer require cache/filesystem-adapter
  

Пример использования с PSR-16 (SimpleCache):


<?php
use Cache\Adapter\Filesystem\FilesystemCachePool;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

$filesystemAdapter = new LocalFilesystemAdapter('/path/to/cache');
$filesystem = new Filesystem($filesystemAdapter);
$cache = new FilesystemCachePool($filesystem);

$key = 'page_index';
if ($cache->has($key)) {
    return $cache->get($key);
}
$html = generateHtml();
$cache->set($key, $html, 3600);
echo $html;
?>
  

Такой подход удобен для кэширования сложных данных, но не заменяет HTTP-заголовки - их нужно добавлять отдельно.

Потенциальные сложности

  • Необходимость настройки инвалидации кэша при изменении данных - иначе пользователь увидит устаревший контент.
  • Выбор хранилища (файлы, Redis, Memcached) в зависимости от нагрузки. Файлы быстрее для небольшого числа ключей, Redis - для высоких нагрузок.
  • Библиотеки могут конфликтовать с версиями PHP - требуется проверка совместимости.

Как кэшировать готовые HTML-страницы в PHP?

Для полностью статических страниц можно сохранять вывод буфера в файл и при повторном запросе отдавать его без генерации. Класс-пример:


<?php
class PageCache {
    private $cacheDir = __DIR__ . '/cache';
    private $ttl = 3600;

    public function start() {
        $key = md5($_SERVER['REQUEST_URI']);
        $file = $this->cacheDir . '/' . $key . '.html';
        $life = file_exists($file) ? time() - filemtime($file) : $this->ttl + 1;
        if ($life < $this->ttl) {
            readfile($file);
            exit;
        }
        ob_start();
    }

    public function end() {
        $content = ob_get_clean();
        $key = md5($_SERVER['REQUEST_URI']);
        file_put_contents($this->cacheDir . '/' . $key . '.html', $content);
        echo $content;
    }
}
?>
  

Использование в index.php:


<?php
$cache = new PageCache();
$cache->start();
// ... генерация страницы ...
$cache->end();
?>
  

Такой кэш снижает нагрузку на базу данных и ускоряет ответ, но требует очистки при обновлении контента.

Проблемы файлового кэша

  • Конкуренция при записи - могут возникнуть гонки. Решение: использовать блокировки flock или атомарные операции.
  • Медленный поиск при большом количестве файлов - используйте хеш-директории.
  • Ручная очистка кэша при обновлении контента - автоматизируйте через вызов метода invalidate().

Расширенные примеры реализации HTTP-кэширования в PHP

Пример 1: Полный класс с поддержкой различных типов ETag

Усовершенствованная версия HttpCache, поддерживающая слабые и сильные ETag, а также автоматическое вычисление для файлов.

Пример

<?php
class AdvancedHttpCache {
    private $maxAge = 3600;
    private $etag = null;
    private $lastModified = null;
    private $isWeakEtag = false;

    public function setEtag($value, $weak = false) {
        $this->etag = $weak ? 'W/"' . $value . '"' : '"' . $value . '"';
        $this->isWeakEtag = $weak;
    }

    public function setEtagFromFile($path) {
        $this->setEtag(md5_file($path), true);
        $this->setLastModified(filemtime($path));
    }

    public function setLastModified($timestamp) {
        $this->lastModified = gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
    }

    public function setMaxAge($seconds) {
        $this->maxAge = $seconds;
    }

    public function applyHeaders() {
        header('Cache-Control: public, max-age=' . $this->maxAge . ', must-revalidate');
        if ($this->etag) {
            header('ETag: ' . $this->etag);
        }
        if ($this->lastModified) {
            header('Last-Modified: ' . $this->lastModified);
        }
        // Добавляем Vary для корректной работы с кэшем разного контента
        header('Vary: Accept-Encoding');
    }

    public function checkNotModified() {
        $etag = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim($_SERVER['HTTP_IF_NONE_MATCH']) : null;
        $lastModified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : null;

        if ($etag && $this->etag) {
            // Сравнение как сильных, так и слабых ETag
            if ($etag === $this->etag) {
                header('HTTP/1.1 304 Not Modified');
                exit;
            }
            // Проверка для слабых ETag (W/...)
            $weakEtag = str_replace('W/', '', $etag);
            $currentWeak = str_replace('W/', '', $this->etag);
            if ($this->isWeakEtag && $weakEtag === $currentWeak) {
                header('HTTP/1.1 304 Not Modified');
                exit;
            }
        }

        if ($lastModified && $this->lastModified) {
            // Сравнение дат с точностью до секунды
            $clientTime = strtotime($lastModified);
            $serverTime = strtotime($this->lastModified);
            if ($clientTime === $serverTime) {
                header('HTTP/1.1 304 Not Modified');
                exit;
            }
        }
    }
}
?>

Результат: при запросе вкладки браузера вы увидите ответ 304 (Not Modified) без тела, что подтверждается в панели разработчика.

Пример 2: Комбинирование с кэшированием в памяти (Redis)

Сочетание HTTP-заголовков и внутреннего кэша. Сначала проверяем локальный кэш (Redis), затем отправляем заголовки.

Пример

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cacheKey = 'page_' . md5($_SERVER['REQUEST_URI']);
$cached = $redis->get($cacheKey);

if ($cached !== false) {
    $etag = $redis->get($cacheKey . '_etag');
    $lastModified = $redis->get($cacheKey . '_lm');
    $cacheControl = new AdvancedHttpCache();
    if ($etag) {
        $cacheControl->setEtag($etag, true);
    }
    if ($lastModified) {
        $cacheControl->setLastModified(strtotime($lastModified));
    }
    $cacheControl->setMaxAge(3600);
    $cacheControl->applyHeaders();
    $cacheControl->checkNotModified();
    echo $cached;
    exit;
}

ob_start();
// ... генерация страницы ...
$html = ob_get_clean();

$etag = '"' . md5($html) . '"';
$lastModified = time();

$redis->set($cacheKey, $html, 3600);
$redis->set($cacheKey . '_etag', $etag, 3600);
$redis->set($cacheKey . '_lm', gmdate('D, d M Y H:i:s', $lastModified) . ' GMT', 3600);

header('Cache-Control: public, max-age=3600, must-revalidate');
header('ETag: ' . $etag);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
echo $html;
?>

Результат: при первом запросе страница генерируется и сохраняется в Redis. Повторные запросы проверяют If-None-Match и If-Modified-Since, отправляя 304, если данные не изменились. При изменении данных в БД достаточно сбросить соответствующие ключи в Redis.

Пример 3: Обработка кэширования для разных типов контента (JSON, XML, HTML)

Единый обработчик, который в зависимости от Accept заголовка выдаёт разные форматы с разными ETag.

Пример

<?php
function serveContent($data, $type = 'html') {
    $cache = new AdvancedHttpCache();
    $etag = '"' . md5(serialize($data)) . '"';
    $cache->setEtag($etag, false);
    $cache->setLastModified(time());
    $cache->setMaxAge(3600);
    $cache->applyHeaders();
    $cache->checkNotModified();

    switch ($type) {
        case 'json':
            header('Content-Type: application/json');
            echo json_encode($data);
            break;
        case 'xml':
            header('Content-Type: application/xml');
            $xml = new SimpleXMLElement('<root/>');
            array_walk_recursive($data, array($xml, 'addChild'));
            echo $xml->asXML();
            break;
        default:
            header('Content-Type: text/html');
            echo '<html><body>' . htmlspecialchars(print_r($data, true)) . '</body></html>';
    }
}

// Пример использования
$userData = ['name' => 'Иван', 'age' => 30];
serveContent($userData, 'json');
?>

Результат: в зависимости от запроса (например, Accept: application/json) клиент получит JSON. Если данные не изменились, браузер использует кэшированную копию, и статус 304 освобождает сервер от повторной генерации.

Пример 4: Динамическое кэширование с инвалидацией по тегам (tag-based)

Используем библиотеку php-cache/tag-interop (PSR-6 с тегами) для фрагментов страницы. Установка:

Пример

composer require cache/filesystem-adapter cache/taggable-cache

Реализация:

Пример

<?php
use Cache\Adapter\Filesystem\FilesystemCachePool;
use Cache\Taggable\TaggablePSR6PoolAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

$filesystemAdapter = new LocalFilesystemAdapter('/tmp/cache');
$filesystem = new Filesystem($filesystemAdapter);
$pool = new FilesystemCachePool($filesystem);
$taggablePool = new TaggablePSR6PoolAdapter($pool);

// Функция получения комментариев с кэшированием
function getComments($postId) {
    global $taggablePool;
    $key = 'comments_' . $postId;
    $item = $taggablePool->getItem($key);
    if ($item->isHit()) {
        return $item->get();
    }
    $comments = fetchCommentsFromDB($postId);
    $item->set($comments);
    $item->expiresAfter(3600);
    $item->addTag('post_' . $postId);
    $item->addTag('global_comments');
    $taggablePool->save($item);
    return $comments;
}

// При добавлении нового комментария очищаем кэш по тегу поста
function addComment($postId, $text) {
    saveCommentToDB($postId, $text);
    global $taggablePool;
    $taggablePool->clearTags(['post_' . $postId]);
}
?>

Результат: кэш комментариев обновляется только при добавлении нового комментария к данному посту, а не глобально. Это снижает издержки на очистку большого кэша.

HTTP-кэширование в PHP - comments

En
Php http cache (php)