Tarantool: когда на сервис оповещения миллиона пользователей нужно 500 строк кода
Tarantool — это не просто база данных. Tarantool — это app-сервер с базой данных на борту, поэтому для реализации кое-каких вещей, на которые люди тратят большое количество времени, с Tarantool нужно очень немного ресурсов.
На написание данной статьи меня натолкнула эта статья.
Очень много людей в IT-мире занимается одним и тем же. Расскажу о своем опыте решения этих же проблем.
Несколько лет назад мы копали ровно ту же задачу: пользователь хочет в веб-приложении в реальном времени (с задержками, определяемыми только сетью) получать уведомления о тех или иных событиях.
Рассмотрим требования в указанной статье:
- на странице «слушаем» в среднем 10 событий;
- миллион пользователей;
- требуется персистентность (терять события мы не хотим).
Поскольку данных недостаточно, сделаем дополнительно несколько допущений/прикидок. Если это, например, некий чат, где весь миллион пользователей что-то постоянно пишет, то предположим, что один пользователь создает одно сообщение за 10 секунд. Тогда получим, что миллион пользователей генерирует 100 тыс. сообщений в секунду.
И тут появляется первый важный аспект: что передаем?
События или данные?
Это традиционная дилемма всех очередей, агрегаторов, серверов событий: что является единицей хранения? Поэтому введем понятия:
- Событие — это по возможности минимальная информационная структура, хранящая в себе информацию о факте события.
С событием могут быть связаны данные:
- Данные — это полный набор данных, в том числе слабо связанных с событием.
Например: пользователь 345
оформил заказ на перевозку груза X
из точки А
в точку Б
, при оформлении задействовал банковскую карту Z
и т. п.
Информацию о том, кто (источник события) оформил заказ (факт события), будем называть событием, а информацию, откуда транспортируется груз, какой груз, прочие сведения о пользователе, заказе и грузе, — данными. То есть событие — это структура, описывающая в себе факт события, а данные — это все остальное.
Линия разделения «событие — данные» условная, но все же.
Пример события:
{
"type": "order",
"user": 345,
"status": "created",
"orderid": 12345
}
Пользователь 345
создал заказ 12345
. Заказ на момент отправки события имел статус created
(это уже избыточные данные).
Теперь сформирую некое эмпирическое правило правильного архитектурного выбора:
При разработке очередей, агрегаторов, серверов событий серверы должны манипулировать именно событиями, а не данными.
Однако еще раз повторюсь, линия разделения условная: событие из примера содержит часть данных (поле status
).
Итак, вернемся к задаче: мы строим именно сервер событий, поэтому можем прикинуть трафик. Например, среднее событие будет представлять собой JSON-хеш от 4 до 10 элементов — текст размером 60—160 байт. То есть поток событий, обеспечивающий работу миллиона пользователей (100 тыс. событий в секунду), по средним прикидкам, составит от 6 до 16 мегабайт в секунду. Для того чтобы прокачать этот трафик через один узел сети, достаточно сети с пропускной способностью — 200 мегабит в секунду.
Теперь прикинем, сколько ресурсов надо на то, чтобы доставить эти события миллиону пользователей. У каждого, конечно, своя архитектура, но можно говорить о некоторых общих принципах. Скорее всего, если одно сообщение надо доставить миллиону пользователей, то это ошибка в архитектуре (хотя и такое бывает). Нам же надо задаться какой-то средней величиной. Будем считать, что одно событие доставляется в среднем десяти пользователям: в чате у вас в друзьях редко будет более 10 друзей онлайн, если говорить об исполнении заказов — редко будет более 10 исполнителей и т. п.
Таким образом, чтобы доставить события в нашей задаче до пользователей, нужно где-то 2—3 гигабит трафика. Поэтому имеем второй ключевой аспект: данную задачу можно решить, используя всего один современный сервер с сетевой картой на 10 гигабит и RAM ~10 гигабайт (если выбрать интервал кеширования 10 минут). Убедившись, что данную систему можно строить на одном сервере, попытаемся построить реальную систему масштабируемо и на нескольких.
Сохранение данных
Один из самых быстрых способов хранения закешированных данных на диске — WAL-лог: данные поступают в кеш RAM и дописываются в WAL-лог. Поскольку данные в WAL-лог только пишутся, пишутся в режиме append
, то таким способом можно утилизировать практически 100 % пропускной способности записи диска. Опущу тут рассмотрение недостатков WAL, упомяну лишь то, что WAL-логи очень хорошо приспособлены к репликации.
В БД Tarantool реализован WAL-лог, он не только позволяет реплицировать данные на другой хост, но и предоставляет двунаправленную асинхронную мастер-мастер репликацию.
Бенчмарки Тарантула на средненьком ноутбуке (2012 года выпуска) на размере сообщения 220 байт показывают производительность 160—180 тыс. записей в секунду, что в полтора-два раза больше, чем нам требуется для данной задачи.
Доставка данных клиенту
Способов доставки данных может быть множество (мы поговорим подробнее о них позже). Сейчас мы рассмотрим пока не транспортную, а алгоритмическую часть способа доставки.
Для того чтобы доставка работала в условиях реального мира, выдвигаем к ней следующие требования:
- устойчивость к разрывам связи (разрыв и последующий реконнект не должен приводить к потере сообщений);
- простота клиента (хотелось бы, чтобы большую часть логики работы реализовывал сервер, а не клиент).
Опираясь на эти требования, методом проб и ошибок мы пришли к следующей схеме клиента:
- клиент в процессе работы «помнит» (персистентность тут не нужна) номер последнего принятого сообщения;
- этот номер используется при восстановлении подключения к серверу событий.
Соответственно, под подобный алгоритм работы клиента подходит в качестве транспорта как обычный long-polling (в каждом запросе передается номер последнего принятого сообщения), так и websocket (номер последнего принятого сообщения передается только при реконнектах).
Схема данных/событий
Методом проб и ошибок мы пришли к тому, что все события мы характеризуем уникальным ключом события. Уникальный ключ события представляет собой в общем виде строковый идентификатор события. Поскольку зачастую этот строковый идентификатор формируется из нескольких разных идентификаторов, то мы пришли к тому, что идентификатором события является некий массив строковых идентификаторов.
Например: пользователь 123
пишет сообщение в чат 345
, упоминая пользователя 567
. Генерируется событие с ключом [ 'chat', 345 ]
, которое доставляется всем онлайн-пользователям, находящимся в чате 345
, и еще одно событие ['user', 567]
, которое получает пользователь 567
.
В развернутом виде эти события могут выглядеть, например, так:
{
"key": [ "chat", 345 ],
"data": {
"type": "new_message",
"msgid": 9876
}
}
и
{
"key": [ "user", 567 ],
"data": {
"type": "notice",
"chatid": 345,
"msgid": 9876
}
}
Мы подошли к схеме формирования ключей сообщений.
Не имеет большого смысла (и даже иногда вредно) иметь множество ключей сообщений под примерно одинаковые вещи.
Имеет смысл выделять новый ключ только для качественно иной сущности.
Пример 1: имеет смысл использовать один ключ для задач:
- послать всем пользователям чата уведомление о новом сообщении;
- послать всем пользователям чата уведомление о новом присоединившемся пользователе.
Пример 2: имеет смысл использовать разные ключи для задач:
- послать всем пользователям чата уведомление о новом сообщении;
- послать пользователю X чата уведомление о том, что его упомянули.
То есть ключ сообщения должен примерно определять круг получателей. Воспринимайте ключ примерно как адрес на конверте.
Реализация
Определившись со схемой данных и событий, подходим к реализации проекта в железе.
Сообщения будем хранить в плоской таблице вида:
- номер сообщения (постоянно возрастающая последовательность);
- время сохранения события;
- ключ сообщения;
- данные сообщения.
Для этой таблицы нам понадобится два индекса:
- индекс по номеру сообщения (Primary Key). Этот индекс будет использоваться в алгоритмах чистки БД от старых сообщений;
- индекс для выборки сообщений по ключу (составной: ключ:номер)
Схему получившейся у меня БД можно посмотреть здесь.
БД Tarantool помогает нам легко писать pub/sub-приложения при помощи встроенной библиотеки fiber
.
Каждый клиентский запрос обрабатывается в отдельном fiber
(легковесный аналог процесса). При помощи этой парадигмы легко обслуживать десятки тысяч соединений одним процессором, причем:
- код не разбит на «лапшу» из callback’ов (как Node.js);
- легко решается проблема 10К.
Алгоритм подписки (subscribe) на один ключ примерно следующий:
- Смотрим, есть ли данные по ключу (новые события), если есть — сразу их возвращаем.
- Записываем текущий
fiber
в списокfiber
’ов, подписанных на данный ключ. - Засыпаем на некоторый таймаут (для мобильных сетей, чтобы они не рвали websocket’ы, экспериментально установлено хорошее значение — 25 секунд).
- Отвечаем пользователю (в том числе и пустым ответом, об этом ниже).
Алгоритм записи (push) примерно следующий:
- Записываем новое событие в БД.
- Если есть подписанные на записанные ключи клиенты — будим их
fiber
.
Весь серверный код уместился менее чем в 500 строк кода на LUA, при этом код включает в себя еще и масштабирование системы на несколько CPU/серверов.
В данный момент эта система, функционируя на трех Тарантулах (расположенных на одном виртуальном (OpenVZ нода) сервере), утилизирует на 10 % два выделенных ей ядра CPU и обслуживает где-то 50 тыс. пользователей.
По расчетам, на этом одном «железном» сервере можно спокойно крутить где-то 500 тыс. пользователей. Возможно, потребуется выделить еще ядро-два CPU.
Проблемы с числом сокетов на хост, описанные в упомянутой выше статье, решаются идентично.
Чистка старых сообщений
На (каждом) мастер-инстансе работает демон (fiber
) очистки, удаляющий устаревшие сообщения. Алгоритм демона примитивен:
- Выбрать самое старое (минимальный номер) сообщение.
- Посмотреть время его создания.
- Если время жизни не исчерпалось — подождать необходимый интервал.
- Удалить старое сообщение.
- Перейти к п. 1.
Масштабирование
Начали мы делать эту систему еще во времена Tarantool 1.5, который еще не умел делать двунаправленную асинхронную репликацию. Поэтому архитектурно система представляет собой:
- мастер-сервер (в него можно делать push сообщений);
- реплики (к ним могут коннектиться клиенты).
Мастер и реплики — полностью идентичные инстансы, просто push делаем (пока) строго в один сервер.
То есть в данный момент масштабирование производится добавлением реплик, а максимальная производительность ограничена производительностью одного мастера (для серверного современного юнита это где-то 400—500 тыс. сообщений в секунду).
Развитие
Поскольку на Tarantool 1.6 появилась двунаправленная мастер-мастер репликация, то возникла возможность масштабироваться и в сторону ее использования. План примерно такой (пока не реализовано):
- преобразуем номер сообщения в массив: номер мастер-сервера, номер сообщения;
- клиент между реконнектами «помнит» не одно значение, а этот массив значений;
- PROFIT!
В остальном алгоритм не меняется. Таким образом можно отмасштабироваться без серьезного изменения архитектуры до 10—30 мастер-серверов (то есть 4—20 млн исходящих сообщений в секунду).
Недостатки (куда без них)
- LUA (главный недостаток). Язык простой, но ограничения (1 гигабайт RAM на инстанс) заставляют масштабироваться несколько раньше, чем мы могли бы достигнуть пределов роста в рамках одной «железки».
- К сожалению, транспортную http-часть мы пока не выложили в открытый доступ (причина — зависимости от сугубо внутренних модулей вроде чтения конфигов и т. п.).
Она включает в себя простой асинхронный http-сервер (для случая long polling), ну, или асинхронный сервер посложнее (для случая websocket + lp). На Perl + AnyEvent данный сервер-прослойка займет где-то 200 строк кода.
Клиентская авторизация
Мы не используем клиентскую авторизацию в подсистеме сервера событий (клиент в данном контексте — пользователь сайта), поскольку не видим необходимости.
Но в принципе, добавив к каждому сообщению пару «ключ — значение», информирующую, «кому можно увидеть эти данные», и сравнив ее, например, с информацией из кук запроса, эту авторизацию несложно сделать.
Поскольку мы реализуем сервер, манипулирующий событиями, а не данными, пока нам не требовалось возиться с авторизацией.
О транспорте
Мы стали работать в этом направлении еще во времена, когда только начинались разговоры о веб-сокетах и появлялись draft’ы стандартов. Поэтому очень долго основным транспортом был (и во многом остается) обычный http long polling. Эксперименты с веб-сокетами в условиях мобильных сетей показали, что:
- Веб-сокет требует прокачки через себя события (пусть и пустого) один-два раза в минуту, иначе мобильные сети принудительно закрывают соединение.
- Вследствие этого нивелируется разница между веб-сокетом и long polling + keep alive.
- Есть еще довольно много мобильных устройств, браузеры которых не дружат с веб-сокетами.
Поэтому с точки зрения интерактивных сайтов, работающих на мобильных сетях, говорить о применении веб-сокетов уже можно (можно охватить около 70 % устройств), но пока все-таки рановато (30 % неохваченных — это много).
Применения
Если в составе веб-проекта имеются очередь и сервер событий, то многие вещи, которые вызывают сложности в других архитектурах, делаются просто.
Вариант с чатом, описанный выше, очевиден. Еще очень красивое применение сервера событий — работа с длительными/затратными алгоритмами (генерация отчетов, сбор статистики и даже кодирование видео).
Например, мы хотим перекодировать видео пользователей. Понятно, что процесс этот длительный, выполнять его в обработчике запросов HTTP нельзя. Пользователь, загрузив видео, хочет понимать, что происходит. Ставим задачу конвертации видео в очередь, пользовательский JS запоминает номер задачи и начинает «ловить» события, связанные с ней. Процесс конвертации отправляет события по мере выполнения задачи. Браузер на основании событий может показывать аккуратный и, главное, актуальный прогресс-бар.