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


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


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


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


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




Геймплей


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




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


  1. Установить Tarantool 2-ой версии по инструкции.


  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
    • Кластер пересоздавался, а реплика пытается присоединиться со старыми настройками
    • Удалить снапы и логи и перезапустить

Если у вас ошибка не такая как из списка, или вы делаете что-то ещё и возникают вопросы, то у нас есть русскоязычный чат в телеграме.




Как это выглядит





Игровой «спейс»


Все объекты игры будут содержаться в одном спейсе (таблице), который будет реплицироваться между узлами.


Вот как она будет выглядеть:


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 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 про создание очереди.