Кхм. Громковатый заголовок, но я всё объясню.

Итак, у меня был сервис. Обычная молотилка данных, каждый с такой хотя бы раз да сталкивался - что-то на входе, что-то на выходе, а внутри походы в базу, HTTP-вызовы, шаблоны, скриптовая логика... В общем, много всякого.

Ну, ладно, тут стоит сразу уточнить, что сервис с особенностями - молотилка данных устроена так, что пытается работать с разными форматами на входе и выходе, а внутри держать всё в одном представлении. Но вот из-за этой потребности работать с разным, внутреннее представление это - мапы, слайсы, мапы в слайсах, слайсы в мапах, да ещё и из всех щелей торчит куча метрик.

Поэтому вот такая картина потребления памяти меня до недавних пор особо не смущала:

Слева направо, сверху вниз - потребление памяти кучей (оранжевое - объем памяти, взятый у ОСи), объемы памяти, возвращенные ОСи, 75-й перцентиль времени на работу GC, потребление памяти стеком, рейт аллокаций (байт в секунду), частота пауз на сборку мусора
Слева направо, сверху вниз - потребление памяти кучей (оранжевое - объем памяти, взятый у ОСи), объемы памяти, возвращенные ОСи, 75-й перцентиль времени на работу GC, потребление памяти стеком, рейт аллокаций (байт в секунду), частота пауз на сборку мусора

Тем более, что РПС на сервисе - что-то около двух с половиной тысяч в секунду на под, и это только внутренняя логика, походы наружу никто не отменял. В общем, выглядело нормально.

Но разве, дорогой читатель, в какой-то момент тебя бы не посетила мысль попробовать оптимизироваться?

Куда уходит память

Вечер, чай, готовность поковыряться в кишках, снимаю дамп хипа, и... это:

> 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, как некая внутренняя статистика с собственными счётчиками, которые нужно просто отобразить наружу как есть.

В случае с Прометеем, всё проще некуда, пусть и многословно:

  1. Выделяем дескриптор

  2. Создаем кастомный коллектор и регистрируем его

  3. В коллекторе пишем метрики

Удобно то, что в одном коллекторе можно писать сразу пачку векторов - например, по всем пулам БД, предварительно собрав их в коллекторе. Если что-то будет записано, метрика будет отображена, не будет - не будет и метрики, и не надо заморачиваться с регистрацией и дерегистрацией отдельных коллекторов или метрик:

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% памяти ценой процессорного времени, так что целесообразность этого решения остается на твоё усмотрение, дорогой читатель, но внимания эта альтернатива устоявшемуся способу сбора и публикации метрик точно заслуживает.

Сорцы можно посмотреть здесь и здесь.

Комментарии (2)


  1. ollegio
    28.10.2025 10:23

    Было бы интересно посмотреть на opentelemetry в сравнении с этими библиотеками
    да, с одной стороны - лишняя абстракция, но с другой стороны удобство использования (универсальность и поддерживаемость). Плюс, реализацию экспортера можно написать используя викторию, и если всё остальное там достаточно оптимизировано, то по производительности сильно бить не должно


    1. M0rdecay Автор
      28.10.2025 10:23

      Кстати, да, мне нравится ваша мысль. OTEL, по сути, предоставляет интерфейсы и предлагает самому подложить реализацию. Возможно, однажды попробую