Кхм. Громковатый заголовок, но я всё объясню.
Итак, у меня был сервис. Обычная молотилка данных, каждый с такой хотя бы раз да сталкивался - что-то на входе, что-то на выходе, а внутри походы в базу, HTTP-вызовы, шаблоны, скриптовая логика... В общем, много всякого.
Ну, ладно, тут стоит сразу уточнить, что сервис с особенностями - молотилка данных устроена так, что пытается работать с разными форматами на входе и выходе, а внутри держать всё в одном представлении. Но вот из-за этой потребности работать с разным, внутреннее представление это - мапы, слайсы, мапы в слайсах, слайсы в мапах, да ещё и из всех щелей торчит куча метрик.
Поэтому вот такая картина потребления памяти меня до недавних пор особо не смущала:

Тем более, что РПС на сервисе - что-то около двух с половиной тысяч в секунду на под, и это только внутренняя логика, походы наружу никто не отменял. В общем, выглядело нормально.
Но разве, дорогой читатель, в какой-то момент тебя бы не посетила мысль попробовать оптимизироваться?
Куда уходит память
Вечер, чай, готовность поковыряться в кишках, снимаю дамп хипа, и... это:
> go tool pprof .\heap-prometheus File: neptunus Build ID: 7bc6d5b83ef5b73652dec17446e214deb2f215a7 Type: inuse_space Time: 2025-10-26 01:14:36 MSK Entering interactive mode (type "help" for commands, "o" for options) (pprof) top Showing nodes accounting for 104.75MB, 80.42% of 130.26MB total Dropped 121 nodes (cum <= 0.65MB) Showing top 10 nodes out of 155 flat flat% sum% cum cum% 25.29MB 19.41% 19.41% 25.29MB 19.41% github.com/beorn7/perks/quantile.newStream (inline) 18.04MB 13.85% 33.26% 18.54MB 14.23% runtime.allocm 16.67MB 12.80% 46.06% 16.67MB 12.80% text/template.addValueFuncs 15.64MB 12.01% 58.06% 15.64MB 12.01% text/template.addFuncs 12.28MB 9.42% 67.49% 14.97MB 11.49% github.com/goccy/go-json.unmarshalNoEscape 7.13MB 5.48% 72.97% 7.13MB 5.48% github.com/beorn7/perks/quantile.(*stream).merge 3.01MB 2.31% 75.28% 28.30MB 21.73% github.com/prometheus/client_golang/prometheus.newSummary 2.69MB 2.07% 77.34% 2.69MB 2.07% github.com/goccy/go-json/internal/decoder.initDecoder.func1 2MB 1.54% 78.88% 2MB 1.54% github.com/gekatateam/neptunus/core/unit.newProcessorSoftUnit 2MB 1.54% 80.42% 2MB 1.54% github.com/gekatateam/mappath.putInNode
Почти тридцать (тридцать!) мегабайт уходит исключительно на метрики! По дампу чётко видно, что корни прорастают через пакет https://github.com/prometheus/client_golang прямиком к квантилям через Summary метрики.
Что делать? Тут многие скажут - не нужно было использовать саммари - вычисление квантилей на стороне приложения всегда обходится дорого, это база. Но борды под сервис уже собраны, список метрик закреплен и менять его не хочется, к тому же, для более точного расчёта квантилей нужно много бакетов в гистограмме. Значит, буду искать альтернативные пути, решил я.
Беглый поиск привел к пакету https://github.com/VictoriaMetrics/metrics от авторов VictoriaMetrics - то, что в конечном итоге привело к написанию этой статьи. В любой инфраструктуре, с которой мне приходилось работать, Виктория всегда занимала почётное место Главного Хранилища Метрик, посему решено - время щупать новую библиотеку.
Величие и нищета
И вот тут хочется рассказать, в какую цену обходится замена одного пакета на другой.
Во-первых, основной апи. Типичная работа с метриками через пакет от Прометея таков:
Создаем вектор (для упрощения жизни можно не заморачиваться с отдельным реестром):
inputSummary = prometheus.NewSummaryVec( prometheus.SummaryOpts{ Name: "input_plugin_processed_events", Help: "Events statistic for inputs.", MaxAge: time.Minute, Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001, 1.0: 0}, }, []string{"plugin", "name", "pipeline", "status"}, )
"Наблюдаем" вектор:
func ObserveInputSummary(plugin, name, pipeline string, status EventStatus, t time.Duration) { inputSummary.WithLabelValues(plugin, name, pipeline, string(status)).Observe(t.Seconds()) }
Цепляем хэндлер к HTTP серверу:
mux.Handle("/metrics", promhttp.Handler())
Пакет от Прометея разделяет векторы и скаляры, но глобально отличий нет - обратились к объекту метрики, достали нужный кусок из вектора по лейблам (а если его ещё нет - он будет создан автоматически), "обозрели", готово.
Пакет Виктории устроен иначе.
Документация рекомендует, и даже настаивает (и это на самом деле удобно) на необходимости группировать метрики по сетам:
var ( // CoreSet is a set with plugins core metrics CoreSet = metrics.NewSet() // PluginsSet is a set with plugins custom metrics PluginsSet = metrics.NewSet() // PipelinesSet is a set with pipelines metrics PipelinesSet = metrics.NewSet() )
И писать метрики из каждого сета:
import ( vmetrics "github.com/VictoriaMetrics/metrics" ) //.............. //.............. //.............. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) metrics.PipelinesSet.WritePrometheus(w) metrics.CoreSet.WritePrometheus(w) metrics.PluginsSet.WritePrometheus(w) vmetrics.WriteProcessMetrics(w) // <- это набор (не сет!) с метриками рантайма "из коробки" })
Получается более явно, а явное, как известно, всегда лучше неявного.
Сильно ощутимое отличие заключается в создании и "обозрении" метрики. В отличие от Прометея, Виктория не разделяет имя метрики и лейблы:
var ( DefaultMetricWindow = time.Minute DefaultSummaryQuantiles = []float64{0.5, 0.9, 0.99, 1.0} ) func ObserveInputSummary(plugin, name, pipeline string, status EventStatus, t time.Duration) { CoreSet.GetOrCreateSummaryExt( fmt.Sprintf("input_plugin_processed_events{plugin=%q,name=%q,pipeline=%q,status=%q}", plugin, name, pipeline, status), DefaultMetricWindow, DefaultSummaryQuantiles, ).Update(t.Seconds()) }
Также, и тут начинается первое серьезное отличие, Виктория не принимает HELP метадату (потому что VictoriaMetrics игнорирует HELP и TYPE). Отображение метадаты можно включить, но это всего лишь заглушки для совместимости с скраперами. Наглядно:
Это Прометей: # HELP pipeline_state Pipeline state: 1-6 is for Created, Building, Starting, Running, Stopping, Stopped. # TYPE pipeline_state gauge pipeline_state{pipeline="test.pipeline.beats"} 1 pipeline_state{pipeline="test.pipeline.chanmetrics"} 1 А это Виктория: # HELP pipeline_state # TYPE pipeline_state gauge pipeline_state{pipeline="test.pipeline.beats"} 1 pipeline_state{pipeline="test.pipeline.chanmetrics"} 1
Во-вторых, кастомные метрики. В качестве примера отлично подойдет sql.DBStats, как некая внутренняя статистика с собственными счётчиками, которые нужно просто отобразить наружу как есть.
В случае с Прометеем, всё проще некуда, пусть и многословно:
Выделяем дескриптор
Создаем кастомный коллектор и регистрируем его
В коллекторе пишем метрики
Удобно то, что в одном коллекторе можно писать сразу пачку векторов - например, по всем пулам БД, предварительно собрав их в коллекторе. Если что-то будет записано, метрика будет отображена, не будет - не будет и метрики, и не надо заморачиваться с регистрацией и дерегистрацией отдельных коллекторов или метрик:
var ( dbMetricsCollector = &dbCollector{ dbs: make(map[dbDescriptor]*sqlx.DB), mu: &sync.Mutex{}, } dbConnectionsMax = prometheus.NewDesc( "plugin_db_connections_max", "Pipeline plugin DB pool maximum number of open connections.", []string{"pipeline", "plugin_name", "driver"}, nil, ) ) type dbDescriptor struct { pipeline string pluginName string driver string } type dbCollector struct { dbs map[dbDescriptor]*sqlx.DB mu *sync.Mutex } // появился новый пул - добавили в коллектор func (c *dbCollector) append(d dbDescriptor, db *sqlx.DB) { c.mu.Lock() defer c.mu.Unlock() c.dbs[d] = db } // пул больше не нужен - удалили func (c *dbCollector) delete(d dbDescriptor) { c.mu.Lock() defer c.mu.Unlock() delete(c.dbs, d) } func (c *dbCollector) Describe(ch chan<- *prometheus.Desc) { ch <- dbConnectionsMax } func (c *dbCollector) Collect(ch chan<- prometheus.Metric) { c.mu.Lock() defer c.mu.Unlock() for desc, db := range c.dbs { stats := db.Stats() ch <- prometheus.MustNewConstMetric( dbConnectionsMax, prometheus.GaugeValue, float64(stats.MaxOpenConnections), desc.pipeline, desc.pluginName, desc.driver, ) } }
У Виктории аналога коллектора... нет. Остается единственный вариант - написать самостоятельно. Причем начать придется с того, что будет инициировать работу коллектора:
var ( GlobalCollectorsRunner = &collectorsRunner{} DefaultMetricCollectInterval = 15 * time.Second ) type Collector interface { Collect() } type collectorsRunner struct { collectors []Collector } func (cr *collectorsRunner) Append(c Collector) { cr.collectors = append(cr.collectors, c) } func (cr *collectorsRunner) Run(ctx context.Context, interval time.Duration) { metrics.ExposeMetadata(true) ticker := time.NewTicker(interval) go func() { defer ticker.Stop() defer logger.Default.Info("metric collectors runner exited") for { select { case <-ctx.Done(): return case <-ticker.C: logger.Default.Info("metric collectors runner - collection cycle started") for _, c := range cr.collectors { c.Collect() } logger.Default.Info("metric collectors runner - collection cycle done") } } }() }
А затем и сам коллектор:
var ( pluginDbConnectionsMax = func(d dbDescriptor) string { return fmt.Sprintf("plugin_db_connections_max{pipeline=%q,plugin_name=%q,driver=%q}", d.pipeline, d.pluginName, d.driver) } ) var ( dbMetricsCollector = &dbCollector{ dbs: make(map[dbDescriptor]*sqlx.DB), mu: &sync.Mutex{}, } ) type dbDescriptor struct { pipeline string pluginName string driver string } type dbCollector struct { dbs map[dbDescriptor]*sqlx.DB mu *sync.Mutex } func (c *dbCollector) append(d dbDescriptor, db *sqlx.DB) { c.mu.Lock() defer c.mu.Unlock() c.dbs[d] = db } func (c *dbCollector) delete(d dbDescriptor) { c.mu.Lock() defer c.mu.Unlock() delete(c.dbs, d) // дерегистрировать метрику нужно явно metrics.PluginsSet.UnregisterMetric(pluginDbConnectionsMax(d)) } func (c *dbCollector) Collect() { c.mu.Lock() defer c.mu.Unlock() for d, db := range c.dbs { stats := db.Stats() metrics.PluginsSet.GetOrCreateGauge(pluginDbConnectionsMax(d), nil).Set(float64(stats.MaxOpenConnections)) } }
Такие сложности возникают из-за того, что Виктория принимает хуки только для датчиков (Gauge), а внутренняя статистика, например, того же sql.DBStats содержит в том числе счётчики, а для них хуки не поддерживаются. В качестве альтернативы предлагается писать метрики через Write* функции, но так теряется привязка к сету.
Различия между решениями достаточно ощутимые - Прометей предоставляет кучу опций для практически любых кейсов, даже коллекторы из коробки есть, Виктория же сильно минималистична, но от того проста как топор, и столь же эффективна.
Стоило того?
Потребление памяти теперь выглядит так:

А вот дамп:
> go tool pprof .\heap-victoria File: neptunus Build ID: cff6ce9201aa331dc7a87cd4f23ef621ed33ca55 Type: inuse_space Time: 2025-10-27 16:02:55 MSK Entering interactive mode (type "help" for commands, "o" for options) (pprof) top Showing nodes accounting for 71.42MB, 75.23% of 94.93MB total Showing top 10 nodes out of 256 flat flat% sum% cum cum% 16.67MB 17.56% 17.56% 16.67MB 17.56% text/template.addValueFuncs 13.62MB 14.35% 31.90% 13.62MB 14.35% text/template.addFuncs 13.03MB 13.72% 45.63% 13.53MB 14.25% runtime.allocm 10.43MB 10.99% 56.62% 13.10MB 13.80% github.com/goccy/go-json.unmarshalNoEscape 4.50MB 4.74% 61.36% 4.50MB 4.74% github.com/gekatateam/neptunus/plugins/processors/stats.(*Stats).withoutLabels 4MB 4.21% 65.57% 4MB 4.21% bytes.(*Buffer).String 2.66MB 2.80% 68.38% 2.66MB 2.80% github.com/goccy/go-json/internal/decoder.initDecoder.func1 2.50MB 2.63% 71.01% 2.50MB 2.63% maps.clone 2.01MB 2.11% 73.12% 2.01MB 2.11% bufio.NewWriterSize 2MB 2.11% 75.23% 2MB 2.11% runtime.malg
Пропали тяжелые аллокации на квантилях (потому что Виктория под капотом собирает их через более легковесную библиотеку), но выросла нагрузка на сборщик мусора. Полагаю, тут дело в десятках строк, которые создаются каждый раз, когда нужно обновить метрику. Здесь точно есть что улучшать - сами авторы предлагают не выдергивать метрику из сета постоянно через New*, а получать её один раз, и затем обрабатывать.
В конечном итоге, смена библиотеки почти на ровном месте сэкономила 25-30% памяти ценой процессорного времени, так что целесообразность этого решения остается на твоё усмотрение, дорогой читатель, но внимания эта альтернатива устоявшемуся способу сбора и публикации метрик точно заслуживает.
