
Tarantool Proxy — «умный посредник», который делает работу с кластером Tarantool надежнее, быстрее и проще, беря на себя рутинные задачи вроде балансировки и безопасности. Это компонент в архитектуре шардированного Tarantool, который нужно написать самому на основе библиотеки vshard. То есть это не standalone приложение из коробки. Заметим, что изначально основная библиотека Tarantool Proxy — vshard была написана на Lua, из-за чего для получения всех профитов от работы с ним нужна была специфическая экспертиза и готовность мириться с некоторыми сопутствующими издержками, что подходило не всем. Поэтому мы решили оптимизировать работу с Tarantool и использовали для этого толстый клиент на Go.
Меня зовут Максим Коновалов, я архитектор Tarantool в VK Tech. В этой статье я расскажу, зачем и как мы уходили от Lua и что получили в итоге.
Начнем с теории
Масштабирование бывает двух видов: горизонтальное и вертикальное. Горизонтальное также делится на два типа:
репликация, которая отвечает за масштабирование вычислений;
шардинг, который используется для масштабирования данных.
Мы выбрали виртуальный шардинг. В Tarantool горизонтальное масштабирование осуществляется при помощи виртуального шардирования. Для этого используется модуль Tarantool VShard. Его основная задача — упростить распределение данных между узлами, обеспечивая прозрачность доступа, балансировку нагрузки и отказоустойчивость. В основе архитектуры шардированного кластера лежат два ключевых компонента:
Роутеры. Они не хранят данные, а отвечают за маршрутизацию запросов к нужным шардам. Для вычисления целевого шарда роутеры используют ключ шардирования, например хеш от bucket_id, и кэшируют информацию о расположении данных для ускорения операций. Роутеры получают целевое хранилище, вычисляя crc32 или crc64 хеш от выбранного ключа шардирования.
Хранилища. Это физические узлы, управляющие виртуальными шардами (логическими сегментами данных). Причем каждый узел (хранилище) может обслуживать несколько виртуальных шардов, что позволяет гибко перераспределять данные при изменении топологии кластера.
Кластер обеспечивает отказоустойчивость за счет репликации — каждый шард имеет мастер-узел для записи и реплики для чтения. Причем в Tarantool репликасет практически аналогичен конфигурации master-slave, но из таких репликасетов состоит весь кластер — они сосуществуют практически независимо, но хранят лишь часть записей.

Примечание: Больше о горизонтальном масштабировании в Tarantool можно почитать здесь.
Работа с кластером
Изначально идея Tarantool заключалась в том, что использование бизнес-логики на Lua сможет упростить работу и ускорить ее — в таком случае Tarantool Proxy и есть tarantool-router. Но со временем от идеи внесения бизнес-логики в Tarantool стали отходить.
Сейчас в базовой комплектации горизонтально масштабируемый кластер Tarantool выглядит примерно так:

Но если tarantool-router (из модуля vshard.router) работает на уровне данных и шардирования, то Proxy функционирует на сетевом/транспортном/прикладном уровне, не анализируя содержимое запросов.
Нюанс лишь в том, что Tarantool Proxy фактически представляет собой инстанс базы данных на Tarantool с кодом для виртуального шардирования на Lua. То есть пользователь получает библиотеку, с помощью которой нужно написать самому поверх базы данных некое Proxy. При этом каждый из узлов Proxy является Stateless. В итоге пользователи сталкивались с некоторыми барьерами и сложностями.
Работа с языком Lua
Работа с Lua имеет несколько нюансов.
Lua считается достаточно простым в написании языком программирования. Но это не совсем так: Lua один из немногих языков, у которых пакет — метатаблица, класс — метатаблица.
Lua используется нечасто, поэтому специалисты с достаточной экспертизой в работе с Lua — редкость. Соответственно, чтобы начать работать с Tarantool, команде надо учить новый язык, а это время и деньги.
В Tarantool используется JIT-компилятор Lua, который позволяет работать коду в разы быстрее. Однако по той же причине дебажить код становится тяжело. Если в Go запустить дебагер — более чем решаемая проблема, то в LuaJIT это может превратиться в настоящий квест с неочевидным результатом — например, я не смог раздебажить написанный код даже спустя день работы с EmmyLua.
Безусловно, это не значит, что работа с Lua — непосильная задача. Но работать, например, с Go комфортнее.
Примечание: На GitHub есть очень подробная статья по дебагу Tarantool — можете почитать ее здесь.
Лишний сетевой хоп
Наличие промежуточного компонента между сервисом и хранилищем приводит к появлению дополнительного (лишнего) сетевого взаимодействия. В отдельных реализациях это может быть значимым недостатком.
Примечание: Лишний сетевой хоп — один из наиболее холиварных вопросов. Но я принимаю это как плюс. Особенно с учетом того, что первая реализация vshard-router была на Lua поверх платформы, оптимизированной для хранения данных. Если говорить про роутер, это позволяло сделать инфраструктуру менее запутанной, уменьшить количество точек отказа, получить более простое масштабирование (хотя и менее атомарное) и ускорить сетевой доступ за счет отсутствия лишнего сетевого похода.
Неоправданные затраты на Data-платформу
Вместе с Tarantool запускаются системные файберы и создаются системные спейсы. То есть появляются соответствующие затраты на Data-платформу, которые не вполне оправданы.
Мы понимали нюансы работы с Lua и издержки, связанные с наличием промежуточного компонента на пути между сервисом и хранилищем. Поэтому решили перейти к новой реализации.
В результате мы разработали go-vshard-router — библиотеку для отправки запросов напрямую в хранилище на шардированном кластере Tarantool без использования tarantool-router на Lua. Библиотека написана на основе модуля библиотеки Tarantool vshard-router и коннектора go-tarantool. Go-vshard-router использует новый подход к созданию кластера:

Теперь подробнее о самой реализации и аспектах перехода к новой библиотеке.
Переход к новой реализации
Базово библиотека vshard-router на Lua отвечает за:
получение и использование топологии кластера;
отслеживание текущей карты бакетов;
формирование и отправку сообщений;
правильный декодинг и обработку сообщений.
Соответственно, в клиенте на Go мы должны были проработать каждый из этих аспектов.
Получение и использование топологии кластера
В случае с реализацией для Lua управление топологией происходит на уровне кода статичной конфигурации: прописываются список репликасетов и адреса всех инстансов в этом репликасете.
-- Создание конфигурации для кластера
local cfg = {
sharding = {
['cbf06940-0790-498b-948d-042b62cf3d29'] = { — replicaset #1
replicas = {
['8a274925-a26d-47fc—9e1b-af88ce939412'] = {
uri = 'storage:storage@127.0.0.1:3301',
name = 'storage_1_a',
master = true,
},
['3de2e3e1-9ebe-4d0d—abb1-26d301b84633'] = {
uri = 'storage:storage@127.0.0.1:3302',
name = 'storage_1_b'
},
},
}, — replicaset #1
['ac522f65-aa94-4134-9f64-51ee384f1a54'] = { -- replicaset #2
replicas = {
['1e02ae8a—afc0-4e91-ba34-843a356b8ed7'] = {
uri = 'storage:storage@127.0.0.1:3303',
name = 'storage_2_a',
master = true,
},
['001688c3-66f8-4a31-8e19-036c17d489c2'] = {
uri = 'storage:storage@127.0.0.1:3304',
name = 'storage_2_b'
}
},
}, — replicaset #2
}, — sharding
}
Вместе с тем это не единственный сценарий. Например, можно реализовать собственную обертку поверх этой библиотеки и сделать логику смены состояния топологии, и многие это делали через etcd v2.
Но здесь важно учитывать, что контроль над топологией будет отличаться в зависимости от реализованных решений и версии Tarantool. То есть все потенциальные сложности в части адаптации выбранного в vshard-router провайдера топологии — зона ответственности самого пользователя. Например, в Tarantool ниже версии 3.0 есть два вида реализации:
статическая запись топологии;
реализация через etcd v2 с помощью библиотеки moonlibs/config.
А в Tarantool 3 используется топология через etcd v3 и также имеет несколько другой вид. Первая версия moonlibs библиотеки для Tarantool использовала топологию, которая передавалась статически в коде.
Примечание: Понимание, что источников топологии может быть много, подтолкнуло нас к дальнейшей реализации адаптеров топологии и провайдеров в целом. Но об этом позже.
Отслеживание текущей карты бакетов
Отслеживание карты бакетов было реализовано по аналогии с оригинальным vshard-router. При конфигурации роутера есть возможность выбора режима отслеживания карты бакетов:
DiscoveryModeOnce (получить карту один раз);
DiscoveryModeOn (фоновое обновление карты бакетов).
С учетом того, что число бакетов всегда от 1 (в Lua нумерация начинается с единицы) до N (числа, указанного при конфигурации), в качестве хранилища карты бакетов мы выбрали массив Atomic-указателей на репликасеты (в дальнейшем это позволит делать CAS-операции, если репликасеты не совпадают).
type routeMap = []atomic.Pointer[Replicaset]
Здесь все просто:
берем оригинальную реализацию Tarantool с помощью Fiber;
переписываем ее на фоновую горутину;
управление прекращением отдаем context.WithCancel();
из конфигов забираем тайм-аут.
Важно, что при получении карты бакетов мы, как и в оригинале, повторили пагинацию — отсутствие пагинации может привести к ненужной нагрузке на кластер.

Далее просто записываем в карту соответствие бакета репликасету.
Формирование и отправка сообщений
Отправка и формирование сообщений в новом Go-клиенте основывается на уже готовых инструментах библиотеки Go-tarantool. Так мы соблюли изначальные интерфейсы библиотеки:
func (r *Router) Call(ctx context.Context, bucketID uint64, mode CallMode,
fnc string, args interface{}, opts Call0pts) (VshardRouterCallResp, error) {
...
}
Правильный декодинг и обработка сообщений
Правильный декодинг — самая сложная часть библиотеки. Дело в том, что декодинг сообщений msgpack не является примитивной задачей. Причин несколько.
Структурная вложенность — массивы/словари внутри других структур требуют рекурсивной обработки.
Разнообразие типов — в msgpack есть не только строки и числа, но и бинарные данные, временные метки, числа переменной длины. Для каждого типа действуют свои правила чтения байтов и их интерпретации.
Обработка ошибок — если данные повреждены (например, не хватает байтов), декодер должен понять, что что-то не так, и сообщить об ошибке. Если алгоритм не работает, приложение может сломаться из-за некорректных данных.
Оптимизация — декодинг должен быть быстрым даже для больших данных, то есть нужны минимум копирований в памяти и потоковая обработка. Это требует сложных низкоуровневых оптимизаций.
Для удобства отображения содержимого сообщений msgpack мы решили использовать JSON. Здесь важны несколько аспектов:
Сообщения msgpack приходят в бинарном формате без контракта сообщений, как, например, это принято в grpc.
Vshard.storage не придерживается единого формата ответа, поскольку изначально библиотека предполагала использование только Lua (Tarantool) router, в котором декодинг ответа происходит максимально просто.
То есть мы могли получить три типа ответа из Tarantool:
1. В случае внутренней ошибки vshard:
[
"nil",
{
"bucket_id": 20,
"reason": "",
"code": 9,
"type": "",
"message": "",
"name": "",
"master": "00000000-0000—0002-0002-000000000000",
"replicaset": "00000000-0000-0002-0002-000000000000",
"replica": "00000000—0000-0002-0002-000000000000",
"destination": '
},
]
2. В случае ошибки внутри процедуры:
[
"false",
{
"code": 0,
"base_type": "",
"type": aaa
"message": "",
"trace": null
},
]
3. В случае правильного ответа:
[
"true",
...
]
Сейчас это может казаться очевидным, но прийти к правильной реализации было не самой простой задачей. Поскольку декодинг может отличаться от первого параметра, мы сталкивались с дополнительными сложностями реализации — это можно заметить по количеству комментариев, которые мы передавали друг другу при реализации кастомного декодера:
func (r *xvshardStorageCallResponseProto) DecodeMsgpack(d *msgpack.Decoder) error
/* vshard.storage.call(func) response has the next 4 possbile formats:
See: https://github.com/tarantool/vshard/blob/dfa2cc8a2af f221d5f421298851a9a
1. vshard error has occurred:
array[nil, vshard_error]
2. User method has finished with some error:
array[false, assert error]
3. User mehod has finished successfully
a) but has not returned anything
array [true]
b) has returned 1 element
array[true, elem1]
c) has returned 2 element
array[true, elem1, elem2]
d) has returned 3 element
array[true, elem1, elem2, elem3]
*/
// Ensure it is an array and get array len for protocol violation check
respArrayLen, err := d.DecodeArrayLen()
if err != nil {
return err
}
if respArrayLen <= 0 {
return fmt.Errorf("protocol violation: invalid array length: %d", respAr
}
// We need peek code to make our check faster than decode interface
// later we will check if code nil or bool
code, err := d.PeekCode()
if err != nil {
return err
}
Провайдеры
Осознавая, что существует много реализаций и интерфейсов логгеров, метрик и источников топологии, мы решили использовать логику провайдеров — интерфейсов, которые можно передать внутрь конфигурации библиотеки, которая будет с ними работать. За счет этого можно подстроить собственную имплементацию логгера, метрик и провайдера, который используется в сервисе, или взять уже готовые реализации.

Топология
В качестве источника конфигурации можно использовать провайдер топологии (конфигурации). Сейчас Tarantool поддерживает несколько провайдеров:
etcd — для конфигурации аналогичной moonlibs/config в etcd v2 для Tarantool версии ниже 3;
static — для указания конфигурации прямо из кода и простоты тестирования;
viper — etcd v3, consul, files.
Также доступны готовые провайдеры метрик, которые можно зарегистрировать в системе мониторинга и передать go-vshard-router — это позволит не задумываться над опциями и настройкой метрик. Например, сейчас доступен провайдер для Prometheus.
Аналогично есть роутеры и для логирования: stdout (builtin) и slog.
Полученные результаты и выводы
Теперь к тому, какие результаты дало создание толстого клиента на Go для обращения к Tarantool в обход Lua.
Для сравнения реализаций мы оценивали три параметра:
Время (ns/op) — среднее время выполнения одной операции в наносекундах. Показывает, насколько быстро работает код.
Память (B/op) — объем памяти (в байтах), выделяемый на одну операцию.
Аллокации (allocs/op) — количество выделений памяти (Memory allocations) на операцию.
Мы провели два исследования бенчмарков, в ходе которых сравнивали пулы соединений от библиотеки, управляющей пулами соединений, к роутерам.
Получили следующие результаты:
Бенчмарк | Число запусков | Время (ns/op) | Память (B/op) | Аллокации (allocs/op) |
BenchmarkCallSimpleInsert_GO-12 | 14 929 | 77 443 | 2308 | 34 |
BenchmarkCallSimpleInsert_Lua-12 | 10 196 | 116 101 | 1098 | 19 |
BenchmarkCallSimpleSelect_GO-12 | 20 065 | 60 521 | 2534 | 40 |
BenchmarkCallSimpleSelect_Lua-12 | 12 064 | 99 874 | 1153 | 20 |
Исходя из полученных результатов, можем сделать некоторые выводы.
Примечание: Здесь важно отметить, что на прогонах с Go-клиентом количество запусков больше, поскольку в тесте использовался стенд, который был не полностью закрыт. Это отчасти объясняет перекос показателей. Но даже в этом случае общие тенденции прослеживаются четко, что позволяет делать аргументированные выводы.
Go быстрее Lua как для операций вставки (Insert), так и для выборки (Select):
Вставка (Insert): Go: 77,443 ns/op vs Lua: 116,101 ns/op.
Выборка (Select): Go: 60,521 ns/op vs Lua: 99,874 ns/op.
Это обусловлено тем, что Go работает напрямую через API, что эффективнее.

Прямая обработка Go в Lua прокси экономичнее по памяти:
Вставка (Insert): Go: 2308 B/op vs Lua: 1098 B/op.
Выборка (Select): Go: 2534 B/op vs Lua: 1153 B/op.
Это объясняется тем, что в Lua данные обрабатываются внутри Tarantool без промежуточных преобразований, тогда как в Go могут создаваться временные структуры.
Go напрямую требует больше аллокаций на операцию:
Вставка (Insert): Go: 34 allocs/op vs Lua: 19 allocs/op.
Выборка (Select): Go: 40 allocs/op vs Lua: 20 allocs/op.
Это вызвано тем, что соединение происходит уже не с прокси, а нужно самому проходить весь путь, начиная с устройства бакетов, отслеживания их состояния. Далее нужно удерживать пулы соединений уже не к прокси, а напрямую к хранилищам.
Вместе с тем надо понимать, что нашей основной целью при создании новой реализации было не улучшение перформанса, а упрощение разработки, отладки и инфраструктуры. Поэтому заметный буст по скорости даже при большем количестве запусков — дополнительный профит, который изначально не был самоцелью.
Таким образом, у нас получилось не просто переписать изначальную реализацию, но и получить инструмент, который позволяет ускорить разработку, упростить внедрение мониторинга, логирования и источников топологии, а также добавить еще один полезный вариант для реализации шардированного кластера.
P. S. За помощь в подготовке реализации и публикации проекта выражаю благодарность Владиславу Грубову, Нуржану Сактаганову и всей команде Tarantool.