По мере роста Twitch мы сталкивались с множеством разных интересных проблем с масштабированием. Одна из наших самых больших проблем - это более 500 миллионов последующих взаимоотношений между пользователями. Еще несколько месяцев назад эти отношения хранились очень неэффективно и больше не могли масштабироваться в соответствии с нашими потребностями. Слежение за отношениями чрезвычайно важно для наших вещателей и зрителей, поскольку они являются средством, с помощью которого мы уведомляем пользователей, когда их любимые вещатели начинают потоковую передачу. Это лежит в основе коллективного характера Twitch; Таким образом, при планировании улучшений мы решили, что простои во время любой миграции недопустимы. Чтобы эффективно решить эту задачу, мы построили TAO-подобие и полностью исключили время простоя при миграции. Вот как.

Прокладывая путь вперед

Первоначальная схема не была рассчитана на миллиард отношений. Ему не хватало правильной индексации, и он был частью большого централизованного кластера PostgreSQL, который не выдерживал нагрузки в часы пик. Один из основных вариантов использования нашей следующей системы - уведомлять пользователей, когда их любимые вещательные компании выходят в сеть. Это означает как можно быстрее перебирать всех подписчиков канала. Чтобы проиллюстрировать проблему, это график времени, необходимого для последовательного извлечения всех последователей с получением 100 результатов за раз. Старая модель неустойчива, а новая идеально подходит.

На пике загрузки следующие запросы составляли 40% доступного процессорного времени нашего кластера PostgreSQL. Уже доведя этот кластер до предела возможностей, мы попытались перейти к более оптимальной модели данных. Вдохновленные TAO Facebook, мы поняли, что можем просто смоделировать данные наших подписчиков как ассоциации в форме [Entity A, Association Type, Entity B] как таковые:

Установив целевую модель данных, мы создали план миграции высокого уровня:

  1. Сделайте дамп базы данных таблицы PostgreSQL
  2. Одновременно начните записывать все новые события записи (отслеживание и отмену подписки) в упорядоченную по времени очередь событий.
  3. Импортируйте дамп базы данных в новую базу данных и преобразуйте данные в новый формат схемы.
  4. Воспроизвести все записи в очереди в новое хранилище данных, пока оно не будет загружено
  5. Перенаправить трафик в новую базу данных

У нас была отдельная программа, обрабатывающая события записи и записывающая их в новую базу данных с использованием новой схемы. Хотя на бумаге это позволяет нам перенаправлять трафик и переключаться, мы хотели быть уверены, что наши недавно отформатированные данные соответствуют семантике наших старых данных. Клиенты нашего API не должны знать, что происходит это изменение, и все ранее сохраненные данные должны быть доступны для извлечения. Поскольку каждый из этих шагов миграции может привести к неочевидным ошибкам, мы разработали стратегию, обеспечивающую согласованное поведение между двумя представлениями данных.

Обеспечение успеха

Чтобы обеспечить согласованность данных, мы вдохновились библиотекой Github Scientist для рефакторинга критических путей. Мы написали наш сервис для поддержки более чем одного хранилища данных: основного и произвольного количества второстепенных. Мы можем параллельно зеркалировать каждый входящий запрос во все хранилища данных и возвращать результат от основного запроса клиенту. Затем мы можем в другом потоке сравнить результат вторичного хранилища данных с первичным, чтобы убедиться, что он правильный. Обладая этой информацией, мы можем с уверенностью сказать, когда миграция будет готова к продолжению. Мы также можем измерить время запроса для каждого вторичного хранилища данных, чтобы гарантировать, что время ответа находится в пределах ожидаемых. Идея проиллюстрирована здесь, при этом шаги пронумерованы в следующем порядке:

Ниже приведен фрагмент кода, иллюстрирующий упомянутый процесс. Клиент предоставляет идентификаторы от и до, а также тип ассоциации. Наша служба отвечает одним результатом, если запись существовала, в противном случае - ничего. Интерфейсы выглядят следующим образом (обратите внимание, что я исключил контексты везде, обрезал имена пакетов и упростил некоторые вещи для краткости):

type Reader interface {
    GetAssoc(Association) (*AssocResponse, error)
}
type Backender interface {
    GetAssoc(Association, *Params) (AssocResponse, error)
}
// Backend provides storage and stats implementations
type Backend struct {
    primaryReader Reader
    secondaryReaders []Reader
}

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

func (b *Backend) GetAssoc(assoc Association) ([]AssocResponse, error) {
    res, err := b.PrimaryReader.GetAssoc(assoc)
    if err == nil {
        go func() { b.secondaryGet(assoc, res) }()
    }
    return res, err
}

Вторичные чтения обрабатываются после того, как первичное уже было возвращено. Мы создаем горутину для каждого зарегистрированного вторичного читателя и подсчитываем, сколько времени займет каждый из них. Это позволяет нам сравнивать полученный результат с первичным результатом и сообщать статистику по нашему кластеру statsd. Наконец, в целях безопасности все они выполняются в пределах тайм-аута.

func (b *Backend) secondaryGet(assoc Association, primaryResult AssocReponse) {
    for _, r := range b.secondaryReaders {
        go func(reader Reader) {
            res, err := reader.GetAssoc(assoc)
            if err != nil || res != primaryResult {
                // Log failed comparison
            } else {
                // Log successful comparison
            }
        }(r)
    }
}

Регистрация успешных и неудачных сравнений в наших кластерах statsd позволяет нам измерять частоту несоответствия:

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

  • Наборы результатов заказываются неправильно
  • Записи включаются, когда их не должно было быть
  • Отметки времени возвращаются в неправильном часовом поясе

Заключение

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

Перенести 500 миллионов строк без простоев было очень весело, особенно с учетом нашей уверенности в результате. Мы знали, что наша новая схема, подобная TAO, будет значительным обновлением по сравнению со старой схемой, и, поскольку мы переместили эту информацию в отдельное хранилище данных, мы сняли около 30–40% нагрузки с нашего основного кластера базы данных. И никто не заметил!

Мы нанимаем!

Нам было интересно поработать над этой задачей. У нас осталась масса других забавных проблем. Если вас интересует работа над системами и масштабированием, пожалуйста, загляните на сайт Twitch Engineering!