Рейтинг@Mail.ru
Документация Tarantool 2.6.0
Документация Tarantool 2.6.0

Документация Tarantool 2.6.0

Замечание

Документация находится в процессе перевода и может отставать от английской версии.

Tarantool - Documentation

Предисловие

Добро пожаловать в мир Tarantool! Сейчас вы читаете «Руководство пользователя». Мы советуем начинать именно с него, а затем переходить к «Справочникам», если вам понадобятся более подробные сведения.

Как пользоваться документацией

Для начала можно установить и запустить Tarantool, используя Docker-контейнер, менеджер пакетов или онлайн-сервер Tarantool’а http://try.tarantool.org. В любом случае для пробы можно сделать вводные упражнения из главы 2 «Руководство для начинающих». Если хотите получить практический опыт, переходите к Практическим заданиям после работы с главой 2.

В главе 3 «Функциональность СУБД» рассказано о возможностях Tarantool’а как NoSQL СУБД, а в главе 4 «Сервер приложений» – о возможностях Tarantool’а как сервера приложений Lua.

Глава 5 «Администрирование серверной части» и Глава 6 «Репликация» предназначены в первую очередь для системных администраторов.

Глава 7 «Коннекторы» актуальна только для тех пользователей, которые хотят устанавливать соединение с Tarantool’ом с помощью программ на других языках программирования (например C, Perl или Python) – для прочих пользователей эта глава неактуальна.

Глава 8 «Вопросы и ответы» содержит ответы на некоторые часто задаваемые вопросы о Tarantool’е.

Опытным же пользователям будут полезны «Справочники», «Руководство участника проекта» и комментарии в исходном коде.

Как связаться с сообществом разработчиков Tarantool’а

Оставить сообщение о найденных дефектах или сделать запрос на новые функции можно тут: http://github.com/tarantool/tarantool/issues

Пообщаться напрямую с командой разработки Tarantool’а можно в telegram или на форумах (англоязычном или русскоязычном).

Условные обозначения, используемые в руководстве

В квадратные скобки [ и ] включается синтаксис необязательных элементов.

Две точки подряд .. означают, что предыдущие токены могут повторяться.

Вертикальная черта | означает, что предыдущий и последующий токены представляют собой взаимоисключающие альтернативы.

Руководство для начинающих

В этой главе объясняются основы работы с Tarantool как с СУБД, а также приводятся способы подключения к базе на Tarantool из других языков программирования.

Создаем свою первую базу данных на Tarantool

Первым делом давайте установим Tarantool, запустим его и создадим простую базу данных.

Вы можете установить Tarantool и работать с ним либо локально, либо в Docker – как вам удобнее.

Использование Docker-образа

Для практики и тестирования мы рекомендуем использовать официальные образы Tarantool для Docker. Официальный образ содержит определенную версию Tarantool и все популярные внешние модули для Tarantool. Все необходимое уже установлено и настроено на платформе Linux. Данные образы - это самый простой способ установить и запустить Tarantool.

Примечание

Если вы никогда раньше не работали с Docker, рекомендуем сперва прочитать эту обучающую статью.

Запуск контейнера

Если Docker не установлен на вашей машине, следуйте официальным инструкциям по установке для вашей ОС.

Чтобы использовать полнофункциональный экземпляр Tarantool, запустите контейнер с минимальными настройками:

$ docker run \
  --name mytarantool \
  -d -p 3301:3301 \
  -v /data/dir/on/host:/var/lib/tarantool \
  tarantool/tarantool:2.6.0

Эта команда запускает новый контейнер с именем mytarantool. Docker запускает его из официального образа tarantool/tarantool:2.6.0 с предустановленным Tarantool 2.6.0 и всеми внешними модулями.

Tarantool будет принимать входящие подключения по адресу localhost:3301. Можно сразу начать его использовать как key-value хранилище.

Tarantool сохраняет данные внутри контейнера. Чтобы ваше тестовые данные остались доступны после остановки контейнера, эта команда также монтирует директорию /data/dir/on/host (здесь необходимо указать абсолютный путь до существующей локальной директории), расположенную на машине, в директорию /var/lib/tarantool (Tarantool традиционно использует эту директорию в контейнере для сохранения данных), расположенную в контейнере. Таким образом все изменения в смонтированной директории, внесенные на стороне контейнера, также отражаются в расположенной на пользовательском диске директории.

Модуль Tarantool для работы с базой данных уже настроен и запущен в контейнере. Ручная настройка не требуется, если только вы не используете Tarantool как сервер приложений и не запускаете его вместе с приложением.

Примечание

Если ваш контейнер рушится вскоре после запуска, перейдите на эту страницу, чтобы найти возможное решение.

Подключение к экземпляру Tarantool

Чтобы подключиться к запущенному в контейнере экземпляру Tarantool, выполните эту команду:

$ docker exec -i -t mytarantool console

Эта команда:

  • Дает Tarantool команду открыть порт с интерактивной консолью для входящих подключений.
  • Подключается через стандартный Unix-сокет к Tarantool-серверу, запущенному внутри контейнера, из-под пользователя admin.

Tarantool показывает приглашение командной строки:

tarantool.sock>

Теперь вы можете вводить запросы в командной строке.

Примечание

На боевых серверах интерактивный режим Tarantool предназначен только для системных администраторов. Мы же используем его в большинстве примеров в данном руководстве, потому что интерактивный режим хорошо подходит для обучения.

Создание базы данных

Подключившись к консоли, создадим простую тестовую базу данных.

Сначала создайте первый спейс (с именем tester):

tarantool.sock> s = box.schema.space.create('tester')

Форматируйте созданный спейс, указав имена и типы полей:

tarantool.sock> s:format({
              > {name = 'id', type = 'unsigned'},
              > {name = 'band_name', type = 'string'},
              > {name = 'year', type = 'unsigned'}
              > })

Создайте первый индекс (с именем primary):

tarantool.sock> s:create_index('primary', {
              > type = 'hash',
              > parts = {'id'}
              > })

Это первичный индекс по полю id в каждом кортеже.

Вставьте в созданный спейс три кортежа (наш термин для записей):

tarantool.sock> s:insert{1, 'Roxette', 1986}
tarantool.sock> s:insert{2, 'Scorpions', 2015}
tarantool.sock> s:insert{3, 'Ace of Base', 1993}

Для выборки кортежей по первичному индексу primary выполните команду:

tarantool.sock> s:select{3}

Теперь вывод в окне терминала выглядит следующим образом:

tarantool.sock> s = box.schema.space.create('tester')
---
...
tarantool.sock> s:format({
              > {name = 'id', type = 'unsigned'},
              > {name = 'band_name', type = 'string'},
              > {name = 'year', type = 'unsigned'}
              > })
---
...
tarantool.sock> s:create_index('primary', {
              > type = 'hash',
              > parts = {'id'}
              > })
---
- unique: true
  parts:
  - type: unsigned
    is_nullable: false
    fieldno: 1
  id: 0
  space_id: 512
  name: primary
  type: HASH
...
tarantool.sock> s:insert{1, 'Roxette', 1986}
---
- [1, 'Roxette', 1986]
...
tarantool.sock> s:insert{2, 'Scorpions', 2015}
---
- [2, 'Scorpions', 2015]
...
tarantool.sock> s:insert{3, 'Ace of Base', 1993}
---
- [3, 'Ace of Base', 1993]
...
tarantool.sock> s:select{3}
---
- - [3, 'Ace of Base', 1993]
...

Для добавления вторичного индекса по полю band_name используйте эту команду:

tarantool.sock> s:create_index('secondary', {
              > type = 'hash',
              > parts = {'band_name'}
              > })

Для выборки кортежей по вторичному индексу secondary выполните команду:

tarantool.sock> s.index.secondary:select{'Scorpions'}
---
- - [2, 'Scorpions', 2015]
...

Чтобы удалить индекс, выполните:

tarantool> s.index.secondary:drop()
---
...

Остановка контейнера

После завершения тестирования для корректной остановки контейнера выполните эту команду:

$ docker stop mytarantool

Это был временный контейнер, поэтому после остановки содержимое его диска/памяти обнулилось. Но так как вы монтировали локальную директорию в контейнер, все данные Tarantool сохранились на диске вашей машины. Если вы запустите новый контейнер и смонтируете в него ту же директорию с данными, Tarantool восстановит все данные с диска и продолжит с ними работать.

Использование менеджера пакетов

Для реальных условий эксплуатации мы рекомендуем устанавливать Tarantool с помощью официального менеджера пакетов. Можно выбрать одну из трех версий: LTS, stable или beta. Автоматическая система сборки создает, тестирует и публикует пакеты после каждого коммита в соответствующую ветку репозитория Tarantool на GitHub.

Чтобы скачать и установить подходящий пакет, откройте командную строку и введите инструкции, которые даны для вашей операционной системы на странице для скачивания.

Запуск экземпляра Tarantool’а

Чтобы начать работу с Tarantool, выполните эту команду:

$ tarantool
$ # при этом создается новый экземпляр Tarantool

Tarantool запускается в интерактивном режиме и показывает приглашение командной строки:

tarantool>

Теперь вы можете вводить запросы в командной строке.

Примечание

На боевых серверах интерактивный режим Tarantool предназначен только для системных администраторов. Мы же используем его в большинстве примеров в данном руководстве, потому что интерактивный режим хорошо подходит для обучения.

Создание базы данных

Далее объясняется, как создать простую тестовую базу данных после установки Tarantool.

  1. Чтобы Tarantool хранил данные в определенном месте, создайте предназначенную специально для тестов директорию:

    $ mkdir ~/tarantool_sandbox
    $ cd ~/tarantool_sandbox
    

    Ее можно удалить после окончания тестирования.

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

    В зависимости от версии, Tarantool может во время установки запустить экземпляр example.lua, который настроен на прослушивание по порту 3301 по умолчанию. В файле example.lua показана базовая конфигурация; его можно найти в директории /etc/tarantool/instances.enabled или /etc/tarantool/instances.available.

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

    Убедитесь, что свободен порт, используемый по умолчанию:

    1. Чтобы проверить статус работы демонстрационного экземпляра, выполните команду:

      $ lsof -i :3301
      COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
      tarantool 6851 root   12u  IPv4  40827      0t0  TCP *:3301 (LISTEN)
      
    2. Если он запущен, отключите соответствующий процесс. В данном примере:

      $ kill 6851
      
  3. Чтобы запустить модуль Tarantool для работы с базой данных и сделать так, чтобы запущенный экземпляр принимал TCP-запросы на порт 3301, выполните эту команду:

    tarantool> box.cfg{listen = 3301}
    
  4. Создайте первый спейс (с именем tester):

    tarantool> s = box.schema.space.create('tester')
    
  5. Форматируйте созданный спейс, указав имена и типы полей:

    tarantool> s:format({
             > {name = 'id', type = 'unsigned'},
             > {name = 'band_name', type = 'string'},
             > {name = 'year', type = 'unsigned'}
             > })
    
  6. Создайте первый индекс (с именем primary):

    tarantool> s:create_index('primary', {
             > type = 'hash',
             > parts = {'id'}
             > })
    

    Это первичный индекс по полю id в каждом кортеже.

  7. Вставьте в созданный спейс три кортежа (наш термин для записей):

    tarantool> s:insert{1, 'Roxette', 1986}
    tarantool> s:insert{2, 'Scorpions', 2015}
    tarantool> s:insert{3, 'Ace of Base', 1993}
    
  8. Для выборки кортежей по первичному индексу primary выполните команду:

    tarantool> s:select{3}
    

    Теперь вывод в окне терминала выглядит следующим образом:

    tarantool> s = box.schema.space.create('tester')
    ---
    ...
    tarantool> s:format({
             > {name = 'id', type = 'unsigned'},
             > {name = 'band_name', type = 'string'},
             > {name = 'year', type = 'unsigned'}
             > })
    ---
    ...
    tarantool> s:create_index('primary', {
             > type = 'hash',
             > parts = {'id'}
             > })
    ---
    - unique: true
      parts:
      - type: unsigned
        is_nullable: false
        fieldno: 1
      id: 0
      space_id: 512
      name: primary
      type: HASH
    ...
    tarantool> s:insert{1, 'Roxette', 1986}
    ---
    - [1, 'Roxette', 1986]
    ...
    tarantool> s:insert{2, 'Scorpions', 2015}
    ---
    - [2, 'Scorpions', 2015]
    ...
    tarantool> s:insert{3, 'Ace of Base', 1993}
    ---
    - [3, 'Ace of Base', 1993]
    ...
    tarantool> s:select{3}
    ---
    - - [3, 'Ace of Base', 1993]
    ...
    
  9. Для добавления вторичного индекса по полю band_name используйте эту команду:

    tarantool> s:create_index('secondary', {
             > type = 'hash',
             > parts = {'band_name'}
             > })
    
  10. Для выборки кортежей по вторичному индексу secondary выполните команду:

    tarantool> s.index.secondary:select{'Scorpions'}
    ---
    - - [2, 'Scorpions', 2015]
    ...
    
  11. Теперь, чтобы подготовиться к примеру в следующем разделе, попробуйте следующее:

    tarantool> box.schema.user.grant('guest', 'read,write,execute', 'universe')
    

Установка удаленного подключения

В запросе box.cfg{listen = 3301}, который мы отправили ранее, параметр listen может принимать в качестве значения URI (унифицированный идентификатор ресурса) любой формы. В нашем случае это просто локальный порт 3301. Вы можете отправлять запросы на указанный URI, используя:

  1. telnet,
  2. коннектор,
  3. другой экземпляр Tarantool (с помощью модуля console),
  4. административную утилиту tarantoolctl.

Давайте попробуем (3) способ.

Переключитесь на другой терминал. Например, в Linux-системе для этого нужно запустить еще один экземпляр Bash. В новом терминале можно сменить текущую рабочую директорию на любую другую, необязательно использовать ~/tarantool_sandbox.

Запустите еще один экземпляр tarantool:

$ tarantool

Используйте net.box, чтобы подключиться к экземпляру Tarantool, который слушает по адресу localhost:3301»:

tarantool> net_box = require('net.box')
---
...
tarantool> net_box.connect(3301)
---
...

Введите следующий запрос:

tarantool> conn.space.tester:select{2}

Это означает «отправить запрос тому экземпляру Tarantool и вывести результат на экран». Сам запрос аналогичен запросу box.space.tester:select{2} к локальному экземпляру. Результатом в данном случае будет один из кортежей, что вы вставляли ранее. В окне терминала теперь должно отображаться примерно следующее:

$ tarantool

Tarantool 2.6.1-32-g53dbba7c2
type 'help' for interactive help
tarantool> net_box = require('net.box')
---
...
tarantool> conn = net_box.connect(3301)
---
...
tarantool> conn.space.tester:select{2}
---
- - [2, 'Scorpions', 2015]
...

Можно посылать запросы box.space...:insert{} and box.space...:select{} (или же conn.space...:insert{} и conn.space...:select{}) неограниченное количество раз на любом из двух экземпляров Tarantool.

Закончив тестирование, выполните следующие шаги:

  • Для удаления спейса: s:drop()
  • Для остановки tarantool: Ctrl+C или Ctrl+D
  • Чтобы остановить Tarantool (другой способ): стандартная Lua-функция os.exit()
  • Для остановки Tarantool (из другого терминала): sudo pkill -f tarantool
  • Для удаления директории-песочницы: rm -r ~/tarantool_sandbox

Подключаемся к базе из разных языков программирования

In the previous sections, you have learned how to create a Tarantool database. Now let’s see how to connect to the database from different programming languages, such as Python, PHP, Go, and C++, and execute typical requests for manipulating the data (select, insert, delete, and so on).

Connecting from Python

Pre-requisites

Before we proceed:

  1. Install the tarantool module. We recommend using python3 and pip3.

  2. Start Tarantool (locally or in Docker) and make sure that you have created and populated a database as we suggested earlier:

    box.cfg{listen = 3301}
    s = box.schema.space.create('tester')
    s:format({
             {name = 'id', type = 'unsigned'},
             {name = 'band_name', type = 'string'},
             {name = 'year', type = 'unsigned'}
             })
    s:create_index('primary', {
             type = 'hash',
             parts = {'id'}
             })
    s:create_index('secondary', {
             type = 'hash',
             parts = {'band_name'}
             })
    s:insert{1, 'Roxette', 1986}
    s:insert{2, 'Scorpions', 2015}
    s:insert{3, 'Ace of Base', 1993}
    

    Важно

    Please do not close the terminal window where Tarantool is running – you’ll need it soon.

  3. In order to connect to Tarantool as an administrator, reset the password for the admin user:

    box.schema.user.passwd('pass')
    

Connecting to Tarantool

To get connected to the Tarantool server, say this:

>>> import tarantool
>>> connection = tarantool.connect("localhost", 3301)

You can also specify the user name and password, if needed:

>>> tarantool.connect("localhost", 3301, user=username, password=password)

The default user is guest.

Manipulating the data

A space is a container for tuples. To access a space as a named object, use connection.space:

>>> tester = connection.space('tester')
Inserting data

To insert a tuple into a space, use insert:

>>> tester.insert((4, 'ABBA', 1972))
[4, 'ABBA', 1972]
Querying data

Let’s start with selecting a tuple by the primary key (in our example, this is the index named primary, based on the id field of each tuple). Use select:

>>> tester.select(4)
[4, 'ABBA', 1972]

Next, select tuples by a secondary key. For this purpose, you need to specify the number or name of the index.

First off, select tuples using the index number:

>>> tester.select('Scorpions', index=1)
[2, 'Scorpions', 2015]

(We say index=1 because index numbers in Tarantool start with 0, and we’re using our second index here.)

Now make a similar query by the index name and make sure that the result is the same:

>>> tester.select('Scorpions', index='secondary')
[2, 'Scorpions', 2015]

Finally, select all the tuples in a space via a select with no arguments:

>>> tester.select()
Updating data

Update a field value using update:

>>> tester.update(4, [('=', 1, 'New group'), ('+', 2, 2)])

This updates the value of field 1 and increases the value of field 2 in the tuple with id = 4. If a tuple with this id doesn’t exist, Tarantool will return an error.

Now use replace to totally replace the tuple that matches the primary key. If a tuple with this primary key doesn’t exist, Tarantool will do nothing.

>>> tester.replace((4, 'New band', 2015))

You can also update the data using upsert that works similarly to update, but creates a new tuple if the old one was not found.

>>> tester.upsert((4, 'Another band', 2000), [('+', 2, 5)])

This increases by 5 the value of field 2 in the tuple with id = 4, – or inserts the tuple (4, "Another band", 2000) if a tuple with this id doesn’t exist.

Deleting data

To delete a tuple, use delete(primary_key):

>>> tester.delete(4)
[4, 'New group', 2012]

To delete all tuples in a space (or to delete an entire space), use call. We’ll focus on this function in more detail in the next section.

To delete all tuples in a space, call space:truncate:

>>> connection.call('box.space.tester:truncate', ())

To delete an entire space, call space:drop. This requires connecting to Tarantool as the admin user:

>>> connection.call('box.space.tester:drop', ())

Executing stored procedures

Switch to the terminal window where Tarantool is running.

Примечание

If you don’t have a terminal window with remote connection to Tarantool, check out these guides:

Define a simple Lua function:

function sum(a, b)
    return a + b
end

Now we have a Lua function defined in Tarantool. To invoke this function from python, use call:

>>> connection.call('sum', (3, 2))
5

To send bare Lua code for execution, use eval:

>>> connection.eval('return 4 + 5')
9

Connecting from PHP

Pre-requisites

Before we proceed:

  1. Install the tarantool/client library.

  2. Start Tarantool (locally or in Docker) and make sure that you have created and populated a database as we suggested earlier:

    box.cfg{listen = 3301}
    s = box.schema.space.create('tester')
    s:format({
             {name = 'id', type = 'unsigned'},
             {name = 'band_name', type = 'string'},
             {name = 'year', type = 'unsigned'}
             })
    s:create_index('primary', {
             type = 'hash',
             parts = {'id'}
             })
    s:create_index('secondary', {
             type = 'hash',
             parts = {'band_name'}
             })
    s:insert{1, 'Roxette', 1986}
    s:insert{2, 'Scorpions', 2015}
    s:insert{3, 'Ace of Base', 1993}
    

    Важно

    Please do not close the terminal window where Tarantool is running – you’ll need it soon.

  3. In order to connect to Tarantool as an administrator, reset the password for the admin user:

    box.schema.user.passwd('pass')
    

Connecting to Tarantool

To configure a connection to the Tarantool server, say this:

use Tarantool\Client\Client;

require __DIR__.'/vendor/autoload.php';
$client = Client::fromDefaults();

The connection itself will be established at the first request. You can also specify the user name and password, if needed:

$client = Client::fromOptions([
    'uri' => 'tcp://127.0.0.1:3301',
    'username' => '<username>',
    'password' => '<password>'
]);

The default user is guest.

Manipulating the data

A space is a container for tuples. To access a space as a named object, use getSpace:

$tester = $client->getSpace('tester');
Inserting data

To insert a tuple into a space, use insert:

$result = $tester->insert([4, 'ABBA', 1972]);
Querying data

Let’s start with selecting a tuple by the primary key (in our example, this is the index named primary, based on the id field of each tuple). Use select:

use Tarantool\Client\Schema\Criteria;

$result = $tester->select(Criteria::key([4]));
printf(json_encode($result));
[[4, 'ABBA', 1972]]

Next, select tuples by a secondary key. For this purpose, you need to specify the number or name of the index.

First off, select tuples using the index number:

$result = $tester->select(Criteria::index(1)->andKey(['Scorpions']));
printf(json_encode($result));
[2, 'Scorpions', 2015]

(We say index(1) because index numbers in Tarantool start with 0, and we’re using our second index here.)

Now make a similar query by the index name and make sure that the result is the same:

$result = $tester->select(Criteria::index('secondary')->andKey(['Scorpions']));
printf(json_encode($result));
[2, 'Scorpions', 2015]

Finally, select all the tuples in a space via a select:

$result = $tester->select(Criteria::allIterator());
Updating data

Update a field value using update:

use Tarantool\Client\Schema\Operations;

$result = $tester->update([4], Operations::set(1, 'New group')->andAdd(2, 2));

This updates the value of field 1 and increases the value of field 2 in the tuple with id = 4. If a tuple with this id doesn’t exist, Tarantool will return an error.

Now use replace to totally replace the tuple that matches the primary key. If a tuple with this primary key doesn’t exist, Tarantool will do nothing.

$result = $tester->replace([4, 'New band', 2015]);

You can also update the data using upsert that works similarly to update, but creates a new tuple if the old one was not found.

use Tarantool\Client\Schema\Operations;

$tester->upsert([4, 'Another band', 2000], Operations::add(2, 5));

This increases by 5 the value of field 2 in the tuple with id = 4, – or inserts the tuple (4, "Another band", 2000) if a tuple with this id doesn’t exist.

Deleting data

To delete a tuple, use delete(primary_key):

$result = $tester->delete([4]);

To delete all tuples in a space (or to delete an entire space), use call. We’ll focus on this function in more detail in the next section.

To delete all tuples in a space, call space:truncate:

$result = $client->call('box.space.tester:truncate');

To delete an entire space, call space:drop. This requires connecting to Tarantool as the admin user:

$result = $client->call('box.space.tester:drop');

Executing stored procedures

Switch to the terminal window where Tarantool is running.

Примечание

If you don’t have a terminal window with remote connection to Tarantool, check out these guides:

Define a simple Lua function:

function sum(a, b)
    return a + b
end

Now we have a Lua function defined in Tarantool. To invoke this function from php, use call:

$result = $client->call('sum', 3, 2);

To send bare Lua code for execution, use eval:

$result = $client->evaluate('return 4 + 5');

Connecting from Go

Pre-requisites

Before we proceed:

  1. Install the go-tarantool library.

  2. Start Tarantool (locally or in Docker) and make sure that you have created and populated a database as we suggested earlier:

    box.cfg{listen = 3301}
    s = box.schema.space.create('tester')
    s:format({
             {name = 'id', type = 'unsigned'},
             {name = 'band_name', type = 'string'},
             {name = 'year', type = 'unsigned'}
             })
    s:create_index('primary', {
             type = 'hash',
             parts = {'id'}
             })
    s:create_index('secondary', {
             type = 'hash',
             parts = {'band_name'}
             })
    s:insert{1, 'Roxette', 1986}
    s:insert{2, 'Scorpions', 2015}
    s:insert{3, 'Ace of Base', 1993}
    

    Важно

    Please do not close the terminal window where Tarantool is running – you’ll need it soon.

  3. In order to connect to Tarantool as an administrator, reset the password for the admin user:

    box.schema.user.passwd('pass')
    

Connecting to Tarantool

To get connected to the Tarantool server, write a simple Go program:

package main

import (
    "fmt"

    "github.com/tarantool/go-tarantool"
)

func main() {

    conn, err := tarantool.Connect("127.0.0.1:3301", tarantool.Opts{
            User: "admin",
            Pass: "pass",
    })

    if err != nil {
            log.Fatalf("Connection refused")
    }

    defer conn.Close()

    // Your logic for interacting with the database
}

The default user is guest.

Manipulating the data

Inserting data

To insert a tuple into a space, use Insert:

resp, err = conn.Insert("tester", []interface{}{4, "ABBA", 1972})

This inserts the tuple (4, "ABBA", 1972) into a space named tester.

The response code and data are available in the tarantool.Response structure:

code := resp.Code
data := resp.Data
Querying data

To select a tuple from a space, use Select:

resp, err = conn.Select("tester", "primary", 0, 1, tarantool.IterEq, []interface{}{4})

This selects a tuple by the primary key with offset = 0 and limit = 1 from a space named tester (in our example, this is the index named primary, based on the id field of each tuple).

Next, select tuples by a secondary key.

resp, err = conn.Select("tester", "secondary", 0, 1, tarantool.IterEq, []interface{}{"ABBA"})

Finally, it would be nice to select all the tuples in a space. But there is no one-liner for this in Go; you would need a script like this one.

For more examples, see https://github.com/tarantool/go-tarantool#usage

Updating data

Update a field value using Update:

resp, err = conn.Update("tester", "primary", []interface{}{4}, []interface{}{[]interface{}{"+", 2, 3}})

This increases by 3 the value of field 2 in the tuple with id = 4. If a tuple with this id doesn’t exist, Tarantool will return an error.

Now use Replace to totally replace the tuple that matches the primary key. If a tuple with this primary key doesn’t exist, Tarantool will do nothing.

resp, err = conn.Replace("tester", []interface{}{4, "New band", 2011})

You can also update the data using Upsert that works similarly to Update, but creates a new tuple if the old one was not found.

resp, err = conn.Upsert("tester", []interface{}{4, "Another band", 2000}, []interface{}{[]interface{}{"+", 2, 5}})

This increases by 5 the value of the third field in the tuple with id = 4, – or inserts the tuple (4, "Another band", 2000) if a tuple with this id doesn’t exist.

Deleting data

To delete a tuple, use сonnection.Delete:

resp, err = conn.Delete("tester", "primary", []interface{}{4})

To delete all tuples in a space (or to delete an entire space), use Call. We’ll focus on this function in more detail in the next section.

To delete all tuples in a space, call space:truncate:

resp, err = conn.Call("box.space.tester:truncate", []interface{}{})

To delete an entire space, call space:drop. This requires connecting to Tarantool as the admin user:

resp, err = conn.Call("box.space.tester:drop", []interface{}{})

Executing stored procedures

Switch to the terminal window where Tarantool is running.

Примечание

If you don’t have a terminal window with remote connection to Tarantool, check out these guides:

Define a simple Lua function:

function sum(a, b)
    return a + b
end

Now we have a Lua function defined in Tarantool. To invoke this function from go, use Call:

resp, err = conn.Call("sum", []interface{}{2, 3})

To send bare Lua code for execution, use Eval:

resp, err = connection.Eval("return 4 + 5", []interface{}{})

Connecting to Tarantool from C++

To simplify the start of your working with the Tarantool C++ connector, we will use the example application from the connector repository. We will go step by step through the application code and explain what each part does.

The following main topics are discussed in this manual:

Pre-requisites

To go through this Getting Started exercise, you need the following pre-requisites to be done:

Installation

The Tarantool C++ connector is currently supported for Linux only.

The connector itself is a header-only library, so, it doesn’t require installation and building as such. All you need is to clone the connector source code and embed it in your C++ project.

Also, make sure you have other necessary software and Tarantool installed.

  1. Make sure you have the following third-party software. If you miss some of the items, install them:

  2. If you don’t have Tarantool on your OS, install it in one of the ways:

  3. Clone the Tarantool C++ connector repository.

    git clone git@github.com:tarantool/tntcxx.git
    
Starting Tarantool and creating a database

Start Tarantool locally or in Docker and create a space with the following schema and index:

box.cfg{listen = 3301}
t = box.schema.space.create('t')
t:format({
         {name = 'id', type = 'unsigned'},
         {name = 'a', type = 'string'},
         {name = 'b', type = 'number'}
         })
t:create_index('primary', {
         type = 'hash',
         parts = {'id'}
         })

Важно

Do not close the terminal window where Tarantool is running. You will need it later to connect to Tarantool from your C++ application.

Setting up access rights

To be able to execute the necessary operations in Tarantool, you need to grant the guest user with the read-write rights. The simplest way is to grant the user with the super role:

box.schema.user.grant('guest', 'super')

Connecting to Tarantool

There are three main parts of the C++ connector: the IO-zero-copy buffer, the msgpack encoder/decoder, and the client that handles requests.

To set up connection to a Tarantool instance from a C++ application, you need to do the following:

Embedding connector

Embed the connector in your C++ application by including the main header:

#include "../src/Client/Connector.hpp"
Instantiating objects

First, we should create a connector client. It can handle many connections to Tarantool instances asynchronously. To instantiate a client, you should specify the buffer and the network provider implementations as template parameters. The connector’s main class has the following signature:

template<class BUFFER, class NetProvider = DefaultNetProvider<BUFFER>>
class Connector;

The buffer is parametrized by allocator. It means that users can choose which allocator will be used to provide memory for the buffer’s blocks. Data is organized into a linked list of blocks of fixed size that is specified as the template parameter of the buffer.

You can either implement your own buffer or network provider or use the default ones as we do in our example. So, the default connector instantiation looks as follows:

using Buf_t = tnt::Buffer<16 * 1024>;
using Net_t = DefaultNetProvider<Buf_t >;
Connector<Buf_t, Net_t> client;

To use the BUFFER class, the buffer header should also be included:

#include "../src/Buffer/Buffer.hpp"

A client itself is not enough to work with Tarantool instances–we also need to create connection objects. A connection also takes the buffer and the network provider as template parameters. Note that they must be the same as ones of the client:

Connection<Buf_t, Net_t> conn(client);
Connecting

Our Tarantool instance is listening to the 3301 port on localhost. Let’s define the corresponding variables as well as the WAIT_TIMEOUT variable for connection timeout.

const char *address = "127.0.0.1";
int port = 3301;
int WAIT_TIMEOUT = 1000; //milliseconds

To connect to the Tarantool instance, we should invoke the Connector::connect() method of the client object and pass three arguments: connection instance, address, and port.

int rc = client.connect(conn, address, port);
Error handling

Implementation of the connector is exception free, so we rely on the return codes: in case of fail, the connect() method returns rc < 0. To get the error message corresponding to the last error occured during communication with the instance, we can invoke the Connection::getError() method.

if (rc != 0) {
	assert(conn.status.is_failed);
	std::cerr << conn.getError() << std::endl;
	return -1;
}

To reset connection after errors, that is, to clean up the error message and connection status, the Connection::reset() method is used.

Working with requests

In this section, we will show how to:

We will also go through the case of having several connections and executing a number of requests from different connections simultaneously.

In our example C++ application, we execute the following types of requests:

  • ping
  • replace
  • select.

Примечание

Examples on other request types, namely, insert, delete, upsert, and update, will be added to this manual later.

Each request method returns a request ID that is a sort of future. This ID can be used to get the response message when it is ready. Requests are queued in the output buffer of connection until the Connector::wait() method is called.

Preparing requests

At this step, requests are encoded in the MessagePack format and saved in the output connection buffer. They are ready to be sent but the network communication itself will be done later.

Let’s remind that for the requests manipulating with data we are dealing with the Tarantool space t created earlier, and the space has the following format:

t:format({
         {name = 'id', type = 'unsigned'},
         {name = 'a', type = 'string'},
         {name = 'b', type = 'number'}
         })

ping

rid_t ping = conn.ping();

replace

Equals to Lua request <space_name>:replace(pk_value, "111", 1).

uint32_t space_id = 512;
int pk_value = 666;
std::tuple data = std::make_tuple(pk_value /* field 1*/, "111" /* field 2*/, 1.01 /* field 3*/);
rid_t replace = conn.space[space_id].replace(data);

select

Equals to Lua request <space_name>.index[0]:select({pk_value}, {limit = 1}).

uint32_t index_id = 0;
uint32_t limit = 1;
uint32_t offset = 0;
IteratorType iter = IteratorType::EQ;
auto i = conn.space[space_id].index[index_id];
rid_t select = i.select(std::make_tuple(pk_value), limit, offset, iter);
Sending requests

To send requests to the server side, invoke the client.wait() method.

client.wait(conn, ping, WAIT_TIMEOUT);

The wait() method takes the connection to poll, the request ID, and, optionally, the timeout as parameters. Once a response for the specified request is ready, wait() terminates. It also provides a negative return code in case of system related fails, for example, a broken or timeouted connection. If wait() returns 0, then a response has been received and expected to be parsed.

Now let’s send our requests to the Tarantool instance. The futureIsReady() function checks availability of a future and returns true or false.

while (! conn.futureIsReady(ping)) {
	/*
	 * wait() is the main function responsible for sending/receiving
	 * requests and implements event-loop under the hood. It may
	 * fail due to several reasons:
	 *  - connection is timed out;
	 *  - connection is broken (e.g. closed);
	 *  - epoll is failed.
	 */
	if (client.wait(conn, ping, WAIT_TIMEOUT) != 0) {
		assert(conn.status.is_failed);
		std::cerr << conn.getError() << std::endl;
		conn.reset();
	}
}
Receiving responses

To get the response when it is ready, use the Connection::getResponse() method. It takes the request ID and returns an optional object containing the response. If the response is not ready yet, the method returns std::nullopt. Note that on each future, getResponse() can be called only once: it erases the request ID from the internal map once it is returned to a user.

A response consists of a header and a body (response.header and response.body). Depending on success of the request execution on the server side, body may contain either runtime error(s) accessible by response.body.error_stack or data (tuples)–response.body.data. In turn, data is a vector of tuples. However, tuples are not decoded and come in the form of pointers to the start and the end of msgpacks. See the «Decoding and reading the data» section to understand how to decode tuples.

There are two options for single connection it regards to receiving responses: we can either wait for one specific future or for all of them at once. We’ll try both options in our example. For the ping request, let’s use the first option.

std::optional<Response<Buf_t>> response = conn.getResponse(ping);
/*
 * Since conn.futureIsReady(ping) returned <true>, then response
 * must be ready.
 */
assert(response != std::nullopt);
/*
 * If request is successfully executed on server side, response
 * will contain data (i.e. tuple being replaced in case of :replace()
 * request or tuples satisfying search conditions in case of :select();
 * responses for pings contain nothing - empty map).
 * To tell responses containing data from error responses, one can
 * rely on response code storing in the header or check
 * Response->body.data and Response->body.error_stack members.
 */
printResponse<Buf_t>(conn, *response);

For the replace and select requests, let’s examine the option of waiting for both futures at once.

/* Let's wait for both futures at once. */
rid_t futures[2];
futures[0] = replace;
futures[1] = select;
/* No specified timeout means that we poll futures until they are ready.*/
client.waitAll(conn, (rid_t *) &futures, 2);
for (int i = 0; i < 2; ++i) {
	assert(conn.futureIsReady(futures[i]));
	response = conn.getResponse(futures[i]);
	assert(response != std::nullopt);
	printResponse<Buf_t>(conn, *response);
}
Several connections at once

Now, let’s have a look at the case when we establish two connections to Tarantool instance simultaneously.

/* Let's create another connection. */
Connection<Buf_t, Net_t> another(client);
if (client.connect(another, address, port) != 0) {
	assert(conn.status.is_failed);
	std::cerr << conn.getError() << std::endl;
	return -1;
}
/* Simultaneously execute two requests from different connections. */
rid_t f1 = conn.ping();
rid_t f2 = another.ping();
/*
 * waitAny() returns the first connection received response.
 * All connections registered via :connect() call are participating.
 */
Connection<Buf_t, Net_t> *first = client.waitAny(WAIT_TIMEOUT);
if (first == &conn) {
	assert(conn.futureIsReady(f1));
} else {
	assert(another.futureIsReady(f2));
}
Closing connections

Finally, a user is responsible for closing connections.

client.close(conn);
client.close(another);

Building and launching C++ application

Now, we are going to build our example C++ application, launch it to connect to the Tarantool instance and execute all the requests defined.

Make sure you are in the root directory of the cloned C++ connector repository. To build the example application:

cd examples
cmake .
make

Make sure the Tarantool session you started earlier is running. Launch the application:

./Simple

As you can see from the execution log, all the connections to Tarantool defined in our application have been established and all the requests have been executed successfully.

Decoding and reading the data

Responses from a Tarantool instance contain raw data, that is, the data encoded into the MessagePack tuples. To decode client’s data, the user has to write their own decoders (readers) based on the database schema and include them in one’s application:

#include "Reader.hpp"

To show the logic of decoding a response, we will use the reader from our example.

First, the structure corresponding our example space format is defined:

/**
 * Corresponds to tuples stored in user's space:
 * box.execute("CREATE TABLE t (id UNSIGNED PRIMARY KEY, a TEXT, d DOUBLE);")
 */
struct UserTuple {
	uint64_t field1;
	std::string field2;
	double field3;
};
Base reader prototype

Prototype of the base reader is given in src/mpp/Dec.hpp:

template <class BUFFER, Type TYPE>
struct SimpleReaderBase : DefaultErrorHandler {
   using BufferIterator_t = typename BUFFER::iterator;
   /* Allowed type of values to be parsed. */
   static constexpr Type VALID_TYPES = TYPE;
   BufferIterator_t* StoreEndIterator() { return nullptr; }
};

Every new reader should inherit from it or directly from the DefaultErrorHandler.

Parsing values

To parse a particular value, we should define the Value() method. First two arguments of the method are common and unused as a rule, but the third one defines the parsed value. In case of POD (Plain Old Data) structures, it’s enough to provide a byte-to-byte copy. Since there are fields of three different types in our schema, let’s define the corresponding Value() functions:

struct UserTupleValueReader : mpp::DefaultErrorHandler {
	explicit UserTupleValueReader(UserTuple& t) : tuple(t) {}
	static constexpr mpp::Type VALID_TYPES = mpp::MP_UINT | mpp::MP_STR | mpp::MP_DBL;
	template <class T>
	void Value(const BufIter_t&, mpp::compact::Type, T v)
	{
		using A = UserTuple;
		static constexpr std::tuple map(&A::field1, &A::field3);
		auto ptr = std::get<std::decay_t<T> A::*>(map);
		tuple.*ptr = v;
	}
	void Value(const BufIter_t& itr, mpp::compact::Type, mpp::StrValue v)
	{
		BufIter_t tmp = itr;
		tmp += v.offset;
		std::string &dst = tuple.field2;
		while (v.size) {
			dst.push_back(*tmp);
			++tmp;
			--v.size;
		}
	}
	void WrongType(mpp::Type expected, mpp::Type got)
	{
		std::cout << "expected type is " << expected <<
			     " but got " << got << std::endl;
	}

	BufIter_t* StoreEndIterator() { return nullptr; }
	UserTuple& tuple;
};
Parsing array

It’s also important to understand that a tuple itself is wrapped in an array, so, in fact, we should parse the array first. Let’s define another reader for that purpose.

template <class BUFFER>
struct UserTupleReader : mpp::SimpleReaderBase<BUFFER, mpp::MP_ARR> {

	UserTupleReader(mpp::Dec<BUFFER>& d, UserTuple& t) : dec(d), tuple(t) {}

	void Value(const iterator_t<BUFFER>&, mpp::compact::Type, mpp::ArrValue u)
	{
		assert(u.size == 3);
		dec.SetReader(false, UserTupleValueReader{tuple});
	}
	mpp::Dec<BUFFER>& dec;
	UserTuple& tuple;
};
Setting reader

The SetReader() method sets the reader that is invoked while each of the array’s entries is parsed. To make two readers defined above work, we should create a decoder, set its iterator to the position of the encoded tuple, and invoke the Read() method (the code block below is from the example application).

template <class BUFFER>
std::vector<UserTuple>
decodeUserTuple(BUFFER &buf, Data<BUFFER> &data)
{
	std::vector<UserTuple> results;
	for(auto const& t: data.tuples) {
		assert(t.begin != std::nullopt);
		assert(t.end != std::nullopt);
		UserTuple tuple;
		mpp::Dec dec(buf);
		dec.SetPosition(*t.begin);
		dec.SetReader(false, UserTupleReader<BUFFER>{dec, tuple});
		mpp::ReadResult_t res = dec.Read();
		assert(res == mpp::READ_SUCCESS);
		results.push_back(tuple);
	}
	return results;
}

Создаем свое первое приложение на Tarantool Cartridge

Здесь мы показываем, как сделать простое кластерное приложение.

Первым делом настройте среду разработки.

Затем создайте приложение с именем myapp. Выполните:

$ cartridge create --name myapp

Эта команда создает новое Tarantool Cartridge-приложение в директории ./myapp. Там теперь содержатся созданные по шаблону файлы и директории.

Войдите внутрь этой директории и запустите ваше приложение:

$ cd ./myapp
$ cartridge build
$ cartridge start

Эта команда собирает приложение локально, стартует 5 экземпляров Tarantool и запускает приложение в том виде, как оно было создано – без какой-либо интересной бизнес-логики.

Откуда взялись 5 экземпляров? Загляните внутрь файла instances.yml.Там задается конфигурация всех экземпляров, которые вы можете настроить внутри вашего кластера. По умолчанию, там задана конфигурация 5 экземпляров.

myapp.router:
  workdir: ./tmp/db_dev/3301
  advertise_uri: localhost:3301
  http_port: 8081

myapp.s1-master:
  workdir: ./tmp/db_dev/3302
  advertise_uri: localhost:3302
  http_port: 8082

myapp.s1-replica:
  workdir: ./tmp/db_dev/3303
  advertise_uri: localhost:3303
  http_port: 8083

myapp.s2-master:
  workdir: ./tmp/db_dev/3304
  advertise_uri: localhost:3304
  http_port: 8084

myapp.s2-replica:
  workdir: ./tmp/db_dev/3305
  advertise_uri: localhost:3305
  http_port: 8085

Вы можете увидеть все эти экземпляры в веб-интерфейсе для управления кластером по адресу http://localhost:8081 (порт 8081 – это HTTP-порт первого экземпляра из файла instances.yml).

_images/cluster_dry_run-border-5px.png

Теперь временно остановите кластер с помощью Ctrl + C.

Пора заняться написанием бизнес-логики для вашего приложения. Чтобы не слишком усложнять наш пример, возьмем канонический «Hello world!»».

Переименуйте шаблонный файл app/roles/custom.lua в hello-world.lua.

$ mv app/roles/custom.lua app/roles/hello-world.lua

Это будет ваша роль. Роль в Tarantool Cartridge – это Lua-модуль, в котором реализованы специфичные для экземпляра Tarantool функции и логика. Далее мы покажем, как добавлять в роль свой код, собирать ее, назначать и тестировать.

У вашей роли уже есть некоторый код внутри функции init().

 local function init(opts) -- luacheck: no unused args
     -- if opts.is_master then
     -- end

     local httpd = cartridge.service_get('httpd')
     httpd:route({method = 'GET', path = '/hello'}, function()
         return {body = 'Hello world!'}
     end)

     return true
 end

Этот код экспортирует конечную точку /hello для выполнения HTTP-запросов. Например, для первого экземпляра из файла instances.yml это будет http://localhost:8081/hello . Если вы зайдете по этому адресу в браузере после того, как роль будет назначена (чуть позже мы покажем, как это делается), то увидите на странице слова «Hello world!».

Добавим сюда еще немного кода.

 local function init(opts) -- luacheck: no unused args
     -- if opts.is_master then
     -- end

     local httpd = cartridge.service_get('httpd')
     httpd:route({method = 'GET', path = '/hello'}, function()
         return {body = 'Hello world!'}
     end)

     local log = require('log')
     log.info('Hello world!')

     return true
 end

Здесь мы пишем «Hello, world!» в консоль в момент назначения роли, что даст вам возможность отследить данное событие. Пока ничего сложного.

Далее изменим значение параметра role_name в «return»-блоке файла hello-world.lua. Этот текст будет показан в качестве имени вашей роли в веб-интерфейсе для управления кластером.

 return {
     role_name = 'Hello world!',
     init = init,
     stop = stop,
     validate_config = validate_config,
     apply_config = apply_config,
 }

Последнее, что осталось сделать – это добавить ваше роль в список доступных ролей кластера, в файл init.lua.

 local ok, err = cartridge.cfg({
     workdir = 'tmp/db',
     roles = {
         'cartridge.roles.vshard-storage',
         'cartridge.roles.vshard-router',
         'app.roles.hello-world'
     },
     cluster_cookie = 'myapp-cluster-cookie',
 })

Теперь кластер будет знать про вашу роль.

Почему мы указали app.roles.hello-world? По умолчанию, имя роли в данном файле должно включать в себя полный путь от корня приложения (./myapp) до файла роли (app/roles/hello-world.lua).

Отлично! Роль готова. Теперь заново соберите и запустите ваше приложение:

$ cartridge build
$ cartridge start

Все экземпляры запущены, но они пока ничего не делают, а ждут, что им назначат роли.

Экземпляры (реплики) в кластере Tarantool Cartridge должны быть собраны в наборы реплик. Роли назначаются каждому набору, и любой экземпляр в наборе реплик видит все роли, которые назначены этому набору.

Давайте создадим набор реплик, в котором будет всего один экземпляр, и назначим этому набору вашу роль.

  1. Откройте веб-интерфейс для управления кластером по адресу http://localhost:8081.

  2. Нажмите кнопку Configure.

  3. Установите флажок напротив роли Hello world!, чтобы назначить ее. Заметьте, что имя роли здесь совпадает с тем текстом, который вы задали в параметре role_name в файле hello-world.lua.

  4. (По желанию) Задайте имя набора реплик, например «hello-world-replica-set».

    _images/cluster_create_replica_set-border-5px.png
  5. Нажмите кнопку Create replica set. Информация о вашем наборе реплик появится в веб-интерфейсе.

_images/cluster_new_replica_set-border-5px.png

Итак, ваша роль назначена. В консоли вы можете увидеть такое сообщение:

_images/cluster_hello_world_console-border-5px.png

А если вы сейчас откроете в браузере страницу http://localhost:8081/hello , то увидите ответ вашей роли на HTTP GET-запрос.

_images/cluster_hello_http-border-5px.png

Все работает! Что же дальше?

  • Загляните в это руководство, чтобы настроить оставшиеся наборы реплик и опробовать разные кластерные возможности.
  • Посмотрите эти примеры приложений и реализуйте более сложную логику для вашей роли.
  • Упакуйте ваше приложение для дальнейшего распространения. Вы можете выбрать любой из поддерживаемых видов пакетов: DEB, RPM, архив TGZ или Docker-образ.

Руководство пользователя

Функциональность СУБД

В данной главе мы рассмотрим основные понятия при работе с Tarantool’ом в качестве системы управления базой данных.

Эта глава состоит из следующих разделов:

Модель данных

В этом разделе описывается то, как в Tarantool организовано хранение данных и какие операции с данными он поддерживает.

Если вы пробовали создать базу данных, как предлагается в упражнениях в «Руководстве для начинающих», то ваша тестовая база данных выглядит следующим образом:

_images/data_model.png

Спейсы

Спейс — с именем „tester“ в нашем примере — это контейнер.

Когда Tarantool используется для хранения данных, всегда существует хотя бы один спейс. У каждого спейса есть уникальное имя, указанное пользователем. Кроме того, пользователь может указать уникальный числовой идентификатор, но обычно Tarantool назначает его автоматически. Наконец, в спейсе всегда есть движок: memtx (по умолчанию) — in-memory движок, быстрый, но ограниченный в размере, или vinyl — дисковый движок для огромного количества данных.

Спейс – это контейнер для кортежей. Для работы ему необходим первичный индекс. Также возможно использование вторичных индексов.

Кортежи

Кортеж играет такую же роль, как “строка” или “запись”, а компоненты кортежа (которые мы называем “полями”) играют такую же роль, что и “столбец” или “поле записи”, не считая того, что:

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

В любом кортеже может быть любое количество полей, и это могут быть поля разных типов. Идентификатором поля является его номер, начиная с 1 (в Lua и других языках с индексацией с 1) или с 0 (в PHP или C/C++). Например, 1 или 0 могут использоваться в некоторых контекстах для обозначения первого поля кортежа.

Количество кортежей в спейсе не ограничено.

Кортежи в Tarantool хранятся в виде массивов MsgPack.

Когда Tarantool выводит значение в кортеже в консоль, используется формат YAML, например: [3, 'Ace of  Base', 1993].

Индексы

Read the full information about indexes on page Indexes.

Индекс — это совокупность значений ключей и указателей.

Как и для спейсов, индексам следует указать имена, а Tarantool определит уникальный числовой идентификатор («ID индекса»).

An index always has a type. The default index type is TREE. TREE indexes are provided by all Tarantool engines, can index unique and non-unique values, support partial key searches, comparisons and ordered results. Additionally, memtx engine supports HASH, RTREE and BITSET indexes.

Индекс может быть многокомпонентным, то есть можно объявить, что ключ индекса состоит из двух или более полей в кортеже в любом порядке. Например, для обычного TREE-индекса максимальное количество частей равно 255.

Индекс может быть уникальным, то есть можно объявить, что недопустимо дважды задавать одно значение ключа.

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

Типы данных

Tarantool представляет собой систему управления базой данных и сервер приложений одновременно. Поэтому разработчику часто приходится работать с двумя системами типов данных: типы языка программирования (например, Lua) и типы формата хранения данных Tarantool (MsgPack).

Lua в сравнении с MsgPack
Скалярный / составной MsgPack-тип   Lua-тип Пример значения
скалярный nil «nil» nil
скалярный boolean «boolean» (логическое значение) true
скалярный string «string» 'A B C'
скалярный integer «number» 12345
скалярный float 64 (double) «number» 1.2345
скалярный float 64 (double) «cdata» 1.2345
скалярный binary «cdata» [!!binary 3t7e]
скалярный ext (для decimal в Tarantool) «cdata» 1.2
скалярный ext (для uuid в Tarantool) «cdata» 12a34b5c-de67-8f90-
123g-h4567ab8901
составной map (ассоциативный массив) «table» (таблица со строковыми ключами) {'a': 5, 'b': 6}
составной array (массив) «table» (таблица с целочисленными ключами) [1, 2, 3, 4, 5]
составной array (массив) tuple («cdata») (кортеж) [12345, 'A B C']

Примечание

Данные в формате MsgPack имеют переменный размер. Так, например, для наименьшего значения number потребуется только один байт, a для наибольшего потребуется девять байтов.

Описание типов полей

nil. В языке Lua nil может иметь только одно значение, также называемое nil (отображается как null в командной строке Tarantool, где значения выводятся в формате YAML по умолчанию). Значение nil можно сравнивать со значениями любых типов данных с помощью операторов == (равен) или ~= (не равен), но никакие другие операции для nil не доступны. Nil также нельзя использовать в Lua-таблицах — вместо nil в таком случае можно указать box.NULL, поскольку условие nil == box.NULL является истинным. Пример: nil.

boolean. Логический тип данных boolean принимает значения true или false. Пример: true.

integer. В Tarantool тип полей integer используется для целых чисел от −9 223 372 036 854 775 808 до 18 446 744 073 709 551 615, то есть до примерно 18 квинтиллионов. Такой тип данных соответствует типу number в Lua или integer в MsgPack. Пример: -2^63.

unsigned. В Tarantool тип unsigned используется для целых чисел от 0 до 18 446 744 073 709 551 615, то есть представляет собой подмножество integer. Пример: 123456.

double. Тип поля double создан специально для соответствия типу данных DOUBLE в Tarantool/SQL. В интерфейсе Tarantool к MsgPack, который называется msgpuck.h, используется тип хранения MP_DOUBLE, а размер кодируемого значения всегда составляет 9 байтов. В Lua поля „double“ могут содержать только нецелые числа и значения типа cdata с плавающей запятой двойной точности. Примеры: 1.234, -44, 1.447e+44.
Чтобы случайно не использовать неправильные значения, используйте ffi.cast() для поиска или изменения полей типа „double“. Например, вместо space_object:insert{value} вводите ffi = require('ffi') ... space_object:insert({ffi.cast('double',value)}). Пример:

s = box.schema.space.create('s', {format = {{'d', 'double'}}})
s:create_index('ii')
s:insert({1.1})
ffi = require('ffi')
s:insert({ffi.cast('double', 1)})
s:insert({ffi.cast('double', tonumber('123'))})
s:select(1.1)
s:select({ffi.cast('double', 1)})

Арифметические операции с cdata „double“ не дадут достоверный результат, поэтому для Lua лучше использовать тип „number“. Это не относится к Tarantool/SQL, так как Tarantool/SQL выполняет неявное приведение типов.

number. В Lua number — это числа с плавающей запятой двойной точности, но в Tarantool поле типа „number“ может содержать как целые числа, так и числа с плавающей запятой. Tarantool по возможности сохраняет значения number языка Lua в виде чисел с плавающей запятой, если числовое значение содержит десятичную запятую или если оно очень велико (более 100 триллионов = 1e14). В противном случае, Tarantool сохранит такое значение в виде целого числа. Чтобы даже очень большие числа гарантированно обрабатывались как целые числа, используйте функцию tonumber64 или же приписывайте в конце суффикс LL (Long Long) или ULL (Unsigned Long Long). Вот примеры записи чисел в обычном представлении, экспоненциальном, с суффиксом ULL и с использованием функции tonumber64: -55, -2.7e+20, 100000000000000ULL, tonumber64('18446744073709551615').

decimal. Тип данных decimal в Tarantool хранится в формате MsgPack ext (Extension). Значения с типом decimal не являются числами с плавающей запятой, хотя могут содержать десятичную запятую. Они представляют собой числа с точностью до 38 знаков. Пример: значение, которое возвращает функция в модуле decimal.

string. Значения строкового типа string содержат последовательность байтов переменной длины, обычно представленную буквенно-цифровыми символами в одинарных кавычках. Как в Lua, так и в MsgPack строки рассматриваются как двоичные данные без определения набора символов строки или преобразования строки, кроме случаев сортировки. Таким образом, обычно сортировка и сравнение строк выполняются побайтово, а дополнительные правила сравнения символов не применяются. Например, числа упорядочены по их положению на числовой прямой, поэтому 2345 больше, чем 500. Строки же упорядочены сначала по кодировке первого байта, затем по кодировке второго байта и так далее, таким образом, '2345' меньше '500'. Пример: 'A, B, C'.

bin. Значения типа bin (двоичные значения) не поддерживаются непосредственно в Lua, но в Tarantool есть тип varbinary, который кодируется в тип binary из MsgPack. Пример вставки varbinary в базу данных см. в рекомендациях по разработке ffi_varbinary_insert (продвинутого уровня). Пример: "\65 \66 \67".

uuid. Тип данных uuid в Tarantool хранится в формате MsgPack ext (Extension). Значения с типом uuid представляют собой Универсальные уникальные идентификаторы.
Пример: 64d22e4d-ac92-4a23-899a-e5934af5479.

array. В Lua массив типа array заключается в {...} (фигурные скобки). Примерами могут быть списки чисел, которые обозначают точки геометрической фигуры: {10, 11}, {3, 5, 9, 10}.

table. Lua-таблицы со строковыми ключами хранятся в виде ассоциативных массивов MsgPack map. Lua-таблицы с целочисленными ключами, начиная с 1, хранятся в виде массивов MsgPack array. В Lua-таблицах нельзя использовать nil; вместо этого можно использовать box.NULL. Пример: запрос box.space.tester:select() вернет Lua-таблицу.

tuple. Кортеж (tuple) представляет собой легкую ссылку на массив типа MsgPack array, который хранится в базе данных. Это особый тип (cdata), который создан во избежание конвертации в Lua-таблицу при выборке данных. Некоторые функции могут возвращать таблицы с множеством кортежей. Примеры с кортежами см. в разделе box.tuple.

scalar. Значения в поле scalar могут быть типа boolean, или integer, или unsigned, или double, или number, или decimal, или string, или varbinary, но не array, map или tuple. Примеры: true, 1, 'xxx'.

any. Значения в поле any могут быть типа boolean, или integer, или unsigned, или double, или number, или decimal, или string, или varbinary, или array, или map, или tuple. Примеры: true, 1, 'xxx', {box.NULL, 0}.

Примеры запросов вставки с разными типами полей:

tarantool> box.space.K:insert{1,nil,true,'A B C',12345,1.2345}
---
- [1, null, true, 'A B C', 12345, 1.2345]
...
tarantool> box.space.K:insert{2,{['a']=5,['b']=6}}
---
- [2, {'a': 5, 'b': 6}]
...
tarantool> box.space.K:insert{3,{1,2,3,4,5}}
---
- [3, [1, 2, 3, 4, 5]]
...
Типы индексированных полей

Индексы ограничивают значения, которые Tarantool может хранить в формате MsgPack. Вот зачем, например, нужны отдельные типы полей 'unsigned' и 'integer': хотя в MsgPack оба содержат целочисленные значения, но индекс типа 'unsigned' содержит только неотрицательные целочисленные значения, а индекс типа 'integer' содержит любые целочисленные значения.

Здесь снова приводятся типы полей, описанные в Описании типов полей, а также типы индексов, где их можно использовать. По умолчанию, тип поля — 'unsigned', тип индекса — TREE. Хотя в качестве типа индексированого поля 'nil' использовать нельзя, индексы могут содержать nil как опцию, которая не используется по умолчанию. Более подробную информацию см. в разделе Описание типов индексированных полей.

Имя типа поля Тип поля
Тип индекса
'boolean' boolean TREE или HASH
'integer' (также может называться ‘int’) integer, может включать в себя беззнаковые значения unsigned TREE или HASH
'unsigned' (также может называться ‘uint’ или ‘num’, но ‘num’ объявлен устаревшим) unsigned TREE, BITSET или HASH
'double' double TREE или HASH
'number' number, может включать в себя значения типа integer или double TREE или HASH
'decimal' decimal TREE или HASH
'string' (также может называться ‘str’) string TREE, BITSET или HASH
'varbinary' varbinary TREE, BITSET или HASH
'uuid' uuid TREE или HASH
'array' array RTREE
'scalar'

может содержания значения nil, или boolean, или integer, или unsigned, или number, или decimal, или string, или varbinary

Когда поле типа scalar содержит значения различных базовых типов, то порядок ключей следующий: nil, затем boolean, затем number, затем string, затем varbinary.

TREE или HASH

Сортировка

Когда Tarantool сравнивает строки, по умолчанию он использует так называемую «двоичную» сортировку. Единственный фактор, который учитывается, — это числовое значение каждого байта в строке. Таким образом, если строка кодируется по ASCII или UTF-8, то 'A' < 'B' < 'a', поскольку кодировка 'A' (раньше называлась «значение ASCII») соответствует 65, 'B'— 66, а 'a' — 98. Двоичная сортировка лучше всего подходит для быстрого детерминированного простого обслуживания и поиска с помощью индексов Tarantool.

Если же необходимо такое упорядочение, как в телефонных справочниках и словарях, то вам нужна дополнительная сортировка Tarantool — unicode или unicode_ci — которые обеспечивают 'a' < 'A' < 'B' и 'a' = 'A' < 'B' соответственно.

Дополнительные виды сортировки unicode и unicode_ci обеспечивают упорядочение в соответствии с Таблицей сортировки символов Юникода по умолчанию (DUCET) и правилами, указанными в Техническом стандарте Юникода №10 – Алгоритм сортировки по Юникоду (Unicode® Technical Standard #10 Unicode Collation Algorithm (UTS #10 UCA)). Единственное отличие между двумя видами сортировки — вес:

  • сортировка unicode принимает во внимание уровни веса L1, L2 и L3 (уровень = „tertiary“, третичный),
  • сортировка unicode_ci принимает во внимание только вес L1 (уровень = „primary“, первичный), поэтому, например, „a“ = „A“ = „á“ = „Á“.

Для примера возьмем некоторые русские слова:

'ЕЛЕ'
'елейный'
'ёлка'
'еловый'
'елозить'
'Ёлочка'
'ёлочный'
'ЕЛь'
'ель'

…и покажем разницу в упорядочении и выборке по индексу:

  • с сортировкой по unicode:

    tarantool> box.space.T:create_index('I', {parts = {{field = 1, type = 'str', collation='unicode'}}})
    ...
    tarantool> box.space.T.index.I:select()
    ---
    - - ['ЕЛЕ']
      - ['елейный']
      - ['ёлка']
      - ['еловый']
      - ['елозить']
      - ['Ёлочка']
      - ['ёлочный']
      - ['ель']
      - ['ЕЛь']
    ...
    tarantool> box.space.T.index.I:select{'ЁлКа'}
    ---
    - []
    ...
    
  • с сортировкой по unicode_ci:

    tarantool> box.space.T:create_index('I', {parts = {{field = 1, type ='str', collation='unicode_ci'}}})
    ...
    tarantool> box.space.S.index.I:select()
    ---
    - - ['ЕЛЕ']
      - ['елейный']
      - ['ёлка']
      - ['еловый']
      - ['елозить']
      - ['Ёлочка']
      - ['ёлочный']
      - ['ЕЛь']
    ...
    tarantool> box.space.S.index.I:select{'ЁлКа'}
    ---
    - - ['ёлка']
    ...
    

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

Для английского языка используйте «unicode» и «unicode_ci». Для русского языка используйте «unicode» и «unicode_ci» (хотя некоторые русские, возможно, предпочтут киргизскую сортировку, в которой совпадают кириллические буквы „Е“ и „Ё“ с весом 1 уровня). Для голландского, немецкого (словарь), французского, индонезийского, ирландского, итальянского, лингальского, малайского, португальского, сесото, коса или зулу подойдут «unicode» и «unicode_ci».

Специализированные дополнительные виды сортировки: Что касается других языков, Tarantool предлагает специализированные виды сортировки для любого современного языка, на котором говорят более миллиона человек, а также для особых случаев, отличных от сортировки в словарном порядке и по примеру телефонного справочника. Чтобы увидеть полный список, выполните команду box.space._collation:select(). Названия специализированных видов сортировки составлены по форме unicode_[language code]_[strength], где language code — это стандартный код языка из 2 или 3 символов, а значение strength может быть s1 для уровня «primary» (вес уровня 1), s2 для уровня «secondary», s3 для уровня «tertiary». Tarantool использует те же коды языка, которые указаны в списке специализированных вариантов языковых настроек на страницах справочников по Ubuntu и Fedora. Схемы с объяснением отличий от упорядочения по DUCET можно найти по ссылке Common Language Data Repository.

Последовательности

Последовательность – это генератор упорядоченных значений целых чисел.

Как и для спейсов и индексов, для последовательностей следует указать имена, а Tarantool определит уникальный числовой идентификатор («ID последовательности»).

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

Параметры для box.schema.sequence.create()
Имя параметра Тип и значение Значение по умолчанию Примеры
start Integer. Значение генерируется, когда последовательность используется впервые 1 start=0
min Integer. Значения, ниже указанного, генерироваться не могут 1 min=-1000
max Integer. Значения, выше указанного, генерироваться не могут 9223372036854775807 max=0
cycle Логическое значение. Если значения не могут быть сгенерированы, начинать ли заново false cycle=true
cache Integer. Количество значений, которые будут храниться в кэше 0 cache=0
step Integer. Что добавить к предыдущему сгенерированному значению, когда генерируется новое значение 1 step=-1
if_not_exists (если отсутствует) Логическое значение. Если выставлено в true (истина) и существует последовательность с таким именем, то игнорировать другие опции и использовать текущие значения false if_not_exists=true

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

Для первоначального примера сгенерируем последовательность под названием „S“.

tarantool> box.schema.sequence.create('S',{min=5, start=5})
---
- step: 1
  id: 5
  min: 5
  cache: 0
  uid: 1
  max: 9223372036854775807
  cycle: false
  name: S
  start: 5
...

В результате видим, что в новой последовательность есть все значения по умолчанию, за исключением указанных min и start.

Затем получаем следующее значение с помощью функции next().

tarantool> box.sequence.S:next()
---
- 5
...

Результат точно такой же, как и начальное значение. Если мы снова вызовем next(), то получим 6 (потому что предыдущее значение плюс значение шага составит 6) и так далее.

Затем создадим новую таблицу и скажем, что ее первичный ключ можно получить из последовательности.

tarantool> s=box.schema.space.create('T')
---
...
tarantool> s:create_index('I',{sequence='S'})
---
- parts:
  - type: unsigned
    is_nullable: false
    fieldno: 1
  sequence_id: 1
  id: 0
  space_id: 520
  unique: true
  type: TREE
  sequence_fieldno: 1
  name: I
...
---
...

Затем вставим кортеж, не указывая значение первичного ключа.

tarantool> box.space.T:insert{nil,'other stuff'}
---
- [6, 'other stuff']
...

В результате имеем новый кортеж со значением 6 в первом поле. Такой способ организации данных, когда система автоматически генерирует значения для первичного ключа, иногда называется «автоинкрементным» (т.е. с автоматическим увеличением) или «по идентификатору».

Для получения подробной информации о синтаксисе и методах реализации см. справочник по box.schema.sequence.

Персистентность

В Tarantool’е обновления базы данных записываются в так называемые файлы журнала упреждающей записи (WAL-файлы). Это обеспечивает персистентность данных. При отключении электроэнергии или случайном завершении работы экземпляра Tarantool’а данные в оперативной памяти теряются. В такой ситуации WAL-файлы используются для восстановления данных так: Tarantool прочитывает WAL-файлы и повторно выполняет запросы (это называется «процессом восстановления»). Можно изменить временные настройки метода записи WAL-файлов или отключить его с помощью wal_mode.

Tarantool также сохраняет ряд файлов со статическими снимками данных (snapshots). Файл со снимком – это дисковая копия всех данных в базе на какой-то момент. Вместо того, чтобы зачитывать все WAL-файлы, появившиеся с момента создания базы, Tarantool в процессе восстановления может загрузить самый свежий снимок и затем зачитать только те WAL-файлы, которые были сделаны с момента сохранения снимка. После создания новых файлов, старые WAL-файлы могут быть удалены в целях экономии места на диске.

Чтобы принудительно создать файл со снимком, можно использовать запрос box.snapshot() в Tarantool’е. Чтобы включить автоматическое создание файлов со снимком, можно использовать демон создания контрольных точек Tarantool’а. Демон создания контрольных точек определяет интервалы для принудительного создания контрольных точек. Он обеспечивает синхронизацию и сохранение на диск образов движков базы данных (как memtx, так и vinyl), а также автоматически удаляет старые WAL-файлы.

Файлы со снимками можно создавать, даже если WAL-файлы отсутствуют.

Примечание

Движок memtx регулярно создает контрольные точки с интервалом, указанным в настройках демона создания контрольных точек.

Движок vinyl постоянно сохраняет состояние в контрольной точке в фоновом режиме.

Для получения более подробной информации о методе записи WAL-файлов и процессе восстановления см. раздел Внутренняя реализация.

Операции

Операции с данными

Tarantool поддерживает следующие основные операции с данными:

  • пять операций по изменению данных (INSERT, UPDATE, UPSERT, DELETE, REPLACE) и
  • одну операцию по выборке данных (SELECT).

Все они реализованы в виде функций во вложенном модуле box.space.

Примеры:

  • INSERT: добавить новый кортеж к спейсу „tester“.

    Первое поле, field[1], будет 999 (тип MsgPack – integer, целое число).

    Второе поле, field[2], будет „Taranto“ (тип MsgPack – string, строка).

    tarantool> box.space.tester:insert{999, 'Taranto'}
    
  • UPDATE: обновить кортеж, изменяя поле field[2].

    Оператор «{999}» со значением, которое используется для поиска поля, соответствующего ключу в первичном индексе, является обязательным, поскольку в запросе update() должен быть оператор, который указывает уникальный ключ, в данном случае – field[1].

    Оператор «{{„=“, 2, „Tarantino“}}» указывает, что назначение нового значения относится к field[2].

    tarantool> box.space.tester:update({999}, {{'=', 2, 'Tarantino'}})
    
  • UPSERT: обновить или вставить кортеж, снова изменяя поле field[2].

    Синтаксис upsert() похож на синтаксис update(). Однако логика выполнения двух запросов отличается. UPSERT означает UPDATE или INSERT, в зависимости от состояния базы данных. Кроме того, выполнение UPSERT откладывается до коммита транзакции, поэтому в отличие от``update()``, upsert() не возвращает данные.

    tarantool> box.space.tester:upsert({999, 'Taranted'}, {{'=', 2, 'Tarantism'}})
    
  • REPLACE: заменить кортеж, добавляя новое поле.

    Это действие также можно выполнить с помощью запроса update(), но обычно запрос update() более сложен.

    tarantool> box.space.tester:replace{999, 'Tarantella', 'Tarantula'}
    
  • SELECT: провести выборку кортежа.

    Оператор «{999}» все еще обязателен, хотя в нем не должен упоминаться первичный ключ.

    tarantool> box.space.tester:select{999}
    
  • DELETE: удалить кортеж.

    В этом примере мы определяем поле, соответствующее ключу в первичном индексе.

    tarantool> box.space.tester:delete{999}
    

Подводя итоги по примерам:

  • Функции insert и replace принимают кортеж (где первичный ключ – это часть кортежа).
  • Функция upsert принимает кортеж (где первичный ключ – это часть кортежа), а также операции по обновлению.
  • Функция delete принимает полный ключ любого уникального индекса (первичный или вторичный).
  • Функция update принимает полный ключ любого уникального индекса (первичный или вторичный), а также операции к выполнению.
  • Функция select принимает любой ключ: первичный/вторичный, уникальный/неуникальный, полный/часть.

Для получения более подробной информации по использованию операций с данными см. справочник по box.space.

Примечание

Помимо Lua можно использовать коннекторы к Perl, PHP, Python или другому языку программирования. Клиент-серверный протокол открыт и задокументирован. См. БНФ с комментариями.

Факторы сложности

Во вложенных модулях box.space и Вложенный модуль box.index содержится информация о том, как факторы сложности могут повлиять на использование каждой функции.

Фактор сложности Эффект
Размер индекса Количество ключей в индексе равно количеству кортежей в наборе данных. В случае с TREE-индексом: с ростом количества ключей увеличивается время поиска, хотя зависимость здесь, конечно же, не линейная. В случае с HASH-индексом: с ростом количества ключей увеличивается объем оперативной памяти, но количество низкоуровневых шагов остается примерно тем же.
Тип индекса Как правило, поиск по HASH-индексу работает быстрее, чем по TREE-индексу, если в спейсе более одного кортежа.
Количество обращений к индексам

Обычно для выборки значений одного кортежа используется только один индекс. Но при обновлении значений в кортеже требуется N обращений, если в спейсе N индексов.

Примечание по движку базы данных: Vinyl отклоняет такой доступ, если обновление не затрагивает поля вторичного индекса. Таким образом, этот фактор сложности влияет только на memtx, поскольку он всегда создает копию всего кортежа при каждом обновлении.

Количество обращений к кортежам Некоторые запросы, например SELECT, могут возвращать несколько кортежей. Как правило, это наименее важный фактор из всех.
Настройки WAL Важным параметром для записи в WAL является wal_mode. Если запись в WAL отключена или задана запись с задержкой, но этот фактор не так важен. Если же запись в WAL производится при каждом запросе на изменение данных, то при каждом таком запросе приходится ждать, пока отработает обращение к более медленному диску, и данный фактор становится важнее всех остальных.

Транзакции

Транзакции в Tarantool’е происходят в файберах в одном потоке. Вот почему Tarantool дает гарантию атомарности выполнения. На этом следует сделать акцент.

Потоки, файберы и передача управления

Как Tarantool выполняет основные операции? Для примера возьмем такой запрос:

tarantool> box.space.tester:update({3}, {{'=', 2, 'size'}, {'=', 3, 0}})

Это эквивалентно следующему SQL-выражению (оно работает с таблицей, где первичные ключи в field[1]):

UPDATE tester SET "field[2]" = 'size', "field[3]" = 0 WHERE "field[1]" = 3

Assuming this query is received by Tarantool via network, it will be processed with three operating system threads:

  1. The network thread on the server side receives the query, parses the statement, checks if it’s correct, and then transforms it into a special structure–a message containing an executable statement and its options.

  2. Сетевой поток отправляет это сообщение в поток обработки транзакций с помощью шины передачи сообщений без блокировок. Lua-программы выполняются непосредственно в потоке обработки транзакций и не требуют разбора и подготовки.

    The instance’s transaction processor thread uses the primary-key index on field[1] to find the location of the tuple. It determines that the tuple can be updated (not much can go wrong when you’re merely changing an unindexed field value).

  3. The transaction processor thread sends a message to the write-ahead logging (WAL) thread to commit the transaction. When done, the WAL thread replies with a COMMIT or ROLLBACK result to the transaction processor which gives it back to the network thread, and the network thread returns the result to the client.

Обратите внимание, что в Tarantool’е есть только один поток обработки транзакций. Некоторые уже привыкли к мысли, что потоков для обработки данных в базе данных может быть много (например, поток №1 читает данные из строки №x, в то время как поток №2 записывает данные в столбец №y). В случае с Tarantool’ом такого не происходит. Доступ к базе есть только у потока обработки транзакций, и на каждый экземпляр Tarantool’а есть только один такой поток.

Как и любой другой поток Tarantool’а, поток обработки транзакций может управлять множеством файберов. Файбер – это набор команд, среди которых могут быть и сигналы «передачи управления». Поток обработки транзакций выполняет все команды, пока не увидит такой сигнал, и тогда он переключается на выполнение команд из другого файбера. Например, таким образом поток обработки транзакций сначала выполняет чтение данных из строки №x для файбера №1, а затем выполняет запись в строку №y для файбера №2.

Передача управления необходима, в противном случае, поток обработки транзакции заклинит на одном файбере. Есть два типа передачи управления:

  • неявная передача управления: каждая операция по изменению данных или доступ к сети вызывают неявную передачу управления, а также каждое выражение, которое проходит через клиент Tarantool’а, вызывает неявную передачу управления.
  • явная передача управления: в Lua-функции можно (и нужно) добавить выражения «передачи управления» для предотвращения захвата ЦП. Это называется кооперативной многозадачностью.

Кооперативная многозадачность

Кооперативная многозадачность означает, что если запущенный файбер намеренно не передаст управление, он не вытесняется каким-либо другим файбером. Но запущенный файбер намеренно передает управление, когда обнаруживает “точку передачи управления”: коммит транзакции, вызов операционной системы или запрос явной «передачи управления». Любой вызов системы, который может блокировать файбер, будет производиться асинхронно, а запущенный файбер, который должен ожидать системного вызова, будет вытеснен так, что другой готовый к работе файбер занимает его место и становится запущенным файбером.

This model makes all programmatic locks unnecessary: cooperative multitasking ensures that there will be no concurrency around a resource, no race conditions, and no memory consistency issues. The way to achieve this is quite simple: in critical sections, don’t use yields, explicit or implicit, and no one can interfere into the code execution.

При небольших запросах, таких как простые UPDATE, INSERT, DELETE или SELECT, происходит справедливое планирование файберов: немного времени требуется на обработку запроса, планирование записи на диск и передачу управления на файбер, обслуживающий следующего клиента.

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

Транзакции

В отсутствие транзакций любая функция, в которой есть точки передачи управления, может видеть изменения в состоянии базы данных, вызванные вытесняющими файберами. Составные транзакции предназначены для изоляции: каждая транзакция видит постоянное состояние базы данных и делает атомарные коммиты изменений. Во время коммита происходит передача управления, а все транзакционные изменения записываются в журнал упреждающей записи в отдельный пакет. Или, при необходимости, можно откатить изменения – полностью или на определенную точку сохранения.

In Tarantool, transaction isolation level is serializable with the clause «if no failure during writing to WAL». In case of such a failure that can happen, for example, if the disk space is over, the transaction isolation level becomes read uncommitted.

In vinyl, to implement isolation Tarantool uses a simple optimistic scheduler: the first transaction to commit wins. If a concurrent active transaction has read a value modified by a committed transaction, it is aborted.

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

Иногда при тестировании механизма транзакций в Tarantool можно заметить, что выдача после box.begin(), но перед любой операцией чтения/записи не приводит к прерыванию, как это должно происходить согласно описанию. Причина в том, что на самом деле box.begin() не запускает транзакцию: это просто метка, указывающая Tarantool запустить транзакцию после некоторого запроса к базе данных, который следует за этим.

In memtx, if an instruction that implies yields, explicit or implicit, is executed during a transaction, the transaction is fully rolled back. In vinyl, we use more complex transactional manager that allows yields.

Примечание

На сегодняшний день нельзя смешивать движки базы данных в транзакции.

Правила неявной передачи управления

Единственные запросы явной передачи данных в Tarantool’е отправляют fiber.sleep() и fiber.yield(), но многие другие запросы «неявно» подразумевают передачу управления, поскольку цель Tarantool’а – избежать блокировок.

Database requests imply yields if and only if there is disk I/O. For memtx, since all data is in memory, there is no disk I/O during a read request. For vinyl, since some data may not be in memory, there may be disk I/O for a read (to fetch data from disk) or for a write (because a stall may occur while waiting for memory to be free). For both memtx and vinyl, since data-change requests must be recorded in the WAL, there is normally a commit. A commit happens automatically after every request in default «autocommit» mode, or a commit happens at the end of a transaction in «transaction» mode, when a user deliberately commits by calling box.commit(). Therefore for both memtx and vinyl, because there can be disk I/O, some database operations may imply yields.

Многие функции в модулях fio, net_box, console и socket (запросы «ОС» и «сети») передают управление.

Поэтому выполнение отдельных команд, таких как select(), insert(), update() в консоли внутри транзакции, приведет к прерыванию транзакции. Это связано с тем, что после выполнения каждого фрагмента кода в консоли происходит неявная передача управления (yield).

Пример №1

  • Engine = memtx
    The sequence select() insert() has one yield, at the end of insertion, caused by implicit commit; select() has nothing to write to the WAL and so does not yield.
  • Engine = vinyl
    The sequence select() insert() has one to three yields, since select() may yield if the data is not in cache, insert() may yield waiting for available memory, and there is an implicit yield at commit.
  • Последовательность begin() insert() insert() commit() передает управление только при коммите, если движок – memtx, и может передавать управление до 3 раз, если движок – vinyl.

Пример №2

Assume that in the memtx space ‘tester’ there are tuples in which the third field represents a positive dollar amount. Let’s start a transaction, withdraw from tuple#1, deposit in tuple#2, and end the transaction, making its effects permanent.

tarantool> function txn_example(from, to, amount_of_money)
         >   box.begin()
         >   box.space.tester:update(from, {{'-', 3, amount_of_money}})
         >   box.space.tester:update(to,   {{'+', 3, amount_of_money}})
         >   box.commit()
         >   return "ok"
         > end
---
...
tarantool> txn_example({999}, {1000}, 1.00)
---
- "ok"
...

Если wal_mode = ‘none’, то при коммите управление не передается неявно, потому что не идет запись в WAL-файл.

If a task is interactive – sending requests to the server and receiving responses – then it involves network I/O, and therefore there is an implicit yield, even if the request that is sent to the server is not itself an implicit yield request. Therefore, the following sequence

conn.space.test:select{1}
conn.space.test:select{2}
conn.space.test:select{3}

causes yields three times sequentially when sending requests to the network and awaiting the results. On the server side, the same requests are executed in common order possibly mixing with other requests from the network and local fibers. Something similar happens when using clients that operate via telnet, via one of the connectors, or via the MySQL and PostgreSQL rocks, or via the interactive mode when using Tarantool as a client.

После того, как файбер передал управление, а затем вернул его, он незамедлительно вызывает testcancel.

Transactional manager

Since version 2.6.1, Tarantool has another option for transaction behavior that allows yielding inside a memtx transaction. This is controled by the transactional manager.

The transactional manager is designed for isolation of concurrent transactions and provides serializable transaction isolation level. It consists of two parts:

  • MVCC engine
  • conflict manager.

The MVCC engine provides personal read views for transactions if necessary. The conflict manager tracks transactions“ changes and determines their correctness in serialization order. Of course, once yielded, a transaction could interfere with other transactions and could be aborted due to conflict.

Another important thing to mention is that the transaction manager provides non-classic snapshot isolation level. It means that a transaction can get a consistent snapshot of the database (that is common) but this snapshot is not necessarily bound to the moment of the beginning of the transaction (that is not common). The conflict manager makes decisions on whether and when each transaction gets which snapshot. That allows to avoid some conflicts comparing with classical snapshot isolation approach.

The transactional manager can be switched on and off by the box.cfg option memtx_use_mvcc_engine.

Управление доступом

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

Вкратце:

  • Существует метод, который с помощью паролей проверяет, что пользователи являются теми, за кого себя выдают (“аутентификация”).
  • Существует системный спейс _user, где хранятся имена пользователей и хеши паролей.
  • Существуют функции, чтобы дать определенным пользователям право совершать определенные действия (“права”).
  • Существует системный спейс _priv, где хранятся права. Когда пользователь пытается выполнить операцию, проводится проверка на наличие у него прав на выполнение такой операции (“управление доступом”).

Подробная информация приводится ниже.

Пользователи

Для любой локальной или удаленной программы, работающей с Tarantool’ом, есть текущий пользователь. Если удаленное соединение использует бинарный порт, то текущим пользователем, по умолчанию, будет „guest“ (гость). Если соединение использует порт для административной консоли, текущим пользователем будет „admin“ (администратор). При выполнении скрипта инициализации на Lua, текущим пользователем также будет ‘admin’.

Имя текущего пользователя можно узнать с помощью box.session.user().

Текущего пользователя можно изменить:

  • Для соединения по бинарному порту – с помощью команды протокола AUTH, которая поддерживается большинством клиентов;
  • Для соединения по порту для административной консоли и при выполнении скрипта инициализации на Lua – с помощью box.session.su();
  • Для соединения по бинарному порту, которое вызывает хранимую функцию с помощью команды CALL – если для функции включена настройка SETUID, Tarantool временно заменит текущего пользователя на создателя функции со всеми правами создателя во время выполнения функции.

Пароли

У каждого пользователя (за исключением гостя „guest“) может быть пароль. Паролем является любая буквенно-цифровая строка.

Пароли Tarantool’а хранятся в системном спейсе _user с криптографической хеш-функцией, так что если паролем является ‘x’, хранится хеш-пароль в виде длинной строки, например ‘lL3OvhkIPOKh+Vn9Avlkx69M/Ck=‘. Когда клиент подключается к экземпляру Tarantool’а, экземпляр отправляет случайное значение соль, которое клиент должен сложить вместе с хеш-паролем перед отправкой на экземпляр. Таким образом, изначальное значение ‘x’ никогда не хранится нигде, кроме как в голове самого пользователя, а хешированное значение никогда не передается по сети, кроме как в смешанном с солью виде.

Примечание

Для получения дополнительной информации об алгоритме хеширования паролей (например, для написания нового клиентского приложения), прочтите файл заголовка scramble.h.

Система не дает злоумышленнику определить пароли путем просмотра файлов журнала или слежения за активностью. Это та же система, несколько лет назад внедренная в MySQL, которой оказалось достаточно для объектов со средней степенью безопасности. Тем не менее, администраторы должны предупреждать пользователей, что никакая система не защищена полностью от постоянных длительных атак, поэтому пароли следует охранять и периодически изменять. Администраторы также должны рекомендовать пользователям выбирать длинные неочевидные пароли, но сами пользователи выбирают свои пароли и изменяют их.

Для управления паролями в Tarantool’е есть две функции: box.schema.user.passwd() для изменения пароля пользователя и box.schema.user.password() для получения хеша пароля пользователя.

Владельцы и права

В Tarantool’е одна база данных. Она может называться «box.schema» или «universe». База данных содержит объекты базы данных, включая спейсы, индексы, пользователей, роли, последовательности и функции.

Владелец объекта базы данных – это пользователь, который создал его. Владельцем самой базы данных и объектов, которые изначально были созданы (системные спейсы и пользователи по умолчанию) является „admin“.

У владельцев автоматически есть права на то, что они создают. Владельцы могут поделиться этими правами с другими пользователями или ролями с помощью запросов box.schema.user.grant(). Можно предоставить следующие права:

  • „read“ (чтение), например, разрешить выборку из спейса
  • „write“ (запись), например, разрешить обновление спейса
  • „execute“ (выполнение), например, разрешить вызов функции, или (реже) разрешить использование роли
  • „create“ (создание), например, разрешить выполнение box.schema.space.create (также необходим доступ к определенным системным спейсам)
  • „alter“ (изменение), например, разрешить выполнение box.space.x.index.y:alter (также необходим доступ к определенным системным спейсам
  • „drop“, e.g. allow box.sequence.x:drop (access to certain system spaces is also necessary)
  • „usage“ (использование), например, допустимо ли любое действие, несмотря на другие права (иногда удобно отменить право на использование, чтобы временно заблокировать пользователя, не удаляя ег
  • „session“ (сессия), например, может ли пользователь выполнить подключение „connect“.

Чтобы создавать объекты, у пользователей должны быть права на создание „create“ и хотя бы права на чтение „read“ и запись „write“ в системный спейс с похожим именем (например, на спейс _space, если пользователю необходимо создавать спейсы.

Чтобы получать доступ к объектам, у пользователей должны быть соответствующие права на объект (например, права на выполнение „execute“ на функцию F, если пользователям необходимо выполнить функцию F). См. ниже некоторые примеры предоставления определенных прав, которые может выдать „admin“ или создатель объекта.

To drop an object, users must be „admin“ or have the „super“ role. Some objects may also be dropped by their creators. As the owner of the entire database, „admin“ can drop any object including other users.

Чтобы предоставить права пользователю, владелец объекта выполняет команду box.schema.user.grant(). Чтобы отменить права пользователя, владелец объекта выполняет команду box.schema.user.revoke(). В любом случае можно использовать до пяти параметров:

(user-name, privilege, object-type [, object-name [, options]])
  • user-name – это пользователь (или роль), который получит или потеряет права;

  • privilege – это тип прав: „read“, „write“, „execute“, „create“, „alter“, „drop“, „usage“ или „session“ (или список прав, разделенных запятыми);

  • object-type is any of „space“, „index“, „sequence“, „function“, „user“, „role“, or „universe“;

  • object-name is what the privilege is for (omitted if object-type is „universe“) (may be omitted or nil if the intent is to grant for all objects of the same type);

  • options – это список параметров, приведенный в скобках, например, {if_not_exists=true|false} (как правило, не указывается, поскольку допускаются значения по умолчанию).

    Every update of user privileges is reflected immediately in the existing sessions and objects, e.g. functions.

Пример предоставления нескольких типов прав одновременно

В данном примере пользователь „admin“ выдает много типов прав на множество объектов пользователю „U“ в едином запросе.

box.schema.user.grant('U','read,write,execute,create,drop','universe')

Примеры предоставления прав на определенные действия

In these examples an administrator grants precisely the minimal privileges necessary for particular operations, to user „U“.

-- So that 'U' can create spaces:
  box.schema.user.grant('U','create','space')
  box.schema.user.grant('U','write', 'space', '_schema')
  box.schema.user.grant('U','write', 'space', '_space')
-- So that 'U' can  create indexes on space T
  box.schema.user.grant('U','create,read','space','T')
  box.schema.user.grant('U','read,write','space','_space_sequence')
  box.schema.user.grant('U','write', 'space', '_index')
-- So that 'U' can  alter indexes on space T (assuming 'U' did not create the index)
  box.schema.user.grant('U','alter','space','T')
  box.schema.user.grant('U','read','space','_space')
  box.schema.user.grant('U','read','space','_index')
  box.schema.user.grant('U','read','space','_space_sequence')
  box.schema.user.grant('U','write','space','_index')
-- So that 'U' can alter indexes on space T (assuming 'U' created the index)
  box.schema.user.grant('U','read','space','_space_sequence')
  box.schema.user.grant('U','read,write','space','_index')
-- So that 'U' can create users:
  box.schema.user.grant('U','create','user')
  box.schema.user.grant('U', 'read,write', 'space', '_user')
  box.schema.user.grant('U', 'write', 'space', '_priv')
-- So that 'U' can create roles:
  box.schema.user.grant('U','create','role')
  box.schema.user.grant('U', 'read,write', 'space', '_user')
  box.schema.user.grant('U', 'write', 'space', '_priv')
-- So that 'U' can create sequence generators:
  box.schema.user.grant('U','create','sequence')
  box.schema.user.grant('U', 'read,write', 'space', '_sequence')
-- So that 'U' can create functions:
  box.schema.user.grant('U','create','function')
  box.schema.user.grant('U','read,write','space','_func')
-- So that 'U' can create any object of any type
  box.schema.user.grant('guest','read,write,create','universe')
-- So that 'U' can grant access on objects that 'U' created
  box.schema.user.grant('U','write','space','_priv')
-- So that 'U' can select or get from a space named 'T'
  box.schema.user.grant('U','read','space','T')
-- So that 'U' can update or insert or delete or truncate a space named 'T'
  box.schema.user.grant('U','write','space','T')
-- So that 'U' can execute a function named 'F'
  box.schema.user.grant('U','execute','function','F')
-- So that 'U' can use the "S:next()" function with a sequence named S
  box.schema.user.grant('U','read,write','sequence','S')
-- So that 'U' can use the "S:set()" or "S:reset() function with a sequence named S
  box.schema.user.grant('U','write','sequence','S')
-- So that 'U' can drop a sequence (assuming 'U' did not create it)
  box.schema.user.grant('U','drop','sequence')
  box.schema.user.grant('U','write','space','_sequence_data')
  box.schema.user.grant('U','write','space','_sequence')
-- So that 'U' can drop a function (assuming 'U' did not create it)
  box.schema.user.grant('U','drop','function')
  box.schema.user.grant('U','write','space','_func')
-- So that 'U' can drop a space that has some associated objects
  box.schema.user.grant('U','create,drop','space')
  box.schema.user.grant('U','write','space','_schema')
  box.schema.user.grant('U','write','space','_space')
  box.schema.user.grant('U','write','space','_space_sequence')
  box.schema.user.grant('U','read','space','_trigger')
  box.schema.user.grant('U','read','space','_fk_constraint')
  box.schema.user.grant('U','read','space','_ck_constraint')
  box.schema.user.grant('U','read','space','_func_index')
-- So that 'U' can drop any space (ignore if the privilege exists already)
  box.schema.user.grant('U','drop','space',nil,{if_not_exists=true})

Пример создания пользователей и объектов и последующей выдачи прав

Здесь создадим Lua-функцию, которая будет выполняться от ID пользователя, который является ее создателем, даже если она вызывается другим пользователем.

Для начала создадим два спейса („u“ и „i“) и дадим полный доступ к ним пользователю без пароля („internal“). Затем определим функцию („read_and_modify“), и пользователь без пароля становится создателем функции. Наконец, дадим другому пользователю („public_user“) доступ на выполнение Lua-функций, созданных пользователем без пароля.

box.schema.space.create('u')
box.schema.space.create('i')
box.space.u:create_index('pk')
box.space.i:create_index('pk')

box.schema.user.create('internal')

box.schema.user.grant('internal', 'read,write', 'space', 'u')
box.schema.user.grant('internal', 'read,write', 'space', 'i')
box.schema.user.grant('internal', 'create', 'universe')
box.schema.user.grant('internal', 'read,write', 'space', '_func')

function read_and_modify(key)
  local u = box.space.u
  local i = box.space.i
  local fiber = require('fiber')
  local t = u:get{key}
  if t ~= nil then
    u:put{key, box.session.uid()}
    i:put{key, fiber.time()}
  end
end

box.session.su('internal')
box.schema.func.create('read_and_modify', {setuid= true})
box.session.su('admin')
box.schema.user.create('public_user', {password = 'secret'})
box.schema.user.grant('public_user', 'execute', 'function', 'read_and_modify')

Роли

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

Информация о роли хранится в спейсе _user, но третье поле кортежа – поле типа – это ‘роль’, а не ‘пользователь’.

В управлении доступом на основе ролей один из главных моментов – это то, что роли могут быть вложенными. Например, роли R1 можно предоставить право типа «роль R2», то есть пользователи с ролью R1 тогда получат все права роли R1 и роли R2. Другими словами, пользователь получает все права, которые предоставляются ролям пользователя напрямую и опосредованно.

Фактически есть два способа предоставить или отменить роль: box.schema.user.grant-or-revoke(имя-пользователя-или-имя-роли,'execute', 'role',имя-роли...) или box.schema.user.grant-or-revoke(имя-пользователя-или-имя-роли,имя-роли...). Рекомендуется использовать второй способ.

Права типов „usage“ и „session“ нельзя предоставить для роли.

Пример

-- Этот пример сработает для пользователя со множеством прав, например, 'admin'
-- или для пользователя с заданной ролью 'super'
-- Создать спейс T с первичным индексом
box.schema.space.create('T')
box.space.T:create_index('primary', {})
-- Создать пользователя U1, чтобы затем можно было заменить текущего пользователя на U1
box.schema.user.create('U1')
-- Создать две роли, R1 и R2
box.schema.role.create('R1')
box.schema.role.create('R2')
-- Предоставить роль R2 для роли R1, а роль R1 пользователю U1 (порядок не имеет значения)
-- Есть два способа предоставить роль, здесь используется более короткий способ
box.schema.role.grant('R1', 'R2')
box.schema.user.grant('U1', 'R1')
-- Предоставить права на чтение/запись на спейс T для роли R2
-- (но не для роли R1 и не пользователю U1)
box.schema.role.grant('R2', 'read,write', 'space', 'T')
-- Изменить текущего пользователя на пользователя U1
box.session.su('U1')
-- Теперь вставка в спейс T сработает, потому что благодаря вложенным ролям,
-- у пользователя U1 есть права на запись в спейс T
box.space.T:insert{1}

Более подробную информацию см. в справочнике по встроенным модулям: box.schema.user.grant() и box.schema.role.grant().

Сессии и безопасность

Сессия – это состояние подключения к Tarantool’у. Она содержит:

  • идентификатор в виде целого числа, определяющий соединение,
  • текущий пользователь, использующий соединение,
  • текстовое описание подключенного узла и
  • локальное состояние сессии, например, переменные и функции на Lua.

В Tarantool’е отдельная сессия может выполнять несколько транзакций одновременно. Каждая транзакция определяется по уникальному идентификатору в виде целого числа, который можно запросить в начале транзакции с помощью box.session.sync().

Примечание

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

Триггеры

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

To associate an event with a callback, one should pass the callback to the corresponding on_event function:

Then the server will store the callback function and call it when the corresponding event happens.

У всех триггеров есть следующие особенности:

  • Только пользователь „admin“ определяет триггеры.
  • Триггеры хранятся в памяти экземпляра Tarantool’а, а не в базе данных. Поэтому триггеры пропадают, когда экземпляр отключают. Чтобы сохранить их, поместите определения функции и настройки триггера в скрипт инициализации Tarantool’а.
  • Триггеры не тратят много ресурсов. Если триггер не задан, то требуется минимум вычислений — разыменование и проверка указателя. Если триггер определен, то стоимость вызова равна стоимости вызова функции.
  • There can be multiple triggers for one event. In this case, triggers are executed in the reverse order that they were defined in. (Exception: member triggers are executed in the order that they appear in the member list.)
  • Триггеры должны работать в контексте события. Однако результат не определен, если функция содержит запросы, которые при нормальных условиях не могут быть выполнены непосредственно после события, а только после возврата из события. Например, если указать os.exit() или box.rollback() в триггерной функции, запросы не будут выполняться в контексте события.
  • Triggers are replaceable. The request to «redefine a trigger» implies passing a new trigger function and an old trigger function to one of the on_event functions.
  • The on_event functions all have parameters which are function pointers, and they all return function pointers. Remember that a Lua function definition such as function f() x = x + 1 end is the same as f = function () x = x + 1 end - in both cases f gets a function pointer. And trigger = box.session.on_connect(f) is the same as trigger = box.session.on_connect(function () x = x + 1 end) - in both cases trigger gets the function pointer which was passed.
  • You can call any on_event function with no arguments to get a list of its triggers. For example, use box.session.on_connect() to return a table of all connect-trigger functions.
  • Triggers can be useful in solving problems with replication. See details in Resolving replication conflicts.

Example:

Здесь мы записываем события подключения и отключения в журнал на сервере Tarantool’а.

log = require('log')

function on_connect_impl()
 log.info("connected "..box.session.peer()..", sid "..box.session.id())
end

function on_disconnect_impl()
 log.info("disconnected, sid "..box.session.id())
end

function on_auth_impl(user)
 log.info("authenticated sid "..box.session.id().." as "..user)
end

function on_connect() pcall(on_connect_impl) end
function on_disconnect() pcall(on_disconnect_impl) end
function on_auth(user) pcall(on_auth_impl, user) end

box.session.on_connect(on_connect)
box.session.on_disconnect(on_disconnect)
box.session.on_auth(on_auth)

Ограничения

Количество частей в индексе

Для TREE-индексов или HASH-индексов максимальное количество – 255 частей (box.schema.INDEX_PART_MAX). Для RTREE-индексов максимальное количество – 1, но это поля типа ARRAY (массив) с размерностью до 20. Для BITSET-индексов максимальное количество – 1.

Количество индексов в спейсе

128 (box.schema.INDEX_MAX).

Количество полей в кортеже

Теоретически максимальное количество составляет 2 147 483 647 полей (box.schema.FIELD_MAX). Практически максимальное количество указано в поле field_count спейса или соответствует максимальной длине кортежа.

Количество байтов в кортеже

Максимальное количество байтов в кортеже примерно равно memtx_max_tuple_size или vinyl_max_tuple_size (с ресурсами метаданных около 20 байтов на кортеж, которые добавляются к полезным байтам). Значение memtx_max_tuple_size или vinyl_max_tuple_size по умолчанию составляет 1 048 576. Чтобы его увеличить, укажите большее значение при запуске экземпляра Tarantool’а. Например, box.cfg{memtx_max_tuple_size=2*1048576}.

Количество байтов в индекс-ключе

Если поле в кортеже может содержать миллион байтов, то индекс-ключ может содержать миллион байтов, поэтому максимальное количество определяется такими факторами, как количество байтов в кортеже, а не параметрами индекса.

Количество спейсов

Теоретически максимальное количество составляет 2 147 483 647 (box.schema.SPACE_MAX), но практически максимальное количество – около 65 000.

Количество соединений

Практически пределом является количество файловых дескрипторов, которые можно определить с операционной системой.

Размер спейса

Итоговый максимальный размер всех спейсов фактически определяется в memtx_memory, который в свою очередь ограничен общим размером свободной памяти.

Число операций обновления

Максимальное количество операций, возможное в рамках одного обновления (для одного тапла), составляет 4000 (BOX_UPDATE_OP_CNT_MAX).

Количество пользователей и ролей

32 (BOX_USER_MAX).

Длина имени индекса, имени спейса или имени пользователя

65000 (box.schema.NAME_MAX).

Количество реплик в наборе реплик

32 (vclock.VCLOCK_MAX).

Движки базы данных

Движок базы данных представляет собой набор очень низкоуровневых процессов, которые фактически хранят и получают значения в кортежах. Tarantool предлагает два движка базы данных на выбор:

  • memtx (in-memory движок базы данных) используется по умолчанию, который был первым.

  • vinyl (движок для хранения данных на диске) – это рабочий движок на основе пар ключ-значение, который особенно понравится пользователям, предпочитающим записывать данные напрямую на диск, чтобы сократить время восстановления и увеличить размер базы данных.

    С другой стороны, vinyl’у не хватает некоторых функций и параметров, доступных в memtx’е. В соответствующих случаях дается дополнительное описание в виде примечания, которое начинается со слов Примечание про движок базы данных.

Далее в разделе рассмотрим подробнее метод хранения данных с помощью движка базы данных vinyl.

Чтобы указать, что следует использовать именно vinyl, необходимо при создании спейса добавить оператор engine = 'vinyl', например:

space = box.schema.space.create('name', {engine='vinyl'})

Различия между движками memtx и vinyl

Основным различием между движками memtx и vinyl является то, что memtx представляет собой «in-memory» движок, тогда как vinyl – это «дисковый» движок. Как правило, in-memory движок быстрее (каждый запрос обычно выполняется меньше, чем за 1 мс), и движок memtx по праву используется в Tarantool’е по умолчанию, но если база данных больше объема доступной памяти, а добавление дополнительной памяти не представляется возможным, рекомендуется использовать дисковый движок как vinyl.

Характеристика memtx vinyl
Поддерживаемый тип индекса TREE, HASH, RTREE или BITSET TREE
Временные спейсы Поддерживается Не поддерживается
функция random() Поддерживается Не поддерживается
функция alter() Поддерживается Поддерживается с версии 1.10.2 (первичный индекс изменять нельзя)
функция len() Возвращает количество кортежей в спейсе Возвращает максимальное примерное количество кортежей в спейсе
функция count() Занимает одинаковые периоды времени Занимает различное количество времени в зависимости от состояния БД
функция delete() Возвращает удаленный кортеж, если есть таковой Всегда возвращает nil
передача управления Does not yield on the select requests unless the transaction is committed to WAL Передает управление на запросах выборки или аналогичных: get() или pairs()

Хранение данных с помощью vinyl

Tarantool – это транзакционная, персистентная СУБД, которая хранит 100% данных в оперативной памяти. Основными преимущества хранения данных оперативной памяти являются скорость и простота использования: нет необходимости в оптимизации, однако производительность остается стабильно высокой.

A few years ago we decided to extend the product by implementing a classical storage engine similar to those used by regular DBMSs: it uses RAM for caching, while the bulk of its data is stored on disk. We decided to make it possible to set a storage engine independently for each table in the database, which is the same way that MySQL approaches it, but we also wanted to support transactions from the very beginning.

Первый вопрос, на который нужен был ответ: создавать свой движок или использовать уже существующую библиотеку? Сообщество разработчиков открытого ПО предлагает готовые библиотеки на выбор. Активнее всего развивалась библиотека RocksDB, которая к настоящему времени стала одной из самых популярных. Есть также несколько менее известных библиотек: WiredTiger, ForestDB, NestDB, LMDB.

Nevertheless, after studying the source code of existing libraries and considering the pros and cons, we opted for our own storage engine. One reason is that the existing third-party libraries expected requests to come from multiple operating system threads and thus contained complex synchronization primitives for controlling parallel data access. If we had decided to embed one of these in Tarantool, we would have made our users bear the overhead of a multithreaded application without getting anything in return. The thing is, Tarantool has an actor-based architecture. The way it processes transactions in a dedicated thread allows it to do away with the unnecessary locks, interprocess communication, and other overhead that accounts for up to 80% of processor time in multithreaded DBMSs.

_images/actor_threads.png

Процесс Tarantool’а состоит из заданного количества потоков

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

Алгоритм

Отказавшись от идеи внедрения существующих библиотек, необходимо было выбрать архитектуру для использования в качестве основы. Есть два альтернативных подхода к хранению данных на диске: старая модель с использованием B-деревьев и их разновидностей и новая – на основе журнально-структурированных деревьев со слиянием, или LSM-деревьев (Log Structured Merge Tree). MySQL, PostgreSQL и Oracle используют B-деревья, а Cassandra, MongoDB и CockroachDB уже используют LSM-деревья.

Считается, что B-деревья более эффективны для чтения, а LSM-деревья – для записи. Тем не менее, с распространением SSD-дисков, у которых в несколько раз выше производительность чтения по сравнению с производительностью записи, преимущества LSM-деревьев стали очевидны в большинстве сценариев.

Прежде чем разбираться с LSM-деревьями в Tarantool’е, посмотрим, как они работают. Для этого разберем устройство обычного B-дерева и связанные с ним проблемы. «B» в слове B-tree означает «Block», то есть это сбалансированное дерево, состоящее из блоков, которые содержат отсортированные списки пар ключ-значение. Вопросы наполнения дерева, балансировки, разбиения и слияния блоков выходят за рамки данной статьи, подробности вы сможете прочитать в Википедии. В итоге мы получаем отсортированный по возрастанию ключа контейнер, минимальный элемент которого хранится в крайнем левом узле, а максимальный – в крайнем правом. Посмотрим, как в B-дереве осуществляется поиск и вставка данных.

_images/classical_b_tree.png

Классическое B-дерево

Если необходимо найти элемент или проверить его наличие, поиск начинается, как обычно, с вершины. Если ключ обнаружен в корневом блоке, поиск заканчивается; в противном случае, переходим в блок с наибольшим меньшим ключом, то есть в самый правый блок, в котором еще есть элементы меньше искомого (элементы на всех уровнях расположены по возрастанию). Если и там элемент не найден, снова переходим на уровень ниже. В конце концов окажемся в одном из листьев и, возможно, обнаружим искомый элемент. Блоки дерева хранятся на диске и читаются в оперативную память по одному, то есть в рамках одного поиска алгоритм считывает logB(N) блоков, где N – это количество элементов в B-дереве. Запись в самом простом случае осуществляется аналогично: алгоритм находит блок, который содержит необходимый элемент, и обновляет (вставляет) его значение.

Чтобы наглядно представить себе эту структуру данных, возьмем B-дерево на 100 000 000 узлов и предположим, что размер блока равен 4096 байтов, а размер элемента – 100 байтов. Таким образом, в каждом блоке можно будет разместить до 40 элементов с учетом накладных расходов, а в B-дереве будет около 2 570 000 блоков, пять уровней, при этом первые четыре займут по 256 МБ, а последний – до 10 ГБ. Очевидно, что на любом современном компьютере все уровни, кроме последнего, успешно попадут в кэш файловой системы, и фактически любая операция чтения будет требовать не более одной операции ввода-вывода.

Ситуация выглядит существенно менее радужно при смене точки зрения. Предположим, что необходимо обновить один элемент дерева. Так как операции с B-деревьями работают через чтение и запись целых блоков, приходится прочитать 1 блок в память, изменить 100 байт из 4096, а затем записать обновленный блок на диск. Таким образом, нам пришлось записать в 40 раз больше, чем реальный объем измененных данных!

Принимая во внимание, что внутренний размер блока в SSD-дисках может быть 64 КБ и больше, и не любое изменение элемента меняет его целиком, объем «паразитной» нагрузки на диск может быть еще выше.

Феномен таких «паразитных» чтений в литературе и блогах, посвященных хранению на диске, называется read amplification (усложнение чтения), а феномен «паразитной» записи – write amplification (усложнение записи).

Коэффициент усложнения, то есть коэффициент умножения, вычисляется как отношение размера фактически прочитанных (или записанных) данных к реально необходимому (или измененному) размеру. В нашем примере с B-деревом коэффициент составит около 40 как для чтения, так и для записи.

Объем «паразитных» операций ввода-вывода при обновлении данных является одной из основных проблем, которую решают LSM-деревья. Рассмотрим, как это работает.

Ключевое отличие LSM-деревьев от классических B-деревьев заключается в том, что LSM-деревья не просто хранят данные (ключи и значения), а также операции с данными: вставки и удаления.

_images/lsm.png


LSM-дерево:

  • Хранит операторы, а не значения:
    • REPLACE
    • DELETE
    • UPSERT
  • Для каждого оператора назначается LSN Обновление файлов происходит только путем присоединения новых записей, сборка мусора проводится после контрольной точки
  • Журнал транзакций при любых изменениях в системе: vylog

Например, элемент для операции вставки, помимо ключа и значения, содержит дополнительный байт с кодом операции – обозначенный выше как REPLACE. Элемент для операции удаления содержит ключ элемента (хранить значение нет необходимости) и соответствующий код операции – DELETE. Также каждый элемент LSM-дерева содержит порядковый номер операции (log sequence number – LSN), то есть значение монотонно возрастающей последовательности, которое уникально идентифицирует каждую операцию. Таким образом, всё дерево упорядочено сначала по возрастанию ключа, а в пределах одного ключа – по убыванию LSN.

_images/lsm_single.png

Один уровень LSM-дерева

Наполнение LSM-дерева

В отличие от B-дерева, которое полностью хранится на диске и может частично кэшироваться в оперативной памяти, в LSM-дереве разделение между памятью и диском явно присутствует с самого начала. При этом проблема сохранности данных, расположенных в энергозависимой памяти, выносится за рамки алгоритма хранения: ее можно решить разными способами, например, журналированием изменений.

Часть дерева, расположенную в оперативной памяти, называют L0 (level zero – уровень ноль). Объем оперативной памяти ограничен, поэтому для L0 отводится фиксированная область. В конфигурации Tarantool’а, например, размер L0 задается с помощью параметра vinyl_memory. В начале, когда LSM-дерево не содержит элементов, операции записываются в L0. Следует отметить, что элементы в дереве упорядочены по возрастанию ключа, а затем по убыванию LSN, так что в случае вставки нового значения по данному ключу легко обнаружить и удалить предыдущее значение. L0 может быть представлен любым контейнером, который сохраняет упорядоченность элементов. Например, для хранения L0 Tarantool использует B+*-дерево. Операции поиска и вставки – это стандартные операции структуры данных, используемой для представления L0, и мы их подробно рассматривать не будем.

Рано или поздно количество элементов в дереве превысит размер L0. Тогда L0 записывается в файл на диске (который называется забегом – «run») и освобождается под новые элементы. Эта операция называется «дамп» (dump).

_images/dumps.png


Все дампы на диске образуют последовательность, упорядоченную по LSN: диапазоны LSN в файлах не пересекаются, а ближе к началу последовательности находятся файлы с более новыми операциями. Представим эти файлы в виде пирамиды, где новые файлы расположены вверху, а старые внизу. По мере появления новых файлов забегов, высота пирамиды растет. При этом более свежие файлы могут содержать операции удаления или замены для существующих ключей. Для удаления старых данных необходимо производиться сборку мусора (этот процесс иногда называется «слияние» – в английском языке «merge» или «compaction»), объединяя нескольких старых файлов в новый. Если при слиянии мы встречаем две версии одного и того же ключа, то достаточно оставить только более новую версию, а если после вставки ключа он был удален, то из результата можно исключить обе операции.

_images/purge.png


Ключевым фактором эффективности LSM-дерева является то, в какой момент и для каких файлов делается слияние. Представим, что LSM-дерево в качестве ключей хранит монотонную последовательность вида 1, 2, 3 …, и операций удаления нет. В этом случае слияние будет бесполезным – все элементы уже отсортированы, дерево не содержит мусор и можно однозначно определить, в каком файле находится каждый ключ. Напротив, если LSM-дерево содержит много операций удаления, слияние позволит освободить место на диске. Но даже если удалений нет, а диапазоны ключей в разных файлах сильно пересекаются, слияние может ускорить поиск, так как сократит число просматриваемых файлов. В этом случае имеет смысл выполнять слияние после каждого дампа. Однако следует отметить, что такое слияние приведет к перезаписи всех данных на диске, поэтому если чтений мало, то лучше делать слияния реже.

Для оптимальной конфигурации под любой из описанных выше сценариев в LSM-дереве все файлы организованы в пирамиду: чем новее операции с данными, тем выше они находятся в пирамиде. При этом в слиянии участвуют два или несколько соседних файлов в пирамиде; по возможности выбираются файлы примерно одинакового размера.

_images/compaction.png


  • Многоуровневое слияние может охватить любое количество уровней
  • Уровень может содержать несколько файлов

Все соседние файлы примерно одинакового размера составляют уровень LSM-дерева на диске. Соотношение размеров файлов на различных уровнях определяет пропорции пирамиды, что позволяет оптимизировать дерево под интенсивные вставки, либо интенсивные чтения.

Предположим, что размер L0 составляет 100 МБ, а соотношение размеров файлов на каждом уровне (параметр vinyl_run_size_ratio) равно 5, и на каждом уровне может быть не более 2 файлов (параметр vinyl_run_count_per_level). После первых трех дампов на диске появятся 3 файла по 100 МБ, эти файлы образуют уровень L1. Так как 3 > 2, запустится слияние файлов в новый файл размером 300 МБ, а старые будут удалены. Спустя еще 2 дампа снова запустится слияние, на этот раз файлов в 100, 100 и 300 МБ, в результате файл размером 500 МБ переместится на уровень L2 (вспомним, что соотношение размеров уровней равно 5), а уровень L1 останется пустым. Пройдут еще 10 дампов, и получим 3 файла по 500 МБ на уровне L2, в результате чего будет создан один файл размером 1500 МБ. Спустя еще 10 дампов произойдет следующее: 2 раза произведем слияние 3 файлов по 100 МБ, а также 2 раза слияние файлов по 100, 100 и 300 МБ, что приведет к созданию двух файлов на уровне L2 по 500 МБ. Поскольку на уровне L2 уже есть три файла, запустится слияние двух файлов по 500 МБ и одного файла в 1500 МБ. Полученный в результате файл в 2500 МБ, в силу своего размера, переедет на уровень L3.

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

Управление формой LSM-дерева

Если число файлов для поиска нужно уменьшить, то соотношение размеров файлов на разных уровнях можно увеличить, и, как следствие, уменьшается число уровней. Если, напротив, необходимо снизить затраты ресурсов, вызванные слиянием, то можно уменьшить соотношение размеров уровней: пирамида будет более высокой, а слияние хотя и выполняется чаще, но работает в среднем с файлами меньшего размера, за счет чего суммарно выполняет меньше работы. В целом, «паразитная запись» в LSM-дереве описывается формулой log_{x}(\\frac {N} {L0}) × x или x × \\frac {ln (\\frac {N} {C0})} {ln(x)}, где N – это общий размер всех элементов дерева, L0 – это размер уровня ноль, а x – это соотношение размеров уровней (параметр level_size_ratio). Если \\frac {N} {C0} = 40 (соотношение диск-память), график выглядит примерно вот так:

_images/curve.png


«Паразитное» чтение при этом пропорционально количеству уровней. Стоимость поиска на каждом уровне не превышает стоимости поиска в B-дереве. Возвращаясь к нашему примеру дерева в 100 000 000 элементов: при наличии 256 МБ оперативной памяти и стандартных значений параметров vinyl_run_size_ratio и vinyl_run_count_per_level, получим коэффициент «паразитной» записи равным примерно 13, коэффициент «паразитной» записи может доходить до 150. Разберемся, почему это происходит.

Поиск по диапазону

Если при поиске по одному ключу алгоритм завершается после первого совпадения, то для поиска всех значений в диапазоне (например, всех пользователей с фамилией «Иванов») необходимо просматривать все уровни дерева.

_images/range_search.png

Поиск по диапазону [24,30)

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

Удаление

Зачем вообще хранить операции удаления? И почему это не приводит к переполнению дерева, например, в сценарии for i=1,10000000 put(i) delete(i) end?

Роль операций удаления при поиске – сообщать об отсутствии искомого значения, а при слиянии – очищать дерево от «мусорных» записей с более старыми LSN.

Пока данные хранятся только в оперативной памяти, нет необходимости хранить операции удаления. Также нет необходимости сохранять операции удаления после слияния, если оно затрагивает в том числе самый нижний уровень дерева – на нем находятся данные самого старого дампа. Действительно, отсутствие значения на последнем уровне означает, что оно отсутствует в дереве.

  • Нельзя производить удаление из файлов, которые обновляются только путем присоединения новых записей
  • Вместо этого на уровень L0 вносятся маркеры удаленных записей (tombstones)
_images/deletion_1.png

Удаление, шаг 1: вставка удаленной записи в L0

_images/deletion_2.png

Удаление, шаг 2: удаленная запись проходит через промежуточные уровни

_images/deletion_3.png

Удаление, шаг 3: при значительном слиянии удаленная запись удаляется из дерева

Если мы знаем, что удаление следует сразу за вставкой уникального значения – а это частый случай при изменении значения во вторичном индексе – то операцию удаления можно отфильтровывать уже при слиянии промежуточных уровней. Эта оптимизация реализована в vinyl’е.

Преимущества LSM-дерева

Помимо снижения «паразитной» записи, подход с периодическими дампами уровня L0 и слиянием уровней L1-Lk имеет ряд преимуществ перед подходом к записи, используемым в B-деревьях:

  • При дампах и слиянии создаются относительно большие файлы: стандартный размер L0 составляет 50-100 MБ, что в тысячи раз превышает размер блока B-дерева.
  • Большой размер позволяет эффективно сжимать данные перед записью. В Tarantool’е сжатие происходит автоматически, что позволяет еще больше уменьшить «паразитную» запись.
  • Издержки фрагментации отсутствуют, потому что в файле элементы следуют друг за другом без пустот/заполнений.
  • Все операции создают новые файлы, а не заменяют старые данные. Это позволяет избавиться от столь ненавистных нам блокировок, при этом несколько операций могут идти параллельно, не приводя к конфликтам. Это также упрощает создание резервных копий и перенос данных на реплику.
  • Хранение старых версий данных позволяет эффективно реализовать поддержку транзакций, используя подход управления параллельным доступом с помощью многоверсионности.
Недостатки LSM-дерева и их устранение

Одним из ключевых преимуществ B-дерева как структуры данных для поиска является предсказуемость: любая операция занимает не более чем log_{B}(N). В классическом LSM-дереве скорость как чтения, так и записи могут может отличаться в лучшем и худшем случае в сотни и тысячи раз. Например, добавление всего лишь одного элемента в L0 может привести к его переполнению, что в свою очередь, может привести к переполнению L1, L2 и т.д. Процесс чтения может обнаружить исходный элемент в L0, а может задействовать все уровни. Чтение в пределах одного уровня также необходимо оптимизировать, чтобы добиться скорости, сравнимой с B-деревом. К счастью, многие недостатки можно скрасить или полностью устранить с помощью вспомогательных алгоритмов и структур данных. Систематизируем эти недостатки и опишем способы борьбы с ними, используемые в Tarantool’е.

Непредсказуемая скорость записи

Вставка данных в LSM-дерево почти всегда задействует исключительно L0. Как избежать простоя, если заполнена область оперативной памяти, отведенная под L0?

Освобождение L0 подразумевает две долгих операции: запись на диск и освобождение памяти. Чтобы избежать простоя во время записи L0 на диск, Tarantool использует упреждающую запись. Допустим, размер L0 составляет 256 MБ. Скорость записи на диск составляет 10 МБ/с. Тогда для записи L0 на диск понадобится 26 секунд. Скорость вставки данных составляет 10 000 запросов в секунду, а размер одного ключа – 100 байтов. На время записи необходимо зарезервировать около 26 MБ доступной оперативной памяти, сократив реальный полезный размер L0 до 230 MБ.

Все эти расчеты Tarantool делает автоматически, постоянно поддерживая скользящее среднее значение нагрузки на СУБД и гистограмму скорости работы диска. Это позволяет максимально эффективно использовать L0 и избежать истечения времени ожидания доступной памяти для операций записи. При резком всплеске нагрузки ожидание все же возможно, поэтому также существует время ожидания операции вставки (параметр vinyl_timeout), значение которого по умолчанию составляет 60 секунд. Сама запись осуществляется в выделенных потоках, число которых (2 по умолчанию) задается в параметре vinyl_write_threads. Используемое по умолчанию значение 2 позволяет выполнять дамп параллельно со слиянием, что также необходимо для предсказуемой работы системы.

Слияния в Tarantool’е всегда выполняются независимо от дампов, в отдельном потоке выполнения. Это возможно благодаря природе LSM-дерева – после записи файлы в дереве никогда не меняются, а слияние лишь создает новый файл.

К задержкам также может приводить ротация L0 и освобождение памяти, записанной на диск: в процессе записи памятью L0 владеют два потока операционной системы – поток обработки транзакций и поток записи. Хотя в L0 во время ротации элементы не добавляются, он может участвовать в поиске. Чтобы избежать блокировок на чтение во время поиска, поток записи не освобождает записанную память, а оставляет эту задачу потоку обработки транзакций. Само освобождение после завершения дампа происходит мгновенно: для этого в L0 используется специализированный механизм распределения, позволяющий освободить всю память за одну операцию.

_images/dump_from_shadow.png
  • упреждающий дамп
  • загрузка

Дамп происходит из так называемого «теневого» L0, не блокируя новые вставки и чтения

Непредсказуемая скорость чтений

Чтение – самая сложная задача для оптимизации в LSM-деревьях. Главным фактором сложности является большое количество уровней: это не только значительно замедляет поиск, но и потенциально значительно увеличивает требования к оперативной памяти при почти любых попытках оптимизации. К счастью, природа LSM-деревьев, где файлы обновляются только путем присоединения новых записей, позволяет решать эти проблемы нестандартными для традиционных структур данных способами.

_images/read_speed.png
  • постраничный индекс
  • фильтры Блума
  • кэш диапазона кортежей
  • многоуровневое слияние
Сжатие и постраничный индекс

Сжатие данных в B-деревьях – это либо сложнейшая в реализации задача, либо больше средство маркетинга, чем действительно полезный инструмент. Сжатие в LSM-деревьях работает следующим образом:

При любом дампе или слиянии мы разбиваем все данные в одном файле на страницы. Размер страницы в байтах задается в параметре vinyl_page_size, который можно менять отдельно для каждого индекса. Страница не обязана занимать строго то количество байт, которое прописано vinyl_page_size – она может быть чуть больше или чуть меньше, в зависимости от хранящихся в ней данных. Благодаря этому страница никогда не содержит пустот.

Для сжатия используется потоковый алгоритм Facebook под названием «zstd». Первый ключ каждой страницы и смещение страницы в файле добавляются в так называемый постраничный индекс (page index) – отдельный файл, который позволяет быстро найти нужную страницу. После дампа или слияния постраничный индекс созданного файла также записывается на диск.

Все файлы типа .index кэшируются в оперативной памяти, что позволяет найти нужную страницу за одно чтение из файла .run (такое расширение имени файла используется в vinyl’е для файлов, полученных в результате дампа или слияния). Поскольку данные в странице отсортированы, после чтения и декомпрессии нужный ключ можно найти с помощью простого бинарного поиска. За чтение и декомпрессию отвечают отдельные потоки, их количество определяется в параметре vinyl_read_threads.

Tarantool использует единый формат файлов: например, формат данных в файле .run ничем не отличается от формата файла .xlog (файл журнала). Это упрощает резервное копирование и восстановление, а также работу внешних инструментов.

Фильтры Блума

Хотя постраничный индекс позволяет уменьшить количество страниц, просматриваемых при поиске в одном файле, он не отменяет необходимости искать на всех уровнях дерева. Есть важный частный случай, когда необходимо проверить отсутствие данных, и тогда просмотр всех уровней неизбежен: вставка в уникальный индекс. Если данные уже существуют, то вставка в уникальный индекс должна завершиться с ошибкой. Единственный способ вернуть ошибку до завершения транзакции в LSM-дереве – произвести поиск перед вставкой. Такого рода чтения в СУБД образуют целый класс, называемый «скрытыми» или «паразитными» чтениями.

Другая операция, приводящая к скрытым чтениям, – обновление значения, по которому построен вторичный индекс. Вторичные ключи представляют собой обычные LSM-деревья, в которых данные хранятся в другом порядке. Чаще всего, чтобы не хранить все данные во всех индексах, значение, соответствующее данному ключу, целиком сохраняется только в первичном индексе (любой индекс, хранящий и ключ, и значение, называется покрывающим или кластерным), а во вторичном индексе сохраняются лишь поля, по которым построен вторичный индекс, и значения полей, участвующих в первичном индексе. Тогда при любом изменении значения, по которому построен вторичный ключ, приходится сначала удалять из вторичного индекса старый ключ, и только потом вставлять новый. Старое значение во время обновления неизвестно – именно его и нужно читать из первичного ключа с точки зрения внутреннего устройства.

Например:

update t1 set city=’Moscow’ where id=1

Чтобы уменьшить количество чтений с диска, особенно для несуществующих значений, практически все LSM-деревья используют вероятностные структуры данных. Tarantool не исключение. Классический фильтр Блума – это набор из нескольких (обычно 3-5) битовых массивов. При записи для каждого ключа вычисляется несколько хеш-функций, и в каждом массиве выставляется бит, соответствующий значению хеша. При хешировании могут возникнуть коллизии, поэтому некоторые биты могут быть проставлены дважды. Интерес представляют биты, которые оказались не проставлены после записи всех ключей. При поиске также вычисляются выбранные хеш-функции. Если хотя бы в одном из битовых массивов бит не стоит, то значение в файле отсутствует. Вероятность срабатывания фильтра Блума определяется теоремой Байеса: каждая хеш-функция представляет собой независимую случайную величину, благодаря чему вероятность того, что во всех битовых массивах одновременно произойдет коллизия, очень мала.

Ключевым преимуществом реализации фильтров Блума в Tarantool’е является простота настройки. Единственный параметр, который можно менять независимо для каждого индекса, называется vinyl_bloom_fpr (FPR в данном случае означает сокращение от «false positive ratio» – коэффициент ложноположительного срабатывания), который по умолчанию равен 0,05, или 5%. На основе этого параметра Tarantool автоматически строит фильтры Блума оптимального размера для поиска как по полному ключу, так и по компонентам ключа. Сами фильтры Блума хранятся вместе с постраничным индексом в файле .index и кэшируются в оперативной памяти.

Кэширование

Многие привыкли считать кэширование панацеей от всех проблем с производительностью: «В любой непонятной ситуации добавляй кэш». В vinyl’е мы смотрим на кэш скорее как на средство снижения общей нагрузки на диск, и, как следствие, получения более предсказуемого времени ответов на запросы, которые не попали в кэш. В vinyl’е реализован уникальный для транзакционных систем вид кэша под названием «кэш диапазона кортежей» (range tuple cache). В отличие от RocksDB, например, или MySQL, этот кэш хранит не страницы, а уже готовые диапазоны значений индекса, после их чтения с диска и слияния всех уровней. Это позволяет использовать кэш для запросов как по одному ключу, так и по диапазону ключей. Поскольку в кэше хранятся только горячие данные, а не, скажем, страницы (в странице может быть востребована лишь часть данных), оперативная память используется наиболее оптимально. Размер кэша задается в параметре vinyl_cache.

Управление сборкой мусора

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

В vinyl’е устройство одного LSM-дерева – это лишь фрагмент мозаики. Vinyl создает и обслуживает несколько LSM-деревьев даже для одной таблицы (так называемого спейса) – по одному дереву на каждый индекс. Но даже один единственный индекс может состоять из десятков LSM-деревьев. Попробуем разобраться, зачем.

Рассмотрим наш стандартный пример: 100 000 000 записей по 100 байтов каждая. Через некоторое время на самом нижнем уровне LSM у нас может оказаться файл размером 10 ГБ. Во время слияния последнего уровня мы создадим временный файл, который также будет занимать около 10 ГБ. Данные на промежуточных уровнях тоже занимают место: по одному и тому же ключу дерево может хранить несколько операций. Суммарно для хранения 10 ГБ полезных данных нам может потребоваться до 30 ГБ свободного места: 10 ГБ на последний уровень, 10 ГБ на временный файл и 10 ГБ на всё остальное. А если данных не 1 ГБ, а 1 ТБ? Требовать, чтобы количество свободного места на диске всегда в несколько раз превышало объем полезных данных, экономически нецелесообразно, да и создание файла в 1ТБ может занимать десятки часов. При любой аварии или перезапуске системы операцию придется начинать заново.

Рассмотрим другую проблему. Представим, что первичный ключ дерева – это монотонная последовательность, например, временной ряд. В этом случае основные вставки будут приходиться на правую часть диапазона ключей. Нет смысла заново производить слияние лишь для того, чтобы дописать в конец и без того огромного файла еще несколько миллионов записей.

А если вставки происходят, в основном, в одну часть диапазона ключей, а чтения – из другой части? Как в этом случае оптимизировать форму дерева? Если оно будет слишком высоким, пострадают чтения, если слишком низким – запись.

Tarantool «факторизует» проблему, создавая не одно, а множество LSM-деревьев для каждого индекса. Примерный размер каждого поддерева можно задать в конфигурационном параметре vinyl_range_size. Такие поддеревья называется диапазонами («range»).

_images/factor_lsm.png


Факторизация больших LSM-деревьев с помощью диапазонов

  • Диапазоны отражают статичную структуру упорядоченных файлов
  • Срезы объединяют упорядоченный файл в диапазон

Изначально, пока в индексе мало элементов, он состоит из одного диапазона. По мере добавления элементов суммарный объем может превысить максимальный размер диапазона. В таком случае выполняется операция под названием «разделение» (split), которая делит дерево на две равные части. Разделение происходит по срединному элементу диапазона ключей, хранящихся в дереве. Например, если изначально дерево хранит полный диапазон -inf… +inf, то после разделения по срединному ключу X получим два поддерева: одно будет хранить все ключи от -inf до X, другое – от X до +inf. Таким образом, при вставке или чтении мы однозначно знаем, к какому поддереву обращаться. Если в дереве были удаления и каждый из соседних диапазонов уменьшился, выполняется обратная операция под названием «объединение» (coalesce). Она объединяет два соседних дерева в одно.

Разделение и объединение не приводят к слиянию, созданию новых файлов и прочим тяжеловесным операциям. LSM-дерево – это лишь набор файлов. В vinyl’е мы реализовали специальный журнал метаданных, позволяющий легко отслеживать, какой файл принадлежит какому поддереву или поддеревьям. Журнал имеет расширение .vylog, по формату он совместим с файлом .xlog. Как и файл .xlog, происходит автоматическая ротация файла при каждой контрольной точке. Чтобы избежать повторного создания файлов при разделении и объединении, мы ввели промежуточную сущность – срез (slice). Это ссылка на файл с указанием диапазона значений ключа, которая хранится исключительно в журнале метаданных. Когда число ссылок на файл становится равным нулю, файл удаляется. А когда необходимо произвести разделение или объединение, Tarantool создает срезы для каждого нового дерева, старые срезы удаляет, и записывает эти операции в журнал метаданных. Буквально, журнал метаданных хранит записи вида <идентификатор дерева, идентификатор среза> или <идентификатор среза, идентификатор файла, мин, макс>.

Таким образом, непосредственно тяжелая работа по разбиению дерева на два поддерева, откладывается до слияния и выполняется автоматически. Огромным преимуществом подхода с разделением всего диапазона ключей на диапазоны является возможность независимо управлять размером L0, а также процессом создания дампов и слиянием для каждого поддерева. В результате эти процессы являются управляемыми и предсказуемыми. Наличие отдельного журнала метаданных также упрощает выполнение таких операций, как усечение и удаление – в vinyl’е они обрабатываются мгновенно, потому что работают исключительно с журналом метаданных, а удаление мусора выполняется в фоне.

Расширенные возможности vinyl’а
Upsert (обновление и вставка)

В предыдущих разделах упоминались лишь две операции, которые хранит LSM-дерево: удаление и замена. Давайте рассмотрим, как представлены все остальные. Вставку можно представить с помощью замены – необходимо лишь предварительно убедиться в отсутствии элемента указанным ключом. Для выполнения обновления необходимо предварительно считывать старое значение из дерева, так что и эту операцию проще записать в дерево как замену – это ускорит будущие чтения по этому ключу. Кроме того, обновление должно вернуть новое значение, так что скрытых чтений никак не избежать.

В B-деревьях скрытые чтения почти ничего не стоят: чтобы обновить блок, его в любом случае необходимо прочитать с диска. Для LSM-деревьев идея создания специальной операции обновления, которая не приводила бы к скрытым чтениям, выглядит очень заманчивой.

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

На этапе выполнения транзакции Tarantool лишь сохраняет всю операцию в LSM-дереве, а «выполняет» ее уже только во время слияния.

Операция обновления и вставки:

space:upsert(tuple, {{operator, field, value}, ... })
  • Обновление без чтения или вставка
  • Отложенное выполнение
  • Фоновое сжатие операций обновления и вставки предотвращает накапливание операций

К сожалению, если откладывать выполнение операции на этап слияния, возможностей для обработки ошибок не остается. Поэтому Tarantool стремится максимально проверять операции обновления и вставки upsert перед записью в дерево. Тем не менее, некоторые проверки можно выполнить лишь имея старые данные на руках. Например, если обновление прибавляет число к строке или удаляет несуществующее поле.

Операция с похожей семантикой присутствует во многих продуктах, в том числе в PostgreSQL и MongoDB. Но везде она представляет собой лишь синтаксический сахар, объединяющий обновление и вставку, не избавляя СУБД от необходимости выполнять скрытые чтения. Скорее всего, причиной этого является относительная новизна LSM-деревьев в качестве структур данных для хранения.

Хотя обновление и вставка upsert представляет собой очень важную оптимизацию, и ее реализация стоила нам долгой напряженной работы, следует признать, что ее применимость ограничена. Если в таблице есть вторичные ключи или триггеры, скрытых чтений не избежать. А если у вас есть сценарии, для которых не нужны вторичные ключи и обновление после завершения транзакции однозначно не приведет к ошибкам – эта операция для вас.

Небольшая история, связанная с этим оператором: vinyl только начинал «взрослеть», и мы впервые запустили операцию обновления и вставки upsert на рабочие серверы. Казалось бы, идеальные условия: огромный набор ключей, текущее время в качестве значения, операции обновления либо вставляют ключ, либо обновляют текущее время, редкие операции чтения. Нагрузочные тесты показали отличные результаты.

Тем не менее, после пары дней работы процесс Tarantool’а начал потреблять 100 % CPU, а производительность системы упала практически до нуля.

Начали подробно изучать проблему. Оказалось, что распределение запросов по ключам существенно отличалось от того, что мы видели в тестовом окружении. Оно было… очень неравномерное. Большая часть ключей обновлялась 1-2 раза за сутки, и база для них не была нагружена. Но были ключи гораздо более горячие – десятки тысяч обновлений в сутки. Tarantool прекрасно справлялся с этим потоком обновлений. А вот когда по ключу с десятком тысяч операций обновления и вставки upsert происходило чтение, всё шло под откос. Чтобы вернуть последнее значение, Tarantool’у приходилось каждый раз прочитать и «проиграть» историю из десятков тысяч команд обновления и вставки upsert. На стадии проекта мы надеялись, что это произойдет автоматически во время слияния уровней, но до слияния дело даже не доходило: памяти L0 было предостаточно, и дампы не создавались.

Решили мы проблему добавлением фонового процесса, осуществляющего упреждающие чтения для ключей, по которым накопилось больше нескольких десятков операций обновления и вставки upsert с последующей заменой на прочитанное значение.

Вторичные ключи

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

_images/secondary.png


Если вторичные индексы не уникальны, то удаление из них «мусора» также можно перенести в фазу слияния, что мы и делаем в Tarantool’е. Природа LSM-дерева, в котором файлы обновляются путем присоединения новых записей, позволила нам реализовать в vinyl’е полноценные сериализуемые транзакции. Запросы только для чтения при этом используют старые версии данных и не блокируют запись. Сам менеджер транзакций пока довольно простой: в традиционной классификации он реализует класс MVTO (multiversion timestamp ordering – упорядочение временных меток на основе многоверсионности), при этом в конфликте побеждает та транзакция, что завершилась первой. Блокировок и свойственных им взаимоблокировок нет. Как ни странно, это скорее недостаток, чем преимущество: при параллельном выполнении можно повысить количество успешных транзакций, задерживая некоторые из них в нужный момент на блокировке. Развитие менеджера транзакций в наших ближайших планах. В текущей версии мы сфокусировались на том, чтобы сделать алгоритм корректным и предсказуемым на 100%. Например, наш менеджер транзакций – один из немногих в NoSQL-среде, поддерживающих так называемые «блокировки разрывов» (gap locks).

Tarantool Cartridge

Cluster management in Tarantool is powered by the Tarantool Cartridge framework.

Here we explain how you can benefit with Tarantool Cartridge, a framework for developing, deploying, and managing applications based on Tarantool.

This documentation contains the following sections:

Tarantool Cartridge

A framework for distributed applications development.

About Tarantool Cartridge

Tarantool Cartridge allows you to easily develop Tarantool-based applications and run them on one or more Tarantool instances organized into a cluster.

This is the recommended alternative to the old-school practices of application development for Tarantool.

As a software development kit (SDK), Tarantool Cartridge provides you with utilities and an application template to help:

  • easily set up a development environment for your applications;
  • plug the necessary Lua modules.

The resulting package can be installed and started on one or multiple servers as one or multiple instantiated services – independent or organized into a cluster.

A Tarantool cluster is a collection of Tarantool instances acting in concert. While a single Tarantool instance can leverage the performance of a single server and is vulnerable to failure, the cluster spans multiple servers, utilizes their cumulative CPU power, and is fault-tolerant.

To fully utilize the capabilities of a Tarantool cluster, you need to develop applications keeping in mind they are to run in a cluster environment.

As a cluster management tool, Tarantool Cartridge provides your cluster-aware applications with the following key benefits:

  • horizontal scalability and load balancing via built-in automatic sharding;
  • asynchronous replication;
  • automatic failover;
  • centralized cluster control via GUI or API;
  • automatic configuration synchronization;
  • instance functionality segregation.

A Tarantool Cartridge cluster can segregate functionality between instances via built-in and custom (user-defined) cluster roles. You can toggle instances on and off on the fly during cluster operation. This allows you to put different types of workloads (e.g., compute- and transaction-intensive ones) on different physical servers with dedicated hardware.

Tarantool Cartridge has an external utility called cartridge-cli which provides you with utilities and an application template to help:

  • easily set up a development environment for your applications;
  • plug the necessary Lua modules;
  • pack the applications in an environment-independent way: together with module binaries and Tarantool executables.

Getting started

Prerequisites

To get a template application that uses Tarantool Cartridge and run it, you need to install several packages:

  • tarantool and tarantool-dev (see these instructions);
  • cartridge-cli (see these instructions)
  • git, gcc, cmake and make.
Create your first application

Long story short, copy-paste this into the console:

cartridge create --name myapp
cd myapp
cartridge build
cartridge start -d
cartridge replicasets setup --bootstrap-vshard

That’s all! Now you can visit http://localhost:8081 and see your application’s Admin Web UI:

https://user-images.githubusercontent.com/32142520/109290877-3d30a800-7839-11eb-8fcf-8b3de1237a3b.png

Contributing

The most essential contribution is your feedback, don’t hesitate to open an issue. If you’d like to propose some changes in code, see the contribution guide.

Developer’s guide

For a quick start, skip the details below and jump right away to the Cartridge getting started guide.

For a deep dive into what you can develop with Tarantool Cartridge, go on with the Cartridge developer’s guide.

Introduction

Короче говоря, чтобы разработать и запустить приложение, вам необходимо выполнить следующие шаги:

  1. Install Tarantool Cartridge and other components of the development environment.
  2. Create a project.
  3. Разработать приложение. Если это приложение с поддержкой кластеров, реализуйте его логику в виде отдельной (пользовательской) кластерной роли, чтобы инициализировать базу данных в кластерной среде.
  4. Развернуть приложение на сервере или серверах. Это включает в себя настройку и запуск экземпляров.
  5. Если это приложение с поддержкой кластеров, развернуть кластер.

В следующих разделах подробно описывается каждый из этих шагов.

Установка Tarantool Cartridge

  1. Install cartridge-cli, a command-line tool for developing, deploying, and managing Tarantool applications.
  2. Install git, a version control system.
  3. Install npm, a package manager for node.js.
  4. Install the unzip utility.

Creating a project

To set up your development environment, create a project using the Tarantool Cartridge project template. In any directory, say:

$ cartridge create --name <app_name> /path/to/

This will automatically set up a Git repository in a new /path/to/<app_name>/ directory, tag it with version 0.1.0, and put the necessary files into it.

В этом Git-репозитории можно разработать приложение (просто редактируя файлы из шаблона), подключить необходимые модули, а затем с легкостью упаковать все для развертывания на своих серверах.

The project template creates the <app_name>/ directory with the following contents:

  • файл <имя_приложения>-scm-1.rockspec, где можно указать зависимости приложения.
  • скрипт deps.sh, который решает проблемы с зависимостями из файла .rockspec.
  • файл init.lua, который является точкой входа в ваше приложение.
  • файл .git, необходимый для Git-репозитория.
  • файл .gitignore, чтобы не учитывать ненужные файлы.
  • файл env.lua, который устанавливает общие пути для модулей, чтобы приложение можно было запустить из любой директории.
  • файл custom-role.lua, который представляет собой объект-заполнитель для пользовательской кластерной роли.

The entry point file (init.lua), among other things, loads the cartridge module and calls its initialization function:

...
local cartridge = require('cartridge')
...
cartridge.cfg({
-- cartridge options example
  workdir = '/var/lib/tarantool/app',
  advertise_uri = 'localhost:3301',
  cluster_cookie = 'super-cluster-cookie',
  ...
}, {
-- box options example
  memtx_memory = 1000000000,
  ... })
 ...

Вызов cartridge.cfg() позволяет управлять экземпляром через административную консоль, но не вызывает box.cfg() для настройки экземпляров.

Предупреждение

Запрещается вызывать функцию box.cfg().

Сам кластер сделает это за вас, когда придет время:

  • загрузить текущий экземпляр, когда вы:
    • выполните cartridge.bootstrap() в административной консоли, или
    • нажмете Create (Создать) в веб-интерфейсе;
  • присоединить экземпляр к существующему кластеру, когда вы:
    • выполните cartridge.join_server({uri = ''uri_другого_экземпляра'}) в консоли, или
    • нажмете Join (Присоединить – к уже существующему набору реплик) или Create (Создать – для нового набора реплик) в веб-интерфейсе.

Обратите внимание, что вы можете указать cookie для кластера (параметр cluster_cookie), если необходимо запустить несколько кластеров в одной сети. Cookie может представлять собой любое строковое значение.

Now you can develop an application that will run on a single or multiple independent Tarantool instances (e.g. acting as a proxy to third-party databases) – or will run in a cluster.

If you plan to develop a cluster-aware application, first familiarize yourself with the notion of cluster roles.

Кластерные роли

Cluster roles are Lua modules that implement some specific functions and/or logic. In other words, a Tarantool Cartridge cluster segregates instance functionality in a role-based way.

Since all instances running cluster applications use the same source code and are aware of all the defined roles (and plugged modules), you can dynamically enable and disable multiple different roles without restarts, even during cluster operation.

Note that every instance in a replica set performs the same roles and you cannot enable/disable roles individually on some instances. In other words, configuration of enabled roles is set up per replica set. See a step-by-step configuration example in this guide.

Встроенные роли

В модуль cartridge входят две встроенные роли, которые реализуют автоматический шардинг:

  • vshard-router обрабатывает ресурсоемкие вычисления в vshard: направляет запросы к узлам хранения данных.

  • vshard-storage работает с большим количеством транзакций в vshard: хранит подмножество набора данных и управляет им.

    Примечание

    For more information on sharding, see the vshard module documentation.

With the built-in and custom roles, you can develop applications with separated compute and transaction handling – and enable relevant workload-specific roles on different instances running on physical servers with workload-dedicated hardware.

Пользовательские роли

You can implement custom roles for any purposes, for example:

  • определять хранимые процедуры;
  • implement extra features on top of vshard;
  • полностью обойтись без vshard;
  • внедрить одну или несколько дополнительных служб, таких как средство уведомления по электронной почте, репликатор и т.д.

To implement a custom cluster role, do the following:

  1. Take the app/roles/custom.lua file in your project as a sample. Rename this file as you wish, e.g. app/roles/custom-role.lua, and implement the role’s logic. For example:

    -- Implement a custom role in app/roles/custom-role.lua
    #!/usr/bin/env tarantool
    local role_name = 'custom-role'
    
    local function init()
    ...
    end
    
    local function stop()
    ...
    end
    
    return {
        role_name = role_name,
        init = init,
        stop = stop,
    }
    

    Here the role_name value may differ from the module name passed to the cartridge.cfg() function. If the role_name variable is not specified, the module name is the default value.

    Примечание

    Имена ролей должны быть уникальными, поскольку невозможно зарегистрировать несколько ролей с одним именем.

  2. Зарегистрируйте новую роль в кластере, изменив вызов cartridge.cfg() в файле входа в приложение init.lua:

    -- Register a custom role in init.lua
    ...
    local cartridge = require('cartridge')
    ...
    cartridge.cfg({
      workdir = ...,
      advertise_uri = ...,
      roles = {'custom-role'},
    })
    ...
    

    где custom-role (пользовательская роль) – это название загружаемого Lua-модуля.

The role module does not have required functions, but the cluster may execute the following ones during the role’s life cycle:

  • init() – это функция инициализации роли.

    Inside the function’s body you can call any box functions: create spaces, indexes, grant permissions, etc. Here is what the initialization function may look like:

    local function init(opts)
        -- The cluster passes an 'opts' Lua table containing an 'is_master' flag.
        if opts.is_master then
            local customer = box.schema.space.create('customer',
                { if_not_exists = true }
            )
            customer:format({
                {'customer_id', 'unsigned'},
                {'bucket_id', 'unsigned'},
                {'name', 'string'},
            })
            customer:create_index('customer_id', {
                parts = {'customer_id'},
                if_not_exists = true,
            })
        end
    end
    

    Примечание

    • Neither vshard-router nor vshard-storage manage spaces, indexes, or formats. You should do it within a custom role: add a box.schema.space.create() call to your first cluster role, as shown in the example above.
    • Тело функции заключено в условный оператор, который позволяет вызывать функции box только на мастерах. Это предотвращает конфликты репликации, так как данные автоматически передаются на реплики.
  • stop() is the role’s termination function. Implement it if initialization starts a fiber that has to be stopped or does any job that needs to be undone on termination.

  • validate_config() and apply_config() are functions that validate and apply the role’s configuration. Implement them if some configuration data needs to be stored cluster-wide.

Next, get a grip on the role’s life cycle to implement the functions you need.

Определение зависимостей для ролей

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

Например:

-- Role dependencies defined in app/roles/custom-role.lua
local role_name = 'custom-role'
...
return {
    role_name = role_name,
    dependencies = {'cartridge.roles.vshard-router'},
    ...
}

Здесь роль vshard-router будет инициализирована автоматически для каждого экземпляра, в котором включена роль custom-role.

Использование нескольких групп vshard storage

Для наборов реплик с ролью vshard-storage можно задавать группы. Например, группы hot и cold предназначены для независимой обработки горячих и холодных данных.

Группы указаны в конфигурации кластера:

-- Specify groups in init.lua
cartridge.cfg({
    vshard_groups = {'hot', 'cold'},
    ...
})

Если ни одна группа не указана, кластер предполагает, что все наборы реплик входят в группу default (по умолчанию).

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

Есть еще одно ограничение – нельзя добавлять группы динамически (такая возможность появится в будущих версиях).

Finally, mind the syntax for router access. Every instance with a vshard-router role enabled initializes multiple routers. All of them are accessible through the role:

local router_role = cartridge.service_get('vshard-router')
router_role.get('hot'):call(...)

If you have no roles specified, you can access a static router as before (when Tarantool Cartridge was unaware of groups):

local vhsard = require('vshard')
vshard.router.call(...)

However, when using the current group-aware API, you must call a static router with a colon:

local router_role = cartridge.service_get('vshard-router')
local default_router = router_role.get() -- or router_role.get('default')
default_router:call(...)
Role’s life cycle (and the order of function execution)

The cluster displays the names of all custom roles along with the built-in vshard-* roles in the web interface. Cluster administrators can enable and disable them for particular instances – either via the web interface or via the cluster public API. For example:

cartridge.admin.edit_replicaset('replicaset-uuid', {roles = {'vshard-router', 'custom-role'}})

If you enable multiple roles on an instance at the same time, the cluster first initializes the built-in roles (if any) and then the custom ones (if any) in the order the latter were listed in cartridge.cfg().

If a custom role has dependent roles, the dependencies are registered and validated first, prior to the role itself.

Кластер вызывает функции роли в следующих случаях:

  • Функция init() обычно выполняется один раз: либо когда администратор включает роль, либо при перезапуске экземпляра. Как правило, достаточно один раз включить роль.
  • Функция stop() – только когда администратор отключает роль, а не во время завершения работы экземпляра.
  • Функция validate_config(): сначала до автоматического вызова box.cfg()` (инициализация базы данных), а затем при каждом обновлении конфигурации.
  • Функция apply_config() – при каждом обновлении конфигурации.

As a tryout, let’s task the cluster with some actions and see the order of executing the role’s functions:

  • Присоединение экземпляра или создание набора реплик (в обоих случаях с включенной ролью):
    1. validate_config()
    2. init()
    3. apply_config()
  • Перезапуск экземпляра с включенной ролью:
    1. validate_config()
    2. init()
    3. apply_config()
  • Отключение роли: stop().
  • При вызове cartridge.confapplier.patch_clusterwide():
    1. validate_config()
    2. apply_config()
  • При запущенном восстановлении после отказа:
    1. validate_config()
    2. apply_config()

Учитывая вышеописанное поведение:

  • Функция init() может:
    • Вызывать функции box.
    • Запускать файбер, и в таком случае функция stop() должна позаботиться о завершении работы файбера.
    • Настраивать встроенный HTTP-сервер.
    • Выполнять любой код, связанный с инициализацией роли.
  • The stop() functions must undo any job that needs to be undone on role’s termination.
  • Функция validate_config() должна валидировать любые изменения конфигурации.
  • Функция apply_config() может выполнять любой код, связанный с изменением конфигурации, например, следить за файбером expirationd.

The validation and application functions together allow you to change the cluster-wide configuration as described in the next section.

Конфигурация пользовательских ролей

Доступны следующие операции:

  • Хранить настройки пользовательских ролей в виде разделов в конфигурации на уровне кластера, например:

    # in YAML configuration file
    my_role:
      notify_url: "https://localhost:8080"
    
    -- in init.lua file
    local notify_url = 'http://localhost'
    function my_role.apply_config(conf, opts)
      local conf = conf['my_role'] or {}
      notify_url = conf.notify_url or 'default'
    end
    
  • Download and upload cluster-wide configuration using the web interface or API (via GET/PUT queries to admin/config endpoint like curl localhost:8081/admin/config and curl -X PUT -d "{'my_parameter': 'value'}" localhost:8081/admin/config).

  • Utilize it in your role’s apply_config() function.

Каждый экземпляр в кластере хранит копию конфигурационного файла в своей рабочей директории (которую можно задать с помощью cartridge.cfg({workdir = ...})):

  • /var/lib/tarantool/<instance_name>/config.yml для экземпляров, развернутых из RPM-пакетов, под управлением systemd.
  • /home/<username>/tarantool_state/var/lib/tarantool/config.yml for instances deployed from tar+gz archives.

The cluster’s configuration is a Lua table, downloaded and uploaded as YAML. If some application-specific configuration data, e.g. a database schema as defined by DDL (data definition language), needs to be stored on every instance in the cluster, you can implement your own API by adding a custom section to the table. The cluster will help you spread it safely across all instances.

Such section goes in the same file with topology-specific and vshard-specific sections that the cluster generates automatically. Unlike the generated, the custom section’s modification, validation, and application logic has to be defined.

Самый распространенный способ заключается в том, чтобы:

  • validate_config(conf_new, conf_old) для валидации изменений, сделанных в новой конфигурации (conf_new) по отношению к старой конфигурации (conf_old).
  • apply_config(conf, opts) для выполнения любого кода, связанного с изменениями конфигурации. Входными данными для этой функции будут применяемая конфигурация (conf, которая и есть новая конфигурация, проверенная чуть ранее с помощью validate_config()), а также параметры (аргумент opts включает в себя описываемый ниже логический флаг is_master ).

Важно

Функция validate_config() должна обнаружить все проблемы конфигурации, которые могут привести к ошибкам apply_config(). Для получения дополнительной информации см. следующий раздел.

When implementing validation and application functions that call box ones for some reason, mind the following precautions:

  • Жизненный цикл роли не предполагает, что кластер автоматически вызовет box.cfg() до вызова validate_config().

    If the validation function calls any box functions (e.g., to check a format), make sure the calls are wrapped in a protective conditional statement that checks if box.cfg() has already happened:

    -- Inside the validate_config() function:
    
    if type(box.cfg) == 'table' then
    
        -- Here you can call box functions
    
    end
    
  • Unlike the validation function, apply_config() can call box functions freely as the cluster applies custom configuration after the automatic box.cfg() call.

    However, creating spaces, users, etc., can cause replication collisions when performed on both master and replica instances simultaneously. The appropriate way is to call such box functions on masters only and let the changes propagate to replicas automatically.

    По выполнении apply_config(conf, opts) кластер передает флаг is_master в таблице opts, который можно использовать для заключения функций из box в защитный условный оператор, если они могут вызвать конфликт:

    -- Inside the apply_config() function:
    
    if opts.is_master then
    
        -- Here you can call box functions
    
    end
    
Пример пользовательской конфигурации

Рассмотрим следующий код как часть реализации модуля роли (custom-role.lua):

#!/usr/bin/env tarantool
-- Custom role implementation

local cartridge = require('cartridge')

local role_name = 'custom-role'

-- Modify the config by implementing some setter (an alternative to HTTP PUT)
local function set_secret(secret)
    local custom_role_cfg = cartridge.confapplier.get_deepcopy(role_name) or {}
    custom_role_cfg.secret = secret
    cartridge.confapplier.patch_clusterwide({
        [role_name] = custom_role_cfg,
    })
end
-- Validate
local function validate_config(cfg)
    local custom_role_cfg = cfg[role_name] or {}
    if custom_role_cfg.secret ~= nil then
        assert(type(custom_role_cfg.secret) == 'string', 'custom-role.secret must be a string')
    end
    return true
end
-- Apply
local function apply_config(cfg)
    local custom_role_cfg = cfg[role_name] or {}
    local secret = custom_role_cfg.secret or 'default-secret'
    -- Make use of it
end

return {
    role_name = role_name,
    set_secret = set_secret,
    validate_config = validate_config,
    apply_config = apply_config,
}

После настройки конфигурации выполните одно из следующих действий:

Применение конфигурации пользовательской роли

With the implementation showed by the example, you can call the set_secret() function to apply the new configuration via the administrative console – or an HTTP endpoint if the role exports one.

Функция set_secret() вызывает cartridge.confapplier.patch_clusterwide(), которая производит двухфазную фиксацию транзакций:

  1. Исправляет активную конфигурацию в памяти: копирует таблицу и заменяет раздел "custom-role" в копии на раздел, который задан функцией set_secret().
  2. The cluster checks if the new configuration can be applied on all instances except disabled and expelled. All instances subject to update must be healthy and alive according to the membership module.
  3. (Фаза подготовки) Кластер передает исправленную конфигурацию. Каждый экземпляр валидирует ее с помощью функции validate_config() каждой зарегистрированной роли. В зависимости от результата валидации:
    • В случае успеха (то есть возврата значения true) экземпляр сохраняет новую конфигурацию во временный файл с именем config.prepare.yml в рабочей директории.
    • (Abort phase) Otherwise, the instance reports an error and all the other instances roll back the update: remove the file they may have already prepared.
  4. (Фаза фиксации) После успешной подготовки всех экземпляров кластер фиксирует изменения. Каждый экземпляр:
    1. Создает жесткую ссылку активной конфигурации.
    2. Atomically replaces the active configuration file with the prepared one. The atomic replacement is indivisible – it can either succeed or fail entirely, never partially.
    3. Вызывает функцию apply_config() каждой зарегистрированной роли.

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

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

Использование встроенного HTTP-сервера

Кластер запускает экземпляр httpd-сервера во время инициализации (cartridge.cfg()). Можно привязать порт к экземпляру через переменную окружения:

-- Get the port from an environmental variable or the default one:
local http_port = os.getenv('HTTP_PORT') or '8080'

local ok, err = cartridge.cfg({
   ...
   -- Pass the port to the cluster:
   http_port = http_port,
   ...
})

Чтобы использовать httpd-экземпляр, получите к нему доступ и настройте маршруты в рамках функции init() для какой-либо роли (например, для роли, которая предоставляет API через HTTP):

local function init(opts)

...

   -- Get the httpd instance:
   local httpd = cartridge.service_get('httpd')
   if httpd ~= nil then
       -- Configure a route to, for example, metrics:
       httpd:route({
               method = 'GET',
               path = '/metrics',
               public = true,
           },
           function(req)
               return req:render({json = stat.stat()})
           end
       )
   end
end

For more information on using Tarantool’s HTTP server, see its documentation.

Реализация авторизации в веб-интерфейсе

To implement authorization in the web interface of every instance in a Tarantool cluster:

  1. Используйте модуль, к примеру, auth с функцией check_password. Данная функция проверяет учетные данные любого пользователя, который пытается войти в веб-интерфейс.

    Функция check_password принимает имя пользователя и пароль и возвращает результат аутентификации: пройдена или нет.

    -- auth.lua
    
    -- Add a function to check the credentials
    local function check_password(username, password)
    
        -- Check the credentials any way you like
    
        -- Return an authentication success or failure
        if not ok then
            return false
        end
        return true
    end
    ...
    
  2. Передайте имя используемого модуля auth в качестве параметра для cartridge.cfg(), чтобы кластер мог использовать его:

    -- init.lua
    
    local ok, err = cartridge.cfg({
        auth_backend_name = 'auth',
        -- The cluster will automatically call 'require()' on the 'auth' module.
        ...
    })
    

    Это добавит кнопку Log in (Войти) в верхний правый угол в веб-интерфейсе, но все же позволит неавторизованным пользователям взаимодействовать с интерфейсом, что удобно для тестирования.

    Примечание

    Кроме того, для авторизации запросов к API кластера можно использовать базовый заголовок HTTP для авторизации.

  3. Чтобы требовать авторизацию каждого пользователя в веб-интерфейсе даже до начальной загрузки кластера, добавьте следующую строку:

    -- init.lua
    
    local ok, err = cartridge.cfg({
        auth_backend_name = 'auth',
        auth_enabled = true,
        ...
    })
    

    С включенной аутентификацией при использовании модуля auth пользователь не сможет даже загрузить кластер без входа в систему. После успешного входа в систему и начальной загрузки можно включить и отключить аутентификацию для всего кластера в веб-интерфейсе, а параметр auth_enabled игнорируется.

Управление версиями приложения

Tarantool Cartridge understands semantic versioning as described at semver.org. When developing an application, create new Git branches and tag them appropriately. These tags are used to calculate version increments for subsequent packing.

Например, если версия вашего приложения – 1.2.1, пометьте текущую ветку тегом 1.2.1 (с аннотациями или без них).

Чтобы получить значение текущей версии из Git, выполните команду:

$ git describe --long --tags
1.2.1-12-g74864f2

Вывод показывает, что после версии 1.2.1 было 12 коммитов. Если мы соберемся упаковать приложение на данном этапе, его полная версия будет 1.2.1-12, а пакет будет называться <имя_приложения>-1.2.1-12.rpm.

Запрещается использовать не семантические теги. Вы не сможете создать пакет из ветки, если последний тег не будет семантическим.

После упаковки приложения его версия сохраняется в файл VERSION в корневой каталог пакета.

Using .cartridge.ignore files

You can add a .cartridge.ignore file to your application repository to exclude particular files and/or directories from package builds.

For the most part, the logic is similar to that of .gitignore files. The major difference is that in .cartridge.ignore files the order of exceptions relative to the rest of the templates does not matter, while in .gitignore files the order does matter.

.cartridge.ignore entry игнорирует все…
target/ папки (поскольку в конце стоит /) под названием target рекурсивно
target файлы или папки под названием target рекурсивно
/target файлы или папки под названием target в самой верхней директории (поскольку в начале стоит /)
/target/ папки под названием target в самой верхней директории (в начале и в конце стоит /)
*.class файлы или папки, оканчивающиеся на .class, рекурсивно
#comment ничего, это комментарий (первый символ – #)
\#comment файлы или папки под названием #comment (\\ для выделения)
target/logs/ папки под названием logs, которые представляют собой поддиректорию папки под названием target
target/*/logs/ папки под названием logs на два уровня ниже папки под названием target (* не включает /)
target/**/logs/ папки под названием logs где угодно в пределах папки target (** включает /)
*.py[co] файлы или папки, оканчивающиеся на .pyc или .pyo, но не на .py!
*.py[!co] файлы или папки, оканчивающиеся на что угодно, кроме c или o
*.file[0-9] файлы или папки, оканчивающиеся на цифру
*.file[!0-9] файлы или папки, оканчивающиеся на что угодно, кроме цифры
* всё
/* всё в самой верхней директории (поскольку в начале стоит /)
**/*.tar.gz файлы *.tar.gz или папки, которые находятся на один или несколько уровней ниже исходной папки
!file файлы и папки будут проигнорированы, даже если они подходят под другие типы

Failover architecture

An important concept in cluster topology is appointing a leader. Leader is an instance which is responsible for performing key operations. To keep things simple, you can think of a leader as of the only writable master. Every replica set has its own leader, and there’s usually not more than one.

Which instance will become a leader depends on topology settings and failover configuration.

An important topology parameter is the failover priority within a replica set. This is an ordered list of instances. By default, the first instance in the list becomes a leader, but with the failover enabled it may be changed automatically if the first one is malfunctioning.

Instance configuration upon a leader change

When Cartridge configures roles, it takes into account the leadership map (consolidated in the failover.lua module). The leadership map is composed when the instance enters the ConfiguringRoles state for the first time. Later the map is updated according to the failover mode.

Every change in the leadership map is accompanied by instance re-configuration. When the map changes, Cartridge updates the read_only setting and calls the apply_config callback for every role. It also specifies the is_master flag (which actually means is_leader, but hasn’t been renamed yet due to historical reasons).

It’s important to say that we discuss a distributed system where every instance has its own opinion. Even if all opinions coincide, there still may be races between instances, and you (as an application developer) should take them into account when designing roles and their interaction.

Leader appointment rules

The logic behind leader election depends on the failover mode: disabled, eventual, or stateful.

Disabled mode

This is the simplest case. The leader is always the first instance in the failover priority. No automatic switching is performed. When it’s dead, it’s dead.

Eventual failover

In the eventual mode, the leader isn’t elected consistently. Instead, every instance in the cluster thinks that the leader is the first healthy instance in the failover priority list, while instance health is determined according to the membership status (the SWIM protocol).

The member is considered healthy if both are true:

  1. It reports either ConfiguringRoles or RolesConfigured state;
  2. Its SWIM status is either alive or suspect.

A suspect member becomes dead after the failover_timout expires.

Leader election is done as follows. Suppose there are two replica sets in the cluster:

  • a single router «R»,
  • two storages, «S1» and «S2».

Then we can say: all the three instances (R, S1, S2) agree that S1 is the leader.

The SWIM protocol guarantees that eventually all instances will find a common ground, but it’s not guaranteed for every intermediate moment of time. So we may get a conflict.

For example, soon after S1 goes down, R is already informed and thinks that S2 is the leader, but S2 hasn’t received the gossip yet and still thinks he’s not. This is a conflict.

Similarly, when S1 recovers and takes the leadership, S2 may be unaware of that yet. So, both S1 and S2 consider themselves as leaders.

Moreover, SWIM protocol isn’t perfect and still can produce false-negative gossips (announce the instance is dead when it’s not).

Stateful failover

Similarly to the eventual mode, every instance composes its own leadership map, but now the map is fetched from an external state provider (that’s why this failover mode called «stateful»). Nowadays there are two state providers supported – etcd and stateboard (standalone Tarantool instance). State provider serves as a domain-specific key-value storage (simply replicaset_uuid -> leader_uuid) and a locking mechanism.

Changes in the leadership map are obtained from the state provider with the long polling technique.

All decisions are made by the coordinator – the one that holds the lock. The coordinator is implemented as a built-in Cartridge role. There may be many instances with the coordinator role enabled, but only one of them can acquire the lock at the same time. We call this coordinator the «active» one.

The lock is released automatically when the TCP connection is closed, or it may expire if the coordinator becomes unresponsive (in stateboard it’s set by the stateboard’s --lock_delay option, for etcd it’s a part of clusterwide configuration), so the coordinator renews the lock from time to time in order to be considered alive.

The coordinator makes a decision based on the SWIM data, but the decision algorithm is slightly different from that in case of eventual failover:

  • Right after acquiring the lock from the state provider, the coordinator fetches the leadership map.
  • If there is no leader appointed for the replica set, the coordinator appoints the first leader according to the failover priority, regardless of the SWIM status.
  • If a leader becomes dead, the coordinator makes a decision. A new leader is the first healthy instance from the failover priority list. If an old leader recovers, no leader change is made until the current leader down. Changing failover priority doesn’t affect this.
  • Every appointment (self-made or fetched) is immune for a while (controlled by the IMMUNITY_TIMEOUT option).
The case: external provider outage

In this case instances do nothing: the leader remains a leader, read-only instances remain read-only. If any instance restarts during an external state provider outage, it composes an empty leadership map: it doesn’t know who actually is a leader and thinks there is none.

The case: coordinator outage

An active coordinator may be absent in a cluster either because of a failure or due to disabling the role everywhere. Just like in the previous case, instances do nothing about it: they keep fetching the leadership map from the state provider. But it will remain the same until a coordinator appears.

Manual leader promotion

It differs a lot depending on the failover mode.

In the disabled and eventual modes, you can only promote a leader by changing the failover priority (and applying a new clusterwide configuration).

In the stateful mode, the failover priority doesn’t make much sense (except for the first appointment). Instead, you should use the promotion API (the Lua cartridge.failover_promote or the GraphQL mutation {cluster{failover_promote()}}) which pushes manual appointments to the state provider.

The stateful failover mode implies consistent promotion: before becoming writable, each instance performs the wait_lsn operation to sync up with the previous one.

Information about the previous leader (we call it a vclockkeeper) is also stored on the external storage. Even when the old leader is demoted, it remains the vclockkeeper until the new leader successfully awaits and persists its vclock on the external storage.

If replication is stuck and consistent promotion isn’t possible, a user has two options: to revert promotion (to re-promote the old leader) or to force it inconsistently (all kinds of failover_promote API has force_inconsistency flag).

Consistent promotion doesn’t work for replicasets with all_rw flag enabled and for single-instance replicasets. In these two cases an instance doesn’t even try to query vclockkeeper and to perform wait_lsn. But the coordinator still appoints a new leader if the current one dies.

Fencing

Neither eventual nor stateful failover modes don’t protect a replicaset from the presence of multiple leaders when the network is partitioned. But fencing does. It enforces at-most-one leader policy in a replicaset.

Fencing operates as a fiber that occasionally checks connectivity with the state provider and with replicas. Fencing fiber runs on vclockkeepers; it starts right after consistent promotion succeeds. Replicasets which don’t need consistency (single-instance and all_rw) don’t defense, though.

The condition for fencing actuation is the loss of both the state provider quorum and at least one replica. Otherwise, if either state provider is healthy or all replicas are alive, the fencing fiber waits and doesn’t intervene.

When fencing is actuated, it generates a fake appointment locally and sets the leader to nil. Consequently, the instance becomes read-only. Subsequent recovery is only possible when the quorum reestablishes; replica connection isn’t a must for recovery. Recovery is performed according to the rules of consistent switchover unless some other instance has already been promoted to a new leader.

Failover configuration

These are clusterwide parameters:

  • mode: «disabled» / «eventual» / «stateful».
  • state_provider: «tarantool» / «etcd».
  • failover_timeout – time (in seconds) to mark suspect members as dead and trigger failover (default: 20).
  • tarantool_params: {uri = "...", password = "..."}.
  • etcd2_params: {endpoints = {...}, prefix = "/", lock_delay = 10, username = "", password = ""}.
  • fencing_enabled: true / false (default: false).
  • fencing_timeout – time to actuate fencing after the check fails (default: 10).
  • fencing_pause – the period of performing the check (default: 2).

It’s required that failover_timeout > fencing_timeout >= fencing_pause.

GraphQL API

Use your favorite GraphQL client (e.g. Altair) for requests introspection:

  • query {cluster{failover_params{}}},
  • mutation {cluster{failover_params(){}}},
  • mutation {cluster{failover_promote()}}.
Stateboard configuration

Like other Cartridge instances, the stateboard supports cartridge.argprase options:

  • listen
  • workdir
  • password
  • lock_delay

Similarly to other argparse options, they can be passed via command-line arguments or via environment variables, e.g.:

.rocks/bin/stateboard --workdir ./dev/stateboard --listen 4401 --password qwerty
Fine-tuning failover behavior

Besides failover priority and mode, there are some other private options that influence failover operation:

  • LONGPOLL_TIMEOUT (failover) – the long polling timeout (in seconds) to fetch new appointments (default: 30);
  • NETBOX_CALL_TIMEOUT (failover/coordinator) – stateboard client’s connection timeout (in seconds) applied to all communications (default: 1);
  • RECONNECT_PERIOD (coordinator) – time (in seconds) to reconnect to the state provider if it’s unreachable (default: 5);
  • IMMUNITY_TIMEOUT (coordinator) – minimal amount of time (in seconds) to wait before overriding an appointment (default: 15).

Конфигурация экземпляров

Cartridge orchestrates a distributed system of Tarantool instances – a cluster. One of the core concepts is clusterwide configuration. Every instance in a cluster stores a copy of it.

Clusterwide configuration contains options that must be identical on every cluster node, such as the topology of the cluster, failover and vshard configuration, authentication parameters and ACLs, and user-defined configuration.

Clusterwide configuration doesn’t provide instance-specific parameters: ports, workdirs, memory settings, etc.

Configuration basics

Конфигурация экземпляра состоит из двух наборов параметров:

Задать эти параметры можно:

  1. В аргументах в командной строке.
  2. В переменных окружения.
  3. В конфигурационном файле формата YAML.
  4. В файле init.lua.

Вышеуказанный порядок определяет приоритет: аргументы в командной строке замещают переменные окружения и т.д.

Независимо от того, как вы запускаете экземпляры, необходимо задать следующие параметры cartridge.cfg() для каждого экземпляра:

  • advertise_uri – либо <ХОСТ>:<ПОРТ>, либо <ХОСТ>:, либо <ПОРТ>. Используется другими экземплярами для подключения. НЕ указывайте 0.0.0.0 – это должен быть внешний IP-адрес, а не привязка сокета.
  • http_port – порт, который используется, чтобы открывать административный веб-интерфейс и API. По умолчанию: 8081. Чтобы отключить, укажите "http_enabled": False.
  • workdir – директория, где хранятся все данные: файлы снимка, журналы упреждающей записи и конфигурационный файл cartridge. По умолчанию: ..

Если вы запустите экземпляры, используя интерфейс командной строки cartridge или systemctl, сохраните конфигурацию в формате YAML, например:

my_app.router: {"advertise_uri": "localhost:3301", "http_port": 8080}
my_app.storage_A: {"advertise_uri": "localhost:3302", "http_enabled": False}
my_app.storage_B: {"advertise_uri": "localhost:3303", "http_enabled": False}

С помощью интерфейса командной строки cartridge вы можете передать путь к этому файлу в качестве аргумента командной строки --cfg для команды cartridge start – или же указать путь в конфигурации cartridge./.cartridge.yml или ~/.cartridge.yml):

cfg: cartridge.yml
run_dir: tmp/run
apps_path: /usr/local/share/tarantool

С помощью systemctl сохраните файл в формате YAML в /etc/tarantool/conf.d/ (по умолчанию путь systemd) или в место, указанное в переменной окружения TARANTOOL_CFG.

Если вы запускаете экземпляры с помощью tarantool init.lua, необходимо также передать другие параметры конфигурации в качестве параметров командной строки и переменных окружения, например:

$ tarantool init.lua --alias router --memtx-memory 100 --workdir "~/db/3301" --advertise_uri "localhost:3301" --http_port "8080"
Internal representation of clusterwide configuration

In the file system, clusterwide configuration is represented by a file tree. Inside workdir of any configured instance you can find the following directory:

config/
├── auth.yml
├── topology.yml
└── vshard_groups.yml

This is the clusterwide configuration with three default config sectionsauth, topology, and vshard_groups.

Due to historical reasons clusterwide configuration has two appearances:

  • old-style single-file config.yml with all sections combined, and
  • modern multi-file representation mentioned above.

Before cartridge v2.0 it used to look as follows, and this representation is still used in HTTP API and luatest helpers.

# config.yml
---
auth: {...}
topology: {...}
vshard_groups: {...}
...

Beyond these essential sections, clusterwide configuration may be used for storing some other role-specific data. Clusterwide configuration supports YAML as well as plain text sections. It can also be organized in nested subdirectories.

In Lua it’s represented by the ClusterwideConfig object (a table with metamethods). Refer to the cartridge.clusterwide-config module documentation for more details.

Two-phase commit

Cartridge manages clusterwide configuration to be identical everywhere using the two-phase commit algorithm implemented in the cartridge.twophase module. Changes in clusterwide configuration imply applying it on every instance in the cluster.

Almost every change in cluster parameters triggers a two-phase commit: joining/expelling a server, editing replica set roles, managing users, setting failover and vshard configuration.

Two-phase commit requires all instances to be alive and healthy, otherwise it returns an error.

For more details, please, refer to the cartridge.config_patch_clusterwide API reference.

Managing role-specific data

Beside system sections, clusterwide configuration may be used for storing some other role-specific data. It supports YAML as well as plain text sections. And it can also be organized in nested subdirectories.

Role-specific sections are used by some third-party roles, i.e. sharded-queue and cartridge-extensions.

A user can influence clusterwide configuration in various ways. You can alter configuration using Lua, HTTP or GraphQL API. Also there are luatest helpers available.

HTTP API

It works with old-style single-file representation only. It’s useful when there are only few sections needed.

Example:

cat > config.yml << CONFIG
---
custom_section: {}
...
CONFIG

Upload new config:

curl -v "localhost:8081/admin/config" -X PUT --data-binary @config.yml

Download it:

curl -v "localhost:8081/admin/config" -o config.yml

It’s suitable for role-specific sections only. System sections (topology, auth, vshard_groups, users_acl) can be neither uploaded nor downloaded.

If authorization is enabled, use the curl option --user username:password.

GraphQL API

GraphQL API, by contrast, is only suitable for managing plain-text sections in the modern multi-file appearance. It is mostly used by WebUI, but sometimes it’s also helpful in tests:

g.cluster.main_server:graphql({query = [[
    mutation($sections: [ConfigSectionInput!]) {
        cluster {
            config(sections: $sections) {
                filename
                content
            }
        }
    }]],
    variables = {sections = {
      {
        filename = 'custom_section.yml',
        content = '---\n{}\n...',
      }
    }}
})

Unlike HTTP API, GraphQL affects only the sections mentioned in the query. All the other sections remain unchanged.

Similarly to HTTP API, GraphQL cluster {config} query isn’t suitable for managing system sections.

Lua API

It’s not the most convenient way to configure third-party role, but it may be useful for role development. Please, refer to the corresponding API reference:

  • cartridge.config_patch_clusterwide
  • cartridge.config_get_deepcopy
  • cartridge.config_get_readonly

Example (from sharded-queue, simplified):

function create_tube(tube_name, tube_opts)
    local tubes = cartridge.config_get_deepcopy('tubes') or {}
    tubes[tube_name] = tube_opts or {}

    return cartridge.config_patch_clusterwide({tubes = tubes})
end

local function validate_config(conf)
    local tubes = conf.tubes or {}
    for tube_name, tube_opts in pairs(tubes) do
        -- validate tube_opts
    end
    return true
end

local function apply_config(conf, opts)
    if opts.is_master then
        local tubes = cfg.tubes or {}
        -- create tubes according to the configuration
    end
    return true
end
Luatest helpers

Cartridge test helpers provide methods for configuration management:

  • cartridge.test-helpers.cluster:upload_config,
  • cartridge.test-helpers.cluster:download_config.

Internally they wrap the HTTP API.

Example:

g.before_all(function()
    g.cluster = helpers.Cluster.new(...)
    g.cluster:upload_config({some_section = 'some_value'})
    t.assert_equals(
        g.cluster:download_config(),
        {some_section = 'some_value'}
    )
end)

Развертывание приложения

After you’ve developed your application locally, you can deploy it to a test or production environment.

«Deploy» includes packing the application into a specific distribution format, installing to the target system, and running the application.

Развернуть приложение Tarantool Cartridge можно четырьмя способами:

  • в виде rpm-пакета (для эксплуатационной среды);
  • в виде deb-пакета (для эксплуатационной среды);
  • в виде архива tar+gz (для тестирования или для эксплуатационной среды, если отсутствует доступ уровня root).
  • из исходных файлов (только для локального тестирования).
Развертывание приложения в виде пакета rpm или deb

The choice between DEB and RPM depends on the package manager of the target OS. For example, DEB is native for Debian Linux, and RPM – for CentOS.

  1. Упакуйте файлы приложения в распространяемый пакет:

    $ cartridge pack rpm APP_NAME
    # -- OR --
    $ cartridge pack deb APP_NAME
    

    Будет создан RPM-пакет (например, ./my_app-0.1.0-1.rpm) или же DEB-пакет (например, ./my_app-0.1.0-1.deb).

  2. Загрузите пакет на необходимые серверы с поддержкой systemctl.

  3. Установите:

    $ yum install APP_NAME-VERSION.rpm
    # -- OR --
    $ dpkg -i APP_NAME-VERSION.deb
    
  4. Configure the instance(s). Create a file called /etc/tarantool/conf.d/instances.yml. For example:

    my_app:
      cluster_cookie: secret-cookie
    
    my_app.instance-1:
      http_port: 8081
      advertise_uri: localhost:3301
    
    my_app.instance-2:
      http_port: 8082
      advertise_uri: localhost:3302
    

    See details here.

  5. Запустите экземпляры Tarantool’а с соответствующими службами. Например, это можно сделать, используя systemctl:

    # starts a single instance
    $ systemctl start my_app
    
    # starts multiple instances
    $ systemctl start my_app@router
    $ systemctl start my_app@storage_A
    $ systemctl start my_app@storage_B
    
  6. Если это приложение с поддержкой кластеров, далее переходите к развертыванию кластера.

    Примечание

    If you’re migrating your application from local test environment to production, you can re-use your test configuration at this step:

    1. In the cluster web interface of the test environment, click Configuration files > Download to save the test configuration.
    2. In the cluster web interface of the production environment, click Configuration files > Upload to upload the saved configuration.
Развертывание архива tar+gz
  1. Упакуйте файлы приложения в распространяемый пакет:

    $ cartridge pack tgz APP_NAME
    

    Будет создан архив tar+gz (например, ./my_app-0.1.0-1.tgz).

  2. Upload the archive to target servers, with tarantool and (optionally) cartridge-cli installed.

  3. Распакуйте архив:

    $ tar -xzvf APP_NAME-VERSION.tgz
    
  4. Configure the instance(s). Create a file called /etc/tarantool/conf.d/instances.yml. For example:

    my_app:
      cluster_cookie: secret-cookie
    
    my_app.instance-1:
      http_port: 8081
      advertise_uri: localhost:3301
    
    my_app.instance-2:
      http_port: 8082
      advertise_uri: localhost:3302
    

    See details here.

  5. Запустите экземпляры Tarantool’а. Это можно сделать, используя:

    • tarantoolctl, например:

      $ tarantool init.lua # starts a single instance
      
    • или cartridge, например:

      # in application directory
      $ cartridge start # starts all instances
      $ cartridge start .router_1 # starts a single instance
      
      # in multi-application environment
      $ cartridge start my_app # starts all instances of my_app
      $ cartridge start my_app.router # starts a single instance
      
  6. Если это приложение с поддержкой кластеров, далее переходите к развертыванию кластера.

    Примечание

    If you’re migrating your application from local test environment to production, you can re-use your test configuration at this step:

    1. In the cluster web interface of the test environment, click Configuration files > Download to save the test configuration.
    2. In the cluster web interface of the production environment, click Configuration files > Upload to upload the saved configuration.
Развертывание из исходных файлов

Такой метод развертывания предназначен только для локального тестирования.

  1. Вытяните все зависимости в директорию .rocks:

    $ tarantoolctl rocks make

  2. Configure the instance(s). Create a file called /etc/tarantool/conf.d/instances.yml. For example:

    my_app:
      cluster_cookie: secret-cookie
    
    my_app.instance-1:
      http_port: 8081
      advertise_uri: localhost:3301
    
    my_app.instance-2:
      http_port: 8082
      advertise_uri: localhost:3302
    

    See details here.

  3. Запустите экземпляры Tarantool’а. Это можно сделать, используя:

    • tarantoolctl, например:

      $ tarantool init.lua # starts a single instance
      
    • или cartridge, например:

      # in application directory
      cartridge start # starts all instances
      cartridge start .router_1 # starts a single instance
      
      # in multi-application environment
      cartridge start my_app # starts all instances of my_app
      cartridge start my_app.router # starts a single instance
      
  4. Если это приложение с поддержкой кластеров, далее переходите к развертыванию кластера.

    Примечание

    If you’re migrating your application from local test environment to production, you can re-use your test configuration at this step:

    1. In the cluster web interface of the test environment, click Configuration files > Download to save the test configuration.
    2. In the cluster web interface of the production environment, click Configuration files > Upload to upload the saved configuration.

Запуск/остановка экземпляров

В зависимости от способа развертывания вы можете запускать/останавливать экземпляры, используя tarantool, интерфейс командной строки cartridge или systemctl.

Запуск/остановка с помощью tarantool

С помощью tarantool можно запустить только один экземпляр:

$ tarantool init.lua # the simplest command

Можно также задать дополнительные параметры в командной строке или в переменных окружения.

Чтобы остановить экземпляр, используйте Ctrl+C.

Запуск/остановка с помощью CLI в cartridge

С помощью интерфейса командной строки cartridge, можно запустить один или несколько экземпляров:

$ cartridge start [APP_NAME[.INSTANCE_NAME]] [options]

Возможные параметры:

--script FILE

Точка входа в приложение. По умолчанию:

  • TARANTOOL_SCRIPT, либо
  • ./init.lua, если запуск идет из директории приложения, или же
  • :путь_к_приложениям/:имя_приложения/init.lua в среде с несколькими приложениями.
--apps_path PATH
Путь к директории с приложениями при запуске из среды с несколькими приложениями. По умолчанию: /usr/share/tarantool.
--run_dir DIR
Директория с файлами pid и sock. По умолчанию: TARANTOOL_RUN_DIR или /var/run/tarantool.
--cfg FILE
Cartridge instances YAML configuration file. Defaults to TARANTOOL_CFG or ./instances.yml. The instances.yml file contains cartridge.cfg() parameters described in the configuration section of this guide.
--foreground
Не в фоне.

Например:

cartridge start my_app --cfg demo.yml --run_dir ./tmp/run --foreground

Это запустит все экземпляры Tarantool’а, указанные в файле cfg, не в фоновом режиме с принудительным использованием переменных окружения.

Если ИМЯ_ПРИЛОЖЕНИЯ не указано, cartridge выделит его из имени файла ./*.rockspec.

Если ИМЯ_ЭКЗЕМПЛЯРА не указывается, cartridge прочитает файл cfg и запустит все указанные экземпляры:

# in application directory
cartridge start # starts all instances
cartridge start .router_1 # start single instance

# in multi-application environment
cartridge start my_app # starts all instances of my_app
cartridge start my_app.router # start a single instance

Чтобы остановить экземпляры, выполните команду:

$ cartridge stop [APP_NAME[.INSTANCE_NAME]] [options]

Поддерживаются следующие параметры из команды cartridge start`:

  • --run_dir DIR
  • --cfg FILE
Запуск/остановка с помощью systemctl
  • Чтобы запустить отдельный экземпляр:

    $ systemctl start APP_NAME
    

    This will start a systemd service that will listen to the port specified in instance configuration (http_port parameter).

  • Чтобы запустить несколько экземпляров на одном или нескольких серверах:

    $ systemctl start APP_NAME@INSTANCE_1
    $ systemctl start APP_NAME@INSTANCE_2
    ...
    $ systemctl start APP_NAME@INSTANCE_N
    

    где ИМЯ_ПРИЛОЖЕНИЯ@ЭКЗЕМПЛЯР_N – это имя экземпляра сервиса systemd с инкрементным числом N (уникальным для каждого экземпляра), которое следует добавить к порту 3300 для настройки прослушивания (например, 3301, 3302 и т.д.).

  • Чтобы остановить все сервисы на сервере, используйте команду systemctl stop и укажите имена экземпляров по одному. Например:

    $ systemctl stop APP_NAME@INSTANCE_1 APP_NAME@INSTANCE_2 ... APP_NAME@INSTANCE_<N>
    

When running instances with systemctl, keep these practices in mind:

  • You can specify instance configuration in a YAML file.

    This file can contain these options; see an example here).

    Save this file to /etc/tarantool/conf.d/ (the default systemd path) or to a location set in the TARANTOOL_CFG environment variable (if you’ve edited the application’s systemd unit file). The file name doesn’t matter: it can be instances.yml or anything else you like.

    Here’s what systemd is doing further:

    • obtains app_name (and instance_name, if specified) from the name of the application’s systemd unit file (e.g. APP_NAME@default or APP_NAME@INSTANCE_1);
    • sets default console socket (e.g. /var/run/tarantool/APP_NAME@INSTANCE_1.control), PID file (e.g. /var/run/tarantool/APP_NAME@INSTANCE_1.pid) and workdir (e.g. /var/lib/tarantool/<APP_NAME>.<INSTANCE_NAME>). Environment=TARANTOOL_WORKDIR=${workdir}.%i

    Finally, cartridge looks across all YAML files in /etc/tarantool/conf.d for a section with the appropriate name (e.g. app_name that contains common configuration for all instances, and app_name.instance_1 that contain instance-specific configuration). As a result, Cartridge options workdir, console_sock, and pid_file in the YAML file cartridge.cfg become useless, because systemd overrides them.

  • The default tool for querying logs is journalctl. For example:

    # show log messages for a systemd unit named APP_NAME.INSTANCE_1
    $ journalctl -u APP_NAME.INSTANCE_1
    
    # show only the most recent messages and continuously print new ones
    $ journalctl -f -u APP_NAME.INSTANCE_1
    

    If really needed, you can change logging-related box.cfg options in the YAML configuration file: see log and other related options.

Error handling guidelines

Almost all errors in Cartridge follow the return nil, err style, where err is an error object produced by Tarantool’s errors module. Cartridge doesn’t raise errors except for bugs and functions contracts mismatch. Developing new roles should follow these guidelines as well.

Error objects in Lua

Error classes help to locate the problem’s source. For this purpose, an error object contains its class, stack traceback, and a message.

local errors = require('errors')
local DangerousError = errors.new_class("DangerousError")

local function some_fancy_function()

    local something_bad_happens = true

    if something_bad_happens then
        return nil, DangerousError:new("Oh boy")
    end

    return "success" -- not reachable due to the error
end

print(some_fancy_function())
nil DangerousError: Oh boy
stack traceback:
    test.lua:9: in function 'some_fancy_function'
    test.lua:15: in main chunk

For uniform error handling, errors provides the :pcall API:

local ret, err = DangerousError:pcall(some_fancy_function)
print(ret, err)
nil DangerousError: Oh boy
stack traceback:
    test.lua:9: in function <test.lua:4>
    [C]: in function 'xpcall'
    .rocks/share/tarantool/errors.lua:139: in function 'pcall'
    test.lua:15: in main chunk

`lua print(DangerousError:pcall(error, 'what could possibly go wrong?')) `

nil DangerousError: what could possibly go wrong?
stack traceback:
    [C]: in function 'xpcall'
    .rocks/share/tarantool/errors.lua:139: in function 'pcall'
    test.lua:15: in main chunk

For errors.pcall there is no difference between the return nil, err and error() approaches.

Note that errors.pcall API differs from the vanilla Lua pcall. Instead of true the former returns values returned from the call. If there is an error, it returns nil instead of false, plus an error message.

Remote net.box calls keep no stack trace from the remote. In that case, errors.netbox_eval comes to the rescue. It will find a stack trace from local and remote hosts and restore metatables.

> conn = require('net.box').connect('localhost:3301')
> print( errors.netbox_eval(conn, 'return nil, DoSomethingError:new("oops")') )
nil     DoSomethingError: oops
stack traceback:
        eval:1: in main chunk
during net.box eval on localhost:3301
stack traceback:
        [string "return print( errors.netbox_eval("]:1: in main chunk
        [C]: in function 'pcall'

However, vshard implemented in Tarantool doesn’t utilize the errors module. Instead it uses its own errors. Keep this in mind when working with vshard functions.

Data included in an error object (class name, message, traceback) may be easily converted to string using the tostring() function.

GraphQL

GraphQL implementation in Cartridge wraps the errors module, so a typical error response looks as follows:

{
    "errors":[{
        "message":"what could possibly go wrong?",
        "extensions":{
            "io.tarantool.errors.stack":"stack traceback: ...",
            "io.tarantool.errors.class_name":"DangerousError"
        }
    }]
}

Read more about errors in the GraphQL specification.

If you’re going to implement a GraphQL handler, you can add your own extension like this:

local err = DangerousError:new('I have extension')
err.graphql_extensions = {code = 403}

It will lead to the following response:

{
    "errors":[{
        "message":"I have extension",
        "extensions":{
            "io.tarantool.errors.stack":"stack traceback: ...",
            "io.tarantool.errors.class_name":"DangerousError",
            "code":403
        }
    }]
}
HTTP

In a nutshell, an errors object is a table. This means that it can be swiftly represented in JSON. This approach is used by Cartridge to handle errors via http:

local err = DangerousError:new('Who would have thought?')

local resp = req:render({
    status = 500,
    headers = {
        ['content-type'] = "application/json; charset=utf-8"
    },
    json = json.encode(err),
})
{
    "line":27,
    "class_name":"DangerousError",
    "err":"Who would have thought?",
    "file":".../app/roles/api.lua",
    "stack":"stack traceback:..."
}

Руководство администратора

В данном руководстве рассматривается развертывание и управление кластером Tarantool’а с помощью Tarantool Cartridge.

Примечание

Дополнительную информацию по управлению экземплярами Tarantool’а см. в разделе Администрирование серверной части.

Перед тем, как развертывать кластер, ознакомьтесь с понятием кластерных ролей и разверните экземпляры Tarantool’а в соответствии с предполагаемой топологией кластера.

Развертывание кластера

Чтобы развернуть кластер, сначала настройте все экземпляры Tarantool’а в соответствии с предполагаемой топологией кластера, например:

my_app.router: {"advertise_uri": "localhost:3301", "http_port": 8080, "workdir": "./tmp/router"}
my_app.storage_A_master: {"advertise_uri": "localhost:3302", "http_enabled": False, "workdir": "./tmp/storage-a-master"}
my_app.storage_A_replica: {"advertise_uri": "localhost:3303", "http_enabled": False, "workdir": "./tmp/storage-a-replica"}
my_app.storage_B_master: {"advertise_uri": "localhost:3304", "http_enabled": False, "workdir": "./tmp/storage-b-master"}
my_app.storage_B_replica: {"advertise_uri": "localhost:3305", "http_enabled": False, "workdir": "./tmp/storage-b-replica"}

Затем, запустите экземпляры, например, используя CLI в cartridge:

cartridge start my_app --cfg demo.yml --run_dir ./tmp/run --foreground

And bootstrap the cluster. You can do this via the Web interface which is available at http://<instance_hostname>:<instance_http_port> (in this example, http://localhost:8080).

В веб-интерфейсе выполните следующие действия:

  1. В зависимости от статуса аутентификации:

    • Если аутентификация включена (в эксплуатационной среде), введите свои учетные данные и нажмите Login (Войти):

      _images/auth_creds-border-5px.png

       

    • Если отключен (для удобства тестирования), просто переходите к настройке кластера.

  2. Нажмите Configure (Настроить) рядом с первым ненастроенным сервером, чтобы создать первый набор реплик исключительно для роутера (для обработки ресурсоемких вычислений).

    _images/unconfigured-router-border-5px.png

     

    Во всплывающем окне отметьте флажок роли vshard-router или любой пользовательской роли, для которой роль vshard-router будет зависимой (в данном примере это пользовательская роль под названием app.roles.api).

    (Необязательно) Укажите отображаемое имя для набора реплик, например router.

    _images/create-router-border-5px.png

     

    Примечание

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

    Нажмите Create replica set (Создать набор реплик), и созданный набор реплик отобразится в веб-интерфейсе

    _images/router-replica-set-border-5px.png

     

    Предупреждение

    Обратите внимание: после того, как экземпляр подключится к набору реплик, НЕВОЗМОЖНО это отменить или переподключить его к другому набору реплик.

  3. Создайте новый набор реплик для мастер-узлов хранения данных (для обработки большого количества транзакций).

    Отметьте флажок роли vshard-storage или любой пользовательской роли, для которой роль vshard-storage будет зависимой (в данном примере это пользовательская роль под названием app.roles.storage).

    (Необязательно) Задайте определенную группу, например hot (горячие). Наборы реплик с ролями vshard-storage могут относиться к различным группам. В нашем примере группы hot и cold предназначены для независимой обработки горячих и холодных данных соответственно. Эти группы указаны в конфигурационном файле кластера. По умолчанию, кластер не входит ни в одну группу.

    (Необязательно) Укажите отображаемое имя для набора реплик, например hot-storage.

    Нажмите Create replica set (Создать набор реплик).

    _images/create-storage-border-5px.png

     

  4. (Необязательно) Если этого требует топология, добавьте во второй набор реплик дополнительные хранилища:

    1. Нажмите Configure (Настроить) рядом с другим ненастроенным сервером, который выделен для рабочей нагрузки с большим количеством транзакций.

    2. Нажмите на вкладку Join Replica Set (Присоединиться к набору реплик).

    3. Выберите второй набор реплик и нажмите Join replica set (Присоединиться к набору реплик), чтобы добавить к нему сервер.

      _images/join-storage-border-5px.png

       

  5. В зависимости от топологии кластера:

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

    Например:

    _images/final-cluster-border-5px.png

     

  6. (Необязательно) По умолчанию все новые наборы реплик vshard-storage получают вес, равный 1, до загрузки vshard в следующем шаге.

    Примечание

    Если вы добавите новый набор реплик после начальной загрузки vshard, как описано в разделе об изменении топологии, он по умолчанию получит вес 0.

    Чтобы разные наборы реплик хранили разное количество сегментов, нажмите Edit (Изменить) рядом с набором реплик, измените значение веса по умолчанию и нажмите Save (Сохранить):

    _images/change-weight-border-5px.png

     

    Для получения дополнительной информации о сегментах и весах набора реплик см. документацию по модулю vshard.

  7. Загрузите vshard, нажав соответствующую кнопку или же выполнив команду cartridge.admin.boostrap_vshard() в административной консоли.

    Эта команда создает виртуальные сегменты и распределяет их по хранилищам.

    С этого момента всю настройку кластера можно выполнять через веб-интерфейс.

Обновление конфигурации

Конфигурация кластера задается в конфигурационном файле формата YAML. Этот файл включает в себя топологию кластера и описания ролей.

У всех экземпляров в кластере Tarantool’а одинаковые настройки. Для этого каждый экземпляр в кластере хранит копию конфигурационного файла, а кластер синхронизирует эти копии: как только вы подтверждаете обновление конфигурации в веб-интерфейсе, кластер валидирует ее (и отклоняет неприемлемые изменения) и передает ее автоматически по всему кластеру.

Чтобы обновить конфигурацию:

  1. Нажмите на вкладку Configuration files (Конфигурационные файлы).

  2. (Необязательно) Нажмите Downloaded (Загруженные), чтобы получить текущую версию конфигурационного файла.

  3. Обновите конфигурационный файл.

    Можно добавлять/изменять/удалять любы разделы, кроме системных: topology, vshard и vshard_groups.

    Чтобы удалить раздел, просто удалите его из конфигурационного файла.

  4. Создайте сжатую копию конфигурационного файла в виде архива в формате .zip и нажмите кнопку Upload configuration (Загрузить конфигурацию), чтобы загрузить ее.

    В нижней части экрана вы увидите сообщение об успешной загрузке конфигурации или ошибку, если новые настройки не были применены.

Управление кластером

В данной главе описывается, как:

  • изменять топологию кластера,
  • включать автоматическое восстановление после отказа,
  • вручную менять мастера в наборе реплик,
  • отключать наборы реплик,
  • исключать экземпляры.
Изменение топологии кластера

При добавлении нового развернутого экземпляра в новый или уже существующий набор реплик:

  1. Кластер валидирует обновление конфигурации, проверяя доступность нового экземпляра с помощью модуля membership.

    Примечание

    Модуль membership работает по протоколу UDP и может производить операции до вызова функции box.cfg.

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

  2. Новый экземпляр ожидает, пока другой экземпляр в кластере не получит обновление конфигурации (оповещение реализовано с помощью того же модуля membership). На этом шаге у нового экземпляра еще нет своего UUID.

  3. Как только новый экземпляр понимает, что кластер знает о нем, экземпляр вызывает функцию box.cfg и начинает работу.

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

Чтобы добавить в кластер новые узлы, выполните следующие действия:

  1. Разверните новые экземпляры Tarantool, как описано в разделе по развертыванию.

    Если новые узлы не появились в веб-интерфейсе, нажмите Probe server (Найти сервер) и укажите их URI вручную.

    _images/probe-server-border-5px.png

     

    Если узел доступен, он появится в списке.

  2. В веб-интерфейсе:

    • Создайте новый набор реплик с одним из новых экземпляров: нажмите Configure (Настроить) рядом с ненастроенным сервером, отметьте флажками необходимые роли и нажмите Create replica set (Создать набор реплик):

      Примечание

      Если вы добавляете экземпляр vshard-storage, следует помнить, что вес всех таких экземпляров по умолчанию становится равным 0 после начальной загрузки vshard, которая происходит во время первоначального развертывания кластера.

      _images/zero-border-5px.png

       

    • Или добавьте дополнительные экземпляры к существующему набору реплик: нажмите Configure (Настроить) рядом с ненастроенным сервером, нажмите на вкладку Join replica set (Присоединиться к набору реплик), выберите набор реплик и нажмите Join replica set.

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

  3. При развертывании нового набора реплик vshard-storage заполните необходимую информацию: нажмите Edit (Изменить) рядом с необходимым набором реплик, увеличьте его вес и нажмите Save (Сохранить), чтобы начать балансировку данных.

Вместо веб-интерфейса можно использовать GraphQL для просмотра и изменения топологии кластера. Конечная точка кластера для выполнения запросов GraphQL – /admin/api. Можно пользоваться любыми сторонними клиентами GraphQL, такими как GraphiQL или Altair.

Примеры:

  • вывод списка всех серверов в кластере:

    query {
        servers { alias uri uuid }
    }
    
  • вывод списка всех наборов реплик с серверами:

    query {
        replicasets {
            uuid
            roles
            servers { uri uuid }
        }
    }
    
  • подключение сервера к новому набору реплик с включенной ролью хранилища:

    mutation {
        join_server(
            uri: "localhost:33003"
            roles: ["vshard-storage"]
        )
    }
    
Балансировка данных

Балансировка (решардинг) запускается через определенные промежутки времени и при добавлении в кластер нового набора реплик с весом, отличным от нуля. Для получения дополнительной информации см. раздел по балансировке в документации по модулю vshard.

Самый удобный способ мониторинга процесса балансировки заключается в том, чтобы отслеживать количество активных сегментов на узлах хранения. Сначала в новом наборе реплик 0 активных сегментов. Через некоторое время фоновый процесс балансировки начинает переносить сегменты из других наборов реплик в новый. Балансировка продолжается до тех пор, пока данные не будут распределены равномерно по всем наборам реплик.

Чтобы отслеживать текущее количество сегментов, подключитесь к любому экземпляру Tarantool через административную консоль и выполните команду:

tarantool> vshard