В качестве примера мастер-мастер кластера 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
.
digraph first {
foodmaker;
}
- Далее к нему подключается игрок, который настраивает свою репликацию. Стрелкой показан поток данных репликации.
digraph first {
foodmaker -> player1
}
- После успешного подключения игрок настраивает репликацию в обратную сторону.
digraph first {
foodmaker -> player1
foodmaker -> player1[dir=back]
}
- После подключения нескольких игроков получится топология «звезда».
digraph star {
layout=neato;
foodmaker -> player1
foodmaker -> player1[dir=back]
foodmaker -> player2
foodmaker -> player2[dir=back]
foodmaker -> player3
foodmaker -> player3[dir=back]
foodmaker -> player4
foodmaker -> player4[dir=back]
}
Топология 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 false end local server = uri.parse(server) local replica = uri.parse(box.session.peer()) replica.service = server.service replica.login = conf.user replica.password = conf.password replica = uri.format(replica, {include_password=true}) local replication = box.cfg.replication or {} local found = false for _, it in ipairs(replication) do if it == replica then found = true break end end if not found then table.insert(replication, replica) box.cfg({replication={}}) box.cfg({replication=replication}) end return true end
Игрок сохраняет соединение в глобальном неймспейсе, чтобы его не остановил сборщик мусора.
_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) end end) 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>) end end) 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) end end) 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 про создание очереди https://habr.com/ru/company/mailru/blog/510440/.
За поддержку, фичреквесты и отладку кода я хочу поблагодарить несколько отделов
- Tarantool Presale
- Tarantool Solutions
- Облако mail.ru