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

18.05.2026
Дашборд статистики кэша: 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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
const reqStats1m = dbService.getRequestStats(60 * 1000)
// → { count, hits, misses, hitRate, rps }

При каждом обновлении дашборда (каждые 30 секунд) пушу новую точку в историю на фронтенде:

js Copy
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 Copy
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 Copy
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 Copy
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('/')
}

Теперь таблица выглядит осмысленно:

Copy
Эндпоинт                        Ключей  Хиты  Промахи  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 Copy
.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 Copy
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 Copy
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 — фронтенд собирается на этапе сборки, статика и бэкенд живут в одном контейнере.

Читать далее

19.05.2026

Admin-панель мониторинга: дашборд, аналитика, логи и API-эксплорер

Обзор admin-панели для Node.js-сервиса: от сводного дашборда с метриками сервера и кэша до интерактивного API-эксплорера, в котором можно формировать запросы и смотреть ответы прямо в браузере.

Метки
adminmonitoringnodejsvue3devtools
19.05.2026

Мониторинг с алертами в Telegram: гибкие правила и синтетические проверки в admin-панели

Добавил в admin-панель модуль мониторинга — несколько Telegram-ботов, три типа правил алертов с фильтрами и пороговыми значениями, синтетические HTTP-проверки с уведомлениями по провалам.

Метки
monitoringtelegramalertsdevopsnodejs
20.05.2026

Три новых раздела admin-панели: браузер базы данных, управление системой и пользователи

Добавил три новых раздела в admin-панель: интерактивный браузер базы данных с возможностью редактировать записи прямо из интерфейса и выполнять произвольные SQL-запросы, расширенный раздел «Система» с информацией о процессе, cron-задачами и управлением сервером, и полноценный модуль управления учётными записями администраторов с хранением в PostgreSQL.

Метки
node.jsadmin-panelpostgresqlvue3devops