zemlan.in

Cache Already Done

Как не переизобрести части браузера

Видео с KyivJS (начинается после 1:27:15), если предпочитаете смотреть/слушать. Но лучше читать — меньше пауз и более развёрнуто, чем в десяти минутах лайтнинга

Previously

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

Зачем залез в кэширование

Я делаю сервис пользовательского фидбека Colbert. Его суть заключается в том, что «хозяева» сайта встраивают колберовский <script> и получают возможность задавать те или иные вопросы на каждой своей странице. Чтобы получить эти вопросы, скрипт стучится на сервер. Сервер отдаёт (приблизительно) все опросы на этом сайте, на которые посетитель ещё не отвечала, и уже скрипт решает, нужно ли показать опрос про качество поиска или про опыт заказа

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

Свежесть / Валидность

В HTTP есть две группы хэдеров про кэширование: «свежесть» и «валидация»

Свежесть

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

К этой группе относятся Cache-Control: max-age=N, Expires и, в каком-то смысле, Last-Modified (браузеры кэшируют ответы с ним и с Date на время t = (Date - LastModified) / 10)

Отдельно упомяну про то, что Cache-Control: max-age=N начинает отсчёт не по локальным часам, а по значению Date. Так что, если у вас на сервере неправильно настроено время (или вы тестируете сервер внутри VirtualBox’а, где течение времени может заметно отставать от реального), проверка на «свежесть» может не работать. Или перестать работать. Или работать слишком агрессивно, устанавливая инвалидацию в очень далёком будущем, но об этом потом

Валидность

Вторая группа служит для сравнения кэша браузера с новым ответом сервера (если он всё-таки был сделан после проверки на «свежесть»)

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

Сама валидация происходит на сервере, путём сравнения значений If-None-Match и / или If-Modified-Since (которые автоматически отправляются браузерами, если ресурс ранее отдавался с хэдерами ETag и / или Last-Modified соответственно) со значениями, которые вычислил сервер для ответа

Оно звучит немного запутано на словах, но понятнее в коде

Practice

Сервер Колбера написан на Python, так что примеры будут на нём

resp_body = {
    'questionnaires': get_questionnaires(),
}
json_resp = json.dumps(resp_body).encode('utf-8')
etag = f'W/"{hashlib.sha1(json_body).hexdigest()[:8]}"'

resp_headers = {
    'cache-control': 'private, max-age=120',
    'etag': etag,
    # spoilers:
    # 'vary': 'Origin, Cookie'
}

if req.headers.get('if-none-match') == etag:
    return web.HTTPNotModified(  # Status: 304
        headers=resp_headers,
    )

return web.HTTPOk(  # Status: 200
    body=json_resp,
    content_type='application/json',
    headers=resp_headers,
)

Кэши есть не только у браузеров, но и у CDN, reverse proxy, load balancer’ов… Для того, чтобы они не вздумали кэшировать ответы сервера, предназначенные для определённого посетителя, нужен хэдер Cache-Control: private

Браузер кэширует не только тело ответа сервера (в котором находится заветный JSON’чик), но и все хэдеры. В том числе и хэдер Set-Cookie, который устанавливает куки. Если этот хэдер есть в кэшируемом ответе, то браузер может:

  1. либо не кэшировать ответ
  2. либо кэшировать его и делать Set-Cookie при каждом обращении к кэшу

Оба таких поведения довольно нежелательны (конечно, второй сильно хуже первого, так как не только нагружает сервер, но и может ломать авторизацию). Поэтому, если добавляете один из хэдеров про «свежесть» (Cache-Control: max-age=N, Expires и/или Last-Modified), то ни в коем случае не устанавливайте их одновременно Set-Cookie

if should_set_some_cookie:
    response.set_cookie('some_cookie', value)

    # shouldn't cache responses with Set-Cookie
    if 'cache-control' in response.headers:
        del response.headers['cache-control']

    if 'etag' in response.headers:
        del response.headers['etag']

Внимательная читательница скажет, что «Но, если мы на каждый ответ авторизованному посетителю обновляем ему auth-куку, то кэширование же никогда не сработает». Да, не сработает. Потому что не надо ставить Set-Cookie на каждый ответ (даже если не настраиваете кэширование)

Инвалидация

Юморной же читатель уже давно хочет пошутить про «две главных проблемы программирования». На Колбере есть два повода инвалидации кэша:

  1. обновился список активных опросов (текст, статус активности и т.п.)
  2. посетитель уже ответил на один из опросов

Первая причина частично решается обновлением ETag, частично — извинением перед редакторами опросов «ваши изменения будут видны посетителям сайта в течении двух минут»

После ответов же посетителя, извинением не обойтись — нужно обязательно прятать опрос. Есть несколько способов это делать. Например, записывать в localStorage айдишники уже отвеченных опросов. Но, раз уж пишу про HTTP-хэдеры, то воспользуемся ими

Vary

По умолчанию, браузеры идентифицируют кэшируемые ресурсы по их URL’ам и HTTP-методам (GET, POST и так далее). Другой URL — другой ресурс — другой кэш

Но ресурсы для кэширования можно идентифицировать и с помощью хэдеров запроса благодаря хэдеру ответа Vary. Например, можно сделать так, что CORS-запросы с сайта example.com будут кэшироваться отдельно от запросов с сайта example.org с помощью хэдера Vary: Origin

Обратно к примеру Колбера. Сервер, кроме Cache-Control и ETag, устанавливает хэдер Vary: Origin, Cookie

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

Danger Zone

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

Насколько я знаю, в браузерах нет надёжного и / или простого способа инвалидировать кэши из Javascript’ового кода (ServiceWorker’ы, имхо, ещё не обладают достаточной поддержкой и / или простотой использования). Либо пользователь должен лично сбросить кэш (нажатием Cmd+Shift+R, Ctrl-F5 или другого подобного «аккорда»), либо скрипт должен обращаться к другому ресурсу (т.е., нужно поменять URL ресурса и / или один из хэдеров, перечисленных в Vary)

Кейсов для кэширования огромное множество и одного универсального способа для исправления таких ошибок нет. Поэтому могу только посоветовать:

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

Дебаг

Юзайте ДевТулзы ¯\_(ツ)_/¯


Браузеры отдельно кэшируют Preflight OPTIONS-запрос для CORSа, так что он может не выполняться при форсированном обновлении (Cmd+Shift+R). Жмите Disable cache, чтобы полностью сбросить кэш


Пока кэш свежий, браузер отдаёт ресурс (for disk cache)


Когда кэш просрочился, браузер сделал запрос с If-None-Match, на что сервер вернул ответ без тела (начальный ответ был 1.8 KB, последний же — всего 388 B) и со статусом 304 Not Modified


После 304, браузер обновил хэдеры закэшированного ответа и снова отдаёт его (from disk cache)


В Хроме и Firefox есть страница about:cache, на которой можно детально изучить, что же именно закэшировал браузер

И не тестируйте кэшируемые эндпоинт, внаглую открыв его URL в браузере. Когда вы обновляете (Cmd+R/F5) страницу, браузеры игнорируют её «свежесть» и/или «валидность». Например, Хром сделает сетевой запрос с хэдером ETag даже если прошлый ответ ещё не был просрочен

Профит

Так зачем всё это? Какой профит?

После того, как Колбер начал работать с четырьмя хэдерами перечисленными здесь (Cache-Control, ETag, Vary и If-None-Match; шестью, если считать Set-Cookie и Cookie), количество запросов уменьшилось в три раза. И это с очень осторожной «свежестью» в две минуты

Недавно Колбера включили на Проме и других сестринских проектах, где трафик в разы больше суммарного трафика сайтов, на которых Колбер был подключен на тот момент. О чём я узнал не из-за пятисотых ошибок, а из случайного разговора с коллегой. Сомневаюсь, что запуск получился бы насколько же гладким без этих хэдеров

Спасибо