Как стать автором
Обновить

Кэш HTML на стороне NGINX с помощью Redis

Время на прочтение 6 мин
Количество просмотров 4.1K

Предыстория, зачем вообще понадобился кэш 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.

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

Теги:
Хабы:
-1
Комментарии 17
Комментарии Комментарии 17

Публикации

Истории

Работа

PHP программист
149 вакансий

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн