- PVSM.RU - https://www.pvsm.ru -
В качестве примера мастер-мастер кластера Tarantool я предлагаю сделать небольшую текстовую мультиплеер-игру, где каждый участник стремится набрать большее число очков.
Каждый игрок будет некоторым узлом, который меняет данные в игровом мире. Эти данные реплицируются между узлами. Таким образом, репликация Tarantool будет являться своего рода транспортом для игрового процесса.
Но что будет, если два игрока одновременно создадут или поменяют какой-то объект в мире и создадут соответствующие транзакции? Если этого не предусмотреть, то, или данные на разных узлах «разъедутся», и у каждого игрока сложится своя картина мира, или репликация «сломается», и, как следствие, игровой процесс остановится. Есть разные способы решения таких конфликтов. Я выбрал схему данных и распределил операции над данными по узлам так, чтобы конфликтов в кластере не возникало. Чуть позже я объясню это подробнее.
Игра будет с ascii
-графикой, и такое отображение репликации позволяет сразу видеть картину происходящего на каждом инстансе, не требуя дополнительных запросов к данным.
Кроме этого, перезапуская узлы, можно будет визуально проследить процесс запуска базы, загрузки данных, подключения репликации.
Игра чем-то похожа на bomberman
. Игровое поле 80x40
. Каждый игрок управляет своим персонажем. Игроки должны собирать фрукты, которые добавляют жизней. Порции жизней можно потратить на создание бомб. Бомбы взрываются и небольшой волной забирают жизни тех, кто оказался рядом.
Установить Tarantool 2-ой версии по инструкции [1].
Взять исходники игры:
$ 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
$ 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
Если у вас ошибка не такая как из списка, или вы делаете что-то ещё и возникают вопросы, то у нас есть русскоязычный чат в телеграме [2].
Все объекты игры будут содержаться в одном спейсе (таблице), который будет реплицироваться между узлами.
Вот как она будет выглядеть:
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 используется язык Lua
с JIT
-компиляцией.
Сама база данных также конфигурируется с помощью Lua
.
Таким образом вы можете управлять базой данных изнутри с помощью
Lua
-скриптов. И если вам понравилась такая идея, то в реальных проектах я рекомендую пользоваться готовым решением для оркестрации кластера – Tarantool Cartridge [3].
Основное конфигурирование базы данных происходит с помощью функции 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-команды, когда спейсы, индексы и другие объекты уже существуют.
Краткий обзор 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 являются частью сервера приложений и не сохраняются в базе данных.
Чтобы создать триггер, я:
Инициализация базы данных — процесс из нескольких стадий, поэтому установка триггера, на первый взгляд, может показаться сложной. На второй взгляд — скорее всего, тоже :)
Итак, чтобы установить триггер в спейс, предлагается такая схема:
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
секунд создает объект.
Генератор продуктов работает в отдельном файбере.
Чтобы быстро увидеть, идет ли репликация от координатора к игроку, необходим цикл, который создает анимацию поезда и бесконечного индикатора прогресса. Цикл запускается на координаторе в отдельном файбере.
Обработка столкновений состоит из двух частей: «детектора» и «обработчика».
«Детектор» запускается из триггера в случае, когда произошли значимые для этого изменения. Например, позиция игрока поменялась, сгенерировался новый фрукт и т.п.
«Детектор» через межфайберный канал отправляет «обработчику» информацию о столкнувшихся объектах.
«Обработчик» запускается на координаторе в отдельном файбере, в цикле читает сообщения из канала, и, в зависмости от столкновений объектов, меняет значения в полях с жизнями.
На координаторе запущен файбер, который раз в секунду прокручивает жизненный цикл бомбы.
Этот файбер таким же образом следит за ударной волной.
Чтобы игровой процесс чуть больше мотивировал двигаться, на координаторе запущен файбер, который сдувает всех игроков в правый нижний угол.
Я предлагаю самый простой путь для создания таблицы игроков, а именно — сделать анонимную реплику. Она будет подключена к координатору и станет в триггере отображать список игроков с сортировкой начиная с лидеров.
Для подключения анонимной реплики предназначен параметр 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, то есть отличная статья [4] от codesign [5] про создание очереди.
Автор: michael-filonenko
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/vy-sokaya-proizvoditel-nost/358135
Ссылки в тексте:
[1] инструкции: https://www.tarantool.io/ru/download/?utm_source=habr&utm_medium=articles&utm_campaign=2020
[2] чат в телеграме: https://t.me/tarantoolru?utm_source=habr&utm_medium=articles&utm_campaign=2020
[3] Tarantool Cartridge: http://github.com/tarantool/cartridge
[4] статья: https://habr.com/ru/company/mailru/blog/510440/
[5] codesign: https://habr.com/ru/users/codesign/
[6] Источник: https://habr.com/ru/post/524476/?utm_source=habrahabr&utm_medium=rss&utm_campaign=524476
Нажмите здесь для печати.