Кхм. Громковатый заголовок, но я всё объясню.
Итак, у меня был сервис. Обычная молотилка данных, каждый с такой хотя бы раз да сталкивался - что-то на входе, что-то на выходе, а внутри походы в базу, 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% памяти ценой процессорного времени, так что целесообразность этого решения остается на твоё усмотрение, дорогой читатель, но внимания эта альтернатива устоявшемуся способу сбора и публикации метрик точно заслуживает.
ollegio
Было бы интересно посмотреть на opentelemetry в сравнении с этими библиотеками
да, с одной стороны - лишняя абстракция, но с другой стороны удобство использования (универсальность и поддерживаемость). Плюс, реализацию экспортера можно написать используя викторию, и если всё остальное там достаточно оптимизировано, то по производительности сильно бить не должно
M0rdecay Автор
Кстати, да, мне нравится ваша мысль. OTEL, по сути, предоставляет интерфейсы и предлагает самому подложить реализацию. Возможно, однажды попробую