Переезд c PostgreSQL на YDB. Кейс сервиса Яндекс Игры
Привет! Меня зовут Александр Смолин. Я бэкенд-разработчик в команде Яндекс Игр. Уже два года мы используем YDB для задач сервиса. В статье расскажу, как мы в Яндекс Играх внедряли YDB, зачем это было нужно, с какими сложностями столкнулись и какие результаты у нас сейчас.
Яндекс Игры — это платформа, где можно играть в HTML5 игры в браузере без установки. В нашем каталоге сейчас больше 15 тысяч игр. Сервис состоит из двух частей:
Консоль для разработчиков
позволяет управлять своей игрой на платформе и публиковать её,
позволяет управлять продвижением игры на платформе,
позволяет отслеживать метрики игры.
Каталог
Мы предоставляем разработчикам площадку и инфраструктуру в виде SDK для размещения, продвижения и монетизации своих игр.
Почему мы выбрали YDB
Сервис Яндекс Игры впервые перешёл на использование YDB в июне 2021 года. Для работы изначально использовался единый кластер PostgreSQL, в котором хранились не только данные о пользовательской активности (список игр, прогресс, покупки), но и все остальные важные данные для работы сервиса, в том числе данные про сами игры, которые можно увидеть в каталоге.
Одного кластера было вполне достаточно, пока сервис был сравнительно небольшим. Но с ростом аудитории появилась необходимость хранения данных, генерируемых пользователями, отдельно, чтобы их объём и количество запросов к ним не приводило к деградации базы в целом. То есть нам необходимо было либо перевозить данные в отдельный кластер, либо искать альтернативу PostgreSQL.
Расскажу, почемумы выбрали базу данных YDB, которая доступна в Yandex Cloud. Перед нами стояла задача найти хранилище, которое позволяло максимально эффективно читать и писать данные по ключу, при этом количество этих данных могло расти достаточно высокими темпами. То есть необходимо было заранее подумать о том, что данные требуется шардировать.
Насколько мне известно, PostgreSQL имеет гибридные решения по шардированию, основанные на технологии FDW (Foreign Data Wrappers). Но тут требуются обширные познания в администрировании СУБД, а это усложняет поддержку. YDB же рассчитана на такие операции «из коробки». Большим преимуществом стало быстрорастущее комьюнити и прямая связь с командой разработчиков YDB, которые могут помочь и проконсультировать. Это также упрощает работу и поддержку.
YDB по своим функциональным возможностям максимально полно и с минимальными трудозатратами покрывает те задачи, которые мы перед собой поставили. Использование других инструментов для решения тех же задач возможно, но для достижения аналогичных результатов требует значительно больше трудозатрат при разработке и поддержке. А мы, как любой бизнес, стремимся решать свои задачи максимально эффективно. Поэтому и решили использовать YDB.
Задача по хранению прогресса пользователя
Первой задачей для применения YDB было хранение прогресса пользователя в играх. Прогресс сохраняется в виде JSON-объекта, в формате, который генерирует сама игра. Уже на момент переезда размер таблицы превышал 20 Гб (или более 11 млн строк). Стало понятно, что в долгой перспективе такое решение не масштабируется и необходимо выносить прогресс в отдельное хранилище.
Расскажу подробнее про задачу и ограничения, которые перед нами стояли. Вот что было необходимо:
Хранить JSON-структуру, связанную с прогрессом пользователей в играх (формат хранения прогресса задаётся разработчиком игры самостоятельно).
Иметь возможность получать и записывать данные по Primary Key c высокой интенсивностью.
Максимальный размер записи ограничен 200 KB.
Не деградировать по скорости выполнения запросов в зависимости от роста количества записей и общего объёма базы.
Нам требовалось хранилище, которое способно быстро и с минимумом ошибок записывать и отдавать записи по Primary Key и при этом не деградировать от роста размера таблицы.
Мы рассматривали отдельный кластер PostgreSQL, потому что это было легко. Коллеги из Yandex Cloud предложили попробовать YDB. Мы изучили документацию. YDB — распределённая отказоустойчивая СУБД, которая обеспечивает высокую доступность и использует диалект SQL. Звучало как то, что мы ищем.
Поддержка диалекта SQL и общая схожесть по флоу работы с PostgreSQL, наличие готовых Go SDK, а также наличие простых и понятных гайдов позволили нам переехать в течение двух недель.
Миграция на YDB
Переезд проходил в три этапа.
Использование YDB и PostgreSQL одновременно. Необходимо было использовать YDB в качестве дополнительного хранилища и реализовать запись в новую базу YDB. При этом чтение осуществлялось по такой логике: «если в YDB данных нет, то попробуй сходить в PostgreSQL. Если там данные есть, то дозапиши их в YDB и отдай пользователю». Данные начали мигрировать по мере обращения к ним.
Полная миграция данных в YDB. Требовалось провести полную миграцию данных, которые не были перенесены на первом этапе (то, что уже перенесено, перезаписывать не нужно)
Использование только YDB. Последний шаг — сделать YDB основной базой данных и отказаться от чтения из Postgres.
Расскажу подробнее про каждый этап.
Этап 1. Использование YDB и PostgreSQL одновременно
Первым делом завели обёртку вокруг Gо SDK, назвали её YdbDriver.
YdbDriver interface {
Dial(...) error
Close() error
NewReadTxControl() *table.TransactionControl // читающая транзакция с автокоммитом
NewWriteTxControl() *table.TransactionControl // пишущая транзакция с автокоммитом
CreateSession(...) (*table.Session, error)
CloseSession(...) error
Prepare(...) (stmt *table.Statement, err error) // подготовка запроса (на данный момент уже не используется)
ExecuteWithTxControl(...) (err error)
Retry(...) error // удобный ретраер запросов
}
Реализованы методы для получения транзакции с автокоммитом, непосредственно выполнение запроса, ретраер. У YdbDriver было две основные задачи: уметь писать метрики запросов и быть отдалённо похожим на текущий интерфейс клиента PostgreSQL, чтобы не пришлось сильно переделывать код.
Реализация ключевых методов
Создание читающей транзакции с автокоммитом:
func (d *YdbDriver) NewReadTxControl() *table.TransactionControl {
return table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
),
table.CommitTx(),
)
}
Создание пишущей транзакции с автокоммитом:
func (d *YdbDriver) NewWriteTxControl() *table.TransactionControl {
return table.TxControl(
table.BeginTx(
table.WithSerializableReadWrite(),
),
table.CommitTx(),
)
}
Выполнение запроса:
func (d *YdbDriver) ExecuteWithTxControl(
ctx context.Context,
session table.Session,
txControl *table.TransactionControl,
query string,
params *table.QueryParameters,
timeout time.Duration,
) (result.Result, error) {
start := internal.UTCNow()
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
_, res, err := session.Execute(
ctxWithTimeout,
txControl,
query,
params,
options.WithKeepInCache(true),
)
if err != nil {
return nil, err
}
return res, nil
}
Затем завели таблицу authorized_user_progress:
CREATE TABLE authorized_user_progress
(
user_id Uint64,
game_id Uint64,
changed Datetime,
data JsonDocument,
PRIMARY KEY (user_id, app_id)
)
Для создания таблицы использовали стандартные настройки шардирования по размеру — 2048 MБ. PK (Primary Key) составной — user_id, game_id. Проблемы с равномерным шардированием у нас не было: первичный ключ — инкременируемый и равномерно возрастающий (рекомендации по выбору PK), так как идентификатор пользователя имеет разбросанные значения, к тому же есть вторая часть ключа — идентификатор игры.
Запросы хранятся отдельными файлами-ресурсами и собираются при компиляции.
DECLARE $game_id AS Uint64;
DECLARE $user_id AS Uint64;
DECLARE $data AS JsonDocument;
DECLARE $changed AS DateTime;
UPSERT INTO authorized_user_data (
game_id,
user_id,
data,
changed
)
VALUES
(
$game_id,
$user_id,
$data,
$changed
);
Пример запроса на чтение данных:
func (s *UserDataService) GetData(
ctx context.Context,
gameID int,
userID uint64,
) ([]byte, error) {
var (
userData []byte
errYDB error
)
fn := func(ctx context.Context, session table.Session) error {
res, errYDB := s.ydbDriver.ExecuteWithTxControl(
ctx,
session,
s.ydbDriver.NewReadTxControl(),
ydbdriver.GetResource("userDataService/getUserData"), // запросы храним локально, загружаем их в память при необходимости
table.NewQueryParameters(
table.ValueParam("game_id", types.Uint64Value(uint64(gameID))),
table.ValueParam("$user_id", types.Uint64Value(userID)),
),
s.ydbDriver.Timeouts.Read,
)
if errYDB != nil {
return nil, errYDB
}
defer res.Close()
userData, errYDB = s.getUserDataFromYDBResult(ctx, res) // парсит ответ
}
_ = s.ydbDriver.Do(ctx, fn, table.WithIdempotent())
return userData, errYDB
}
В записи всё аналогично, только используется пишущая транзакция.
По завершении первого этапа получили решение, при котором нам приходилось делать фолбек на PostgreSQL, что привело к незначительной деградации скорости ответов, но это решение прожило в продакшене не больше недели и не привело к заметному снижению скорости работы сервиса.
График времени выполнения запроса: чтение данных прогресса пользователя на 80-м процентиле
Этап 2. Полная миграция данных в YDB
Мы выполняли полную миграцию данных самым простым, как нам казалось, способом, который удалось придумать. Сделали дамп существующей таблицы на виртуальную машину и запустили Python-скрипт, который в несколько потоков начал записывать данные в YDB, при этом не перезаписывая уже имеющиеся.
У коллег из YDB есть рекомендации, как правильно мигрировать большое количество данных, а также готовые инструменты, которые позволяют выполнять миграции. Так уж вышло, что большинство из рекомендаций мы проигнорировали:
загружали данные по одной строке в транзакции,
загружали не в порядке увеличения первичного ключа,
не использовали готовые инструменты миграции.
Но тем не менее всё отработало корректно. При этом база работала под нагрузкой. Таким образом мы перенесли порядка 11 миллионов строк (около 15 ГБ). Для пользователей сервиса переезд прошёл незаметно.
Этап 3. Использование только YDB
Отказ от чтения из Postgres. На этом этапе мы просто убрали фолбек. База YDB стала основным хранилищем для прогресса игроков.
Результаты: снижение времени выполнения запросов
Первым результатом было то, что решение работает. Даже без ошибок. Это было приятно. В результате удалось не только решить поставленную задачу с новым масштабируемым хранилищем, но и получить значительный прирост скорости выполнения запросов.
Так нам удалось на 35% уменьшить на 80-м процентиле время выполнения запроса на запись.
График времён выполнения запроса на запись данных прогресса пользователя в базу (до миграции PG, после миграции YDB)
И почти на 50% уменьшить на 80-м процентиле время выполнения запроса на чтение.
График времён выполнения запроса на чтение данных прогресса пользователя из базы (до миграции PG, после миграции YDB)
Время ответов на чтение и запись в течение одного месяца после запуска, которые мы считаем на стороне нашего сервиса:
• 25–30 мс на запись при 150 RPS,
• 20–25 мс на чтение при 20 RPS.
График времени выполнения запроса на чтение и на запись данных прогресса пользователя в течение первого месяца работы после запуска YDB
На данный момент в этой таблице уже более 45 шардов с общим весом больше 70 ГБ.
Время выполнения запроса на 80-ом процентиле:
на запись — 17 мс при 350 RPS,
на чтение — 16–18 мс при 90 RPS.
График времени выполнения запроса на чтение и на запись данных прогресса пользователя в течение текущего месяца работы (то есть спустя почти 2 года)
Конфигурация базы
Вдохновленные результатами, мы решили, что можно продолжать. Думаю, что здесь стоит немного рассказать про конфигурацию базы. Это примерно соответствует конфигурации medium-m64:
База запущена в Yandex Cloud.
Следующий шаг — перенос списка игр неавторизованного пользователя
Следующим шагом был перенос списка игр неавторизованного пользователя. На момент переезда часть возможностей, которые предоставляет сервис, были доступны только авторизованным пользователям. Для неавторизованных пользователей функциональность была сильно ограничена, но это был только вопрос времени. В ближайших планах было: уравнять авторизованных и неавторизованных пользователей в возможностях.
Начать подготовку решили с переноса списка игр неавторизованного пользователя в YDB. Мы ждали прироста количества данных в разы, и начать надо было с того, что мигрировать уже имеющиеся данные.
Неавторизованных пользователей у нас всегда очень много: зашёл, понравилась игра, начал играть — авторизоваться для этого необязательно.
На данный момент у нас на сервисе существуют полноценные неавторизованные пользователи, которые имеют практически полный набор возможностей, как у авторизованных (за небольшим исключением), и все их данные хранятся в YDB. Число неавторизованных пользователей сейчас у нас более 300 млн. Количество объясняется тем, что неавторизованный пользователь привязан к устройству-браузеру по cookie и при очистке или посещении сервиса с другого устройства теряются пользовательские данные.
Задача была следующая: хранить список игр пользователя и его идентификатор. Список игр — это отсортированный набор из целочисленных значений, количество которых не превышает 1000. Нужно уметь добавлять игру в список, удалять игру из списка, а также получать список в отсортированном по дате добавления порядке. То есть при каждой записи нужно прочитать, модифицировать и записать обратно.
Необходимость предварительного чтения потребовала внедрения ручных транзакций (в предыдущем случае мы использовали только транзакции с автокоммитом). Реализовано это было следующим образом: создается транзакция, затем в рамках транзакции выполняются все действия.
Добавили метод для старта транзакции:
func (d *YdbDriver) StartWriteTx(ctx context.Context, session table.Session) (table.Transaction, error) {
return session.BeginTransaction(ctx, table.TxSettings(
table.WithSerializableReadWrite(),
))
}
Добавили метод для коммита транзакции:
func FinishTx(ctx context.Context, tx table.Transaction, err error) error {
if err == nil {
_, err = tx.CommitTx(ctx)
} else {
_ = tx.Rollback(ctx)
}
return err
}
Создали таблицу:
CREATE TABLE unauthorized_user_games
(
id Utf8,
app_ids JsonDocument,
user_id Utf8,
PRIMARY KEY (id)
)
Настройки таблицы:
нардирование по размеру включено — 2048 МБ;
PK Utf8 (равномерное распределение по партициям за счёт того, что ключ генерируется случайным образом).
Это все нововведения, которые были сделаны относительно первой задачи — хранения прогресса пользователя.
Процедура миграции в данном случае прошла по такому же сценарию, как в первый раз, ровно с тем же набором нарушений рекомендаций.
Как вы помните, у нас были три вот таких этапа:
начать записывать данные в YDB с фоллбеком чтения на старую базу,
провести полную миграцию данных,
использовать базу данных YDB в качестве основной и перестать читать данные в старой базе.
Что в результате
В этот раз результаты были ещё лучше. Время выполнения запроса на чтение уменьшилось на 58%.
График времени выполнения запроса на чтения за играми неавторизованного пользователя (до, во время и после миграции на YDB)
Время выполнения запроса на запись уменьшилось на 52%.
График времени выполнения запроса на запись за играми неавторизованного пользователя (до и после миграции на YDB)
Результаты первого месяца работы на 80-ом процентиле:
на запись — 20 мс при 120 RPS,
на чтение —15 мс при 30 RPS.
График времени выполнения запроса на чтение и на запись списка игр неавторизованного пользователя в течение первого месяца работы после запуска YDB
На данный момент удалось добиться снижения времени ответа до 10 мс при пятикратном росте RPS.
Результаты миграции
Основная задача достигнута. Удалось разгрузить PostgreSQL, получили значительный профит по скорости выполнения запросов. При этом использовали «решение из коробки».
YDB и неавторизованные пользователи
За два года работы самым главным применением для YDB у нас стала функциональность неавторизованных пользователей. Это возможность пользователям без авторизации полноценно играть на сервисе и при этом иметь доступ ко всем возможностям авторизованного пользователя.
Что интересного произошло за это время:
1) Переехали на клиент v3
Это дало заметный профит по скорости. Сейчас это стандартный SDK для Gо.
2) Применяли техники по равномерному распределению
При разработке столкнулись с ситуацией, когда идентификатор неавторизованных пользователей — равномерно возрастающее значение. А это приводит к неравномерному распределению по шардам и негативно сказывается на скорости работы. Поэтому решили добавить дополнительную часть ключа хэш от id. Реализовали это с помощью стандартной библиотечной функции NumericHash. Это позволило просто решить проблему с распределением.
Пример SELECT-запроса:
DECLARE $user_id AS Uint64;
SELECT user_id, field1, field2
FROM unauthorized_users
WHERE user_id_hash = Digest::NumericHash($user_id) AND user_id = $user_id;
Использование TTL
Так как неавторизованных пользователей очень много, а банальная очистка браузера делает тебя «новым неавторизованным пользователем», встаёт вопрос удаления ненужных данных.
Мы придумали ряд эвристик, которые позволяют понять, что пользователь ушёл и больше не вернётся, а его данные будут автоматически удаляться через заданный промежуток. Для этого необходимо создать поле, которое будет служить счётчиком времени удаления, и указать, через какое время удалять данные. При этом, если записать в него значение NULL, то данные не удаляются никогда. Эта возможность не позволяет разбухать таблицам, которые хранят данные незарегистрированных пользователей.
ALTER TABLE `mytable` SET (TTL = Interval("PT30D") ON ttl_date);
Текущие показатели
Немного общей статистики: на данный момент средний пиковый RPS в YDB составляет порядка 5000.Ниже график количества запросов и график времени выполнения практически за весь период использования YDB.
График количества запросов YDB (за весь период использования)
График времени выполнения запросов YDB (за весь период использования)
По ним можно заменить, что при более чем пятикратном росте RPS среднее время выполнения запросов остаётся неизменным на уровне 14–16 мс. Все замеры производились на стороне сервиса и представлены на 80-м процентиле.
С какими проблемами столкнулись
Немного расскажу про неожиданные проблемы, с которыми мы столкнулись.
Overloaded datashards
YDB предоставляет удобный UI для просмотра таблиц.
Мы его иногда использовали для просмотра данных «глазами», когда необходимо убедиться в том, что новая функциональность работает. Каким же было наше удивление, когда прощёлкивание по страницам привело к резкому росту потребления ресурсов базы и таймауту продовых запросов. То есть мы фактически перегрузили шарды, и они в течение непродолжительного времени (около минуты) не могли отвечать на запросы. Это проявилось только на больших таблицах, в которых было много шардов. С тех пор мы решили так больше не делать и ограничить доступ для просмотра таблиц через интерфейс.
Аналогичный эффект получили, когда попытались сделать сканирующий запрос без индексов по большой таблице. Чтобы правильно выполнить сканирующий запрос, необходимо добавить префикс.
PRAGMA Kikimr.ScanQuery = "true"; <--------
SELECT * FROM table
WHERE value = 123
YDB и сканирующие запросы
В нашей команде прижилось мнение, что YDB хорошо работает при запросах по ключам. То есть если нужно в рантайме сделать сложный сканирующий запрос, значит мы что-то делаем не так. С таким мнением мы живём и стараемся её использовать больше в REDIS-стиле. То есть применять ключ-значение, поскольку именно на таких запросах YDB показывала максимальную эффективность. Тем не менее уже начали сами применять YDB для сканирующих запросов в рантайме, и она справляется на отлично.
Нельзя менять объект дважды в одной транзакции
Появилась задача: нужно из одной таблицы часть записей удалить, часть изменить, часть добавить и всё это сделать в одной транзакции.
Столкнулись с проблемой, что этого сделать нельзя. Победили это, просто перестроив флоу выполнения запроса, но после использования PostgreSQL этот факт стал неожиданностью.
Подведём итоги
Сервис Игр является активным пользователем YDB уже более 2 лет. Основное назначение — это работа с данными, размер которых быстро растёт и которые требуют высокой скорости чтения и записи. Это всевозможные данные о времени игры, лайках и всего, что помогает нам лучше узнавать пользователя и рекомендовать ему наиболее подходящие игры.
На протяжении всего срока эксплуатации YDB она показывает себя как максимально стабильное и эффективное средство для решения высоконагруженных задач. Время выполнения запросов и утилизация ресурсов остаются на одном уровне, несмотря на многократный рост количества данных и обращений. Остаётся большой резерв ресурсов базы YDB для дальнейшего роста сервиса.