Статических систем не бывает: даже ваш кэш живёт своей жизнью
Каждый хоть раз делал фотографию. Стоишь где-то, смотришь на город: люди снуют, машины ползут, кто-то сигналит, кто-то паркуется боком. Обычная динамическая каша. Нажимаешь кнопку — и вроде поймал момент.
Но на деле это просто застывший кусок движухи. Фотография — пауза. Город — процесс, который продолжается. Хоть распечатай на холсте, он от этого не станет статичным: он продолжает меняться каждую секунду, пробки растут, кто-то снова перекрыл дорогу, всё как обычно.
С цифровыми системами то же самое. Пока код лежит на диске — идеальный музейный экспонат. Никаких зависимостей, никаких багов, GC спит, сервисы никуда не убегают. Красота, покой и статичность.
Но достаточно запустить программу — и начинается обычная, запрограммированная жизнь. Система просыпается, двигает состояние из одного мешка памяти в другой, цепляет задержки, роняет пару пакетов, подбрасывает tail latency просто потому что может. Всё стандартно, ничего нового.
Не очень строго определим - динамическая система это такая система, у которой:
- есть состояние,
- есть время (пусть даже дискретное - тик-так, инструкции CPU),
- состояние меняется со временем,
- есть правила, по которым оно меняется,
- и всё это происходит, хотите вы того или нет.
Статических систем в реальности не существует, разве что на бумаге. Даже камень - и тот квазистатичен: атомы шевелятся, энергия гуляет. Так что уж за наши хрупкие pipelines точно не каменные монолиты.
Возьмём data pipeline - то с чем я обычно работаю каждый день. Определение простое: цепь, по которой данные проходят путь от «железяки в лесу» до «подготовленного набора, который никто не будет читать, но ругать будут все».
Источник всегда независим, непредсказуем и живёт по своим законам. Можно смело говорить, что данные придут «в среднем за 200 мс», но реальность отвечает: «ха-ха, держи 3 секунды и пакет битых значений сверху».
Чтобы хоть как-то упорядочить хаос, строим архитектуру: складываем всё в data lake (или хотя бы S3), запускаем batch-процессы раз в N минут. На словах красиво. По сути - просто описываем поведение динамической системы: данные есть, данных нет, данные битые, данные неправильные, данные обиделись и ушли.
А вокруг ещё живёт толпа других динамических соседей: операционная система со своим настроением; garbage collector, который просыпается «когда решит»; сеть, которая иногда притворяется, что её нет; кэш, который живёт собственной жизнью; очередь, которая растёт, деградирует и всплывает в метриках в самый удобный момент.
И в конце этого парада - tail latency. Чистый результат накопления микрозадержек, флуктуаций, дрейфа и странностей.
Статика существует в цифровых системах? Смирение, господин разработчик, тут ее нет. Если что санитары за дверью. Система живёт. Даже если вам очень хочется, чтобы она стояла смирно, как фотографии на стене.
Кэш - место, где можно убить много времени
На каждом этапе может пойти что-то не так. Возьмём кэш - наш любимый ускоритель, который должен «хранить результаты повторяющихся вычислений, чтобы всем было хорошо». В идеальном мире кэш ведёт себя спокойно: популярные данные держит под рукой, непопулярные - выбрасывает, экономит память, радует разработчиков и бухгалтеров которые смотрят чеки от AWS.
Звучит как песня. В реальности - как джаз: каждый играет своё, и никто не знает, чем закончится.
Кэш живёт на паттернах доступа. Пять минут назад одни ключи были популярны, ещё через пять - уже другие. Какие-то данные пропали из обращений, и кэш их выкинул. Всё это - динамика, которая происходит в реальном времени, а не в вашей голове.
Теперь пример коммерческого опыта:
У нас есть Celery Worker (часть нашего data pipeline). В качестве очереди - Redis. Celery считает симуляции и научную статистику для 100 человек, которые работают в разное время суток и каждый по своим задачам. Всего примерно 30 разных задач и подзадач - и у каждой свой характер.
Чтобы настроить все эти радости, надо понять простую вещь: кто наши пользователи и как они грузят систему.
Иначе получаем настройку категории «пальцем в небо». Это тот случай, когда после сборки шкафа остаются лишние болтики. Шкаф стоит, но доверия уже меньше.
Дано:
- входные данные в S3 (0.5–100 МБ),
- Celery выполняет вычисления,
- Redis - и очередь, и временное хранилище результатов.
И вот начинается веселье.
Большинство разработчиков (и я когда-то тоже) рассуждают так: «TTL вот такой, maxmemory вот такой, LFU прикручу, LRU оставлю - и поедет».
Потом - «подправим по ходу дела». Спойлер: по ходу дела вы чините не Redis. Вы чините собственные нервы. Настройка кэша без анализа поведения - чистое казино. Казино для вас, для ваших коллег и для самого кэша, который живёт в непрерывном стресс-тесте динамики. Пока вы думаете, что выставили «константы», мир меняет нагрузку, люди меняют паттерны, Celery меняет ритм, GC вдруг решает: «ну ладно, проснусь сейчас», и кэш начинает работать по совершенно другой схеме. Ставки делает не вы - ставки делает трафик.
Добро пожаловать в архитектуру как она есть: динамическая, живая, непослушная, но вполне забавная, если перестать ожидать от неё идеальной предсказуемости.
Когда цифры встречают реальность
Допустим, среднее время выполнения задач - 6 секунд, но разброс: от 500 мс до внезапных 26 секунд пару раз в час. (Цифры представленные тут реальные взятые с пылу с жару из Grafana).
Есть задача выгрузки файла в S3 - 100 МБ, и UI/UX тут не спасает: иногда загружают что-то, что вообще не подходит под задачу. Бывает. Фокусируемся на системе.
Настройки Redis:
- TTL = 600 секунд для задач первого класса (лёгких),
- TTL = 3600 секунд для второго класса (тяжёлых),
- результаты: ~3 КБ (JSON) или ~200 КБ (сериализованных данных),
- память под кэш: 4 ГБ.
Другие параметры упускаем для упрощения.
Нагрузка:
- 50 задач/сек лёгких,
- 1 задача/сек тяжёлых.
Вот быстро прикинутая модель кэша. В этой статье без особых объяснений, приймите на веру как говорят в школе.
\[\begin{aligned} \frac{dN_1}{dt} &= \lambda_1 - \frac{N_1}{TTL_1} - \frac{s_1 N_1}{M + \varepsilon_s}\,\alpha\,\max\left(0, M - C\right), \\ \frac{dN_2}{dt} &= \lambda_2 - \frac{N_2}{TTL_2} - \frac{s_2 N_2}{M + \varepsilon_s}\,\alpha\,\max\left(0, M - C\right), \\ M(t) &= s_1 N_1(t) + s_2 N_2(t) \end{aligned}\]\[\begin{aligned} N_1(t),\,N_2(t) &\;\text{— число лёгких и тяжёлых результатов в момент времени } t, \\ \lambda_1,\,\lambda_2 &\;\text{— интенсивности задач (задач/сек)}, \\ TTL_1,\,TTL_2 &\;\text{— время жизни результатов (сек)}, \\ s_1,\,s_2 &\;\text{— средний размер результата каждого класса (байт)}, \\ C &\;\text{— лимит памяти под результаты Celery (байт)}, \\ \alpha &\;\text{— коэффициент жёсткости эвикции (сек}^{-1}\text{), обычно }10^7\!-\!10^8, \\ \varepsilon_s &\;\text{— малая константа (}10^3\text{–}10^6\text{ байт) для предотвращения деления на ноль}. \end{aligned}\]
Если проанализировать математическую модель и прикинуть числа - выходит примерно 800 МБ (загрузка 20%) в кэше. Всё окей, запас есть, кэш-система устойчива при заданных начальных параметрах системы.
Но любая система должна проектироваться с запасом масштабируемости - на чем я всегда наиставаю, а если масштабируемости нет, то нужно о ней подумать уже сейчас. Лучше заранее покрутить математику - дифуры, нелинейные уравнения, банальные математические прикидки. План устареет в момент написания, но хоть какой-то ориентир появляется.
Теперь беда (для разработчика): нагрузка выросла в четыре раза.
- 200 задач/сек первого класса,
- 4 задач/сек второго класса.
Пересчитываем модель - получается около 3.09 ГБ, то есть 75% памяти.
Чуть увеличиваем число тяжёлых задач - и Redis начинает eviction. Система входит в режим «ищу жертву, кого выкинуть».
И в следствии дальнейшего анализа предел примерно такой:
- 200 задач/сек первого класса,
- 5.3 задач/сек второго - и всё, дальше начинается нестабильность.
Одна-две дополнительные тяжёлые задачи - и кэш превращается в охотника, который бегает по памяти с вопросом: «Кого бы удалить, чтобы оставаться полезным?»
Финал: немного фазовых портретов и много здравого смысла

Логично завершить всё это игрушечными графиками из теории динамических систем, которые показывают предел устойчивости.
Как меняется конечное стационарное число тяжёлых результатов при увеличении нагрузки:
- При \(\lambda_2 \in \{1,\dots,4\}\) → рост почти линейный.
- При \(\lambda_2 \approx 4,\dots,6\) → система переходит в зону насыщения памяти.
- При \(\lambda_2 > 6\) → Redis входит в режим постоянных эвикций, рост \(N_2\) замедляется почти до нуля → режим thrashing.
Число \(N_2\) отвечает на вопрос: "Сколько тяжёлых результатов будет в среднем лежать в Redis при данной интенсивности тяжёлых задач λ₂, когда система уже устоялась?" Ответ: близко к 20–21 тысяче.
Новые параметры (TTL, нагрузка, размер результата, память) начинают проявляться в метриках не сразу, а спустя пару TTL. Это значит после изменения конфигурации нужно ждать несколько TTL, прежде чем оценивать эффект.
Да, ничего общего с точной реальностью. Да, куча оговорок. Но это уже способ хотя бы чуть-чуть приручить цифровой хаос. Системы живут. Они не статичны. Они текут, изменяются, дрейфуют, устают, странно себя ведут, иногда радуют, иногда кусаются. И наша задача - не пытаться заставить их стоять на месте, как фотографии на стене.
Наша задача - понимать, что они динамичны, и учитывать это в каждой архитектурной, инженерной и операционной мелочи.
На этом всё. Дальше - только практика, мониторинг и осторожный оптимизм.