Дашборд статистики кэша: real-time мониторинг с графиками и управлением ключами

В моём REST API два слоя кэша. Первый — на уровне базы данных: запросы кэшируются через NodeCache, ключ формируется из SQL и параметров, повторный вызов возвращает данные без похода в PostgreSQL. Второй — HTTP-ответы публичного API блога: первый запрос к /api/macrulez-blog/posts идёт в БД, второй — из памяти, заголовок X-Cache: HIT.
Всё работало. Но работало — как чёрный ящик.
Я не знал: сколько ключей сейчас в памяти, какой hit rate у конкретного эндпоинта, растёт ли heap после деплоя, когда кэш начал промахиваться. Ответить на эти вопросы можно было только через docker logs или написав одноразовый скрипт. Это неудобно, и в какой-то момент я решил сделать нормальный дашборд.
Получилась отдельная страница в admin-панели. Разберу по частям — от верхних плиток до браузера ключей.
Плитки с общей статистикой и диаграммы

Четыре карточки наверху
Первое, что видно при открытии страницы — четыре stat-карточки: количество ключей в L1-кэше, суммарные хиты, промахи и hit ratio.
Последняя карточка чуть умнее остальных. Под процентом — небольшой бейдж с оценкой эффективности: excellent при hit rate выше 80%, good от 60% до 80%, needs_improvement ниже. Это позволяет не смотреть на цифры каждый раз: достаточно цвета бейджа — зелёный, жёлтый или красный.
ECharts с tree-shaking
Для графиков я взял ECharts через обёртку vue-echarts. Из коробки ECharts весит около 800 KB — это много для admin-панели, где графики нужны только на одной странице. Но библиотека поддерживает tree-shaking: импортируешь только то, что реально используешь.
js
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, LineChart, GaugeChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, GraphicComponent } from 'echarts/components'
use([CanvasRenderer, PieChart, LineChart, GaugeChart, GridComponent, TooltipComponent, GraphicComponent])
Плюс в vite.config.js выделяю ECharts в отдельный чанк через manualChunks:
js
rollupOptions: {
output: {
manualChunks: { echarts: ['echarts', 'vue-echarts'] },
},
}
В итоге основной JS приложения — 54 KB, ECharts — 627 KB отдельным файлом (217 KB gzip). Пользователь загружает этот чанк один раз, дальше браузер берёт из кэша. На остальных страницах admin-панели ECharts вообще не загружается.
Donut: Hit / Miss
Под карточками — первый ряд графиков. Два donut-чарта: один для L1-кэша базы данных, второй для HTTP-кэша блога.
Donut — хороший выбор для соотношения двух величин: сразу видно, хиты доминируют или нет. Зелёный сектор — хиты, жёлтый — промахи. В центре кольца — hit rate числом.
С центральным текстом есть нюанс. В ECharts у pie-серии есть label с position: 'center', но при hover на сектор он начинает конфликтовать с подписями — ведёт себя непредсказуемо. Надёжнее поверх серии положить отдельный graphic-элемент:
js
graphic: [
{
type: 'text',
left: 'center',
top: '36%',
style: {
text: `${hitRate}%`,
fill: '#e2e8f0',
fontSize: 22,
fontWeight: 'bold',
},
},
{
type: 'text',
left: 'center',
top: '54%',
style: {
text: 'hit rate',
fill: '#6b7280',
fontSize: 11,
},
},
],
Он не участвует в логике серии — просто рисует текст поверх, всегда на месте.
Gauge: Heap Memory
Третий график в ряду — полукруговой gauge для heap-памяти Node.js-процесса. Показывает отношение heapUsed к heapTotal в процентах.
Цвет трека меняется динамически в зависимости от загруженности:
js
const fillColor = pct >= 85 ? '#fc8181' : pct >= 65 ? '#f6e05e' : '#7c83e5'
axisLine: {
lineStyle: {
width: 14,
color: [[pct / 100, fillColor], [1, 'rgba(255,255,255,0.07)']],
},
},
До 65% — синий, до 85% — жёлтый, выше — красный. Стрелку убрал (pointer: { show: false }), оставил только дугу и цифру по центру. Получается аккуратный индикатор без лишнего шума.
Графики в динамике

Второй ряд — три линейных графика, которые показывают изменения во времени.
RPS за последние 60 секунд
Бэкенд уже считал количество запросов в секунду через скользящее окно. Метод getRequestsPerSecondSeries(60) возвращает массив из 60 чисел — по одному на каждую из последних 60 секунд:
js
getRequestsPerSecondSeries(seconds = 60) {
const now = Date.now()
const buckets = new Array(seconds).fill(0)
for (const r of this.requestMetrics) {
const idx = Math.floor((now - r.ts) / 1000)
if (idx >= 0 && idx < seconds) buckets[idx]++
}
return buckets.reverse() // oldest → newest
}
Этот массив напрямую идёт в data линейного графика. Никаких дополнительных вычислений не нужно — данные уже в нужном формате.
Hit ratio · скользящее окно 1 минута
Это самый полезный из трёх графиков, и тут есть важный момент.
Накопительный hit rate, который NodeCache считает с момента запуска (stats.hits / (stats.hits + stats.misses)), быстро перестаёт быть информативным. Если сервер работает несколько часов с хорошим кэшом, а потом кэш сбросили или начали приходить запросы с новыми параметрами — накопительный показатель почти не отреагирует: он слишком инертен.
Поэтому я беру last1m.hitRate — hit rate за последнюю минуту из rolling-окна. Бэкенд уже считал это для раздела «производительность»:
js
const reqStats1m = dbService.getRequestStats(60 * 1000)
// → { count, hits, misses, hitRate, rps }
При каждом обновлении дашборда (каждые 30 секунд) пушу новую точку в историю на фронтенде:
js
const ratio = data.data.performance?.recent?.last1m?.hitRate
if (ratio !== undefined) {
hitRatioHistory.value.push({ time: t, ratio: Math.round(ratio) })
if (hitRatioHistory.value.length > 60) hitRatioHistory.value.shift()
}
Особое решение для этого графика — visualMap. ECharts умеет перекрашивать линию градиентом в зависимости от значения по оси Y:
js
visualMap: {
show: false,
type: 'continuous',
seriesIndex: 0,
min: 0,
max: 100,
inRange: { color: ['#fc8181', '#f6e05e', '#68d391'] },
}
Линия плавно меняет цвет: красная при 0–30%, жёлтая в середине, зелёная при 80–100%. Если hit rate просел — это видно немедленно, не нужно смотреть на цифры.
Ключей в L1 · динамика сессии
Третий график — количество ключей в L1-кэше за текущую сессию браузера. Позволяет заметить, если кэш неожиданно опустел (кто-то очистил или истёк TTL массово) или, наоборот, растёт быстрее ожидаемого.
История хранится прямо на фронтенде в ref-массиве, максимум 60 точек. При перезагрузке страницы — сброс. Это нормально: данные оперативные, не для долгосрочного анализа. Если нужна персистентность истории между перезагрузками — это отдельная задача для бэкенда (кольцевой буфер или append-лог).
Подробная статистика в цифрах

Под графиками — два столбца с детальными панелями. Это данные, которые не нужны постоянно, но ценны при разборе конкретной проблемы.
L1 Cache
NodeCache из коробки отдаёт getStats() и через ksize/vsize — отдельно размер ключей и значений. Это позволяет понять, не раздулись ли значения: если vsize растёт, а ksize стабилен — значения стали тяжелее (например, начали кэшировать большие выборки).
Дополнительно показываю количество истёкших записей (evictions.expired). Резкий рост — сигнал, что TTL коротковат для нагрузки или сервер начал тормозить и не успевает отвечать до истечения кэша.
HTTP Cache — по эндпоинтам
Это самая показательная панель из всех. Суммарные хиты и промахи — полезно, но намного интереснее видеть статистику по конкретным эндпоинтам.
Реализация: в middleware кэша завёл отдельный Map для счётчиков per-prefix:
js
const prefixStats = new Map() // prefix → { hits, misses }
// В middleware, на HIT:
const s = prefixStats.get(prefix) || { hits: 0, misses: 0 }
s.hits++
prefixStats.set(prefix, s)
// На MISS (при записи в кэш):
const s = prefixStats.get(prefix) || { hits: 0, misses: 0 }
s.misses++
prefixStats.set(prefix, s)
С нормализацией префикса был нюанс. Поначалу я брал первые три сегмента URL, и /api/macrulez-blog/posts вместе с /api/macrulez-blog/posts/some-slug сваливались в одну строку. Это бессмысленно — у листинга и детальной страницы совершенно разные характеристики: листинг запрашивают чаще, но со множеством query-параметров (page, per_page, tag), детальная — реже, зато почти всегда попадает в кэш.
Исправил: беру четыре сегмента, четвёртый нормализую в *:
js
function getUrlPrefix(url) {
const path = url.split('?')[0]
const parts = path.split('/').filter(Boolean)
const normalized = parts.map((seg, i) => (i >= 3 ? '*' : seg))
return '/' + normalized.slice(0, 4).join('/')
}
Теперь таблица выглядит осмысленно:
Эндпоинт Ключей Хиты Промахи Rate
/api/macrulez-blog/posts 1 42 9 82%
/api/macrulez-blog/posts/* 8 130 16 89%
Hit rate в таблице раскрашивается: ≥75% — зелёный, ≥40% — жёлтый, ниже — красный.
Сервер, память, event loop
- Сервер — аптайм форматируется в удобочитаемый вид:
2д 3ч 5мвместо числа секунд, версия Node.js, PID, платформа - Системная память — прогресс-бар + цифры. Дублирует данные от
os.totalmem()иos.freemem() - Процесс Node.js — RSS, heap used, heap total. Полезно при подозрении на memory leak: если
heapUsedне перестаёт расти между запросами к дашборду — повод насторожиться - Event Loop latency — p50/p95/p99 в миллисекундах. Замеряется прямо в обработчике
/statsчерез цикл из пятиsetImmediate-сэмплов. Если p99 улетает выше 100ms — значит event loop блокируется тяжёлыми синхронными операциями - Запросы к БД — количество за скользящее окно, среднее время и p95
Горячие ключи
Топ-10 ключей по числу хитов. Показывает, что реально чаще всего отдаётся из кэша. Бывает неожиданно: думаешь, что нагружен один эндпоинт, а горячим оказывается другой.
Один технический момент: ключи кэша базы данных — это base64 от SQL-запроса с параметрами. Они могут быть длиной в несколько сотен символов. Чтобы такие ключи не разрывали вёрстку, добавил overflow: hidden и text-overflow: ellipsis.
Но ellipsis внутри flex-контейнера не работает без одного хака — браузер по умолчанию не позволяет flex-элементу сжаться ниже размера его содержимого:
css
.key-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0; /* без этого ellipsis не работает в flex-контейнере */
}
Свойство min-width: 0 снимает защиту от сжатия — и элемент начинает обрезаться как ожидается.
Очистка кэша и браузер ключей

Выборочная очистка
Четыре кнопки, каждая с confirm-диалогом перед выполнением:
- Очистить L1 — только in-memory NodeCache, база данных не трогается
- Очистить L2 — только дисковый кэш
- Очистить HTTP — кэш HTTP-ответов блога, L1/L2 остаются нетронутыми
- Очистить всё — все три слоя разом
Раздельные кнопки полезны: если блог начал отдавать устаревшие данные — чистишь только HTTP-кэш, не трогая прогретый L1. Если хочется сбросить результаты тяжёлых SQL-запросов — только L1 или по паттерну.
Очистка по паттерну
Под кнопками — строка ввода для паттерна. Поддерживает * и ? как wildcards. Пишешь airlines:* — удаляются все ключи авиалиний. Пишешь *airport* — всё, что содержит слово airport.
Паттерн нормализуется в регулярное выражение на бэкенде:
js
clearByPattern(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')
const regex = new RegExp(`^${escaped}$`)
const keys = this.cache.keys().filter(k => regex.test(k))
if (keys.length > 0) this.cache.del(keys)
return keys.length
}
После очистки возвращается количество удалённых ключей — отображается в подтверждающем сообщении под формой.
Браузер ключей
По умолчанию браузер скрыт — панель появляется только по нажатию кнопки «Показать». Ленивая загрузка: данные запрашиваются при первом открытии, а не при загрузке страницы.

Для каждого ключа бэкенд собирает: оставшийся TTL в миллисекундах (из cache.getTtl(key) минус текущее время), размер значения в байтах, количество хитов. Список сортируется алфавитно.
Фронтенд переводит TTL в человекочитаемый вид — 4м 20с, 2ч 15м — и раскрашивает в зависимости от срочности: меньше 5 минут до истечения — красный, меньше 30 минут — жёлтый, остальное — нейтральный.
Над таблицей — поле фильтрации. Поиск мгновенный, через computed без лишних запросов к серверу:
js
const filteredKeys = computed(() => {
const q = keysSearch.value.trim().toLowerCase()
return q ? keys.value.filter(k => k.key.toLowerCase().includes(q)) : keys.value
})
У каждого ключа — кнопка удаления. После удаления список и основная статистика обновляются автоматически. Это полезно при отладке: можно сбросить один конкретный ключ и сразу проверить, как приложение поведёт себя без кэша на этом запросе, не сбрасывая всё остальное.
В итоге дашборд отвечает на все практические вопросы: что сейчас в кэше, как он работает, где есть проблемы — и позволяет с этим что-то сделать без перезапуска сервера.
Стек: Vue 3 + vue-echarts на фронтенде, Express + NodeCache на бэкенде, Vite с manualChunks для разделения чанков, Docker с multi-stage build — фронтенд собирается на этапе сборки, статика и бэкенд живут в одном контейнере.