В продакшене
Как Discord перенес триллионы сообщений в ScyllaDB
Оригинал в блоге
Впервые опубликовано Discord Engineering 6 марта 2023
В начале 2022 года Discord хранил триллионы сообщений на 177 узлах Cassandra. Cassandra отлично подходила для высокой нагрузки на запись. Проблема была в чтении: популярный канал мог отправлять тысячи запросов к одному разделу, перегружать узлы и тормозить запросы других каналов на тех же серверах. К тому же уплотнение данных (compaction) постоянно отставало, а паузы сборщика мусора JVM приводили к скачкам задержек.
Чтобы решить проблемы на уровне базы данных, Discord заменил Cassandra на ScyllaDB - совместимую реализацию на C++. Она избавила систему от пауз сборщика мусора JVM, сделала хранилище быстрее и проще в обслуживании.
Но более быстрая база данных не защищает от ситуации, когда тысячи людей одновременно запрашивают одно и то же сообщение. Для этого Discord поставил перед базой данных сервисы данных на Rust. Запросы одного канала направляются на один экземпляр сервиса, где параллельные чтения одного сообщения объединяются в один запрос к базе данных.
Новый кластер работает на 72 узлах вместо 177, а p99 времени чтения упал со 125 мс до стабильных 15 мс.
Мы выбрали эту историю, потому что она четко разделяет две разные проблемы. ScyllaDB сделала само хранилище быстрее и надежнее, а объединение запросов (request coalescing) сократило объем чтений, которые вообще доходят до базы данных.
Проблема
Как Discord хранил сообщения
Discord хранил сообщения в Cassandra с 2017 года. К началу 2022 года кластер вырос до 177 узлов с триллионами сообщений. Все они хранились в одной таблице:
CREATE TABLE messages (
channel_id bigint,
bucket int,
message_id bigint,
author_id bigint,
content text,
PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);В Cassandra составной ключ (channel_id, bucket) служит ключом
партиционирования и определяет, на каких серверах лежат данные. Канал - это чат
на сервере Discord, а bucket - окно времени. Вместе они собирают сообщения
канала за определенный период в один раздел, который реплицируется на три узла.
Кластерный ключ message_id сортирует сообщения внутри раздела в обратном
порядке: новые вначале.
Временная корзина не дает разделу бесконечно расти. Без нее все сообщения за всю историю популярного канала копились бы в одном разделе.
В нашем разборе Cassandra мы подробно разбираем эту схему и показываем, как спроектировать такое партиционирование с нуля.
Эта схема хорошо распределяла нагрузку на запись, но чтение оставалось неравномерным. Почти все живые чтения канала идут в его самую свежую корзину. При этом популярность каналов различается колоссально: активный публичный канал мог направить тысячи запросов в один раздел и перегрузить три узла с его репликами.
Раздел становился горячим из-за популярности самих данных, а не из-за неудачного размера корзины. Дополнительное шардирование могло бы размазать нагрузку, но ценой усложнения главного сценария: последовательного чтения сообщений канала.
Как один популярный канал замедлял запросы других каналов
Cassandra проектировалась для быстрой и дешевой записи. Новое сообщение дописывается в Commit Log и попадает в оперативную память в структуру Memtable. Когда Memtable заполняется, Cassandra сбрасывает ее на диск в виде неизменяемого файла SSTable.
Cassandra никогда не перезаписывает файлы на диске. Поэтому свежая версия строки может быть размазана между Memtable и несколькими SSTable. При чтении приходится опрашивать сразу несколько мест и склеивать результат. Чтобы файлов не становилось слишком много, фоновый процесс уплотнения периодически объединяет SSTable между собой.
Подробнее об этой модели хранения и компромиссах между чтением и записью мы рассказываем в разборе Cassandra.
А теперь представьте тысячи пользователей, которые одновременно читают свежие сообщения популярного канала. Все запросы летят на одни и те же три реплики, и каждая реплика выполняет одинаковую тяжелую работу.
Хуже того: на этих же трех серверах хранились разделы тысяч других, менее
популярных каналов. Discord читал и писал с уровнем согласованности QUORUM:
каждый запрос ждал ответа от двух из трех реплик. Когда один узел был перегружен
трафиком популярного канала, запросы из других каналов вставали в очередь и
тормозили.
Задержка уплотнения и паузы сборщика мусора
Со временем управлять кластером Cassandra становилось все сложнее.
Cassandra зависит от уплотнения, чтобы держать количество SSTable под контролем. Но кластер Discord регулярно отставал от темпов записи. Чем больше копилось неуплотненных файлов, тем медленнее работало чтение. Медленные чтения сжигали CPU и диск, оставляли еще меньше ресурсов на уплотнение, и очередь росла как снежный ком.
Запустить уплотнение агрессивнее не помогало: оно конкурирует с реальным трафиком за диск и CPU, поэтому задержки только росли.
Чтобы нагнать отставание, инженеры устраивали процедуру, которую в Discord называли gossip dance. Они временно снимали узел с боевого трафика, давали ему спокойно выполнить уплотнение, возвращали в строй, ждали догона пропущенных записей и переходили к следующему серверу.
Кроме того, Cassandra работает на JVM, а сборщик мусора периодически останавливает приложение для очистки памяти. Паузы GC вызывали резкие скачки задержек. Иногда узел зависал так надолго, что его приходилось перезагружать вручную. Отставание уплотнения и паузы сборщика мусора сделали кластер хрупким: команда тратила слишком много сил на ручное обслуживание даже без учета горячих разделов.
Решение
Замена Cassandra на ScyllaDB
Чтобы решить проблемы на уровне базы данных, Discord заменил Cassandra на ScyllaDB. ScyllaDB полностью совместима с Cassandra по языку запросов CQL и модели данных, но написана на C++. Это позволило сохранить схему таблиц и навсегда забыть о паузах сборщика мусора.
ScyllaDB построена на архитектуре shard-per-core: каждое ядро процессора изолированно обрабатывает свою часть данных и запросов. За счет этого выросла производительность серверов, ускорилось восстановление реплик и уменьшилось количество ручной рутины.
Но замена базы данных не спасает от горячих разделов. ScyllaDB сделала каждое чтение быстрее и дешевле, но она бессильна, когда тысячи людей одновременно запрашивают сообщения из одного раздела. Чтобы защитить базу, нужно было срезать лишний трафик еще до того, как он до нее дойдет.
Объединение запросов (Request coalescing)
Представьте объявление с тегом @everyone на крупном сервере: тысячи участников
открывают канал одновременно. Без объединения запросов каждый клиент отдельно
идет в базу за одним и тем же сообщением, обрушивая лавину чтений на один
раздел.
Объединение запросов работает иначе: первый запрос уходит в базу, а все одинаковые запросы, пришедшие следом во время его выполнения, встают в очередь к уже запущенной задаче. Когда база возвращает сообщение, сервис данных отдает этот результат всем, кто ждал.
Для этого Discord поставил промежуточный слой сервисов данных на Rust между API и базой данных. Бизнес-логика осталась в API, а сервисы данных взяли на себя работу с хранилищем и объединение дубликатов.
Сначала нужно было гарантировать, чтобы запросы к одним данным приходили на один
и тот же экземпляр сервиса. Для этого использовали согласованное
хеширование по
channel_id. Запросы одного канала попадают на один сервер даже при добавлении
или падении экземпляров в пуле.
Когда запросы попадают в один процесс, сервис объединяет их.
Упрощенно сервис держит в памяти таблицу активных задач по ключу запроса:
// Упрощенная схема. get_or_start атомарно проверяет наличие и запускает задачу.
async fn get_message(channel_id: u64, message_id: u64) -> Message {
let key = (channel_id, message_id);
let task = in_flight.get_or_start(key, || {
spawn_query(channel_id, message_id)
});
task.subscribe().await
}Сервис данных идеально подходит для этой задачи: он понимает контекст запроса целиком. ScyllaDB может переиспользовать данные из кэша страниц, но она ничего не знает о том, что два разных RPC запрашивают одно сообщение. Сервис данных видит одинаковый ключ и склеивает запросы еще до обращения в базу.
Почему не обычный кэш? Кэш закрывал бы и те запросы, которые пришли после ответа базы, но принес бы с собой инвалидацию и правила устаревания (TTL). Объединение запросов устроено строже: оно никогда не возвращает устаревший результат. Запросы лишь делят между собой операцию, которую база выполняет прямо сейчас. Discord пожертвовал процентом попаданий в кэш ради простоты и гарантированной свежести данных.
Сервисы данных запустили еще со старым кластером Cassandra. Это не решило проблему горячих разделов на 100%, но резко срезало повторные чтения. Всплески задержек стали происходить реже, и инженеры смогли спокойно дотестировать и настроить ScyllaDB.
Заключение
Горячие разделы - это в первую очередь проблема характера трафика. Популярный канал обрушивает тысячи чтений на раздел со свежими сообщениями. Новая база данных ускоряет каждое отдельное чтение, но не уменьшает число входящих запросов.
Поэтому Discord понадобились обе части решения. ScyllaDB дала быстрый и надежный уровень хранения без пауз JVM и постоянной ручной возни с уплотнением. А сервисы данных сократили объем чтений, которые вообще доходят до хранилища.
Маршрутизация по каналу собрала одинаковые запросы на одном сервере. Все повторные чтения во время выполнения запроса получили общий ответ вместо отдельных походов в базу данных. Это решение помогло еще до переезда на ScyllaDB, сделав работу со старой Cassandra предсказуемой.