Продвинутые структуры Redis
- вторник, 20 августа 2024 г. в 00:00:08
Привет, Хабр! Я Олег Арутюнов, Go разработчик из Контура. Сейчас я работаю над проектом Мойра – опенсорс-системе реалтайм-алёртинга. Мойру разработали в Контуре ещё в 2015 году для того, чтобы доставлять алёрты на основе метрик из системы мониторинга Graphite, позже появилась поддержка метрик из Prometheus/VictoriaMetrics. Наша задача в случае поломки какой-то системы в Контуре, как можно быстрее уведомить о ней пользователей, разбудить их, достать и заставить всё починить.
Пользователь Мойры создаёт триггер: выбирает источник метрик, пишет запрос для этого источника и задаёт пороговые значения. В случае их превышения Мойра отправляет уведомления в заданные пользователем каналы доставки. Мы поддерживаем Telegram, Slack, Mattermost, Email, телефонные звонки и некоторые менее популярные каналы.
Если в цифрах, то в систему попадает около 3 000 000 входящих метрик в секунду, из которых сохраняется порядка 20 000 метрик в секунду. Каждую минуту проверяются 26 000 триггеров. В среднем отправляем 2000 уведомлений в день.
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, поддерживающий репликацию и шардирование
SET key value [EX seconds]
result, err := c.Set(ctx, key, value, ttl).Result()
Сохраняет строковое значение по ключу. Можно задать таймаут: время через которое ключ автоматически удалится из базы.
GET key
result, err := c.Get(ctx, key).Result()
GET key достаёт строковое значение по ключу.
SADD key member
result, err := c.SAdd(ctx, key, member).Result()
Добавляет значение в множество, хранящееся по ключу. Если такого множества ещё нет — создаёт. Но SADD key member не позволяет задать таймаут: значения из множеств надо удалять самому.
SREM key member [member...]
result, err := c.SRem(ctx, key, members//.).Result()
Удаляет значение из множества, хранящегося по ключу. Если это было последнее значение — сам ключ тоже будет удалён.
Небольшое лирическое отступление. Следующие команды связаны с ZSet, поэтому коротко расскажу про него. ZSet — упорядоченное множество По-моему, оно идеально для выбора данных по временному диапазону, например, метрики. Но можно использовать и как очередь с приоритетом, например, для уведомлений или событий. И что еще важно: ZSet реализован не через дерево, как можно было бы подумать, а через хэшмап с дополнительным скип-индексом.
ZADD key member
result, err := c.ZAdd(ctx, key, member).Result()
ZADD и ZREM работают аналогично SADD и SREM, но с упорядоченными множествами.
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()
Быстро выбирает из упорядоченного множества значения в заданном диапазоне. Можно дополнительно задать количество и сдвиг значений, чтобы, реализовать, например, пагинацию.
Мойра состоит из десятков микросервисов в 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 – это его однопоточность. Все операции чтения и записи производятся синхронно в одном потоке. Это решает много проблем с консистентностью данных, но негативно сказывается на производительности. Если делать много запросов, получим примерно такую картинку:
Расходуем всего 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)
}
На этом кончаются основные нехитрые секреты эксплуатации Редиса в команде Мойры. Многие дальнейшие нюансы улучшения производительности сводятся к выбору удачного представления данных в базе и написания удобных абстракций.
О том, как это сделано у нас — можете посмотреть в репозитории с кодом или даже прийти контрибьютить!