1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 2 из 6

Ключ и что кешировать нельзя

Коротко

Кеш включили, доля HIT выросла, все довольны. Через день на второй странице каталога показывается первая, а в личном кабинете - чужое имя. Обе истории про ключ.

  • Ключ по умолчанию - $scheme$proxy_host$request_uri, то есть вместе со строкой параметров.
  • Замена $request_uri на $uri схлопывает ?page=1 и ?page=2 в одну запись. Замер показывает это прямо.
  • Set-Cookie, no-store и private nginx отсеивает сам - и это защита, а не помеха.
  • proxy_cache_bypass и proxy_no_cache пишут парой: иначе личный ответ уедет следующему анониму.

Если помнишь, что в ключе по умолчанию есть строка запроса, и знаешь пару bypass/no_cache - листай до «Теперь сам».

Сначала ответь сам

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

/etc/nginx/conf.d/shop.local.confhttp › server › location /
proxy_cache_key $scheme$proxy_host$uri;

Что увидит человек, открывший вторую страницу каталога?

↳  выводдва запроса подряд (ключ $scheme$proxy_host$uri, без строки запроса)
/cat?page=1  X-Cache-Status: MISS  бэкенд вернул path=/cat?page=1
/cat?page=2  X-Cache-Status: HIT   бэкенд вернул path=/cat?page=1

Первую страницу. $uri - это путь без параметров, поэтому обе страницы получили один ключ. Пользователь листает каталог и видит одно и то же; в логе nginx при этом честный HIT, а бэкенд молчит - жаловаться некому.

На что это похоже

Гардероб, где номерки выдают по фамилии, а не по человеку. Пока однофамильцев нет, схема работает. Первый же второй Иванов уносит чужое пальто - и никакой ошибки при этом не происходит: система отработала ровно так, как её попросили.

Ключ кеша - это номерок. Всё, что не попало в ключ, для кеша не существует.

Механизм: что входит в ключ

умолчание $scheme $proxy_host $request_uri = /cat?page=2 заменили на $uri $uri = /cat параметры выпали всё, чего нет в ключе, кеш считает одинаковым
Сокращать ключ можно только тем, что точно не влияет на содержимое ответа.

Правило

Персональные ответы не кешируются. Не «кешируются с ключом по cookie», а не кешируются вовсе - редкий сайт может позволить себе хранить персональные страницы всех пользователей.

/etc/nginx/conf.d/shop.local.confhttp › server › location /
proxy_cache_bypass $http_authorization $cookie_session;   # не читать из кеша
proxy_no_cache     $http_authorization $cookie_session;   # не писать в кеш

Обе директивы принимают список переменных и срабатывают, если хотя бы одна не пуста и не равна нулю. Разница принципиальная: bypass не берёт ответ из кеша, no_cache не кладёт его туда. Пишут их парой - иначе авторизованный пользователь обойдёт кеш при чтении, а его личный ответ прекрасно туда запишется и уедет следующему анониму.

Замер, как это выглядит в статусах:

↳  выводproxy_cache_bypass $arg_nocache (запись /mu уже прогрета)
/mu?nocache=1   X-Cache-Status: BYPASS
/mu             X-Cache-Status: HIT      ← прежняя запись цела

Разбор: что nginx не кеширует сам

Кое-что он отсеивает без всяких настроек - по заголовкам ответа бэкенда. Проверено на стенде, два запроса подряд к каждому адресу:

ответ бэкенда статусы
Set-Cookie: sid=abc MISS, MISS - обращений к бэкенду 2
Cache-Control: no-store MISS, MISS - обращений к бэкенду 2
обычный MISS, HIT - обращений к бэкенду 1

Тот же список полностью: Set-Cookie, Cache-Control со значением no-cache, no-store или private, Expires в прошлом, Vary: *.

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

/etc/nginx/conf.d/shop.local.confhttp › server › location /
proxy_ignore_headers Cache-Control Set-Cookie Expires;
proxy_hide_header    Set-Cookie;
↳  выводс этими двумя строками
/cookie   MISS, затем HIT - обращений к бэкенду 1
/nocache  MISS, затем HIT - обращений к бэкенду 1

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

Разбор: не кешировать сразу

/etc/nginx/conf.d/shop.local.confhttp › server › location /
proxy_cache_min_uses 3;
↳  выводчетыре запроса подряд при proxy_cache_min_uses 3 (X-Cache-Status)
запрос 1  MISS   бэкенд: 1
запрос 2  MISS   бэкенд: 2
запрос 3  MISS   бэкенд: 3
запрос 4  HIT    бэкенд: 3

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

Рядом стоит proxy_cache_methods (по умолчанию GET HEAD): кешировать POST можно, но почти никогда не нужно.

Что ломается без этого

Сокращённый ключ ломает не только страницы. Всё, что влияет на содержимое, но не попало в ключ, склеивается в одну запись: язык из Accept-Language, версия API из заголовка, мобильная и десктопная вёрстка, сжатая и несжатая версия ответа.

Последнее особенно коварно и решается не ключом, а заголовком: Vary: Accept-Encoding (первая глава раздела) говорит кешам на пути, что версий несколько. Свой кеш nginx это понимает сам, а вот CDN и корпоративный прокси - только по заголовку.

Зачем это в работе

Первое, что стоит сделать после включения кеша, - проверить его на чужом пользователе. Открой страницу под авторизацией, потом ту же страницу в приватном окне и сравни: если во втором окне видно имя первого пользователя, у тебя ровно та ошибка, ради которой написана эта глава.

Второе - посмотреть в лог на число разных ключей. Если у страницы каталога сотни тысяч ключей из-за меток рекламных кампаний, правильный ход не «убрать параметры из ключа», а вырезать конкретные метки:

/etc/nginx/nginx.confhttp
map $arg_page$arg_sort$arg_per_page $cache_args {
    ""      "";                       # полезных параметров нет - ключ без них
    default "page=$arg_page&sort=$arg_sort&per=$arg_per_page";
}
/etc/nginx/conf.d/shop.local.confhttp › server › location /catalog/
proxy_cache_key "$scheme$proxy_host$uri?$cache_args";

Смысл: ключ собирается из ИЗВЕСТНОГО списка значимых параметров, а всё остальное - включая метки кампаний - в ключ просто не попадает. Так ошибиться нельзя: что не перечислено, то не учтено. Замер на 1.31.6:

↳  выводключ кеша при разных запросах
/catalog/?page=2                    →  /catalog/?page=2&sort=&per=
/catalog/?page=2&utm_source=x       →  /catalog/?page=2&sort=&per=   ← тот же ключ
/catalog/?page=2&sort=asc           →  /catalog/?page=2&sort=asc&per=
/catalog/?page=3&utm_campaign=a&sort=asc  →  /catalog/?page=3&sort=asc&per=
/catalog/?utm_source=x              →  /catalog/?

Метки не меняют ключ, а значимые параметры меняют - именно это и требовалось.

Соблазн вырезать метки регуляркой: не делай так

Напрашивается другое решение - оставить $request_uri и вырезать из него метки одним map с регуляркой. Выглядит короче, работает неверно. Вот ровно такая попытка:

/etc/nginx/nginx.confhttp
map $request_uri $cache_uri {
    ~^(?<clean>[^?]*)\?(.*&)?utm_[^&]*(&(?<rest>.*))?$  $clean?$rest;
    default                                             $request_uri;
}

Замер на nginx 1.31.6 - что получается в $cache_uri:

↳  выводзначение $cache_uri при разных запросах
/cat?utm_source=x                  →  /cat?
/cat?page=2&utm_source=x           →  /cat?            ← page=2 ПОТЕРЯН
/cat?utm_source=x&page=2           →  /cat?page=2
/cat?page=2&utm_source=x&sort=asc  →  /cat?sort=asc    ← page=2 ПОТЕРЯН
/cat?page=2                        →  /cat?page=2

Жадная группа (.*&)? съедает всё, что стоит ПЕРЕД меткой, и вторая страница каталога получает тот же ключ, что первая. Это ровно та ошибка, от которой предостерегает эта глава, - только спрятанная в регулярку. Для произвольной строки параметров безопасный вариант один: либо оставить $request_uri целиком, либо собирать ключ из перечисленных $arg_*, как в конфиге выше.

Разница с $uri в том, что здесь в ключе остаются известные значимые параметры, а не все подряд и не «все, кроме тех, что я вспомнил».

Теперь сам

В кеш попал ответ с чужой сессией: аноним видит имя другого пользователя. В конфиге стоит proxy_cache_bypass $cookie_session;, а proxy_no_cache нет. Как это произошло?

Авторизованный пользователь пришёл с cookie - bypass честно отправил его мимо кеша на бэкенд. Но запретить запись было нечем: персональный ответ уехал в кеш и стал общим. Пара директив пишется всегда вместе; проверяется это тем же опытом с приватным окном.

Главное

Ключ кеша должен различать запросы с разным содержимым ответа. Для персональных запросов отдельно запрети чтение общей копии и запись персонального ответа. Проверь последовательность «авторизованный запрос, затем анонимный»: одиночный BYPASS ещё не доказывает, что личные данные не попали в кеш.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.