Инвалидация nginx кэша
- понедельник, 9 марта 2026 г. в 00:00:06
Когда-то давно, когда я только пришел в IT SEO компанию работать программистом, я восхищался ребятами, которые умели писать на JavaScript. Они могли сделать, например, калькулятор, который складывал и умножал пару чисел. Тогда для меня это было что-то за пределами моего понимания. Но уже через пару месяцев я смог передать AJAXом данные формы на бэкенд. Я испытал волнение и ликование. Так и началась моя карьера frontend-разработчика.
Высоконагруженная система. Несколько миллионов страниц. Много сервисов, много данных. Имеются кэши разного уровня. Возникла идея сделать полный html кэш страницы. Достаем с полки nginx, ставим перед системой, включаем кэширование запросов. Работает. Но как быть, если данные изменяются? Надо сбрасывать кэш. Какие есть решения:
Способ 1. Сброс кэша руками(скриптом) почистив папку с кэшем на сервере.
В таком случае сбрасывается кэш всех страниц. Данные изменяются постоянно, поэтому смысл кэша теряется в таком случае.
Способ 2. Пересобрать nginx с модулем для сброса кэша.
Это самое верное решение. Мы пробовали модуль ngx_cache_purge, других не было. Но при проведении нагрузочного тестирования удаления кэша начинает течь память. Крутили разные настройки и версии этого модуля. Ничего не помогло. В платной версии nginx есть модуль сброса. Вот только купить сейчас лицензию сложно. И нет гарантии, что будет корректно работать.
Способ 3. Использовать метод, предложенный товарищем.
Он предлагает создать переменную(область хранения в памяти) expire_queue, в которой будут храниться адреса страниц для сброса кэша. При чтении, страницы отдаются из кеша. При редактировании дергается страница с заголовком X-Expire-Content, по которому в expire_queue добавляется uri страницы из заголовка. Когда приходит запрос на чтение, то проверяется uri в expire_queue и если там есть, то из expire_queue удаляется значение и устанавливается proxy_cache_bypass. Кэш обновляется и отдается пользователю. Запись в expire_queue и bypass происходят на разные запросы. В expire_queue будут накапливаться данные. Что будет, если произойдет рестарт nginx? Кэш на сервере сразу станет невалидным.
Думаю, что этот способ хорош. Нужно только сделать все в рамках одного запроса. Правда для сброса придется дергать каждую страницу отдельно, а это нагрузка.
Способ 4. Написать свой механизм сброса кэша.
Данный способ нами и был выбран. Разработчиков модулей для nginx среди нас нет. Учиться кодить на lua никакого желания тоже нет. Выбор пал на JavasScript.
Страница на чтение должна кэшироваться на сутки
Кэш для десктопной и мобильной версии должны отличаться
Endpoint на удаление кэша должен уметь принимать список страниц
Endpoint на удаление кэша должен быть защищен уникальным заголовком

Пользователь открывает страницу на чтение. Если в кэше есть страница - отдается пользователю. Если нет, то запрашивается у системы, создается кэш, отдается пользователю.
Редактор изменил данные. Система уведомляет сервис Purger об изменениях. Purger накапливает список страниц и передает в Cache Server для сброса кэша.
Пользователь открывает страницу на чтение. Создается кэш, отдается пользователю.
В качестве Cache Server используем nginx, собранный с модулем njs. По сути, нам всего лишь нужно удалять файлы кэша по запросу.
Создаем необходимые переменные и устанавливаем proxy_cache_path:
# $mobile_detected - десктопное или мобильное устройство map $http_user_agent $mobile_detected { default ?0; "~(?i)^(lg-|sie-|nec-|lge-|sgh-|pg-)|(mobi|240x240|240x320|320x320|alcatel|android|audiovox|bada|benq|blackberry|cdm-|compal-|docomo|ericsson|hiptop|htc[-_]|huawei|ipod|kddi-|kindle|meego|midp|mitsu|mmp\/|mot-|motor|ngm_|nintendo|opera.m|palm|panasonic|philips|phone|playstation|portalmmm|sagem-|samsung|sanyo|sec-|sendo|sharp|softbank|symbian|teleca|up.browser|webos)" ?1; } # $purge_allowed - проверяет, что есть необходимый заголовок map $http_user_agent $purge_allowed { default 0; "~*^purge-allowed" 1; } # $proxy_cache_key - ключ кэширования. Отличается для мобильного и десктопного устройства. map $mobile_detected $proxy_cache_key { default $scheme$host$uri; "?1" "$scheme$host.m.$uri"; } proxy_cache_path /var/cache/nginx/pages levels=1:2 keys_zone=pages:1440m inactive=1h use_temp_path=off;
Файлы кэша будут храниться в /var/cache/nginx/pages. Реализуем кэш:
location /page { proxy_cache pages; # cache settings proxy_cache_valid 200 24h; proxy_cache_methods GET HEAD; proxy_cache_min_uses 1; # cache key proxy_cache_key $proxy_cache_key; # Headers ignore proxy_ignore_headers Cache-Control Expires Set-Cookie Vary; # debug headers add_header X-Proxy-Cach-Key $proxy_cache_key; proxy_pass http://host.docker.internal:8090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
curl http://njs.local:8080/page/test -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" -I
Проверяем файл кэша по адресу /var/cache/nginx/pages/1/73/acdf4c7dca04bb66121191d3af209731
Создадим endpoint для сброса кэша страниц. Так как настройки кэширования могут меняться, то необходимо их пробросить в JS через переменные.
location /purge { set $pages_cache_limit 5000; set $pages_cache_path /var/cache/nginx/pages; set $pages_cache_levels 1:2; set $pages_cache_allowed $purge_allowed; set $pages_cache_key_prefix $scheme$host; set $pages_cache_key_mobile_prefix "$scheme$host.m."; js_content proxyCache.purge; }
Далее пишем на JS функцию которая будет по адресу страницы возвращать путь до файла. /page/test -> /var/cache/nginx/pages/1/73/acdf4c7dca04bb66121191d3af209731. Алгоритм описан в документации.
function getFullCachePath(data) { const cacheKey = `${data.cacheKeyPrefix}${data.filePath}`; const cacheFileName = crypto.createHash('md5').update(cacheKey).digest('hex').toString(); const dirNames = [data.cachePath]; let str = cacheFileName; data.levels.split(':').forEach((level, index) => { const endIndex = Number(level); dirNames.push(str.slice(endIndex * -1)); str = str.substring(0, str.length - endIndex); }); dirNames.push(cacheFileName); return dirNames.join('/').replace(/\/+/g, '/'); }
И функцию удаления файлов, которая делает unlink на десктопные и мобильные файлы кэша:
function deleteFiles(r, filePaths) { /** set $pages_cache_path /var/cache/nginx/pages; */ const cachePath = r.variables.pages_cache_path || '/var/cache/nginx/pages'; /** set $pages_cache_levels 1:2; */ const levels = r.variables.pages_cache_levels || '1:2'; /** set $pages_cache_key_prefix $scheme$host; */ const cacheKeyPrefix = r.variables.pages_cache_key_prefix || `${r.variables.scheme}${r.variables.host}`; /** set $pages_cache_key_mobile_prefix "$scheme$host.m."; */ const cacheKeyMobilePrefix = r.variables.pages_cache_key_mobile_prefix || `${r.variables.scheme}${r.variables.host}.m.`; let count = filePaths.length; const successed = []; const failed = []; return new Promise((resolve) => { function unlink(fullFilePath, filePath) { fs.unlink(fullFilePath, (err) => { count--; if (err) { failed.push(filePath); ngx.log(ngx.INFO, err.message + ' ' + filePath); } else { successed.push(filePath); ngx.log(ngx.INFO, 'File ' + filePath + ' has been removed!') } if (count <= 0) { resolve({ failed, successed }); } }); } filePaths.forEach((filePath) => { const fullFilePath = getFullCachePath({ cacheKeyPrefix, filePath, cachePath, levels }); const fullMobileFilePath = getFullCachePath({ cacheKeyPrefix: cacheKeyMobilePrefix, filePath, cachePath, levels }); unlink(fullFilePath, 'desktop: ' + filePath); unlink(fullMobileFilePath, 'mobile: ' + filePath); }); }); }
Экспортируем функцию, которая проверяет входящий запрос, запускает удаление файлов и возвращает ответ:
async function purge(r) { const timeStart = new Date().getTime(); r.headersOut['Content-Type'] = 'application/json'; if (r.method !== 'POST' || r.headersIn['Content-Type'] !== 'application/json') { r.return(401, JSON.stringify({ status: 'error', message: "Unsupported method" }) + "\n"); return; } try { const payload = JSON.parse(r.requestText); if (!payload || typeof payload !== 'object' || !payload.paths || !Array.isArray(payload.paths)) { r.return(400, JSON.stringify({ status: 'error', message: "Unsupported payload" }) + "\n"); return; } /** set $pages_cache_allowed 1; */ const pagesCacheAllowed = r.variables.pages_cache_allowed === "1"; if (!pagesCacheAllowed) { r.return(403, JSON.stringify({ status: 'error', message: "Purging cache is not allowed" }) + "\n"); return; } /** set $pages_cache_limit 5000; */ const pagesCacheLimit = (r.variables.pages_cache_limit && Number(r.variables.pages_cache_limit)) || 5000; if (payload.paths.length > pagesCacheLimit) { r.return(400, JSON.stringify({ status: 'error', message: `Address limit(${pagesCacheLimit}) exceeded` }) + "\n"); return; } const result = await deleteFiles(r, payload.paths); const timeEnd = new Date().getTime(); r.return(200, JSON.stringify({ status: 'success', time: (timeEnd - timeStart) / 1000, successed: result.successed, failed: result.failed }) + "\n"); } catch(e) { ngx.log(ngx.ERR, e.message); r.return(400, JSON.stringify({ status: 'error', message: e.message }) + "\n"); } } export default { purge }
Для проверки создадим кэш:
# Desktop curl http://njs.local:8080/page/test-1 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" -I curl http://njs.local:8080/page/test-2 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" -I # Mobile curl http://njs.local:8080/page/test-1 -A "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" -I curl http://njs.local:8080/page/test-2 -A "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" -I
Удаляем:
curl -X POST http://njs.local:8080/purge -H "Content-Type: application/json" -A "purge-allowed" -d '{"paths":["/page/test-1","/page/test-2"]}' -i
{"status":"success","time":0.001,"successed":["desktop: /page/test-1","mobile: /page/test-1","desktop: /page/test-2","mobile: /page/test-2"],"failed":[]}
Полный код на github.
Данное решение прошло нагрузочное тестирование и успешно крутится в продакшене. Задача вида «прими запрос, удали файл» не кажется сложной. Можно долго дискутировать на тему целесообразности JS на nginx, но это прекрасно работает. На сколько я понимаю, njs это не NodeJS, а интерпретатор, который позволяет писать более сложную логику на nginx.