Tarantool: история ускорения поиска в 1С
Недавно у наших добрых друзей из крупной розничной сети возникла задача ускорения поиска в 1С.
Во-первых, искать нужно было по клиентам (три справочника, 9 текстовых полей, поиск типа %like%) и всего-то по 2,5 млн записей. Сразу скажем, что полнотекстовый поиск и морфология — это пока не про Tarantool. В результате ряда экспериментов мы остановились на ElasticSearch, но т.к. он не в тему статьи, то можем написать отдельную, если будет интерес. Скажем только, что скорость выросла на порядок по сравнению с тем, что мы могли выжать из полнотекстового поиска MS SQL.
Во-вторых, нужен был поиск и подбор по товарам с выводом остатков по всем складам без дополнительных запросов. Скорость поиска должна была быть сопоставима с обычным откликом интерфейса, то есть около 0,2 сек вместо текущих 5–12 секунд в 1С (в зависимости от уровня нагрузки). 90 тысяч строк, список номенклатур меняется не часто, примерно по 10–50 позиций в день.
Stage 1
И тут в полный рост встал вопрос онлайн-обновления остатков товаров на складах. В двух 1С (производственная и розничная) в общей сложности было порядка 700 складов с постоянно меняющимися остатками и 90 тысяч активных позиций товаров. Планируемая система должна была переваривать 200 пользовательских запросов по остаткам в секунду.
Первым делом посмотрели, что нам скажет Elastic. И мгновенно уперлись в проблему реиндексации (остатки меняются часто и помногу) и значительного замедления поиска из-за вложенности складов и остатков. При 50 запросах в секунду на поиск и при одновременном изменении порядка 100 записей в секунду реиндексация начинала отставать, и скорость поиска росла экспоненциально.
Далее были MySQL и PostgreSQL, результат был примерно одинаковый. Один запрос к номенклатуре от пользователя рассыпался в среднем на 10 запросов к характеристикам номенклатуры и поиску остатков 10 характеристик по 700 складам. При 10 пользовательских запросах в секунду и тех же 100 рандомных insert в секунду поиск был около 0,9 сек, а при 20 запросах уходил за 2,5 секунды. При 30 одновременных запросах всё было сравнимо с поиском в 1С.
Наши 1С-эксперты заявили, что «сейчас оптимизируют поиск в 1С и никаких сторонних систем нам не надо». Пропущу результат и сразу перейду к той части, где 1С-эксперты сказали, что «ну, тогда мы будем обращаться напрямую к MS SQL».
MS SQL показал себя лучше и выдохся примерно на 45 запросах. После чего мы согласились, что нам нужна in-memory БД.
Дальше был скучный поиск решения, которое работало бы с требуемой производительностью и при этом восстанавливалось при перезапуске серверов не позднее, чем через 3–5 секунд.
Мы понимали, что нужны будут очереди, регулярный сброс данных на диск, система восстановления текущих остатков по товарам в случае аварийного перерыва в работе серверов (без потери данных из памяти) и шардирование (оно было бы нужно при любом выбранном варианте, но не хотелось сильно костылить).
Также мы понимали, что вместо привычной в эксплуатации системы на 1С мы получим затратный по администрированию и контролю показателей набор систем.
Конечно, мы начали экспериментировать со стеком RabbitMQ + Memcached / Redis, но открытым оставался вопрос сохранения данных в случае сбоя и быстрой их подгрузки. К тому же нужно было придумывать механизм подтверждения обмена данными с 1С. В довершении всего этот стек не решал будущую задачу — построение «центрального хранилища данных» с быстрым поиском.
Параллельно с этим мы изучали возможности MS SQL 2017 — Memory Optimized Tables.
И вот тут мы познакомились с Tarantool.
Отсутствие хорошей документации и понимания логики разработчиков этого «комбайна» поначалу выбили нас из колеи. Но проведенные замеры показали превосходную скорость поисковой выдачи, а заверения производителя о масштабном шардировании, сохранности данных в случае сбоя, восстановлении (прогревании) данных в течение нескольких секунд после старта сервисов и встроенном механизме очередей заставили нас вплотную погрузиться в изучение.
К сожалению, первичных результатов замеров не сохранилось, поэтому прикладываю более поздние результаты, только для сравнения MS SQL с Tarantool.
Выполняли префиксный поиск (текст*) без морфологии и учета ошибок по базе примерно в 5 млн пользователей (15 полей для каждого пользователя, включая связанные данные, например, бонусные карты).
Stage 2
Первое, что мы сделали после экспериментальных замеров, это поставили Tarantool 2.1 с поддержкой SQL-команд и коннектор к Tarantool на PHP.
Какое-то время ушло на осознание, что каждый инстанс Tarantool является однопоточным и нужно поднимать много инстансов для распределенных запросов, а не выжимать всё из одного.
Далее мы разбирались с компоновкой данных и тем, как их правильно разложить. В итоге написали на Lua свой велосипед по шардированию (тогда мы ещё не знали о Tarantool Cartridge и Vshard). Результаты показали длительность поиска от 0,004 сек до 0,21 сек в зависимости от нагрузки. Последнее значение получено при 30 пользовательских запросах к одному инстансу и 100 обновлениях данных в секунду. Встроенным шардированием до 7 инстансов мы получили требуемые показатели производительности.
По мере знакомства с Tarantool мы всё больше понимали, что использование SQL — это просто привычка. Мы попробовали реализовать всё то же самое на Lua, и результат превзошёл все ожидания. Загрузка и обработка массива данных из 6 млн. записей в первоначальной SQL-реализации занимала до 20 минут, а после перехода на Lua этот же массив стал обрабатываться за 50 секунд и, как бонус, мы получили уменьшение нагрузки на 1С за счет прироста скорости выгрузки.
С помощью двух инстансов Tarantool (мастер и read only, по 4 ядра и 1,5 Гб памяти на инстанс) мы добились требуемой производительности: MS SQL не смог показать хорошую скорость при 50 запросах в секунду, а на Tarantool мы смогли выжать 120 запросов в секунду на одном инстансе (и это не предел: мы тестировали на Tarantool версии 2.2, но разработчики говорят, что в версии 2.3 выборка по префиксу стала использовать индекс, поэтому на Tarantool 2.3 результаты должны быть еще лучше — мы проверим это и добавим результаты в статью). В обоих случаях мы упирались в загрузку процессора. В среднем за 1 запрос выгружалось до 300 строк, данные при этом все хранились в памяти (Tarantool memtx).
Текущий код написан на Lua, встроенном в Tarantool. Порог вхождения в язык для нас оказался минимальный.
Вот пример одной из функций: добавление товаров в очередь обмена с ElasticSearch.
--- Добавить товары в очередь для обмена, на основе изменения остатков
local function get_stockBalance_to_queue(spaceIn)
local x = os.clock();
local space_name = 'STOCKBALANCES';
if spaceIn ~= nil and 'string' == type(spaceIn) and spaceIn ~= '' then
space_name = spaceIn;
end
local maxRows = 50000;
local goods = {};
local goodsIds = '';
--- счетчики
local i = 0;
local iStock = 0;
local session = os.time(os.date("!*t"));
local borderTime = session — (10);
box.begin()
for _, tuple in pairs(box.space[space_name].index.ISPROCESSED:select(0, { iterator = box.index.EQ, offset = 0, limit = maxRows })) do
if i == maxRows then break end
--- tuple[1] — GUID 1С товара
--- tuple[2] — GUID 1С Склада
--- tuple[6] — Время, когда последний раз обрабатывалось
if tuple[6] < borderTime then
--- Все, что попадет в этот массив, будет обрабатываться для установки признака товару — выгрузыть в эластик
if goods[tuple[1]] == nil then
goods[tuple[1]] = 1;
--- Счетчик — сколько товаров может быть обновлено
i = i + 1;
--- последняя запятая обрежется в вызываемых функциях
goodsIds = goodsIds .. '"' .. tuple[1] .. '",';
end
--- Устанавливаем последнее время обработки = текущее время
pcall(function() box.space[space_name]:update({ tuple[1], tuple[2] }, { { '=', 5, 1 }, { '=', 6, session } }) end);
--- Счетчик — сколько товаров всего попало в обработку
iStock = iStock + 1;
end
end
local stockTmp = {};
local goodsInStockTmp = {};
local instock = 0;
--- в функции getSumStock происходит создание массива остатков по товару (сколько в резерве, в наличии и т.д.)
local stockBalance = _G.Functions.getSumStock(goodsIds);
--- в функции getGoodsInStock происходит создание массива со значением in stock — 1/0 (Да/Нет)
local goodsInStock = _G.Functions.getGoodsInStock(goodsIds);
local iGoods = 0;
for _, tuple in pairs(goods) do
--- Берем количество товара в наличии (не может быть такого, что в массиве нет товара с текущим tuple[1], поэтому нет проверки)
stockTmp = stockBalance[tuple[1]]['quantityNet'];
--- Берем значение товара в наличии (не может быть такого, что в массиве нет товара с текущим tuple[1], поэтому нет проверки)
goodsInStockTmp = goodsInStock[tuple[1]];
--- в товаре хранится просто признак в наличии — Да/Нет, поэтому мы приводим все к 1/0 (Да/Нет)
if not stockTmp then
instock = 0;
elseif stockTmp > 0 then
instock = 1;
elseif stockTmp < 0 then
instock = 0;
else
instock = 0;
end
--- если текущее наличие в товаре отличается от наличия по всем складам, то помечаем товар к выгрузке в эластик
if instock ~= goodsInStockTmp then
--- Счетчик — сколько товаров было изменено
iGoods = iGoods + 1;
--- Поле 100 — Обработано (индекс — ISPROCESSED) (ДА/НЕТ), если нет, то попадет в этот цикл
--- Поле 105 — Наличие, Да/Нет (1/0)
pcall(function() box.space.GOODS:update(tuple[1], { { '=', 100, 0 }, { '=', 105, instock } }) end);
end
end
box.commit();
return space_name .. ' for update: ' .. iStock .. ' Goods for update: ' .. i .. ' Updated goods: ' .. iGoods .. ', ' .. (string.format("elapsed time: %.2f", os.clock() — x));
end;
Теперь мы ищем в ElasticSearch с учетом морфологии и ошибок по наименованиям/свойствам и другим текстовым полям, а после этого запрашиваем данные о наличии товаров в различных разрезах в Tarantool и возвращаем результат.
Примерная схема работы такая:
Система получилась простая, легко администрируемая, с контролем показателей и быстрым запуском в случае сбоя. В неё легко, прямо на лету, можно добавлять новые инстансы или отключать их.
Мы мониторим показатели ресурса сервера (процессор, память, диски) и показатели Tarantool box.slab.info. Со стороны 1С мониторится уменьшение очереди на отправку и отсутствие ошибок в ответе от бэкенда. В случае достижения пороговых значений оповещается техподдержка.
И да, мы очень благодарны специалистам Mail.ru Group, которые отвечали на наши вопросы, и сообществу в Telegram, которое пока небольшое, но очень активное.
Stage 3
«Ну вот, — подумали мы. — Всё так здорово работает. А не подключить ли нам сайт напрямую к Tarantool, чтобы избавиться от медленных Bitrix-обменов и Bitrix-кеширования?» (кто в теме, тот грустит вместе с нами)
«Почему только сайт? — спросил заказчик. — Наверняка туда нужно подключить мобильное приложение, партнеров и франшизу».
«Ух» — сказали мы, и поскольку представление о быстром хранилище данных уже сложилось, мы ушли придумывать шину обмена данных на Tarantool. Надо сказать, что результаты уже впечатляющие. Напишем, если эта тема вам, уважаемые читатели, зайдет.
P.S. Спасибо, что уделили прочтению статьи столько своего времени. Ждем народной любви и народного гнева в комментариях. Всех обнимаем, команда Зионек