Топ-5 провальных решений при разработке на Tarantool

hnag93msikryr6vujsjtpfdlvom.png


Tarantool — гибкий инструмент для разработки высоконагруженных систем хранения данных. На нём пишут очереди, кеши и мастер-хранилища с разной топологией.Часто разработчики начинают работать с Tarantool, имея большой опыт работы с SQL, и пытаются применять привычные подходы из реляционных баз данных. В результате приложения становятся «хрупкими», теряют данные или деградируют в производительности.

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

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

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


2gkzqgcvx5ofchtvccjov0hiq2u.png


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

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

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

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. Если такой код попадёт в цикл, который должен считывать все данные из спейса, мы получим full scan. Он будет работать все хуже и хуже на каждой итерации и очень скоро превратится в select(nil).

Как правильно. В pairs() есть опциональные аргументы: ключ, по которому производится сравнение, и таблица с опциями, в которые можно передать { iterator = 'GE' }. Он позволит отобрать 1000 записей начиная с last_key.

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


Фильтрация в cluster join


Даже после того, как мы починим все single node-запросы, перед нами останется проблема кластерных запросов. Давайте представим, что у нас есть БД 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)
        return acc
    end, {})
end


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

msevyiz2zs77nd0g-udsl-_mihs.jpeg


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

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

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


59amf2u2p-7kdw-idiwuolvklhs.png


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

  1. Делаем SELECT из каждого узла.
  2. Отправляем данные на router.
  3. Делаем JOIN.


wqutxbeahymzwkn9nkqxgyontvc.png


Какие проблемы у такого решения?

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


Как можно починить?

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

kakfcjdatr2wkqxnbehngxavmho.png


z5o5sgqynyz-xcembu25ikaw_eg.png


_mssomruzja0omqr7eehjaybt4g.png


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

snomq5nst-uzuvsjf1tihsrnaze.png


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

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


Что посмотреть по теме


SPOF


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

bjrl6u3lmjpuz8b51xhluhxybi0.png


Представьте себе шардированный кластер, в котором есть отдельно стоящий узел. На нём хранятся словари — редко изменяемые данные, которые требуются для формирования ответов клиентам. Каждый запрос в кластере требует обращения к словарю:

SELECT * 
FROM SPACE JOIN DICTIONARY 
WHERE ...


Какие проблемы у такого решения?

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

682llvtxhvvlwuszwqfqcyuuery.png


Как можно починить?

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

ckxmbhzlc-5hyg45egi4lvnyjw0.png


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

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


Что почитать по теме


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


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

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


Чтобы поменять конкретное значение в массиве data, нужно пройтись по всем значениям в массиве:

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() -- обернем все в транзакцию
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 to allocate memory». Если вы с таким столкнулись, это можно исправить с помощью повышения параметра box.cfg slab_alloc_factor.

Что почитать по теме


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


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

75a02zskceyt4xw6eupw_bfyoue.png


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

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

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

-- код для добавления триггера
-- позволит обрабатывать все записи, 
-- начиная с момента восстановления из 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 попадёт только одна запись:

b0xfmziizsuxm87dupwikreknf8.png


Что почитать по теме:


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


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

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

ghnjbzqf-o5br9g7jjmfsnozhuw.png


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

6lzam-g2wgoja79t54ehx09ke3w.png


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

Что почитать по теме


Как не допустить проблем


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


Что дальше

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

© Habrahabr.ru