Из SQL в NoSQL: меняем парадигму запросов

Пользовательский опыт напрямую зависит от скорости выполнения запросов к данным. Мы привыкли, что базы данных SQL строят оптимальный план запроса за нас. Во многих базах данных NoSQL оптимизация запроса ложится на разработчика. Меня зовут Жора, и вместе с yngvar_antonsson мы провели много времени за аудитом запросов у наших заказчиков. Сегодня мы расскажем про перфоманс, оптимизации и тяжелые запросы на примере Tarantool. Будет интересно всем, кто уже работает или только собирается работать с Tarantool, а также тем, кто строит кластерные системы поверх своих БД.

Изображение сгенерировано Midjourney

Изображение сгенерировано Midjourney

Дисклеймер

Не все запросы в примерах можно выполнять на продах. Помните, что Tarantool все еще однопоточный, а select(nil) или бесконечный цикл без вызова fiber.yield() сломает вам инстанс.

В примерах используется Tarantool 2.11 с инициализированными таблицами:

box.schema.space.create('test')
box.space.test:format{
    {name = 'a', type = 'integer'},
    {name = 'b', type = 'integer'},
    {name = 'c', type = 'integer'},
}

box.space.test:create_index('pk', {parts={'a', 'b'}})
box.space.test:create_index('a_index', {parts={'a'}})
box.space.test:create_index('b_index', {parts={'b'}})

Или аналогичный SQL-запрос:

CREATE TABLE test (
    a INTEGER NOT NULL,
    b INTEGER NOT NULL,
    c INTEGER NOT NULL
    PRIMARY KEY (a, b)
);

CREATE INDEX a_index ON test(a);
CREATE INDEX b_index ON test(b);

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

Выборка данных с ограничениями

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

SELECT * FROM test WHERE a > 10 AND b > 5 LIMIT 10
box.space.test:pairs({10, 5}, {iterator = 'GT'}):take_n(10):totable()
box.space.test:select({10, 5}, {iterator = 'GT', limit = 10})

Обратите внимание на параметр iterator в запросах выше: он указывает на тип сравнения, которое используется для выборки по индексу, и формируется из первых букв соответствующего оператора сравнения: GT — greater than.

Для фильтрации по полям, которые не находятся в индексе, можно воспользоваться функцией filter:

SELECT * FROM test WHERE a > 10 AND c > 4
 box.space.test:pairs({10}, {iterator = 'GT'})
  :filter(function(x) return x.c > 4 end)
  :take_n(10)
  :totable()

Пагинация

В статье про антипаттерны в Tarantool мы писали, что пагинация и SELECT без условий — это достаточно опасная вещь. В последней версии (2.11 на момент написания статьи) все стало значительно лучше:

1. Tarantool теперь пишет в логи о потенциально долгих селектах:

box.space.test:select(nil)
2023-03-15 20:40:55.717 [23511] main/103/interactive C> Potentially long select from space 'test' (512)

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

-- где-то в init.lua файле
compat.fiber_slice_default = 'new'
fiber.set_max_slice{warn = 1, err = 3}

-- в коде вашего приложения:
for k, v in box.space.test:pairs() do     
    -- smth
end

2023-03-15 21:41:59.157 [28484] main/103/interactive fiber.h:1054 W> fiber has not 
yielded for more than 1.000 seconds
---- 
error: fiber slice is exceeded
...

3. Пагинацию можно делать с помощью специального параметра в вызове select:

local res, pos, cnt = {}, nil, 0

while cnt < box.space.test:count() do    
    res, pos = box.space.test:select({}, 
        {limit = 1000, after = pos, fetch_pos = true})    
    -- do smth with res    
    cnt = cnt + #res 
end

4. Все еще можно использовать последний выбранный элемент для продолжения итерации:

local last_key, cnt = nil, 0
while cnt < box.space.test:count() do    
    res = box.space.test:pairs(last_key, {iterator = 'GE'}):take_n(1000):totable()   
    -- do smth with res    
    cnt = cnt + #res    
    local last_tuple = res[#res]    
    last_key = {last_tuple.a, last_tuple.b}
end

5. А Enterprise-версия позволит вам сделать Readview и выполнять fiber.yield в цикле, не беспокоясь о том, что данные поменяются между вызовами:

rw = box.read_view.open{name = 'test_rw'}
while cnt < box.space.test:count() do    
    res, pos = rw.space.test:select({}, {limit = 1000, after = pos, fetch_pos = true})    
    -- do smth with res    
    fiber.yield()     
    cnt = cnt + #res 
end
rw:close()

А еще у параметра after есть огромное преимущество: его можно передавать по сети и делать удобнее чтение данных пачками.

Сортировка

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

Индекс B+*-tree, который создается в Tarantool по умолчанию, позволяет выбирать данные уже в отсортированном виде. Подробнее про индексы в целом можно почитать здесь, а про их внутренности в Tarantool здесь.

SELECT * FROM test ORDER BY b LIMIT 10
box.space.test.index.b_index:select({}, {limit = 10}) 
-- ordered

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

SELECT * FROM test ORDER BY b DESC LIMIT 10
box.space.test.index.b_index:select({}, {limit = 10, iterator = 'REQ' })
-- reverse ordered

Важный момент: выборка данных всегда должна происходить в одном порядке. Из-за устройства самого индекса вы не сможете отобрать данные в другом порядке, чем тот, в котором они находятся в индексе. Например, на картинке ниже если есть индекс по полям (a, b), то запрос вида a >= a2 пройдет по последовательным записям в индексе, а запросу b >= b2 пришлось бы перескакивать между различными таплами в индексе, что в Tarantool невозможно.

Все становится немного сложнее, когда сортировать нужно данные, полученные после преобразования — здесь мы переходим из memtx_memory в lua_memory:

data := SELECT * FROM test1 JOIN test_2 ON cond ORDER BY b DESC LIMIT 10
table.sort(data, comparator)

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

  1. Отдать сортировку в вышележащее приложение: многопоточный Go, Python или Java справятся с этим лучше.

  2. Добавить yield в компаратор. Это позволит отдать управление другим функциям, которые работают параллельно с кодом сортировки.

Группировка

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

SELECT MAX(c) FROM test GROUP BY a, b
-- loop way
local aggregate = {}
for _, v in box.space.test:pairs() do
    local key = v.a .. v.b    
    aggregate[key] = max(aggregate[key], v.c)
end

-- luafun way
local aggregate = box.space.test:pairs():reduce(    
    function(acc, x)         
        local key = x.a .. x.b        
        acc[key] = max(acc[key], x.c)         
        return acc     
end, {})

Nested Loop Join

Джойны стандартными средствами Tarantool тоже сделать не получится, для этого нужно писать код. Можно воспользоваться Lua-таблицами, если промежуточный результат JOIN будет достаточно небольшим. Или создать промежуточный тарантульный спейс, в который будем складывать результат JOIN, как в примере ниже:

SELECT a1,a2,a3,fk,b2,b3 FROM a JOIN b ON a.fk = b.pk WHERE a2 > 5 AND b3 < 10 

Вариант с промежуточным спейсом:

box.schema.space.create('tmp', {temporary = true})

box.space.tmp:create_index('tmp_pk', {parts={{field = 1, type = 'string'}, {field = 4, type = 'string'}}})

for _, v1 in box.space.a:pairs():filter(function(x) return x.a2 > 5 end) do    
    local a1, a2, a3, a4 = v1:totable()    
    box.begin()    
    for _, v2 in box.space.b:pairs({a4}):filter(function(x) return x.b3 < 10 end) do 
        local _, b2, b3 = v2:totable()       
        box.space.tmp:insert{a1,a2,a3,a4,b2,b3}    
    end    box.commit()
end

local res = box.space.tmp:select{}
-- do smth with res

box.space.tmp:drop()

Вариант с Lua-таблицей для небольшого объема данных:

local res = {}

for _, v1 in box.space.a:pairs():filter(function(x) return x.a2 > 5 end) do    
    local a1, a2, a3, a4 = v1:totable()    
    for _, v2 in box.space.b:pairs({a4}):filter(function(x) return x.b3 < 10 end) do
        local _, b2, b3 = v2:totable()        
        table.insert(res, {a1,a2,a3,a4,b2,b3})    
    end
end

Кластерные запросы

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

Почти все шардированные кластеры Tarantool построены на базе модуля vshard. Также с недавнего времени в Tarantool появился модуль crud, который совместим с vshard-запросами. Дальнейшие примеры написаны с использованием обоих подходов — pure vshard calls и crud. Начнем с простого.

One shard select

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

SELECT * FROM test WHERE a = 10 LIMIT 10

vshard way:

local bucket_id = bucket_id_calc(sharding_key)

local resultset = vshard.router.callrw(bucket_id, 'box.space.test:select', {{10}, 
{limit = 10}}, {timeout = 5})

crud way:

local resultset = crud.select('test', {{'=', 'a', 10}}, {first = 10, bucket_id = bucket_id_calc(sharding_key)})

Multishard select

(SELECT * FROM test[shard 1] WHERE a > 10 AND c > 4)

UNION ALL

(SELECT * FROM test[shard 2] WHERE a > 10 AND c > 4)

UNION ALL 

...

(SELECT * FROM test[shard N] WHERE a > 10 AND c > 4)

vshard way:

1. Объявить на каждом шарде функцию, возвращающую данные:

--[[global]] function filtered_select(a_val, c_val)
  return box.space.test:pairs({a_val}, {iterator = 'GT'})
    :filter(function(x) return x.c > c_val end):totable()
end

2. На роутере можно воспользоваться функцией vshard.router.routeall:

local resultset = {}

local shards = vshard.router.routeall()

for _, replica in pairs(shards) do
    local set = replica:callro('filtered_select', {10, 4}, {timeout=5})
    for _, item in ipairs(set) do
        table.insert(resultset, item)
    end
end

Или вызовем функцию vshard.router.map_callrw:

local resultset = {}

local res = vshard.router.map_callrw('filtered_select', {10, 4}, {timeout=5})

for _, shard_res in pairs(res) do
    for _, item in ipairs(shard_res[1]) do
        table.insert(resultset, item)
    end
end

crud way:

local resultset = crud.select('test', {{'>', 'a', 10}, {'>', 'c', 4}})

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

One-shard Join

SELECT * FROM a JOIN b ON a.fk = b.pk WHERE a1 = 10 

vshard way:

1. Объявить на каждом шарде функцию, возвращающую данные:

--[[global]] function filtered_select(a1)
    local resultset = {}
    for _, v1 in box.space.a:pairs{a1} do    
        for _, v2 in box.space.b:pairs{v1.fk} do
            table.insert(resultset, {v1.a1, v1.a2, v1.a3, v1.a4, v2.b2, v2.b3})
        end
    end
    return resultset
end

2. На роутере:

local bucket_id = bucket_id_calc(sharding_key)

local resultset = vshard.router.callrw(bucket_id, 'filtered_select', {10}, {timeout = 5})

crud way:

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

local a_data = crud.select('a', {{'=', 'a1', 10}}, {first = 10, bucket_id = bucket_id_calc(sharding_key)})

local resultset = {}

for _, v1 in ipairs(a_data) do
    local b_data = crud.select('b', {{'=', 'pk', v1.fk}}, {first = 10, bucket_id = bucket_id_calc(sharding_key)})
    for _, v2 in ipairs(b_data) do
        table.insert(resultset, {v1.a1, v1.a2, v1.a3, v1.a4, v2.b2, v2.b3})
    end
end

Обязательно проверяйте перф этого запроса — скорее всего, работать на проде это будет очень плохо.

Multi-instance Join

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

SELECT * FROM a[instance A] JOIN b[instance B] ON a.fk = b.pk WHERE a1 = 10 

Такой запрос невозможно выполнить с помощью средств crud или vshard, поэтому его нужно писать на чистом Tarantool.

1. Объявить на разных узлах функции, возвращающие данные:

--[[global]] function filtered_select_a(a1)
    local resultset = {}
    for _, v1 in box.space.a:pairs{a1} do    
        table.insert(resultset, v1)
    end
    return resultset
end

--[[global]] function filtered_select_b(fkeys)
    local resultset = {}
    for _, fk in ipairs(fkeys) do    
        for _, v2 in box.space.b:pairs{fk} do
            table.insert(resultset, v2)
        end
    end
    return resultset
end

2. На роутере:

local netbox = require('net.box') -- модуль для подключения к узлам Tarantool
local a_conn = netbox.connect(instance_A_URI)
local a_res = a_conn:call('filtered_select_a', {10})

-- собираем уникальные внешние ключи для передачи их в filtered_select_b
local fkeys_map = {}
for _, v in ipairs(a_res) do 
   fkeys[v.fk] = true 
end

-- преобразуем мапу с ключами {fkey1 = true, fkey2 = true, ...} в вид {'fkey1', 'fkey2', ...}
local fkeys = {}
for k, _ in pairs(fkeys_map) do 
    table.insert(fkeys, k)
end

local b_conn = netbox.connect(instance_B_URI)
local b_res = b_conn:call('filtered_select_b', {fkeys})

local resultset = {}
for _, v1 in ipairs(a_data) do    
    for _, v2 in ipairs(b_data) do
        table.insert(resultset, {v1.a1, v1.a2, v1.a3, v1.a4, v2.b2, v2.b3})
    end
end

Тестирование запросов

Все сложные запросы обязательно нужно тестировать. На Хабре есть много статей про то, как мы тестируем и мониторим Tarantool, так что, резюмируя накопленный опыт:

Итоги

Tarantool — очень гибкий инструмент для работы с данными. Практически все привычные запросы из SQL вы можете повторить либо с помощью стандартного API Tarantool, либо с помощью небольшого количества кода на Lua. Комбинируйте различные подходы для запросов к данным, чтобы получить оптимальные результаты.

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

Полезные ссылки

© Habrahabr.ru