В качестве примера мастер-мастер кластера Tarantool я предлагаю сделать небольшую текстовую мультиплеер-игру, где каждый участник стремится набрать большее число очков.
Каждый игрок будет некоторым узлом, который меняет данные в игровом мире. Эти данные реплицируются между узлами. Таким образом, репликация Tarantool будет являться своего рода транспортом для игрового процесса.
Но что будет, если два игрока одновременно создадут или поменяют какой-то объект в мире и создадут соответствующие транзакции? Если этого не предусмотреть, то, или данные на разных узлах разъедутся, и у каждого игрока сложится своя картина мира, или репликация сломается, и, как следствие, игровой процесс остановится. Есть разные способы решения таких конфликтов. Я выбрал схему данных и распределил операции над данными по узлам так, чтобы конфликтов в кластере не возникало. Чуть позже я объясню это подробнее.
Игра будет с ascii
-графикой, и такое отображение
репликации позволяет сразу видеть картину происходящего на каждом
инстансе, не требуя дополнительных запросов к данным.
Кроме этого, перезапуская узлы, можно будет визуально проследить процесс запуска базы, загрузки данных, подключения репликации.
Геймплей
Игра чем-то похожа на bomberman
. Игровое поле
80x40
. Каждый игрок управляет своим персонажем. Игроки
должны собирать фрукты, которые добавляют жизней. Порции жизней
можно потратить на создание бомб. Бомбы взрываются и небольшой
волной забирают жизни тех, кто оказался рядом.
Как запустить
-
Установить Tarantool 2-ой версии по инструкции https://bit.ly/2IL3JSc
-
Взять исходники игры:
$ git clone https://github.com/filonenko-mikhail/mmgame.git$ cd mmgame
- Запустить координатора геймплея:
- В аргументе адрес, на котором запустится координатор.
$ reset && clear$ tarantool ./foodmaker.lua 127.0.0.1:3301
- В аргументе адрес, на котором запустится координатор.
- Запустить первого игрока в отдельном терминале:
- Первый аргумент адрес координатора;
- Второй адрес, на котором запустить игрока;
- Третий рабочая директория.
$ reset && clear$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3302 ./player1data
- Стрелками можно управлять черной буквой на сером фоне и
собирать символы на синем фоне. Первая строку вверху экрана
отображает:
- Анимацию, что репликация с координатором работает;
- Персонажа игрока и его жизни.
- Игрок 2 в другом отдельном терминале:
$ reset$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3303 ./player2data
- Пробелом устанавливаем бомбы красный символ на черном фоне.
Troubleshooting
Если что-то случилось во время запуска, я подготовил небольшой
список ситуаций и решений.
Эти рекомендации применимы только к этой игре, в случае проблемных
ситуаций на проде, я, конечно же, рекомендую более детальное
исследование ситуации.
Консоль сломалась так, что ничего не видно
reset
<Enter> не глядя
ER_REPLICASET_UUID_MISMATCH: Replica set UUID mismatch: expected 4f8d5028-3f4e-4f8f-a237-bb3db620813f, got 03982784-c023-4661-afe1-96752d90df86
- Кластер создавался непоследовательно, и в итоге скорее всего кластеров получилось несколько, и реплика не может найти себе место
- Удалить снапы и логи и перезапустить
ER_UNKNOWN_REPLICA: Replica 904c70b2-be5a-4e5f-afd0-daa0be66f729 is not registered with replica set d4f37bc6-3a71-43a2-8ca5-65e2bcc0bfda
- Кластер пересоздавался, а реплика пытается присоединиться со старыми настройками
- Удалить снапы и логи и перезапустить
Если у вас ошибка не такая как из списка, или вы делаете что-то ещё и возникают вопросы, то у нас есть русскоязычный чат в телеграме https://bit.ly/37l0awn
Как это выглядит
Игровой спейс
Все объекты игры будут содержаться в одном спейсе (таблице), который будет реплицироваться между узлами.
Вот как она будет выглядеть:
ID | Icon | X | Y | Type | Health |
---|---|---|---|---|---|
uuid | symbol | int | int | string | int |
Первичным ключом будет является поле ID
. Для
каждого объекта в том числе персонажей это поле будет
уникальным.
Icon
содержит текстовый спрайт объекта.
X
, Y
содержит текущие координаты
объекта.
Type
тип объекта:
- игрок;
- фрукты;
- бомба;
- огонь после бомбы;
- поезд;
- бесконечный прогрессбар.
Health
жизни объекта:
- для игрока это жизни;
- для других объектов это энергетическая ценность.
Все действия над объектами будут производится с помощью обновления соответствующих полей.
Индексация
Индексов на спейсе будет несколько:
- первичный ключ, конечно же:
{ID}
; - позиция объекта для вычисления столкновений:
{x, y, type}
; - тип объекта для быстрого подсчета и итерации по разным
объектам:
{type}
; - жизни для наблюдения статистики:
{health}
.
Бесконфликтность транзакций и консистентность данных
Конфликт транзакций возникает в случае, когда два узла тарантула вставляют новые данные по одному и тому же уникальному ключу. В этому случае репликация останавливается.
Для таких случаев тарантул позволяет написать некоторую логику, которая в случае конфликтов будет выбирать из двух транзакций одну, а другую отбрасывать.
Но в рамках моей задачи, мне показалось это избыточным, и я
распределил создание объектов так, что любой узел создавая игровые
объекты генерировал для них уникальный для всего кластера ключ. Я
воспользовался генерацией uuid
.
Теперь представим, что свойство одного игрового объекта меняется на двух узлах одновременно. Первый узел назначит свойство в значение X, второй узел в значение Y. Во время репликации первый узел получит транзакцию со значением Y и применит её у себя, а второй узел со значением X, и тоже применит её у себя. В результате данные разъедутся. Чтобы такого не происходило, я воспользовался аддитивными операциями. В этом случае, в какой бы последовательности не применялись транзакции, результат окажется одинаковым.
Например:
- Некорретный вариант, присвоить десять жизней игроку.
- Корректный, добавить некоторое число к жизням чтобы получилось десять.
Или, другими словами, операция присваивания значения неаддитивна, а операции сложения и вычитания аддитивны.
Топология
В топологии игры будет один узел-координатор, ответственный за
геймплей, и некоторое количество узлов игроков. Максимальное
количество активных реплик может достигать
32
, это ограничение репликации Tarantool. Узлы,
которые работают только на чтение, называются анонимными
репликами, и их может быть сколько угодно.
Репликасет в Tarantool это группа серверов, которые реплицируют
данные между собой. У каждого узла есть свой уникальный
идентификатор instance uuid
. И одновременно с этим у
узлов репликасета есть одинаковое для всех поле replicaset
uuid
.
Чтобы все эти идентификаторы узлов правильно сошлись, создавать репликасет лучше последовательно.
Я предлагаю сначала запустить координатор, который выполнит все первоначальные настройки, и затем к нему подключать игроков. Их можно будет подключать как одновременно, так и последовательно.
Вот как это будет выглядеть:
- Запускается
foodmaker
(координатор) и создаетcluster uuid
.
- Далее к нему подключается игрок, который настраивает свою репликацию. Стрелкой показан поток данных репликации.
- После успешного подключения игрок настраивает репликацию в обратную сторону.
- После подключения нескольких игроков получится топология звезда.
Топология full-mesh также возможна, но потребует дополнительных действий. Если вы хотите её построить, то можете на координаторе мониторить топологию и рассылать всем игрокам изменения, и игроки будут у себя настраивать репликацию на других игроков.
Программирование на Tarantool
Tarantool, с одной стороны, это база данных с возможностью репликации, а с другой полноценный сервер приложений.
Для создания приложений в Tarantool используется язык
Lua
с JIT
-компиляцией.
Сама база данных также конфигурируется с помощью
Lua
.
Таким образом вы можете управлять базой данных изнутри
с помощью Lua
-скриптов. И если вам понравилась такая
идея, то в реальных проектах я рекомендую пользоваться готовым
решением для оркестрации кластера Tarantool
Cartridge.
Конфигурирование координатора
Основное конфигурирование базы данных происходит с помощью
функции box.cfg
. На координаторе функция должна будет
сделать первоначальную настройку репликасета.
Для настройки репликации используются параметры:
replication
replication_connect_quorum
replication_connect_timeout
В случае координатора я точно знаю, что кворум не нужен, так как это самые первый инстанс, который логически не нуждается в остальных. Соответственно, параметры примут значения:
box.cfg{ listen=server, replication_connect_quorum=0, replication_connect_timeout=0.1, work_dir=wrkdir, log="file:foodmaker.log",}
Конфигурирование игрока
Конфигурирование игрока заключается в том, чтобы прежде всего подключиться к координатору с репликацией, а затем настроить обратную репликацию с игрока на координатор.
box.cfg{ listen=localserver, replication={ remoteserver }, replication_connect_timeout=60, replication_connect_quorum=1, work_dir=wrkdir, log="file:player.log"}
Подключение репликации от координатора к игроку
Координатор, с одной стороны, знает о том, кто к нему подключен по репликации, но, с другой стороны, не знает, как ему самому подключиться к новому игроку.
Я решил это следующим образом:
-
Координатор создает функцию
add_player
. -
Игрок удаленно вызывает эту функцию на координаторе со своим адресом.
-
В случае перезагрузки координатора игрок перенастраивает репликацию, когда тот вернется.
-
Функция на координаторе выглядит так:
function add_player(server)if box.session.peer() == nil then return falseendlocal server = uri.parse(server)local replica = uri.parse(box.session.peer())replica.service = server.servicereplica.login = conf.userreplica.password = conf.passwordreplica = uri.format(replica, {include_password=true})local replication = box.cfg.replication or {}local found = falsefor _, it in ipairs(replication) do if it == replica then found = true break endendif not found then table.insert(replication, replica) box.cfg({replication={}}) box.cfg({replication=replication})endreturn trueend
-
Игрок сохраняет соединение в глобальном неймспейсе, чтобы его не остановил сборщик мусора.
_G.conn = netbox.connect(remoteserver,{wait_connected=false, reconnect_after=2})conn:on_connect(function(client)fiber.new(function () local rc, res = pcall(client.call, client, 'add_player', {localserver}) if not rc then log.info(res) endend)end)
Схема данных
Схема данных задается на координаторе сразу после конфигурирования базы данных. Она может создаваться только на одном узле, остальные получают её по репликации.
В процессе разработки я постоянно перезапускал и дорабатывал
детали на уже инициализированной базе. Чтобы повторное применение
схемы данных не вызывало ошибки, я пользовался флагом
if_not_exists=true
. Он позволяет игнорировать
DDL-команды, когда спейсы, индексы и другие объекты уже
существуют.
Data Definition Language
Краткий обзор DDL
-операций, которые я
использую:
- Создание спейса.
box.schema.space.create(<name>, options)
- Формирование структуры спейса.
box.space.<name>:format({{name=<field_name>, type=<field_type>}, ...,})
- Создание индекса.
box.space.<name>:create_index(<index_name>,{ parts={{field=<field_name> type=<field_type>}, ..., }, unique=false|true,})
- Создания пользователя.
box.schema.user.create(<name>, {password=<pass>})
- Предоставление прав.
box.schema.user.grant(<name>, ....)
- Создание функции для удаленного вызова.
box.schema.func.create(<name>)
Ожидание схемы данных
Часть логики приложения может быть запущена до инициализации базы данных. В этом случае я использую цикл, ожидающий появления таблицы в БД.
while true do if type(box.cfg) ~= 'function' and box.space[conf.space_name] ~= nil and not box.info.ro then break end fiber.sleep(0.1)end
Триггеры в Tarantool
Триггеры в Tarantool являются частью сервера приложений и не сохраняются в базе данных.
Чтобы создать триггер, я:
- Создаю функцию на lua, которая обрабатывает логику.
- При запуске приложения устанавливаю функцию на нужные спейсы в качестве триггера.
Инициализация базы данных процесс из нескольких стадий, поэтому установка триггера, на первый взгляд, может показаться сложной. На второй взгляд скорее всего, тоже :)
Итак, чтобы установить триггер в спейс, предлагается такая схема:
- Установить триггер в инициализацию системной схемы базы
данных.
box.ctl.on_schema_init(<CALLBACK>)
- В триггере инициализации схемы установить триггер на реестр
спейсов.
box.ctl_on_schema_init(function()box.space._space:on_replace(<CALLBACK 2>)end)
- В триггере реестра спейсов обнаружить искомый пользовательский
спейс и создать триггер завершения транзакции.
box.ctl.on_schema_init(function()box.space._space:on_replace(function(old, space) if not old and sp and sp.name == <USER SPACE NAME> then box.on_commit(<CALLBACK 3>) endend)end)
- И вот, наконец, у меня в руках игла Кощея
^W^W
то место, где я устанавливаю пользовательский триггер в пользовательский спейс.
box.ctl.on_schema_init(function()box.space._space:on_replace(function(old, sp) if not old and sp and sp.name == <USER SPACE NAME> then box.on_commit(function() box.space[sp.name]:on_replace(<USER TRIGGER>) end) endend)end)
Логика
Часть логики запускается на координаторе, часть на узлах игроков.
Запуск логики происходит либо в отдельном файбере, либо из триггера.
Файберы легковесные потоки исполнения (сопрограмма, зеленый
тред, корутина, горутина). Они используют кооперативную
многозадачность. То есть, в один момент времени запущен только один
файбер. Когда он выполнил свою логику, то должен явно отдать
управление через fiber.sleep(N)
или
fiber.yield()
, либо вызвав некоторую
io
-операцию.
Вся логика выполняется с помощью изменения данных в спейсе.
Data Modification
Language
Вставка данных
-- вставкаbox.space.Name.insert({id, sprite, x, y, type, health})-- вставка или полная перезаписьbox.space.Name.put({id, sprite, x, y, type, health})
Обновление данных
box.space.Name.update({primary key}, {{operation, field, value}})
Удаление
box.space.Name.delete({primary key})
Игрок
Узел игрока при первом запуске создаёт своего персонажа.
ID
персонажа применяется из значения instance
uuid
.
Далее узел игрока слушает события клавиатуры и меняет позицию персонажа.
Чтобы события от клавиатуры приходили как есть, я использую
функции tcgetattr
и tcsetattr
через
LuaJIT FFI
.
Для создания транзакции с несколькими действиями я пользуюсь паттерном.
box.begin()local rc, res, err = pcall(function() ... box.space[conf.space_name]:put(bomb) box.space[conf.space_name]:update(player['id'], {{'-', conf.health_field, conf.bomb_energy}})end)if not rc then log.info(res) box.rollback()else box.commit()end
Для обработки событий клавиатуры запущен отдельный файбер.
Рендерер
Рендерер отображает любые графически значимые изменения в текстовую консоль. Он запускается из триггера как на узлах игроков, так и на координаторе. На узлах игроков рендерер также отображает информацию о жизнях.
Генератор продуктов
Генератор продуктов запускается на координаторе и раз в
N
секунд создает объект.
Генератор продуктов работает в отдельном файбере.
Анимация поезда
Чтобы быстро увидеть, идет ли репликация от координатора к игроку, необходим цикл, который создает анимацию поезда и бесконечного индикатора прогресса. Цикл запускается на координаторе в отдельном файбере.
Обработка столкновений
Обработка столкновений состоит из двух частей: детектора и обработчика.
Детектор запускается из триггера в случае, когда произошли значимые для этого изменения. Например, позиция игрока поменялась, сгенерировался новый фрукт и т.п.
Детектор через межфайберный канал отправляет обработчику информацию о столкнувшихся объектах.
Обработчик запускается на координаторе в отдельном файбере, в цикле читает сообщения из канала, и, в зависмости от столкновений объектов, меняет значения в полях с жизнями.
Жизненный цикл бомб
На координаторе запущен файбер, который раз в секунду прокручивает жизненный цикл бомбы.
- Отнимает от существования единицу.
- При достижении 0 удаляет бомбу и создаёт ударную волну.
Этот файбер таким же образом следит за ударной волной.
Ветер
Чтобы игровой процесс чуть больше мотивировал двигаться, на координаторе запущен файбер, который сдувает всех игроков в правый нижний угол.
Таблица игроков
Я предлагаю самый простой путь для создания таблицы игроков, а именно сделать анонимную реплику. Она будет подключена к координатору и станет в триггере отображать список игроков с сортировкой начиная с лидеров.
Для подключения анонимной реплики предназначен параметр
replication_anon
.
box.cfg{listen=localserver, replication={ remoteserver }, replication_connect_timeout=60, replication_connect_quorum=1, read_only=true, replication_anon=true, work_dir=wrkdir}
В заключение
Вот так с помощью нехитрых приспособлений буханку хлеба
можно превратить в троллейбус
Таким приложением я хочу:
- Подчеркнуть, как просто создать топологию мастер-мастер в Tarantool.
- Визуализировать процесс репликации.
- Показать, что происходит в моменты перезапуска реплик.
- Напомнить, что терминал содержит в себе много интересного.
Если вам хотелось бы рассмотреть более практичное приложение на Tarantool, то есть отличная статья от codesign про создание очереди.
За поддержку, фичреквесты и отладку кода я хочу поблагодарить несколько отделов
- Tarantool Presale
- Tarantool Solutions
- Облако mail.ru