golang

Продвинутые структуры Redis

  • вторник, 20 августа 2024 г. в 00:00:08
https://habr.com/ru/companies/skbkontur/articles/836944/

Привет, Хабр! Я Олег Арутюнов, Go разработчик из Контура. Сейчас я работаю над проектом Мойра – опенсорс-системе реалтайм-алёртинга. Мойру разработали в Контуре ещё в 2015 году для того, чтобы доставлять алёрты на основе метрик из системы мониторинга Graphite, позже появилась поддержка метрик из Prometheus/VictoriaMetrics. Наша задача в случае поломки какой-то системы в Контуре, как можно быстрее уведомить о ней пользователей, разбудить их, достать и заставить всё починить.

Пользователь Мойры создаёт триггер: выбирает источник метрик, пишет запрос для этого источника и задаёт пороговые значения. В случае их превышения Мойра отправляет уведомления в заданные пользователем каналы доставки. Мы поддерживаем Telegram, Slack, Mattermost, Email, телефонные звонки и некоторые менее популярные каналы.

Если в цифрах, то в систему попадает около 3 000 000 входящих метрик в секунду, из которых сохраняется порядка 20 000 метрик в секунду. Каждую минуту проверяются 26 000 триггеров. В среднем отправляем 2000 уведомлений в день.

Как используем Redis

Redis — это очень популярная СУБД, и вы, скорее всего, с ней уже так или иначе сталкивались. Данные в Redis хранятся в формате ключ-значение: нет никаких реляционных связей и индексов. Так же все эти данные хранятся в оперативной памяти, что обеспечивает высокую производительность. По этим двум причинам Redis чаще всего используют не как полноценную базу данных, а как кеш для временного хранения горячих значений. 

Однако при работе с Redis важно не забывать про ещё одно его отличительное свойство: он однопоточный. Это значит, что все операции чтения и записи данных происходят строго синхронно в одном потоке выполнения. Естественно, для удержания большого количества сетевых соединений и облегчения нагрузки на IO используются вспомогательные потоки. Но поток чтения/запиcи остаётся основным узким местом.

Чтобы начать использовать Redis в golang достаточно взять одну из библиотек и инициализировать клиент: в Мойре используется модуль go-redis и UniversalClient из него. В конфигурации универсального спрятано много неочевидных нюансов: например без заданного поля ReadOnly не будет работать чтение со слейв-узлов Redis-кластера, а грамотно сконфигурированные таймауты и ретраи позволят без потерь пережить перевыборы мастера при недоступности одного из серверов.

import "github.com/go-redis/redis/v8"

c := redis.NewUniversalClient(&redis.UniversalOptions{
// Список адресов машин кластера
Addrs: config.Addrs,
Username: config.Username,
Password: config.Password,

// Для работы с Sentinel
MasterName: config.MasterName,
SentinelPassword: config.SentinelPassword,
SentinelUsername: config.SentinelUsername,
})

Универсальный клиент позволяет поддерживать работу одного и того же кода с различными видами инсталляции Redis: 

  • одиночным сервером, который удобно использовать при локальном тестировании

  • Redis Sentinel, поддерживающий репликацию

  • и Redis Cluster, поддерживающий репликацию и шардирование

Основные команды Redis

  1. SET key value [EX seconds]

result, err := c.Set(ctx, key, value, ttl).Result()

Сохраняет строковое значение по ключу. Можно задать таймаут: время через которое ключ автоматически удалится из базы.

  1. GET key

result, err := c.Get(ctx, key).Result()

GET key достаёт строковое значение по ключу.

  1. SADD key member

result, err := c.SAdd(ctx, key, member).Result()

Добавляет значение в множество, хранящееся по ключу. Если такого множества ещё нет — создаёт. Но SADD key member не позволяет задать таймаут: значения из множеств надо удалять самому.

  1. SREM key member [member...]

result, err := c.SRem(ctx, key, members//.).Result()

Удаляет значение из множества, хранящегося по ключу. Если это было последнее значение — сам ключ тоже будет удалён.


Небольшое лирическое отступление. Следующие команды связаны с ZSet, поэтому коротко расскажу про него. ZSet — упорядоченное множество По-моему, оно идеально для выбора данных по временному диапазону, например, метрики. Но можно использовать и как очередь с приоритетом, например, для уведомлений или событий. И что еще важно: ZSet реализован не через дерево, как можно было бы подумать, а через хэшмап с дополнительным скип-индексом.


  1. ZADD key member

result, err := c.ZAdd(ctx, key, member).Result()

ZADD и ZREM работают аналогично SADD и SREM, но с упорядоченными множествами.

  1. ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count]

result, err := c.ZRangeByScore(ctx, key, &redis.ZRangeBy{
Min: strconv.FormatInt(from, 10),
Max: strconv.FormatInt(to, 10),
Count: int64(limitCount),
Offset: int64(limitOffset),
}).Result()

Быстро выбирает из упорядоченного множества значения в заданном диапазоне. Можно дополнительно задать количество и сдвиг значений, чтобы, реализовать, например, пагинацию.

Плюсы и минусы общения через Redis

Мойра состоит из десятков микросервисов в Kubernetes, которые никак не общаются между собой по сети. Все общение происходит через Redis кластер.  

Плюсы:

  • Умрёт сеть внутри кластера, а нам будет всё равно

  • Все промежуточные состояния сохранены в базе

Минусы:

  • На каждый чих надо перераскатывать все сервисы

  • Нет фич реляционных баз, без которых тяжело делать миграции и очистку данных.

Как мы решаем трудности с миграцией и очисткой данных сейчас расскажу.

Миграция данных

Redis не предоставляет специальных инструментов для миграции данных, поэтому чтобы её провести, надо их все вытащить и снова положить обратно. К сожалению, нет простого способа гарантировать, что:

– кто-то не попробует поменять данные прямо в момент миграции

– все сервисы дождутся миграции 

– и что сохранится консистентность связей между данными в ходе миграции.

А схема данных у нас выглядит как-то так:

Так что перед каждой миграцией данных мы задаемся одним, но крайне важным вопросом: «А нам ТОЧНО надо их мигрировать?» 

Если да, то:

  • Предупреждаем пользователей

  • Включаем read-only режим Мойры

  • Мигрируем данные

Если нет, то пишем код, который обратно совместим с текущей схемой базы.

К примеру, обратной совместимости можно добиться вот так. У нас есть старая версия триггера, в которой предки оставили галочку isRemote для определения источника метрик (в те времена в Мойры было всего два источника метрик локальный иремоут Графит).  А переехать мы хотим на поле с перечислимым типом, чтобы в дальнейшем произвольно расширять набор поддерживаемых источников.  

Было:

type Trigger struct {

IsRemote bool

}

Хотим получить:

type Trigger struct {

TriggerSource TriggerSource

}

Первое, что приходит в голову – заменить все данные в базе на новую версию, с перечислимым типом. В реляционной базе это была бы одна, атомарно выполненная, миграция. Однако в Redis так не выйдет.

Используем более хитрый подход. Триггер, который реально хранится в базе, представлен отдельной структурой trigger Storage Element, и в ней мы сохраняем оба поля. И в прослойке абстракций над работой с базой конвертируем уже в используемую в остальном проекте структуру Trigger, в которой будет содержаться уже актуальное поле, заполненное корректным значением.

type triggerStorageElement struct {
…
IsRemote bool
TriggerSource moira.TriggerSource
}

func (se *triggerStorageElement) toTrigger() Trigger {
...
se.TriggerSource.FillInIfNotSet(se.IsRemote)
...
}

Очистка устаревших данных

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

Итак, мы уже обсуждали, что у ключа есть expire. То есть, по идее, мы могли бы настроить expire через час и готово. Но дело в том, что метрики у нас хранятся в сортированном множестве ZSet с внутренними ключами, поэтому expire не подходит. 

Можно удалять все устаревшие метрики в тот момент, когда мы достаём их из базы. Но окажется, что это тоже не позволяет вычистить всё, потому что бывают ситуации, когда перестали писать метрики и параллельно вместе с этим перестали ими пользоваться. И старые метрики живут себе дальше.

Поэтому для решения этой проблемы надо писать крон-джобу, которая регулярно вычищает эти метрики из базы. И в итоге конечное решение выглядит как-то так: 

Такой подход мы используем, когда удаляем старые метрики, теги, результаты проверки и старых пользователей.

После добавления одной из этих очисток время выполнения ZRange радикально упало:

Результат превзошел все наши ожидания – мы не просто очистили ключи, уменьшив количество выбираемых данных, мы смогли удалить лишние данные, по которым строились дополнительные запросы. А большое количество даже небольших запросов Redis переваривает достаточно тяжело из-за своей однопоточной природы. Кстати об этом… 

Однопоточность и оптимизация Redis

Как уже говорилось выше, самое узкое место Redis  – это его однопоточность. Все операции чтения и записи производятся синхронно в одном потоке. Это решает много проблем с консистентностью данных, но негативно сказывается на производительности. Если делать много запросов, получим примерно такую картинку:

Расходуем всего 20% ресурсов железа, есть куда расширяться! Но на каждой машине одно ядро работает на 80%, пока остальные отдыхают.

Поэтому мы используем несколько подходов к оптимизации: 

  • Использовать сервера с небольшим количеством производительных ядер.

  • Использовать Редис-Кластер – нагрузку можно распределить между несколькими машинами.

  • На одной машине хостить мастеров и слейвов – так получится утилизировать больше ресурсов (самые храбрые могут разместить на одной машине сразу несколько мастеров).

  • Собирать запросы в пайпы – получится меньше запросов на большее количество пачек.

Pipeline позволяет собрать набор запросов в одну большую пачку и отправить её в Redis. Это крайне оптимально, пока вся пачка помещается у вас в памяти. Но если не помещается – можно внезапно получить Out of Memory.

Для распределение постепенно приходящего потока метрик по пачкам в Мойре используется примерно такая логика:

batchTimer := time.NewTimer(timeout)
defer batchTimer.Stop()

for {
batch := make(map[string]*moira.Metric)

retry:
select {
// Читаем метрики из канала с метриками
case metric := <-metrics:
AddMetric(batch, metric)
// Если пачка ещё не заполнилась, продолжаем наполнять
if len(batch) < capacity {
goto retry
}
// Если заполнилась – отправляем её на обработку
batchedMetrics <- batch

// Если пачка не заполнена, но прошло достаточно много времени – тоже отправляем в обработку

case <-batchTimer.C:
batchedMetrics <- batch
}
batchTimer.Reset(timeout)
}

Заключение

На этом кончаются основные нехитрые секреты эксплуатации Редиса в команде Мойры. Многие дальнейшие нюансы улучшения производительности сводятся к выбору удачного представления данных в базе и написания удобных абстракций. 

О том, как это сделано у нас  — можете посмотреть в репозитории с кодом или даже прийти контрибьютить!

https://github.com/moira-alert/moira 

https://t.me/moira_alert