Русский
Русский
English
Статистика
Реклама

Master-master replication

Тестируем играючи мастер-мастер репликация в Tarantool

21.10.2020 16:06:58 | Автор: admin


В качестве примера мастер-мастер кластера Tarantool я предлагаю сделать небольшую текстовую мультиплеер-игру, где каждый участник стремится набрать большее число очков.


Каждый игрок будет некоторым узлом, который меняет данные в игровом мире. Эти данные реплицируются между узлами. Таким образом, репликация Tarantool будет являться своего рода транспортом для игрового процесса.


Но что будет, если два игрока одновременно создадут или поменяют какой-то объект в мире и создадут соответствующие транзакции? Если этого не предусмотреть, то, или данные на разных узлах разъедутся, и у каждого игрока сложится своя картина мира, или репликация сломается, и, как следствие, игровой процесс остановится. Есть разные способы решения таких конфликтов. Я выбрал схему данных и распределил операции над данными по узлам так, чтобы конфликтов в кластере не возникало. Чуть позже я объясню это подробнее.


Игра будет с ascii-графикой, и такое отображение репликации позволяет сразу видеть картину происходящего на каждом инстансе, не требуя дополнительных запросов к данным.


Кроме этого, перезапуская узлы, можно будет визуально проследить процесс запуска базы, загрузки данных, подключения репликации.




Геймплей


Игра чем-то похожа на bomberman. Игровое поле 80x40. Каждый игрок управляет своим персонажем. Игроки должны собирать фрукты, которые добавляют жизней. Порции жизней можно потратить на создание бомб. Бомбы взрываются и небольшой волной забирают жизни тех, кто оказался рядом.




Как запустить


  1. Установить Tarantool 2-ой версии по инструкции https://bit.ly/2IL3JSc


  2. Взять исходники игры:



$ git clone https://github.com/filonenko-mikhail/mmgame.git$ cd mmgame

  1. Запустить координатора геймплея:
    • В аргументе адрес, на котором запустится координатор.
      $ reset && clear$ tarantool ./foodmaker.lua 127.0.0.1:3301
      
  2. Запустить первого игрока в отдельном терминале:
    • Первый аргумент адрес координатора;
    • Второй адрес, на котором запустить игрока;
    • Третий рабочая директория.
      $ reset && clear$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3302 ./player1data
      
  3. Стрелками можно управлять черной буквой на сером фоне и собирать символы на синем фоне. Первая строку вверху экрана отображает:
    • Анимацию, что репликация с координатором работает;
    • Персонажа игрока и его жизни.
  4. Игрок 2 в другом отдельном терминале:

$ reset$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3303 ./player2data

  1. Пробелом устанавливаем бомбы красный символ на черном фоне.

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
Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru