Предыстория, зачем вообще понадобился кэш HTML

Предыстория.

Сервис предоставляет пользователям агрегацию отзывов из Google, Facebook, TripAdvisor, Yelp и прочих и embed виджеты которые они могут встраивать на свои сайты.

Важно отметить, что это всё крутится на одном сервере на стандартном стеке Linux, Nginx, MySql, PHP.

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

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

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

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

Когда вам нужен кэш HTML, быстро, дешево и без оверхэда.

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

Я поставил задачу сделать всё как можно проще, чтоб не увеличивать сложность сервиса и уровень вхождения программистов. На ум сразу приходит Varnish, и подобные инструменты выглядели заманчиво, но они казались оверхедом для такой задачи. Пилить микросервисы или масштабировать сервера тоже

Нам с трудом удалось найти адекватного DevOps, он предложил установить openresty (nginx на стероидах), который поддерживает Lua скрипты и через него можно общаться с Redis.

https://openresty.org/en/

https://habr.com/ru/company/vdsina/blog/504308/

Основная проблема - связать кэш с логикой, чтоб кэш сбрасывать только при изменениях. Если внутри Laravel или другого фреймворка мы имеем доступ к бизнес логике зависимых сущностей, то на стороне nginx у нас только параметры запроса.

Настройка на стороне Laravel

Как показало исследование проблемы, 100% рабочий вариант - использовать сам Url path, как идентификатор конкретного виджета с конкретными настройками. И для того, чтоб можно было управлять кэшем на стороне PHP были созданы таблицы

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

EmbedRequest::firstOrCreate([
  'request_url' =>  request()->path()
]);
embed_requests: id, request_url
embed_requests: id, request_url

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

embed_to_embed_request: id, embed_request_id, embed_id
embed_to_embed_request: id, embed_request_id, embed_id

Таким образом осуществилась привязка URL к конкретным сущностям. Теперь при изменении любых настроек стало возможным найти нужный redis key по request_url и сбросить кэш.

Модели обзавелись такими методами (для примера привел минимальную логику и добавил комментарии)

class EmbedRequest extends Model
{
    const CACHE_DELAY__REQUEST_URL = 29*60;

//ключ кэша
    public static function cacheKeyByRequestUrl($requestUrl)
    {
        return RedisService::KEY_PREFIX__NGINX__EMBED_PAGE_CACHE . $requestUrl;
    }

//сброс зависимого кэша
    public function flushRelatedCache()
    {
        \Cache::store(RedisService::CACHE_STORAGE__REDIS_RAW)->forget(EmbedRequest::cacheKeyByRequestUrl($this->request_url));
    }
}
class Business extends Model
{
    public static $cacheIgnore = [
        'created_at',
        'updated_at',
    ];

//ключ кэша
    public static function cacheKeyByPk($id)
    {
        return 'BusinessId:' . $id;
    }

//сброс зависимого кэша других моделей
    public function flushRelatedCache()
    {
        Cache::tags(Business::cacheKeyByPk($this->id))->flush();
        foreach ($this->widgets as $embedable) {
            if ($embedable->embed) {
                $embedable->embed->flushRelatedCache();
            }
        }
        foreach ($this->badges as $embedable) {
            if ($embedable->embed) {
                $embedable->embed->flushRelatedCache();
            }
        }
        if ($this->collect && $this->collect->embed) {
            $this->collect->embed->flushRelatedCache();
        }
    }
}

а с помощью встроенных событий моделей мы получили возможность каскадом сбрасывать все зависимые кэши модели $model->flushRelatedCache() при обновлении атрибутов модели участвующих в кэшировании $model->isDirty($cachingAttributes)

    public function saved($model)
    {
        $cachingAttributes = array_diff(array_keys($model->getAttributes()), $model::$cacheIgnore);
        if($model->isDirty($cachingAttributes) ) {
            $model->flushRelatedCache();
        }
    }

И наконец сохраняем итоговую страницу в кэш

Вариант с text/html

public function returnIframeView()
{
    $viewFolderPath = $this->resolveViewFolderPath();
    $view = view($viewFolderPath, [
        'badgeDataDto' => $this->getEmbedData(),
        'preview'      => false,
    ]);
    $cacheKey = EmbedRequest::cacheKeyByRequestUrl($this->embedRequest->request_url);
    $view = \Cache::store(RedisService::CACHE_STORAGE__REDIS_RAW)->remember($cacheKey, EmbedRequest::CACHE_DELAY__REQUEST_URL, function () use ($view) {
        return $view->render();
    });
    return $view;
}

Вариант с application/json

$responseContent = response()->json([
    "type" => $primaryWidget->design_theme,
    "html" => $view->render(),
]);
\Cache::store(RedisService::CACHE_STORAGE__REDIS_RAW)->remember($cacheKey, EmbedRequest::CACHE_DELAY__REQUEST_URL, function () use ($responseContent) {
    return $responseContent->content();
});
return $responseContent;

Настройка на стороне Openresty (Nginx)

Устанавливаем Openresty или просим хорошего админа.

под etc\nginx создаём конфиг для инклуда в основной nginx конфиг, например snippets/redis_prod.conf

server {
    ...    
    include snippets/redis_prod.conf;    
    ...
}

И пишем скрипт для получения кэша не заходя в ПХП. Думаю эту часть статьи проще представить в виде кода с комментариями, т.к. всё максимально просто (как и планировалось, чтоб разобрался любой middle ПХП разработчик)

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

-- Добавил комментарии для статьи, а так вроде без комментариев алгоритм очевиден
-- парсим URL чтоб сюда попали только нужные запросы
location ~ ^/(embed/v2/|embed/|badge/)([a-zA-Z0-9\|]+?)[/]?($|\?.*) {
    set $token1 $1;
    set $token2 $2;
    add_header Cache-Control "no-cache, max-age=1800, must-revalidate";
    add_header Access-Control-Allow-Origin *;
    lua_socket_log_errors off;

content_by_lua_block {
    local redis = require "resty.redis"
      
  -- подсчет статистики, можно пропустить.
  -- считаем статистику посещений, для аналитики. coroutine для записи статистики параллельно
    local coroutine = ngx.thread.spawn(function()
        local red2 = redis:new()
        red2:set_timeouts(100, 1000, 1000) -- 1 sec
        local ok, err = red2:connect("127.0.0.1", 6379)
        if not ok then
            ngx.log(ngx.ERR, err)
            ngx.exec('/index.php', ngx.var.args)
        end
        red2:init_pipeline()
        red2:select(1)
        red2:set("nginx:embed_page_referer:" .. ngx.var.token1 .. ngx.var.token2, ngx.var.http_referer)
        red2:incr("nginx:embed_page_count:" .. ngx.var.token1 .. ngx.var.token2)
        assert(red2:commit_pipeline())
        red2:set_keepalive(60000, 50)
    end)
  -- конец подсчета статистики.
      
  -- подключаемся к редису для получения кэша
    local red = redis:new()
    red:set_timeouts(100, 1000, 1000) -- 1 sec
    local ok, err = red:connect("127.0.0.1", 6379)
      
  -- нет подключения - обрабатываем как обычно
    if not ok then
        ngx.log(ngx.ERR, err)
        ngx.exec('/index.php', ngx.var.args)
    end
      
  -- выбор БД редиса
    red:select(2)
      
  -- получаем кэш по ключу
    local res, err = red:get("nginx:embed_page_cache:" .. ngx.var.token1 .. ngx.var.token2)
    red:set_keepalive(60000, 50)
  
  -- данные могут разные, проверка что там
    if res and res ~= ngx.null then
        local is_html = ngx.re.find(res, [[^\s*<]],  "jo")
        if not is_html then
            ngx.header.content_type = "application/json; charset=utf-8"
        else
            ngx.header.content_type = "text/html; charset=utf-8"
        end
        ngx.print(res)
        -- ensure background coroutine finish
        ngx.thread.wait(coroutine)
        ngx.exit(200)
    end
    if err then
        ngx.log(ngx.ERR, err)
    end
    ngx.thread.wait(coroutine)
      
  -- нет кэша - обрабатываем по стандарту 
    ngx.exec('/index.php', ngx.var.args)
}
}

P.S.

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

Комментарии (17)


  1. AlexGluck
    31.03.2022 09:45
    +2

    Можно было бы вместо добавления редиса ванильным нджинксом кешировать, а на стороне бекенда менять етаг и обновлять кеш простым нттр запросом в нджинкс. Чем топорней решение, тем меньше точек на отказ.


    1. vism Автор
      31.03.2022 10:06
      -1

      Погуглил. Это похоже кэш на стороне клиента, верно?

      Я здесь кэш на стороне сервера.

      Т.е. единожды отрендеренный ответ отобразится всем разным пользователям.

      Т.е. с Етаг 10000 разных пользователей заставят отработать веб-приложение (PHP) 10000 раз. А с кэшем через nginx + redis - 1 раз.


      1. Akuma
        31.03.2022 10:08

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


        1. vism Автор
          31.03.2022 10:47

          У etag я пока не понял как сбрасывать, да и много вопросов, как оно повлияет при сотен тысяч страниц... можно ли кэш прогревать, массово сбрасывать, сбрасывать по событию.

          Если речь о моём решении, то кэш на 100% управляется из приложения, этих проблем нет.


          1. AlexGluck
            31.03.2022 11:14

            можно ли кэш прогревать, массово сбрасывать, сбрасывать по событию.

            1. Кеш можно прогревать. Для прогрева необходимо отправить запрос на нджинкс, если страниц много это займёт время.

            2. Можно сбрасывать весь кеш. Просто удалить папку с кешом сбросит его.

            3. Сброс по событию возможен. В случае наступления события необходимо отправить HTTP запрос на нджинкс с заголовком if-modified-since.


        1. AlexGluck
          31.03.2022 11:31

          Для кого проблема, для кого задача)


      1. AlexGluck
        31.03.2022 10:11

        Не верно конечно, кеш находится на стороне клиента И на стороне нджинкса.


        1. vism Автор
          31.03.2022 10:37

          А есть ссылочка, где подробнее почитать о настойке и управлении из веб приложения?

          Я правильно понимаю, что так можно?

          1. etag нужно генерировать на стороне PHP

          2. связать etag с конкретным url

          3. передать его как-то(в хедере?) в nginx вместе с ответом

          4. При изменении сущности проверять какие etag зацепило изменения и как-то(как?) сбрасывать его на стороне nginx

          с ходу я такое не нагуглил. Перебрали десяток nginx админов на upwork со ставкой до 150 в час, никто такого решения не предложил.

          Если реально так можно, прошу поделиться мануалами.

          UPDATE:

          Ещё интересно, а что если клиент придёт с другим etag? (должна отдаться кэш страница)

          А как прогреть этот кэш, если нужно будет?

          А как оно работает с сотнями, миллионами страниц?


  1. AlexGluck
    31.03.2022 11:00

    Тема с кешированием очень обширна и конкретно под ваш случай готовый ответ будет выполнением всей работы самостоятельно. Вот близкое объяснение.


    1. vism Автор
      31.03.2022 11:17

      Основываясь на том, что я нагуглил по теме Etag, он не решает вопросы, которые описаны в статье, он не гибкий, проблемы с валидацией.

      1. В кэше сотни тысяч страниц

      2. Как я понял при Etag кэшировании, для валидации кэша Nginx должен обращаться к веб-приложению. Это значит

        1. либо через redis делать на стороне nginx как-то

        2. либо запускать каждый раз веб-приложение для валидации

        3. либо каждому клиенту заново рендерить страницу (а миллионы клиентов уникальны)

      3. Определённые страницы должны сбрасывать по условиям изменения сущностей (управление из бэкэнда) - проблема валидации Etag опять же

      4. Как оно поведёт себя при сотнях тысяч страниц

      5. Как прогреть этот кэш (вобще не возможно видимо)

      6. Если клиент придёт без Etag или изменит его. Кэш не отработает как я понял

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

      А чтоб настроить Etag для такого-же функционала, как lua+redis, нужно намного больше времени и намного более высокая квалификация nginx админа. Следовательно выше порог вхождения и дороже поддержка.

      Именно эти все проблемы решаются моим рецептом :)


      1. AlexGluck
        31.03.2022 11:28

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


        1. vism Автор
          31.03.2022 11:42

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

          https://symfony.ru/doc/current/http_cache/validation.html

          нужно много тестировать. Тот же сброс, время прогрева, обновление по http request.

          Кстати, а считать статистику посещений страниц при Etag как-то можно через nginx?

          И да, возможно писать статьи не моё, для того рейтинг и есть:)


      1. AotD
        31.03.2022 11:38

        Кмк, Etag можно использовать не вместо, а вместе с вашим решением, чтобы экономить не время, а трафик.

        Т.е. в месте (вместо) ngx.print(res) добавляем логику:

        — Вычисляем значение ETag для содержимого виджета

        — Если от клиента пришел заголовок If-Match и он совпадает с ETag текущего отдаваемого контента - просто вернуть "304 Not Modified".

        — Если контент отличается - отдать тело и новый ETag

        Т.о. если виджеты объемные и показываются пользователю более одного раза за сессию - фактически отдаваться они будет единожды (до тех пор пока не изменятся в Redis)

        Ну а дальше можно поиграться с хранением горячих ETag в LUA LRU табличке, чтобы на этот раз снизить нагрузку на Redis, но только на короткий срок или имея какую-то % вероятность пробива в Redis, чтобы удостовериться что данные там не изменились. (Либо заставить ваш flushRelatedCache отправлять специальный запрос в специальный location nginx, чтобы реализовать логику подчистки таблицы)


        1. vism Автор
          31.03.2022 11:48

          если виджеты объемные и показываются пользователю более одного раза за сессию

          В среднем 1.5 раза за сессию.

          Мне очень нравится ваш вариант, это снизит траффик на 33%


        1. AlexGluck
          31.03.2022 11:55

          Можно вместе, можно вместо. Я имел ввиду хранение кеша не в редисе, а в нджинксе. Как это работает с редисом:

          1. В случае события сброса, мы вызываем генерацию новой страницы.

          1. Помещаем запросом в редис содержимое.

          Как это работает с нджинкс:

          1. Делаем запрос в нджинкс для сброса кеша.

          2. Делаем запрос в нджинкс для сохранения новой страницы в кеше.


          1. AotD
            31.03.2022 12:04

            Ага, давно не общался с nginx настолько плотно, так что навертел логику ETag руками)


  1. sunnybear
    31.03.2022 11:42

    proxy_store и unlink решает проблемы без openresty. Плюс кэш можно в оперативную память смонтировать