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

Как ускорить индексирование сайта поисковыми роботами и повысить его производительность

Вступление

Уже не раз наши клиенты или их seo-специалисты просили “настроить в Битрикс заголовок Last-Modified”. Проведя внушительное время за аналитикой и исследованием этого вопроса, мы пришли к выводу, что сама по себе эта задача отнюдь не проста и за ней скрывается огромное количество подводных камней и связанных вопросов. О том, что же действительно имеется в виду на самом деле, что можно сделать и получить в результате - и пойдёт наша речь. Статья предназначается:

  • Seo-специалистам - для понимания масштабов трагедии;
  • Владельцам сайтов - чтобы увидеть дополнительный вектор развития;
  • Веб-разработчикам - как достаточно подробное руководство для реализации перспективной технологии.

Обычно при поисковой оптимизации сайта основное внимание уделяется семантике и общеизвестному ряду технологических “фишек” вроде микроразметки. В Битриксе полно специальных инструментов для этого. В инфоблоках, например, есть вкладка SEO, на которой можно управлять шаблонами названий, описаний, мета-тегами и пр.:

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

  • это дорого;
  • сложно найти подрядчика, который разберётся и выполнит все “хотелки”;
  • сложно потом объяснить разработчикам, что нужно на самом деле и как оно должно работать. В результате вместо журавля в небе можно получить что-то вроде страуса. Он хоть и быстро бегает, но никогда не взлетит.

Наша статья как раз и будет о том, что имеет отношение к SEO, оптимизации производительности и чего нет в коробке - http-кэшировании.


Почему это важно? Потому что позволяет существенно ускорить индексирование сайта со стороны поисковых роботов. А ещё может весьма ощутимо снизить нагрузку на сервер (конкретные цифры будут приведены ниже), даже если речь идёт только о кэшировании главной страницы сайта и карточек всех товаров. Это совсем не тот управляемый кэш, который работает в Битриксе по умолчанию. И не композитная технология, создающая дополнительную нагрузку на back-end (хоть и незначительную, но всё же). Мы подробно разберём, как с помощью http-заголовков можно положительно повлиять на поведение поисковиков, а также сократить время выполнения многих GET-запросов.

Что нового

В интернете много статей про http-заголовки, в которых либо всё разобрано по верхам, либо наоборот - настолько сложно и теоретично, что начинаешь жалеть, что предварительно не получил комплект докторских степеней по ряду технических специальностей. Мы попробуем найти золотую середину, разобрать случаи работы тех или иных заголовков на конкретных примерах, снабдив каждый пояснениями и скриншотами.

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

Ряд разработчиков модулей уже задавались вопросом об управлении браузерным кэшированием, но почему-то не пошли дальше заголовка Last-Modified (см, например, вот эти: 1, 2, 3). И выбранные ими способы реализации не лишены ощутимых недостатков, как то: отсутствуют настройки, создаётся дополнительная нагрузка (у некоторых), что-то работает только через карту сайта (и зависит от неё) и пр. А часть попросту не работает, потому что карта сайта может состоять из разных файлов, а разработчики этого не учли. Или проставляют неверные значения. Вот, сравните дату изменения элемента в инфоблоке:

...и в установленном заголовке для карточки этого товара:

Last-Modified: Fri, 07 Apr 2017 18:44:45 GMT

Или вот (для этого же товара) в другом модуле:

Last-Modified: Fri, 02 Sep 2016 12:04:27 GMT

Разница с ожидаемым значением настолько велика, что обескураживает. Плюс вообще нет возможности хоть как-то указать, по каким полям изменение элемента должно влиять на значение заголовка. Хочется верить, что разработчики поправят эти недочёты и дополнят функциональность новыми возможностями.

Поисковики рекомендуют

Яндекс-вебмастер рекомендует передавать заголовок Last-Modified, чтобы:

  1. в поисковых результатах показывалась дата рядом со страницами сайта;
  2. сайт был виден в результатах поиска, если используется сортировка по дате;
  3. робот не тратил свои ресурсы на повторное сканирование неизменившихся страниц - и, как следствие, занимался новыми страницами (это, на наш взгляд, главная выгода).

В руководстве от Google указывается, что их PageSpeed Insights (сервис по оценке скорости загрузки веб-страниц) генерирует предупреждение, если “в ответе сервера отсутствуют явно указанные заголовки кеширования или настроено хранение ресурсов в течение слишком короткого времени”. И далее по тексту: “для всех кешируемых ресурсов нужно обязательно указывать один заголовок из пары Expires и Cache-Control max-age, а также один заголовок из пары Last-Modified и ETag”. Также важно сказать, что html-контент самих страниц обычно считается динамическим ресурсом, а не статическим, поэтому особого внимания этим заголовкам не уделяется. И в этом случае Google рекомендует разработчикам сайта самим подумать, какой тип кэширования для них выбрать. Вот и будем думать и решать.

Краткий обзор основных http-заголовков, связанных с кэшем

  1. По праву основной заголовок, играющий важнейшую роль в браузерном кэшировании, это Cache-Control. Но важен не столько он сам по себе, сколько его директивы, коих достаточно большое количество, вот только некоторые: max-age, public/private, no-cache, no-store, must-revalidate. Он позволяет очень гибко управлять поведением браузера или сервера. С его помощью, например, вы можете запретить сохранение кэша. В таком случае даже если установлен Expires, то во внутреннем хранилище клиента будет пусто. Нужно это понимать. Подробнее разберём дальше на примерах. Его опции могут передаваться как от сервера клиенту, так и в обратном направлении.
  2. Следующий заголовок в нашем обзоре это Expires. Он сообщает клиенту о том, до какой даты можно хранить кэш. Если в течение этого срока ресурс запрашивается повторно, он будет взят из внутреннего хранилища клиента, а GET-запрос к серверу выполнен вообще не будет. Важно, что он относится к версии HTTP 1.0, то есть гарантированно поддерживается всеми участниками обмена.
  3. Last-Modified. Наиболее “распиаренный” в seo-сообществе из всех заголовков этой группы (но всего лишь “один из”). Когда браузер получил его от сервера, то при повторном обращении к ресурсу он отправит на сайт заголовок If-Modified-Since со значением полученного ранее Last-Modified. Если разработчики об этом позаботились, то сервер может сравнить значение, поступившее от клиента, с реальной датой изменения данных. И если кэш ещё актуален, возвратить статус 304 Not Modified и (а ради этого технология и задумана) завершить соединение ДО выполнения ресурсоёмких операций. Достойная задача для разработчиков - сделать так, чтобы нагрузка, создаваемая определением реальной даты изменения контента, была существенно меньше, чем полное исполнение страницы. А иначе особого смысла для сервера в этом нет (хотя для поисковых ботов он остаётся).
  4. ETag. Алгоритм проверки актуальности кэша абсолютно идентичен Last-Modified, только здесь в качестве значения выступает не дата, а какой-то хэш (произвольная строка). Получив этот заголовок, браузер запоминает хэш-значение, а при следующем обращении к ресурсу отправляет заголовок If-None-Match с его значением. Сервер должен вычислить актуальный хэш и сравнить его с тем, что пришёл от клиента. Если они совпадают - вернуть 304 Not Modified и завершить выполнение. Если нет - то исполнить страницу и вернуть новый ETag.
  5. Date. Сообщает клиенту, какое сейчас время выставлено на сервере (по Гринвичу). Это имеет значение для определения актуальности кэша. Если Expires <= Date, то кэш взят не будет. Поэтому очень важно выставлять в Date реальное время.
  6. Pragma. О ней обычно принято говорить в контексте запрета кэширования, причём этот заголовок устаревший - он использовался в http 1.0. Если передано значение no-cache, то оно должно работать так же, как и “Cache-Control: no-cache”. Вот что написано на этот счёт в спецификации: “Когда в запросе присутствует директива no-cache, приложение должно переадресовать запрос исходному серверу, даже если имеется кэшированная копия того, что запрошено. Директива pragma имеет ту же семантику, что и директива кэша no-cache <...> и определена здесь для обратной совместимости с HTTP/1.0. Клиентам следует включать в запрос оба заголовка, когда посылается запрос no-cache серверу, о котором неизвестно, совместим ли он с HTTP/1.1”.
  7. Age. Этот заголовок передаётся только промежуточными прокси-серверами между клиентом и основным сервером (если они есть), поддерживающими HTTP/1.1. Промежуточный сервер добавляет этот заголовок в ответ, если вместо передачи запроса вверх по цепочке к основному серверу он решил вернуть ответ из собственного кэша. Тогда значением этого заголовка является возраст использованной кэш-записи. Вот что нам говорит спецификация RFC 7234 (раздел 5.1): “Присутствие заголовка Age подразумевает, что по данному запросу ответ не был сгенерирован или проверен основным сервером. Однако, отсутствие заголовка Age не означает, что запрос дошёл до основного сервера, поскольку ответ мог быть получен от кэша HTTP/1.0, который не поддерживает Age”.

Как всё работает

Упрощённая схема браузерного кэширования выглядит следующим образом:

  • Клиент (браузер или поисковый робот) когда-то впервые запрашивает определённую страницу сервера (URL). И изначально кэш браузера чист.
  • В ответ сервер генерирует контент и в дополнение к нему устанавливает http-заголовки, определяющие поведение браузера в плане возможности что-то закэшировать. Эти заголовки описаны в спецификации протокола http. Установка заголовков сервером не является обязательной, но их наличие может существенно оптимизировать скорость обмена данными браузера с сайтом (речь только о тех заголовках, которые влияют на кэш).
  • Браузер, получив контент и соответствующие заголовки, решает, что можно сохранить в своём кэше, а что нет.
  • При повторном обращении к ресурсам сайта браузер решает, брать ему данные из кэша либо же обращаться к серверу. В сомнительных случаях браузер может сделать не полный, а так называемый условный запрос (передав на сервер дополнительные http-заголовки). В ответ сервер должен отдать не весь контент страницы, а только то, что нужно браузеру для принятия решения. Этот процесс называется валидацией браузерного кэша. Валидация - это очень важный процесс, которому разработчик должен уделить особое внимание.

Важно заметить, что кэширование - процесс двусторонний, им управляет как клиент (через отправляемые заголовки), так и сервер, который должен быть чувствителен к ним. Так что если сервер эти заголовки не учитывает - мы теряем производительность и поисковую индексируемость сайта.

Также в начале спецификации RFC7234 (раздел 2) мы найдём общую установку для браузеров: “повторное использование данных из кэша является поведением по умолчанию, если его не запрещает какое-либо локальное требование или настройка”. Другими словами, всё, что не запрещено - разрешено, при этом кэш является поведением по умолчанию. Но нужно учитывать, что ряд бразуеров (Opera, Internet Explorer 6+, Safari) могут не кэшировать ресурс, если в URL встречается вопросительный знак (то есть задан query string), поскольку интерпретируют такой контент как динамический (это ограничение не распространяется на FireFox).

Чего можно ожидать: немного статистики

Мы собирали статистику о повторных заходах в карточки товаров на протяжении длительного времени в двух никак не связанных друг с другом интернет-магазинах. У них совершенно разные номенклатуры и рыночные ниши. Вот к каким результатам мы пришли:

  • В течение суток количество повторных заходов в карточки товаров (одним и тем же пользователем) относительно мало (3-5% от общей нагрузки на карточки);
  • Но в течение месяца это число резко возрастает до 15-30%. Это значит, что пользователи и поисковые роботы возвращаются к товарам позже (не в те же сутки);
  • Если увеличить интервал наблюдения до полугода, то доля повторных заходов становится ещё больше. В таблице ниже мы представили подробную статистику за этот период:
Параметр * Магазин-1 Магазин-2
Общее кол-во хитов по карточкам за полгода 866 528 612 087
из них ботов (по user-agent) 577 404 427 869
Кол-во уникальных хитов 537 552 431 396
Кол-во повторных хитов 328 976 180 693
из них ботов 248 520 131 654
доля повторных хитов
(от общего кол-ва хитов)
38% 30%
доля повторных хитов ботов
(от общего кол-ва хитов)
29% 22%

* все хиты в данной таблице относятся только к карточкам товаров. Хиты на другие страницы сайта (вроде главной или списков) здесь не учитываются, так как их кэширование на стороне браузера затруднено.

Это прямо означает, что около 30-40% нагрузки, создаваемой карточками товаров, может быть существенно сокращено за счёт внедрения технологии, о которой мы пишем. Это очень ощутимое значение, которое нельзя игнорировать! Даже те сайты, которые находятся в ТОПе и обладают огромными номенклатурами, не всегда используют данную “фишку” (на практике - почти никогда). Это своего рода чисто техническое конкурентное преимущество. Также если вы используете облачный хостинг, где платите за используемые ресурсы (вроде Jelastic, Amazon и пр.), то это прямая экономия при больших нагрузках.

Кому верить?

Ну что ж, пришло время погрузиться с головой в детали работы описанных заголовков. Прежде всего определимся с тем, откуда черпать информацию, каким источникам верить. За основу, очевидно, нужно брать спецификацию протокола http. Осталось только определиться, какая из них актуальна. В интернете очень часто ссылаются на RFC 2616. Тем не менее, в ней явно и самом начале указано, что она устарела:

Obsoleted by: 7230, 7231, 7232, 7233, 7234, 7235

Чтобы понять, почему этих спецификаций аж шесть штук вместо одной, обратимся к блогу Марка Ноттингема, одного из участников группы IETF, разрабатывающей стандарт http (желающие могут найти его в соответствующих списках). В одной из своих статей от 2014 года он явно пишет, что RFC 2616 использовать не нужно и следует вычеркнуть из своих заметок и закладок. Далее он указывает, что её переписали в лучшем виде, разделив на шесть частей. Исходные 176 страниц спецификации 2616 организовали в более логичную структуру, сделав дружественной для восприятия и чтения. Так и получились новые стандарты:

  1. RFC7230 - HTTP/1.1: Message Syntax and Routing - low-level message parsing and connection management
  2. RFC7231 - HTTP/1.1: Semantics and Content - methods, status codes and headers
  3. RFC7232 - HTTP/1.1: Conditional Requests - e.g., If-Modified-Since
  4. RFC7233 - HTTP/1.1: Range Requests - getting partial content
  5. RFC7234 - HTTP/1.1: Caching - browser and intermediary caches
  6. RFC7235 - HTTP/1.1: Authentication - a framework for HTTP authentication

В конце каждого из них чётко прописаны отличия от RFC 2616:

А теперь подробно и с примерами

Как мы собираемся проводить наши “эксперименты”? Возьмём произвольную картинку и будем выводить её содержимое с помощью php (чтобы свободно влиять на заголовки), вот простенький скрипт image.php для этих целей:

<?php
	header('Content-Type: image/jpeg');
	readfile(rtrim($_SERVER['DOCUMENT_ROOT'], '/').'/upload/matrix.jpg');
?>

Также не забудем временно отключить mod_expire нашего apache в .htaccess, чтобы он не мешал менять заголовки:

Теперь открыв скрипт в браузере на нашем тестовом сайте Битрикс в его же веб-окружении, видим следующий ответ на панели разработки Google Chrome (F12):

Как мы видим, из того, что может повлиять на кэширование - только заголовок Date (выводящий верное значение). Нас пока всё устраивает, ничего лишнего, можно начинать.

Ещё раз о валидации

Если у браузера есть актуальный кэш по запрошенному ресурсу - он его использует. Если кэш есть, но он устаревший (что определяется по сохранённым в нём заголовкам ответа), браузер его не отбрасывает. Вместо этого он отправляет на сервер условный запрос, содержащий заголовок If-Modified-Since со значением заголовка Last-Modified, сохранённым в кэше (если он был передан сервером). Или же заголовок If-None-Match, если в сохранённых заголовках ответа сервера есть ETag.

Если в кэше не сохранено ни одного заголовка, с помощью которого браузер может проверить его актуальность, то применяются эвристические вычисления. Тем не менее, устаревший кэш браться строго не будет, если используются директивы no-cache, no-store, s-maxage, must-revalidate, proxy-revalidate заголовка Cache-Control (RFC 7234, р. 4.2.4). Важно и то, что само по себе отсутствие заголовков-валидаторов (Last-Modified, ETag) не является препятствием для использования кэша: “Отклик, который не имеет валидатора, может кэшироваться и использоваться до истечения его срока годности, если только это явно не запрещено директивой Cache-Control”.

Expires

Добавим в php этот заголовок со значением текущей даты + 1 минута:

header('Expires: '.gmdate('D, d M Y H:i:s T', time() + 60));

Проверяем таким же образом через панель разработки Google Chrome:

Date: Sun, 09 Apr 2017 03:33:42 GMT
Expires: Sun, 09 Apr 2017 03:34:42 GMT

Заголовок успешно установлен и на одну минуту превышает Date. То есть наша картинка должна быть закэширована на этот период. Но если мы будем открывать напрямую image.php через браузер в течение этой минуты, то заметим, что кэш не применяется, ресурс загружается полностью:

Казалось бы - странно, ничего не работает. Но не будем торопить события, создадим обёрточный html-файл container.php, куда поместим тег <img> с нашей картинкой:

<html>
<head></head>
<body><img src="/image.php"/></body>
</html>

При первом хите на container.php видим, что изображение загружается полностью (как и должно быть):

Но уже со второго начинает работать кэш:

Так происходит с изображениями. Но наша основная задача - закэшировать html. А его не внедрить на страницу через тег <img>. Как же будет работать в этом случае? Проверим. Подготавливаем аналогичный файл (html.php):

<?php
header('Content-Type: text/html; charset=utf-8');
header('Expires: '.gmdate('D, d M Y H:i:s T', time() + 60));
?><html>
<head></head>
<body>Test content</body>
</html>

Открывая его в браузере напрямую, мы всегда будем видеть одну и ту же картину, кэш как будто не работает:

Но если мы создадим на сайте другую страницу (или просто html-файл на жёстком диске) и разместим в ней ссылку на эту, а затем перейдём, то кэш начинает использоваться:

Если принять тот факт, что пользователи крайне редко вбивают URL’ы в адресную строку самостоятельно и осуществляют прямые GET-запросы, то всё должно работать корректно. Но главная страница сайта - под вопросом. Да, по всей видимости, осуществив на неё прямой заход путём ввода сайта в адресную строку - мы исключим использование кэша. По крайней мере, это проверено в Google Chrome.

Тем не менее, нужно понимать, что даже если при прямых заходах пользователей от Expires толку мало, для поисковых ботов он всё равно может иметь смысл. Придя однажды, он будет точно знать, раньше какого срока возвращаться на эту страницу ему не нужно. Значит, можно пойти на другие. А от этого хорошо всем: и сайту (меньше общей нагрузки, создаваемой поисковиками), и роботу (не нужно тратить свои ресурсы на страницы, где не произошло значимых изменений контента).

Ещё хочется обратить внимание на следующий момент: место хранение кэша. Заметили ли вы вот это различие в колонке Size?

В одном случае - “from memory cache”, в другом - “from disk cache”. По всей видимости, Google Chrome часть кэша сохраняет в оперативную память. Но при закрытии браузера она записывается на диск. Поэтому после повторного захода на страницу после закрытия/открытия бразуера (не вкладки) мы увидим уже дисковый кэш:

И, соответственно, увеличение времени загрузки контента с нуля до 148 миллисекунд.

Важно, что до июня 2014 года максимально допустимая валидная дата в заголовке Expires была на год больше текущей. Но это ограничение было упразднено в спецификации RFC 7234. Тем не менее, лучше ориентироваться на это ограничение, т.к. старые браузеры ещё не поддерживают новые условия.

И последний момент: что будет, если указывать в Expires дату в прошлом? Всё просто, кэш считается устаревшим. И, соответственно, его актуальность может быть проверена механизмом валидации (через условные запросы к серверу).

Cache-Control

Данный заголовок относится к HTTP/1.1 и может присутствовать как в запросе, так и в ответе. Но он является однонаправленным, то есть сервер, получив какое-то значение этого заголовка, не обязан отправлять его обратно (и вообще может установить совсем другое). Опишем основные директивы этого заголовка (они могут использоваться совместно, для этого их надо перечислить через запятую), отдаваемые с сервера клиенту:

Директива max-age

Синтаксис: Cache-Control: max-age=60

Значением этой директивы является количество секунд, в течение которых кэш считается валидным (актуальным). Это сильный заголовок, который имеет более высокий приоритет над Expires. Из спецификации: “Если отклик включает в себя как заголовок Expires, так и директиву max-age, более высокий приоритет имеет директива max-age, даже если заголовок Expires накладывает более жесткие ограничения”. Логично, что данная директива конфликтует с другими, запрещающими кэширование (вроде no-store).

Для полноты картины сразу скажем, что если Cache-Control: max-age=0 присутствует в заголовке запроса к серверу (а не в ответе сервера), то это значит, что клиент не желает получить какой-либо кэш. В данном случае max-age означает максимальный возраст закэшированного ресурса, который устроит клиента. Ноль - указание на то, что кэш мы получить не хотим (имеется в виду кэш промежуточных звеньев http-цепочки).

Директива s-maxage

Синтаксис: Cache-Control: s-maxage=60

Во всём идентична директиве max-age (её значением тоже является количество секунд, в течение которых кэш является актуальным), но предназначена не для конечных браузеров, а для промежуточных прокси-серверов (например, CDN). Для этих прокси-серверов s-maxage имеет ещё более высокий приоритет, чем max-age. А самими браузерами s-maxage игнорируется. Это позволяет задать разное время кэширования для прокси и для конечного пользователя (на усмотрение разработчика: кто-то решает, что на прокси кэш должен обновляться чаще, чем у конечных клиентов, а кто-то считает, что лучше наоборот).

Вообще порядок вычисления свежести кэша получается такой (в порядке уменьшения приоритета): s-maxage, max-age, Expires, эвристика браузера (на основе других полей - Cache-Control: public; Last-Modified и др.).

Директива no-cache

Синтаксис: Cache-Control: no-cache

Сразу скажем, что ошибочно полагать, что эта директива запрещает кэширование, браузер по-прежнему может сохранять в кэш ответ сервера. Но если она задана, то браузер обязан провести валидацию этого кэша перед возвращением его пользователю. По сути, это значит, что браузер всегда будет слать на сервер условные запросы (If-Modified-Since, If-None-Match) вне зависимости от того, актуален кэш или нет. А дальше всё уже зависит от ответа сервера.

Директива must-revalidate

Синтаксис: Cache-Control: must-revalidate

Данная директива обязывает браузер отправлять условные запросы на валидацию кэша, если он был просрочен. И, по сути, запрещает использование устаревшего кэша без этой валидации. Из спецификации: “когда в отклике, полученном кэшем, содержится директива must-revalidate, этот кэш не должен использовать эту запись для откликов на последующие запросы без сверки ее на исходном сервере. Таким образом, кэш должен выполнить перепроверку end-to-end каждый раз, если согласно значениям Expires или max-age, кэшированный отклик является устаревшим”.

Соответственно, она имеет смысл только тогда, когда чётко заданы Cache-Control: max-age или Expires. По сути, эта директива аналогична no-cache, только относится исключительно к просроченному кэшу.

Директива no-store

Синтаксис: Cache-Control: no-store

Указывает клиенту не сохранять ответ сервера во внутренний кэш (будь то браузер или промежуточный прокси). По сути, это и есть запрет кэширования, ведь если ничего не сохранено, то ничего и не будет отдано в будущем.

Директивы public и private

Синтаксис: Cache-Control: public | Cache-Control: private

В общем случае директива public означает, что данный ответ сервера не является уникальным для пользователя - и его можно использовать для разных клиентов (если речь идёт о промежуточном прокси). При прочих равных условиях, Cache-Control: public - это указание для эвристики браузера (равно как и прокси), что кэш использовать можно.

Cache-Control: private означает, что кэш предназначен конкретному пользователю и не может быть сохранён в промежуточном кэше CDN (например, если речь идёт о данных из личного кабинета). В соответствии со спецификацией, Cache-Control: private напрямую запрещает использование браузерного кэша (наравне с Cache-Control: no-store).

Также важно, что если задан заголовок Cache-Control: private, то директива s-maxage будет игнорироваться (как и другие директивы, имеющие отношение к сохранению кэша - ведь мы его запретили).

Date

Это заголовок, содержащий текущие дату и время сервера на момент формирования ответа. Синтаксис такой:

Date: Tue, 15 Nov 1994 08:12:31 GMT

Именно эта дата берётся за отправную точку для вычисления актуальности кэша относительно текущего момента времени (с ней будут сравниваться даты из Expires и дата, полученная добавлением к Date количества секунд из Cache-Control: max-age).

Наличие этого заголовка имеет критическое значение для случая, когда к вам на сайт приходят пользователи из разных часовых поясов.

Last-Modified

Это заголовок, сообщающий браузеру, когда последний раз был изменён контент по данному URL:

Last-Modified: Tue, 15 Nov 1994 12:45:26 GMT

Получив впервые ответ сервера с этим заголовком, браузер сохраняет его в кэше (если это не запрещено другими заголовками). В будущем при повторном запросе этого ресурса браузер вначале проверяет, актуален ли кэш (по датам). Если не актуален, то выполняет запрос на валидацию, то есть отправляет на сервер заголовок If-Modified-Since со значением сохранённого в кэше Last-Modified.

Важно, что сервер должен быть чувствительным к заголовку If-Modified-Since. Получив его от клиента, он должен проверить эту дату со временем последнего изменения контента. Если дата последнего (значимого!) изменения контента на сервере <= значения If-Modified-Since, сервер должен вернуть статус 304 Not Modified и завершить выполнение скрипта. То есть не нужно генерировать тело страницы, это лишняя нагрузка. Если же дата последнего изменения контента больше значения If-Modified-Since, то сервер должен продолжить генерацию страницы в обычном режиме, генерируя и все обычные заголовки.

Если браузер получает ответ от сервера 304 Not Modified, то он считает имеющийся кэш пригодным для использования и отдаёт пользователю, обновляя также все полученные от сервера заголовки (RFC 7234, 4.3.4), включая новый Expries или Cache-Control: max-age, если они были переданы.

Также важно, что статус 304 Not Modified может возвращать не только сам сервер, но и промежуточное звено http-цепочки. Например, это может быть CDN (а по отношению к PHP этим звеном может выступать и nginx, что особенно важно в случае использования композитного режима Битрикс). В этом случае происходит следующее. Вначале это промежуточное звено определяет, есть ли у него в кэше подходящий ответ, который является актуальным для запроса (что определяется по описанным в спецификации условиям). Если да, то идёт проверка возможности выдать 304 статус. Он должен отдаваться в порядке проверки следующих условий:

  1. В потенциально выбранном из кэша ответе есть заголовок Last-Modified, дата которого <= переданного значения If-Modified-Since;
  2. В выбранном ответе нет значения Last-Modified, но есть заголовок Date, значение которого <= переданного значения If-Modified-Since;
  3. В выбранном ответе нет ни Last-Modified, ни Date, время попадания записи в кэш <= переданного значения If-Modified-Since.

(см. подробности).

ETag

Как мы уже упоминали, работа этого заголовка во многом похожа на Last-Modified, за тем исключением, что его значением является не дата, а произвольная строка (обычно какой-то хэш). Вместо If-Modified-Since клиент отправляет на сервер заголовок If-None-Match со значением сохранённого в кэше ETag. А далее всё происходит по той же схеме, но только разница в том, что в случае с датами мы могли сравнивать на больше/меньше, а здесь идёт только проверка равно/не равно со всеми вытекающими последствиями (дата не имеет никакого значения).

Нужно сказать, что это более сильный заголовок, чем Last-Modified. Если в ответе сервера встречаются они оба, то клиенты сохраняют их оба и отправляют на сервер и If-None-Match, и If-Modified-Since. Но именно первый из них (If-None-Match) имеет более высокий приоритет (см. раздел 4.3.2).

Тем не менее, мы бы хотели обратить внимание на следующую особенность, установленную опытным путём. Приоритетность ETag над Last-Modified справедлива только для случая, когда они изначально отдаются сервером. Если же в первом ответе сервера присутствует только Last-Modified (который и сохраняется в кэш браузера), то в последующем браузер будет отправлять на сервер только заголовок If-Modified-Since даже в том случае, если в будущем ответе сервера встретится ETag. То есть браузер не переключится на заголовок If-None-Match в контексте текущего кэша. Это будет иметь для нас значение, когда мы пристально рассмотрим работу композитного режима Битрикс.

В итоге какой из двух заголовков, ETag или Last-Modified, выбрать для своего сайта? Google и Яндекс не дают однозначных ответов. Главное, что нужно использовать хотя бы один из них (а оба сразу использовать не рекомендуется). Но нужно иметь в виду, что ETag - это заголовок стандарта http 1.1, а Last-Modified - 1.0. Это значит, что количество клиентов, поддерживающих Last-Modified - больше, а ETag - перспективнее, приоритетнее (с точки зрения стандарта http) и более гибок.

Текущее положение дел: Битрикс

Мы проверяем сайты, которые приходят к нам на техподдержку, на предмет того, как у них обстоят дела с http-кэшированием. Давайте посмотрим, какие (как правило) заголовки отдают карточки товаров/новостей сайтов под управлением 1С-Битрикс на момент обращения к нам:

  1. Один из сайтов отдал такие заголовки:
    Date: Fri, 18 Aug 2017 09:41:10 GMT
    Last-Modified: Fri, 18 Aug 2017 00:01:00 GMT
    Expires: Thu, 19 Nov 1981 08:52:00 GMT
    Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
    Pragma: no-cache

    Анализ ситуации:

    Last-Modified устанавливается, но: 1) проверка на http://last-modified.com выявляет, что статус 304 не отдаётся при повторном запросе ресурса; 2) в процессе наблюдения выяснилось, что Last-Modified устанавливается для всех товаров одинаковый и всегда на 00:01:00 GMT текущих суток; 3) текущие значения заголовков Expires и Cache-Control напрямую запрещают использование кэша (приводя к постоянной генерации условных запросов, на которые сервер не возвращает 304 статус).

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

  2. Другой сайт:
    Date: Fri, 18 Aug 2017 09:13:07 GMT
    Expires: Thu, 19 Nov 1981 08:52:00 GMT
    Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
    Pragma: no-cache

    Кэш прямо запрещён заголовками Cache-Control и Expires. Соответственно, использоваться он не будет. Last-Modified или ETag вообще отсутствуют. Ко всему прочему и время по GMT в заголовке Date выдаётся неправильное (на час меньше реального).

  3. Третий:
    Date: Fri, 18 Aug 2017 10:15:57 GMT
    Pragma: no-cache
    Cache-Control: max-age=0, no-cache

    Тут та же ситуация, в соответствии со значением заголовка Cache-Control кэш в полном виде не будет использован.

Это справедливо практически для всех сайтов, здесь мы привели лишь несколько для примера, чтобы показать масштаб трагедии: заказчики сайта не знают и не понимают кэширования, а разработчики не уделяют ему должного внимания и не настраивают. Как итог - ухудшение скорости индексации новых товаров/новостей со стороны поисковых систем + лишняя нагрузка на сайт.

Проверьте свой сайт сервисомhttps://last-modified.com. Если вы увидите сообщение о том, что "Last-Modified не найден!" или "304 Not Modified не найден!" - у вас потенциальные проблемы со скоростью индексации со стороны поисковых ботов и лишней нагрузкой на карточки. Имеет смысл в таком случае обратиться к разработчикам.

Комплексный компонент bitrix:catalog

В компл. компоненте каталога есть параметр, ответственный за заголовки. Он находится в секции "Дополнительные настройки" и называется "Устанавливать в заголовках ответа время модификации страницы":

Если установить этот параметр, то в карточках товаров, выведенных этим компонентом, добавится заголовок Last-Modified. Его значением будет дата изменения элемента инфоблока, то есть вот эта:

Это лучше, чем ничего, но так как Cache-Control/Expires/Pragma компонентом не были изменены, толку от этого мало:

Date: Tue, 22 Aug 2017 08:09:46 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Last-Modified: Tue, 22 Aug 2017 08:01:48 GMT

Ко всему прочему проверка на last-modified.com показывает, что статус 304 Not Modified в ответ на If-Modified-Since по-прежнему не отдаётся сервером.

Композитный режим 1С-Битрикс

По умолчанию (без доп. настройки) композит работает на уровне PHP. Протестируем именно его. Включим в стандартном интернет-магазине Битрикс, установленном в веб-окружение 7.1, композитный режим. Пока режим перезаписи кэша выставим стандартный: “Закешированная страница всегда выполняет фоновый ajax-запрос. Если содержимое страницы изменилось, происходит перезапись кеша”. Посмотрим, какие заголовки в данном случае будет отдавать карточка того же самого товара (имеющие отношение к кэшу):

Хит №1:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1

=> ответ:

HTTP/1.1 200 OK
Date: Tue, 22 Aug 2017 11:19:50 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

Здесь серверный скрипт отработал полностью и установил все те же заголовки, что и возникают при выключенном композитном режиме. Скрытого хита система не делает (потому что в нём нет смысла, все данные и так актуальны).

Хит №2 (показалась кнопка “Быстро с 1С-Битрикс”):

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1
Cache-Control: max-age=0

=> ответ:

HTTP/1.1 200 OK
Date: Tue, 22 Aug 2017 11:23:25 GMT
Expires: Fri, 07 Jun 1974 04:00:00 GMT
Last-Modified: Tue, 22 Aug 2017 11:19:50 GMT
X-Bitrix-Composite: Cache (200)

Браузер добавил в запрос заголовок Cache-Control: max-age=0. Это значит, что он “захотел” достучаться до конечного в цепочке http сервера и получить от него ответ. Тот взял контент из сформированного в хите №1 кэша. Добавил заголовок Last-Modified со значением даты изменения файла кэша. Обратите внимание, что заголовок Cache-Control был удалён из ответа. При этом система сделала скрытый (композитный) хит для загрузки динамических областей:

GET /catalog/shoes-mens/shoes-summer-lightness/?bxrand=1503558406570 HTTP/1.1
BX-CACHE-MODE: HTMLCACHE
BX-CACHE-BLOCKS: {"bx_basketFKauiI":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"},"sender-subscribe":{"hash":"bee5d78f99b3278cba273be2198664f8"},"bx_basketT0kNhm": {"hash":"9a186b2dc4d41b60faf113c03c33daf3"}}
BX-REF:
BX-ACTION-TYPE: get_dynamic

=> ответ:

HTTP/1.1 200 OK
Date: Tue, 22 Aug 2017 11:23:25 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
BX-RAND: 1503558406570
X-Bitrix-Composite: Ajax (stable)

Так как скрытый хит исполняется сервером тоже полностью, то нет ничего странного, что мы видим, по сути, те же заголовки (кроме имеющих отношения к самому композиту) кэша, которые препятствуют его использованию. Обратите внимание, что в заголовках запроса в принципе отсутствует If-Modified-Since.

Хит №3:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1
Cache-Control: max-age=0
If-Modified-Since: Tue, 22 Aug 2017 11:19:50 GMT

=> ответ:

HTTP/1.1 304 Not Modified
Date: Tue, 22 Aug 2017 11:30:54 GMT
ETag: 95b26c951b1101220d5bcd6178c1d81c
Expires: Fri, 07 Jun 1974 04:00:00 GMT

Здесь мы видим, что сработал условный запрос - и сервер вернул 304 статус. Важно, что также в ответе пришёл ETag. Но как мы уже упоминали, в этом случае он в кэш не попадёт, и браузер не будет отправлять If-None-Match вместе с If-Modified-Since.

Скрытый хит:

GET /catalog/shoes-mens/shoes-summer-lightness/?bxrand=1503559294508 HTTP/1.1
BX-CACHE-MODE: HTMLCACHE
BX-CACHE-BLOCKS: {"bx_basketFKauiI":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"},"sender-subscribe":{"hash":"bee5d78f99b3278cba273be2198664f8"},"bx_basketT0kNhm":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"}}
BX-REF:
BX-ACTION-TYPE: get_dynamic

=> ответ:

HTTP/1.1 200 OK
Date: Tue, 22 Aug 2017 11:30:54 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
BX-RAND: 1503559294508
X-Bitrix-Composite: Ajax (stable)

Заголовки скрытого хита, фактически, дублируют их же со скрытого хита, сопровождающие хит №2.

Хит №4 и все последующие хиты из этого браузера:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1
Cache-Control: max-age=0
If-Modified-Since: Tue, 22 Aug 2017 11:19:50 GMT

=> ответ:

HTTP/1.1 304 Not Modified
Date: Tue, 22 Aug 2017 11:32:32 GMT
ETag: 95b26c951b1101220d5bcd6178c1d81c
Expires: Fri, 07 Jun 1974 04:00:00 GMT

Скрытый хит идентичен предыдущему скрытому хиту, поэтому его здесь не приводим. Важно то, что все хиты на данную страницу, начиная с третьего, будут такие же, как третий. И браузер не отправляет на сервер If-None-Match, так как изначально получил Last-Modified вместо ETag.

А теперь переведём работу композита на уровень nginx

Как это сделать для веб-окружения Битрикс см. в инструкции.

Хит №1:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1

=> ответ:

HTTP/1.1 200 OK
Date: Thu, 24 Aug 2017 11:43:45 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

Здесь пока всё так же, как и в случае PHP, потому что композитный кэш только сформировался в конце исполнения страницы.

Хит №2:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1
Cache-Control: max-age=0

=> ответ:

HTTP/1.1 200 OK
Date: Thu, 24 Aug 2017 11:46:08 GMT
Last-Modified: Thu, 24 Aug 2017 11:43:45 GMT
ETag: W/"599ebbf1-1e01a"
Expires: Wed, 24 Aug 2016 11:46:08 GMT
Cache-Control: no-cache
X-Bitrix-Composite: Nginx (file)

А вот здесь уже любопытно. Во-первых, вместе с Last-Modified сразу прилетел заголовок ETag. А, во-вторых, здесь присутствует Cache-Control: no-cache (хотя в данном случае никаких принципиальных отличий по сравнению с со режимом PHP нет).

Скрытый хит:

GET /catalog/shoes-mens/shoes-summer-lightness/?bxrand=1503576241692 HTTP/1.1
BX-CACHE-MODE: HTMLCACHE
BX-CACHE-BLOCKS: {"bx_basketFKauiI":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"},"sender-subscribe":{"hash":"605619f6de254cc653965a91401b3464"},"bx_basketT0kNhm":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"}}
BX-REF:
BX-ACTION-TYPE: get_dynamic

=> ответ:

HTTP/1.1 200 OK
Date: Thu, 24 Aug 2017 11:46:09 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
BX-RAND: 1503576241692
X-Bitrix-Composite: Ajax (stable)

Хит №3:

GET /catalog/shoes-mens/shoes-summer-lightness/ HTTP/1.1
Cache-Control: max-age=0
If-None-Match: W/"599ebbf1-1e01a"
If-Modified-Since: Thu, 24 Aug 2017 11:43:45 GMT

=> ответ:

HTTP/1.1 304 Not Modified
Date: Thu, 24 Aug 2017 11:52:00 GMT
Last-Modified: Thu, 24 Aug 2017 11:43:45 GMT
ETag: "599ebbf1-1e01a"
Expires: Wed, 24 Aug 2016 11:52:00 GMT
Cache-Control: no-cache
X-Bitrix-Composite: Nginx (file)

Скрытый хит:

GET /catalog/shoes-mens/shoes-summer-lightness/?bxrand=1503575520710 HTTP/1.1
BX-CACHE-MODE: HTMLCACHE
BX-CACHE-BLOCKS: {"bx_basketFKauiI":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"},"sender-subscribe":{"hash":"43453b74f80e93b0247309142cb1a78f"},"bx_basketT0kNhm":{"hash":"9a186b2dc4d41b60faf113c03c33daf3"}}
BX-REF:
BX-ACTION-TYPE: get_dynamic

=> ответ:

HTTP/1.1 200 OK
Date: Thu, 24 Aug 2017 11:52:01 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
BX-RAND: 1503575520710
X-Bitrix-Composite: Ajax (stable)

В целом, можно сказать, что с точки зрения браузерного кэширования особой разницы между использованием nginx и php вариантов нет (но это не значит, что её нет вообще, nginx по факту отдаёт статику существенно быстрее). Тем не менее, nginx раньше устанавливает заголовок ETag, что может быть предпочтительнее в некоторых случаях.

На текущий момент мы наглядно продемонстрировали, что стоит вот за этой фразой из документации Битрикс: “Примечание: 304 ответ отдаётся на третий хит: на первый создаётся композитный кеш, на второй передаётся время изменения, на третий - идет с заголовком if-Modified-Since, на который сервер отвечает 304 ответом”. В нашем случае первый хит отработал полностью, отдал свои обычные заголовки + сформировал композитный кэш. Второй хит получил заголовок Last-Modified с датой изменения кэша. И браузер отправил в третьем хите заголовок If-Modified-Since, на что сервер и выдал 304 ответ (вместо Last-Modified используется также и ETag).

На основе рассмотренного процесса работы композитного режима укажем его плюсы и минусы в контексте браузерного кэширования (и только с этой точки зрения, основные достоинства технологии см. на сайте Битрикс):

Достоинства:

  • Если страница есть в композитном кэше, то повторным хитам пользователя на эту страницу будет автоматически возвращён 304 статус.

Потенциальные недостатки:

  • Даже на первый из двух хитов (статичный) каждый раз будет делаться валидация (условный запрос к серверу), потому что интервал кэширования не задан заголовками Expires или Cache-Control: max-age={seconds}. Полноценного кэширования на стороне браузера без GET-запроса в данном случае не происходит;
  • Скрытый хит всегда выполняется сервером полностью и не возвращает 304 Not Modified, даже если ничего не изменилось. Вообще возможность вернуть 304 статус динамическим хитом - вопрос дискуссионный (так как этот хит должен подтянуть именно динамические данные, которые по определению часто меняются). Но эта возможность точно не помешала бы. Очевидный плюс такого решения даже при полном исполнении страницы - экономия трафика (что может оказаться критичным при высоких нагрузках), так как “тело” динамических данных (JSON) в этом случае не передавалось бы и бралось из кэша браузера. Определять актуальность динамических данных можно по ETag, являющимся их хэшем;
  • Факт изменения даты модификации контента определяется системой автоматически на основе хэша контента. Это значит, что у нас нет возможности вручную повлиять на значение Last-Modified, если мы считаем это изменение несущественным. Например, если в карточке товара выводилось, что товара на складе 156 штук, а стало 157 (и это число прямо содержится в теле страницы), то у нас изменится Last-Modified. “Объяснить” системе, что это незначимое изменение, мы не сможем. Поэтому если мы хотим иметь возможность определять, что мы считаем значимым изменением контента для обновления кэша клиента, а что нет - композит нам не подходит (по крайней мере, без дополнительных доработок);
  • Помимо ограничений кэширования динамической части на уровне http-заголовков, оно принудительно блокируется через задание в query_string переменной ?bxrand={значение}, меняющееся от одного скрытого хита к другому.

Для полноты картины упомянем также то, что в настройках композитного режима есть возможность установить “мёртвое” кэширование на заданный интервал, при котором фоновый хит не происходит:

Но в данном виде его практически негде использовать, т.к. маленький срок устанавливать мы не хотим (чтобы уменьшить нагрузку и дисковые операции), а при больших значениях теряется возможность обновления устаревших данных.

Оставшийся и рекомендуемый в админке режим “Стандартный режим с задержкой перезаписи” тоже не даёт нам никаких дополнительных решений, поскольку точно так же генерирует на каждый запрос фоновый хит, полностью исполняющий страницу. Все описанные недостатки справедливы и для него.

Итог: на текущий момент из коробки полноценного браузерного кэширования в Битриксе нет. Максимум, на что мы можем рассчитывать, это 304 статус при композитном режиме. Кэширования, при котором браузер вообще не будет отправлять GET-запросы (даже условные в течение заданного интервала времени), нет (потому что устанавливается Expires в прошлом или Cache-Control: no-cache или даже no-store). И даже при композите скрытый хит полностью игнорирует технологию клиентского кэша (по крайней мере, на момент написания статьи в наших экспериментах мы не увидели его использования).

Что делать: чем мы как разработчики можем помочь сайту

Здесь мы опишем концепт нашего функционала, который мы внедряем на сайты под управлением 1С-Битрикс. Он поможет решить часть важных вопросов с кэшированием и, как следствие, оптимизировать производительность и повысить скорость поисковой индексации страниц.

Какие задачи перед собой мы ставили?

  • Основная задача - добиться того, чтобы браузеры кэшировали карточки товаров, а поисковики не тратили ресурсы на обработку неизменившихся данных;
  • Кэш должен сбрасываться при существенных изменениях в товарах, происходящих на сайте (насколько это вообще возможно технологически);
  • Мы должны предоставить возможность менеджеру сайта самому определять, какие изменения считаются важными для сброса кэша, а какие нет;
  • Критерии существенных и несущественных изменений в контенте могут различаться для людей и поисковых роботов;
  • Сайт должен быть чувствителен к условным запросам клиента (If-None-Match, If-Modified-Since);
  • Если сайт получил условный запрос, проверка на наличие изменений должна происходить быстро, а при их отсутствии возвращаться 304 статус + тело страницы не должно исполняться. Оба требования очень важны! Если проверка актуальности контента занимает время, сопоставимое с генерацией контента, то особого смысла в ней нет (разве что трафик экономить). Высокая производительность в данном случае - принципиально важный момент.

Опережая возможные вопросы, сразу скажем, что выполнять данные задачи мы будем на уровне PHP. Вдумчивый читатель мог бы предложить использовать настройки nginx или apache для управления кэшированием. Но в нашем случае нужно учитывать слишком много факторов, о которых веб-серверу ничего не известно. Например, мы можем не считать товар изменённым, если пришёл поисковый бот и если у товара поменялась только цена или остаток. Мы не сможем предоставить подобную гибкость на уровне более низком, чем PHP.

Но прежде чем создавать что-то новое, надо было разобраться, почему Битрикс из коробки прямо устанавливает заголовки, запрещающие браузерное кэширование. После несколько пристрастного анализа выяснилось, что это побочный эффект вызова php-функции session_start() с параметрами по умолчанию. В документации PHP чётко сказано:

Замечание:
Эта функция отсылает несколько заголовков HTTP в зависимости от настроек. Смотри описание функции session_cache_limiter() для управления этими заголовками.

Смотрим описание функции session_cache_limiter(), видим, что она устанавливает один из режимов кэширования (директивы Cache-Control): public/private/no-cache. То же самое делает конфигурационный параметр PHP session.cache_limiter. Также она устанавливает заголовки Expires и Last-Modified. И если на Expires мы ещё как-то можем повлиять вызовом session_cache_expire(), то с Last-Modified сложнее, он будет установлен в дату последней модификации файла сессии.

Возможны следующие варианты решения проблемы:

  1. На каком-либо этапе до открытия сессии (например, в файле init.php или в коде модуля, там подключенного) вызвать следующий код:
    <?php
    session_cache_limiter('public');
    session_cache_expire(30); 		/* Это время в минутах - значение для примера */
    ?>

    Далее для смены Last-Modified нам понадобится вызвать функцию header со вторым параметром true (что означает перезаписать уже имеющийся заголовок). Это позволит нам установить Last-Modified в произвольное значение, отличное от даты сохранения сессии;

  2. Можно для установки заголовков (Cache-Control, Expires, Last-Modified, Pragma) просто ограничиться функцией header с установленным вторым параметром в true;
  3. В произвольном месте кода уже после старта сессии (но до отправки данных в браузер) можно вызвать функцию header_remove() для удаления заголовков с теми значениями, которые нас не устраивают, чтобы потом установить (или не установить - тогда заголовок просто будет удалён) их значения в нужные:
    <?php
    header_remove('Cache-Control');
    header_remove('Expires');
    header_remove('Last-Modified');
    header_remove('Pragma');
    ?>

На наш взгляд предпочтителен последний вариант, так как он предоставляет наибольшую гибкость разработчику. Запомним эту мысль (что заголовки нужно удалить).

Базовые настройки, которые мы предоставляем администратору:

  1. Список инфоблоков и соответствующих им шаблонов URL, для которых функционал будет работать (по умолчанию шаблон может браться и из настроек инфоблока). Предполагаем, что здесь пока будет указываться только шаблон пути для карточек товаров. На его основе станет понятно, каков символьный код или идентификатор товара;
  2. Список полей/свойств товара, изменения по которым будет считаться значимым для обновления браузерного кэша людей. Отдельная настройка для количества товара (чтобы была возможность указать, что важным считается только факт наличия/отсутствия товара, а конкретное количество не так важно). Также тут нужно указать макс. % отклонения цены, начиная с которого считать его значимым;
  3. Аналогичный список полей/свойств для поисковых роботов;
  4. Срок мёртвого кэширования карточек (max-age). Для людей и поисковых роботов здесь могут быть разные значения.

Описание процесса работы

На хите:

  1. Наш код должен подключаться ещё в init.php (и в самом его начале), чтобы иметь возможность максимально рано завершить исполнение страницы;
  2. Начав своё выполнение, он должен проверить соответствие запрошенного URL шаблонам из своих настроек. Если он не соответствует, выполнение нашей части завершается (и дальше всё работает как обычно);
  3. Извлекаем из URL идентификатор или символьный код элемента инфоблока;
  4. Делаем запрос к индексной таблице для того, чтобы понять, какова дата значимого изменения элемента.

    Базовая структура индексной таблицы (список полей):

    Код поля Описание поля
    iblock_id идентификатор инфоблока
    el_id идентификатор элемента инфоблока
    el_code симв. код элемента инфоблока
    lm_human дата последнего существенного изменения (last modified) элемента для людей
    lm_bot дата последнего существенного изменения элемента для поисковых роботов
    etag_human hash код, уникальный для последнего изменения элемента для людей
    etag_bot hash код, уникальный для последнего изменения элемента для поисковых роботов

    Важно, что engine таблицы должен быть Memory, чтобы запросы к таблице были максимально быстрыми. Индекс выстраиваем по полям (1, 2), (1, 3).

  5. Далее нужно проверить наличие в запросе условных заголовков If-Modified-Since или If-None-Match. Если есть хотя бы один из них, то проверяем соответствие дат/значений ETag. Если изменения нет, то возвращаем 304 статус и завершаем исполнение скрипта.

    Примечание: при желании можно подключить эту часть кода до пролога Битрикс с помощью php-настройки auto_prepend_file. Но тогда подключение к базе нужно будет выполнять самостоятельно. Зато возврат 304 статуса будет максимально быстрым (оставляем взвешивание всех “за”и “против” за самим разработчиком);

  6. В момент события OnEndBufferContent: 1) удаляем все заголовки, имеющие отношение к http-кэшу (удаляем их в этом месте, т.к. на странице они могли быть переустановлены другими компонентами); 2) устанавливаем Last-Modified и ETag на основе значений, которые мы извлекли из индексной таблицы на 4 шаге; 3) на основе значения из настроек и (потенциально) эвристического анализа (как часто в товаре происходят значимые изменения) устанавливаем заголовок Cache-Control: public, must-revalidate, max-age={seconds}.

Это ориентировочная схема, в реальности можно столкнуться с дополнительными сложностями. Например:

  • Есть любопытная особенность с установкой Cache-Control: max-age в конкретное значение. Если вы будете всегда передавать одно и то же значение, допустим, 86400 (что равно 24 часам), то вы никогда не будете знать, когда кэш обновится у всех пользователей. Потому что он у каждого обновится через 24 часа с момента его последнего визита, а эти моменты у всех разные. Если же вы хотите, чтобы кэш обновился у всех в одно и то же время, то, вероятно, наиболее быстрым решением будет побитовый сдвиг timestamp текущего момента времени вправо и влево (на подходящее для вас количество бит), добавление к полученному значению нужного количества секунд и вычисление разницы с текущим моментом. В идеале эти моменты времени должны совпадать с выгрузкой из 1С - так как именно она может изменить Last-Modified или ETag у товаров;
  • Важно оказалось и то, что при работе по протоколу https принципиальное значение для кэширования имеет факт наличия ошибок SSL-сертификата. Если есть хоть одна - браузер не будет сохранять и использовать кэш. Это нужно всегда иметь в виду;
  • Ещё одна сложность - динамические блоки в карточках товаров. Нужно понимать, что если страница берётся из кэша браузера, то берётся целиком. Это значит, что если в карточке есть маленькая корзинка, то она закэшируется вместе со страницей (композитный режим Битрикс как раз “борется” с этим недостатком). Так как скрытого хита не делается, то и обновить эту информацию не представляется возможным. Но можно, например, убрать маленькую корзинку или доработать сайт так, чтобы именно она извлекалась отдельным (и максимально быстрым) скриптом.

Фоновая активность на обработчиках:

В общем случае нам нужно поддерживать индексную таблицу (со значениями Last-Modified и ETag) в актуальном состоянии. Для этого можно либо навесить обработчики внутри самого Битрикс на события, связанные с изменением элементов инфоблока или сущностей каталога, либо же использовать триггеры MySQL. И у того, и у другого варианта есть свои плюсы и минусы, на которых мы не будем останавливаться и которые мы оставляем на откуп разработчиков. Важно, что в момент пересохранения товара нужно проверять, произошло ли какое-то существенное изменение в соответствии с его настройками. И если да, то обновлять данные в индексной таблице.

Также нужно учесть, что раз уж мы расположили эту таблицу в оперативной памяти, нужно позаботиться о своевременном её резервном копировании (решение о том, с какой частотой и в каком виде это делать, мы тоже оставляем за программистом). А иначе любой перезапуск MySQL приведёт к полной потере данных - и данную таблицу придётся восстанавливать.

Также наш функционал “наблюдает” за частотой важных изменений товаров, на основе чего может прогнозировать значения Cache-Control: max-age для данного элемента.

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

Принимая во внимание все возникающие на каждом проекте сложности, по нашему опыту, адаптация кода под требования конкретного заказчика и под шаблон его сайта - задача достаточно уникальная и (пока) плохо тиражируемая (хотя общее, бесспорно, есть).

Как настраивать?

Прежде всего, нужно определиться, какие изменения в товарах вы считаете значимыми для людей и для поисковых роботов. На наш взгляд, для поисковых роботов самым важным изменением является корректировка описания товара (DETAIL_TEXT) или добавление новых изображений. Всё остальное уже вторично. Можно, например, считать важным ещё и отклонение цены на столько-то процентов или факт смены наличия товара (есть/нет на складе). Но нужно понимать, что чем больше полей вы посчитаете значимыми для смены кэша, тем чаще он будет обнуляться (тем больше нагрузки на сайт и меньше внимания поисковиков к новым товарам). В большинстве случаев у товара часто обновляется всего два поля - его цена и количество. Если посчитать эти изменения не важными для поисковых роботов - то для них страница может быть закэширована на очень длительный срок, если сервер будет выдавать им на условные запросы 304 статус.

Также очевидно, что значимых полей для людей должно быть больше. Например, цена очень важна. Равно как и ключевые характеристики товара.

После того, как вы определитесь со списком этих полей, нужно обратиться к разработчикам, чтобы они завершили настройку, указав нужные шаблоны URL, инфоблоки и проведя наполнение индексной таблицы актуальными данными (для этого нужен отдельный скрипт).

Перспективы на будущее

  • Сейчас программный код ориентирован только на карточки товаров. Но есть большой потенциал использовать браузерный кэш и для списков, что особенно актуально для постраничной навигации, ведь пользователь может несколько раз возвращаться к одной и той же странице;
  • В текущей конфигурации функционал не может использоваться вместе с композитным сайтом. Но перспективы для их интеграции есть - и мы планируем двигаться в этом направлении. Максимальная производительность может быть достигнута при комбинировании обоих подходов;
  • Можно также добавить механизм унификация URL’ов, чтобы не было дублей - это оптимизирует кэш. Например, чтобы код понимал, что страницы “news/1/” и “news/1/index.php” - идентичны. И сам делал нужные редиректы;
  • Эвристические алгоритмы для определения адекватных значений Expires и max-age, определённо, можно улучшать. Здесь нет предела аналитике.

Резюме

Основные мысли, к которым мы пришли:

  • Браузерное кэширование - очень перспективная технология, которой уделяется неоправданно мало внимания.
  • Внедрение описанной технологии позволяет: 1) сократить нагрузку, генерируемую карточками товаров, на 30-40% (конкретное значение для вашего магазина могут посчитать разработчики); 2) пропорционально повысить скорость индексации новых товаров/новостей на вашем сайте (за счёт того, что вместо повторной обработки старой информации поисковик загрузит новый товар). Важно! Обращаем ваше внимание, что описанный способ не имеет прямого влияния на ранжирование в поисковых системах. Настроив кэширование, вы не повысите тем самым свои позиции. Эта технология имеет другое назначение: ускорить процесс индексации вашего сайта со стороны роботов. Это значит, что новые товары, статьи и новости быстрее будут появляться в поисковой выдаче;
  • Проверьте свой сайт сервисом https://last-modified.com. Если вы увидите сообщение о том, что “Last-Modified не найден!” или “304 Not Modified не найден!” - у вас потенциальные проблемы со скоростью индексации со стороны поисковых ботов и лишней нагрузкой на карточки. Имеет смысл в таком случае обратиться к разработчикам;
  • Также вы можете воспользоваться зарубежным сервисом https://redbot.org. Если вы увидите предупреждения в разделе “Caching” - обращайтесь за консультацией к разработчикам.

По любым вопросам, связанным с браузерным кэшированием и медленной индексацией новых товаров, вы можете обратиться, например, в Вебформат.

Полезные ссылки по теме

  1. Основная часть спецификации по кэшированию;
  2. Статья о кэшировании с сайта developer.mozilla.org;
  3. Руководство от Марка Ноттингема;
  4. Статья Ильи Григорик с developers.google.com;
  5. Заметка о max-stale с сайта msdn.
30 Авг 2017
Вернуться к списку
Заказать внедрение