Скачать CV

Performance

Stampede в Redis на списке с тремя локалями

После деплоя двадцать PHP-воркеров пересобрали один и тот же список заметок трижды. Redis заполнился и начал вытеснять ключи, которые только что записали.

После деплоя я сбросил notes:list:*. Следующие две минуты публичный список заметок ходил от 8 мс до 400 мс. Посетители ждали сетку. CPU Redis почти не шевелился. MySQL шевелился.

Список лежит в Redis. В ключе есть локаль, потому что английский, русский и немецкий JSON разные. Двадцать воркеров php-fpm одновременно поймали miss. Каждый прогнал запрос списка, сериализовал коллекцию и записал блоб. Три локали, итого шестьдесят записей одних и тех же трёх значений. Победил последний. Остальные пятьдесят семь это сожженный CPU и сожженная память.

Хост это почувствовал как нагрузку на InnoDB. Пользователи как медленный первый кадр после каждого релиза. Redis показал ущерб позже, когда LRU начал вытеснять другие горячие ключи, которые только что записали.

Воркеры сходятся в Redis miss
Воркеры сходятся в Redis miss

Куда ушла память

Один несжатый payload списка около 180 КБ. На этой машине у Redis maxmemory 256 МБ и политика allkeys-lru. Во время stampede RSS подскочил примерно на 12 МБ за несколько секунд. Это не страшная цифра. Страшная цифра была следующей.

LRU начал вытеснять другие горячие ключи: сниппет текущего пользователя, куски каталога стека, строковую копию, которую один эндпоинт держит как запасной путь в Memcached. Эти miss вернули работу в PHP. PHP снова пошёл в Redis. Redis снова вытеснил. Ключи списка сами по себе крупные, поэтому после ухода трафика на страницы статей они стали лёгкими жертвами.

Пока это происходило, я снял INFO memory и MEMORY STATS. used_memory_peak стоял у потолка. evicted_keys рос прямой. С фрагментацией всё было нормально. Это была не фрагментация. Это было слишком много больших строк, записанных разом и сразу выброшенных.

Один сборщик на ключ

Лечится не большим Redis, а замком рядом с ключом кэша. Для notes:list:ru замок это notes:list:ru:lock.

SET notes:list:ru:lock 1 NX EX 10
GET notes:list:ru
GET notes:list:ru:stale

Воркер, который выиграл SET NX, собирает payload, пишет живой ключ с коротким TTL, копирует его в stale-ключ с длинным TTL и снимает замок. Остальные либо ждут несколько миллисекунд и читают живой ключ, либо отдают stale, если бюджет ожидания кончился.

Один воркер держит замок, остальные читают stale
Один воркер держит замок, остальные читают stale

Stale я держу отдельным ключом специально. Если на живой ключ повесить длинный TTL, плохой payload прячется слишком надолго. Stale может быть неточным десять минут. Живой ключ истекает через 45 секунд: stampede схлопывается, а публикация из админки всплывает на следующем обновлении.

Что реально лежит в RAM

JSON из LaraBoom по-прежнему несёт все три локали внутри каждой строки. Кэш HTTP-ответа по локалям значит, что Redis хранит одно и то же поле body трижды, по разу на ключ. Это я бы менял следующим шагом, не замок.

  • Кэшировать результат запроса один раз, как PHP-сериализованную коллекцию без рендера.
  • Выбирать локаль при записи HTTP-ответа, а не при разговоре с MySQL.
  • Сжимать строку gzencode до SET. 180 КБ на этом списке стали 28 КБ. CPU на decode в Redis дешевле, чем eviction.

Hash с полями en, ru, de выглядит аккуратно и меньше тратит на оверхед ключей. С инвалидацией хуже. Публикация должна стереть или переписать весь hash. Три строковых ключа дают истечь одной локали, не трогая остальные. Это важно, когда переводчик сохраняет только русский.

В Memcached этот кэш я не клал. Замку нужен SET NX с TTL, и stale хочется держать рядом с живым ключом. Memcached умеет add-if-not-exists. Остальное без лишних кругов и кривого expiry не складывается.

Как понять, что это держится

Нужный тест это не юнит вокруг фейка Redis. Я сбрасываю три ключа списка и бью 50 параллельных GET в /api/notes. Accept-Language не используется, локаль берётся из URL, как в entry Vue SSR.

До замка MySQL показывал около 50 запросов списка. После замка три, по одному на локаль, плюс несколько проверок замка. evicted_keys в Redis не рос. p99 эндпоинта вернулся ниже 20 мс, как только появились stale-ключи.

Если замок занят, а живого и stale нет, воркер не должен наваливаться. На этот один запрос отдаём пустой список, следующий пересоберёт. Пустая страница заметок на 200 мс после холодного старта лучше, чем толпа в InnoDB.

Какой опыт из этого

После сброса я меряю запросы списка в MySQL и evicted_keys в Redis, не CPU Redis. Тихий Redis при горячем InnoDB это всё ещё stampede.

Большему maxmemory я не верю, что он переживёт шестьдесят одинаковых записей. Один замок SET NX на ключ локали, живой TTL 45 секунд и отдельная stale-копия это то, что удержало.

Правило, которое оставляю: один сборщик на ключ кэша, gzip до SET. Если на холодном старте нет живого и нет stale, один раз отдать пустое. Не давать двадцати воркерам собирать один и тот же JSON.

К заметкам