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

Меня зовут Игорь, и сегодня я покажу вам, как антипаттерны влияют на производительность и стабильность приложений, и с какими неверными решениями я сталкивался, когда разработка на Тарантуле в крупных проектах. Я также предложу способы избежать этих проблем.

Это текстовая версия выступления, которое я представил на HighloadFoundation 2022.

Map-reduce, который убивает select(nil)

Если вы когда-либо писали приложение в Tarantool, то наверняка сталкивались с этой проблемой. Многие производственные серверы вышли из строя из-за того, что кто-то забыл передать аргумент в вызов space:select().

Почему это так опасно: эквивалентно space:select() SELECT * в SQL. Он не передаст управление другим функциям, работающим в Tarantool, пока не выберет все записи из пространства. Невозможно прервать эту команду после ее выполнения. И если вы случайно выполнили select(nil) на большом пространстве, вы получите полное сканирование, которое также выгружает данные в память Lua, ограниченную 2 ГБ. Возможны два варианта развития событий: либо заканчивается память, либо Tarantool очень долго не отвечает на запросы. select(nil) — это гарантированный способ отключить экземпляр Tarantool.

Как от этого защититься? Во-первых, вы можете передать параметр чтобы выбрать, который ограничит количество записей, например:

space:select(nil, {limit = 1000})

Но лучше всего в таких случаях использовать итератор pairs():

space:pairs():take_n(1000):totable()

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

Пагинация по смещению

Рано или поздно нам нужно будет прочитать значительное количество данных из пространства, например, через разбиение на страницы. Имея в виду space:pairs(), мы могли бы написать что-то вроде этого:

space:pairs():drop_n(1000):take_n(1000):totable()

Похоже, что этот код будет проходить по записям, начиная с 1000-го и заканчивая 2000-м. Но на самом деле он будет проходить от 0 до 2000 записей и пропускать первые 1000. Если такой код попадет в цикл, который должен прочитать все данные из пространства, мы снова получим полное сканирование. С каждой итерацией он будет работать все хуже, и очень скоро он закончится как select(nil).

Как сделать это правильно. Pairs() имеет необязательные аргументы: a ключ для сравнения и таблица с параметрами для передачи {iterator = 'GE'}. Это позволяет вам получить 1000 записей, начинающихся с last_key.

space:pairs(last_key, { iterator = ‘GE’ }):take_n(1000):totable()

Фильтрация в объединении кластера

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

SELECT working_group, MIN(start_date)
FROM department JOIN employee
ON employee.employeeId = department.employeeId
AND department.departmentId IN $departmentIds
WHERE employee.jobType = 1 OR employee.position = 'senior'
GROUP BY working_group

Попробуем переписать на Tarantool:

function get_employee_aggregation(bucket_id, groupId, departmentIds)
    return yield_every(500, fun.iter(departmentIds)):reduce(function(acc, departmentId)
        yield_every(500, department.index.employment_date:pairs({groupId, departmentId}, 
            {iterator = 'REQ'})):each(function(y) 
                yield_every(500, employee.index.working_group:pairs({bucket_id, y.employeeId}, 
                {iterator = 'EQ'}))
                :filter(function(x) return x.jobType == 1 or or x.position == 'senior') end)
                :each(function(x)
                    if not acc[y.working_group] then
                        acc[y.working_group] = { 
                            working_group = y.working_group, 
                            start_date = x.start_date, 
                        }
                    end
                end)
            end)

Такой код довольно сложно читать, и мы обязательно упустим какой-нибудь краеугольный случай при его отладке и тестировании, который однажды обязательно сработает на нашем рабочем сервере. Он может начать отставать или выдавать 500 ошибок без видимых причин. Может быть, отсутствует передача управления? Или некоторые из наших инструментов не подходят так хорошо?

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

Предположим, у нас есть кластер с двумя пробелами, над которым мы хотим сделать JOIN:

SELECT * FROM
SPACE_1 JOIN SPACE_2
ON SPACE_1.FK = SPACE_2.FK
WHERE

Наивная реализация этого запроса может выглядеть следующим образом:

1. Выполните SELECT для каждого узла.

2. Отправьте данные на маршрутизатор.

3. Выполните ПРИСОЕДИНЕНИЕ.

Какие проблемы может вызвать такое решение?

  • Много данных, которые необходимо отправить по сети.
  • Может не хватить памяти для объединения данных.

Как это можно исправить?

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

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

Это всего лишь один из способов решения кластерных проблем JOIN. Общие рекомендации по разработке запросов:

  • Хранение связанных данных в целом уменьшит количество сетевых вызовов.
  • Фильтрация хранилища уменьшит объем передаваемых данных.
  • Агрегация на экземплярах маршрутизатора разгрузит экземпляры хранилища.
  • Сортировка по индексу позволяет избежать повторной сортировки данных.
  • Использование временных пространств полезно для объединения больших объемов данных.

Подробнее по теме

SPOF

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

Рассмотрим сегментированный кластер с автономным узлом. В нем хранятся словари — редко изменяемые данные, необходимые для формирования ответов клиентам. Каждый запрос в кластере требует доступа к словарю:

SELECT *
FROM SPACE JOIN DICTIONARY
WHERE

Каковы возможные проблемы с таким решением?

Слишком много сетевых запросов на один кластер JOIN. А что, если экземпляр словаря не справится с нагрузкой, поскольку каждый запрос в кластере требует доступа к его данным? По мере увеличения количества запросов мы начнем замечать отставание словаря и отвечать 500 ошибками даже на самые нетребовательные запросы. Ведь даже при равномерном распределении нагрузки на другие инстансы хранилища спрос на словарь гораздо выше.

Как это можно исправить?

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

Если у вас уже есть единая точка отказа, вы можете увеличить доступность словарей:

  • поднимать дополнительные реплики и балансировать запросы между ними;
  • если вам нужно записать в словари:

Подробнее по теме

Неструктурированные данные

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

box.space.storage1:format({
    {name = 'key', type = 'string'},
    {name = 'data', type = 'any'},
    -- data: {name: string, value: number}
})

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

local tuple = box.space.storage1:get('key')
for i = 1, 1000 do
    local v = tuple.data[i]
    if v.name == 'name' then
        v.value = v.value + 1
    end
    tuple.data[i] = v
end
box.space.storage1:put(tuple)

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

box.space.storage2:format({
    {name = 'key', type = 'string'},
    {name = 'pos', type = 'unsigned'},
    {name = 'name', type = 'string'},
    {name = 'value', type = 'number'},
})

Вместо одного обновления теперь потребуется несколько:

box.begin() -- to wrap everything in a transaction
for _, v in box.space.storage2:pairs({'key'}) do
    if v.name == 'name' then
        box.space.storage2:update({v.key, v.pos}, {{'+', 'value', 1}})
    end
end
box.commit()

Время выполнения 0,00036 секунды — это в 10 тысяч раз лучше предыдущей итерации!

Также при работе с неструктурированными данными вы можете не уместиться в память, выделенную для них Tarantool, и получить «Failed выделить память». Если вы столкнулись с этим, это можно исправить, увеличив параметр slab_alloc_factor файла box.cfg.

Подробнее по теме

Репликация мастер-мастер без триггеров

Репликация master-master — очень распространенное решение в Tarantool. Однако, если вы записываете данные на несколько узлов набора реплик одновременно, вы можете столкнуться с некоторыми проблемами. Например, если мы делаем одновременные изменения на разных узлах, они будут реплицированы на другой узел, и в результате мы можем получить разные данные на разных узлах и нарушенную репликацию.

Как это можно исправить?

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

local my_trigger = function(old, new, _, op)
    if new == nil or old == nil then
        return new
    end
    if op == 'INSERT' then
        if new[2] > old[2] then
            return box.tuple.new(new)
        end
    elseif new[2] > old[2] then
        return new
    end
    return old
end
-- the code to add a trigger
-- will allow processing all entries 
-- from the moment of restoring from the snapshot
box.ctl.on_schema_init(function()
    box.space._space:on_replace(function(_, sp)
        if sp.name == 'test' then
            box.on_commit(function()                
                box.space.test:before_replace(my_trigger)
            end)
        end
    end)
end)

Теперь в WAL будет записываться только одна запись, даже если были конфликты:

Подробнее по теме:

Транзакции перед подключением к реплике

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

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

Как это можно исправить? После перезапуска мастера необходимо дождаться подключения к реплике. В Tarantool за это отвечает параметр box.cfg{replication_connect_quorum=N}, где N — количество реплик, к которым нужно подключиться, прежде чем разрешить запись. Чтобы не было проблем с началом записи до окончания синхронизации, следует установить количество всех узлов в наборе реплик равным N.

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

Подробнее по теме

Как избежать проблем

  • Заранее планируйте расширение таблиц и кластеров. Объем данных всегда будет расти, а кластеры всегда будут расширяться, и архитектура приложения должна это учитывать.
  • Помните о подводных камнях при разработке высоконагруженных систем хранения. Писать код без ошибок еще никто не научился, но можно попытаться минимизировать их.
  • Тщательно проведите нагрузочное тестирование, максимально приближенное к реальным условиям эксплуатации. Это поможет выявить множество проблем.
  • Надлежащий мониторинг также поможет обнаружить проблемы и проанализировать их.

Что дальше

Вы можете скачать Tarantool на официальном сайте и получить помощь в нашем Telegram-чате.