Большинство in-memory решений отдают данные приложению, которое их обрабатывает. Tarantool предлагает обратное: он одновременно in-memory база данных и сервер приложений на Lua, поэтому логику можно исполнять прямо там, где лежат данные, — одним вызовом stored procedure, который возвращает готовый результат вместо всего набора, по которому его считали. Это меняет способ проектирования горячих путей. Picodata выросла из того же ядра, но называть её «Tarantool с кластерным обвесом» неправильно: это самостоятельная СУБД на форке, со своим планировщиком распределённых запросов на Rust, своим кластер-менеджером и — что меняет способ работы с ней сильнее всего — с PostgreSQL в качестве основного протокола общения. Расширяется она тоже иначе: не Lua-хранимками, а плагинами на Rust. Ту же идею «вычисление рядом с данными» две системы реализуют по-разному, и в этом сравнении обе видны лучше, чем поодиночке. Обе системы — российские, и это здесь не деталь для галочки: Tarantool и Picodata реально стоят в проде у российских компаний как основной, а не резервный вариант.
Это третья статья серии «Вычисления в оперативной памяти», продолжение второй статьи про Redis как ускоритель. Там вычисление выносилось в кэш перед источником правды; здесь источник правды и вычисление сходятся в одном месте. У Tarantool это буквально один процесс: хранимка исполняется в том же адресном пространстве, что и кортежи. У Picodata — уже не процесс, а кластер: код плагина работает внутри него, но сам запрос планируется и исполняется распределённо, на всех хранилищах сразу. Разница существенная, и дальше она разбирается отдельно. Все числа ниже — из живых стендов tarantool (три сценария: spaces, storedproc, memtx-vinyl), picodata (три сценария: topology, sharding, sql) и databases/picodata (плагин на Rust, PostgreSQL-протокол, наблюдаемость) на одном и том же датасете серии — 200 000 товаров.
В статье
- Tarantool: spaces и индексы
- memtx против vinyl: одна и та же RAM-дилемма в байтах
- Lua application server: вычисление вместо выгрузки
- Persistence: WAL и snapshot
- Picodata: не обвес, а другой продукт
- Picodata говорит на языке PostgreSQL
- Наблюдаемость: дашборд и метрики из коробки
- Две модели расширения: Lua-хранимка и Rust-плагин
- Чем платим
- Что дальше
Tarantool: spaces и индексы
Tarantool хранит данные в spaces — по сути таблицах, но без обязательной реляционной схемы: формат задаётся явно (format), а строки называются tuples. У пространства может быть несколько индексов — HASH, TREE, BITSET, RTREE, — и, в отличие от многих NoSQL-хранилищ, вторичные индексы здесь не редкость, а обычный инструмент. Стенд создаёт products с первичным индексом по id и вторичным составным индексом (category, views):
local products = box.schema.space.create('products', { if_not_exists = true })
products:format({
{ name = 'id', type = 'unsigned' },
{ name = 'sku', type = 'string' },
{ name = 'title', type = 'string' },
{ name = 'price_cents', type = 'unsigned' },
{ name = 'category', type = 'string' },
{ name = 'views', type = 'unsigned' },
})
products:create_index('primary', { parts = { 'id' }, if_not_exists = true })
products:create_index('category', { parts = { 'category', 'views' },
unique = false, if_not_exists = true })Составной индекс (category, views) даёт готовую сортировку по просмотрам внутри категории без отдельного ORDER BY — именно на нём строится хранимка из следующего раздела. Проверка на живом кластере: box.space.products:len() после заливки полного датасета совпал с count(*) в PostgreSQL — 200 000 = 200 000; чтение по первичному и вторичному индексам вернуло корректные строки.
Заливка данных из Go-клиента идёт пачками через асинхронный конвейер Do()/Get() — запросы ставятся в очередь поверх одного TCP-соединения, ответы собираются позже:
futures := make([]*tarantool.Future, 0, end-i)
for _, p := range rows[i:end] {
tuple := []interface{}{
uint64(p.ID), p.SKU, p.Title, uint64(p.PriceCents), p.Category, uint64(p.Views),
}
req := tarantool.NewReplaceRequest(space).Tuple(tuple)
futures = append(futures, conn.Do(req))
}
for _, f := range futures {
if _, err := f.Get(); err != nil {
return fmt.Errorf("replace в %s: %w", space, err)
}
}Публикуемое число раздела — фактическая цена этого набора в резидентной памяти: 15 861 160 байт (15,13 МБ) на 200 000 кортежей (box.slab.info().items_used, дельта между чистым space и загруженным), стабильно на 22 прогонах подряд (12 у исполнителя стенда + 10 у независимого ревьюера). Это memtx — движок, где данные целиком в RAM; про второй движок Tarantool и то, во что обходится обратный выбор, — дальше.
memtx против vinyl: одна и та же RAM-дилемма в байтах
У Tarantool два движка хранения на выбор для каждого space отдельно. memtx держит все данные в оперативной памяти — записанные выше 15,13 МБ. vinyl — LSM-движок, данные лежат на диске, в памяти резидентны только служебные структуры доступа. Один и тот же датасет, залитый в оба движка стендом memtx-vinyl, даёт прямое сравнение в байтах — без всякой абстракции.
Здесь важна не одна цифра, а то, в какой момент она снята. Сразу после записи vinyl ещё не сбросил свежие тюпли на диск — они лежат в L0-буфере в памяти, и в этой переходной фазе резидентная RAM vinyl временно сопоставима с memtx (наблюдавшееся отношение — 0,699–0,701, на 12 прогонах подряд). Это не заголовочное число серии — это преходящая фаза, которая закончится, как только регулятор (или явный box.snapshot()) сбросит буфер на диск. После дампа картина другая: tuple и level0 уходят в ноль, резидентными остаются только page_index и bloom_filter — служебные структуры на сами смонтированные с диска run’ы, необходимые, чтобы быстро находить нужный кусок данных без полного чтения файла.
Устойчивое, то есть заголовочное состояние: memtx держит те же 200 000 кортежей в RAM за 15 861 160 байт, vinyl после дампа — за 288 359 байт (0,28 МБ). Разница — 55,0 раза (15 861 160 / 288 359 = 55,00 — считать по точным байтам; деление округлённых мегабайт 15,13/0,28 даёт 54, это округление, а не другое число). Плата за эту экономию — 15,63 МБ на диске постоянно (box.stat.vinyl().disk: data + index), тогда как у memtx между чекпоинтами на диске в норме 0 байт данных. Абсолютное время доступа между движками этот стенд не сравнивает и не публикует (почему — в разделе «Границы метода» шестой статьи серииготовится, с 26 сентября) — только память и диск.
Честная оговорка по устойчивому числу: page_index/bloom_filter — это не «вся резидентная RAM vinyl без исключений», а конкретно эти две структуры; поле памяти активных транзакций (Tx) в расчёт стенда не попадает, хотя в последовательном сценарии стенда оно и так остаётся нулевым. И ещё одна деталь, которая стоила стенду двух неверных прогонов подряд: memtx освобождает память дропнутого space асинхронно — фоновой GC-фиброй, а не в момент вызова drop(). Замер RAM без ожидания устоя box.slab.info() (три одинаковых показания подряд с шагом 100 мс) давал заниженную и невоспроизводимую дельту на повторных прогонах одного и того же инстанса. С явным ожиданием устоя число воспроизводится идентично.
Практический вывод не «vinyl лучше memtx» — это разные движки для разных наборов. memtx — когда рабочий набор помещается в RAM и важна предсказуемая скорость доступа без обращения к диску. vinyl — когда датасет заведомо больше доступной памяти, а резидентными нужны держать только индексные структуры, а не сами данные.
Lua application server: вычисление вместо выгрузки
Здесь начинается главная идея серии. Tarantool встраивает интерпретатор Lua прямо в процесс базы данных — хранимая процедура выполняется в том же адресном пространстве, что и сами tuples, без сериализации через сеть внутрь. Контрактная хранимка стенда — топ товаров категории по просмотрам, использующая тот самый составной индекс (category, views):
-- Хранимка: топ товаров категории по просмотрам. Выполняется РЯДОМ с данными —
-- в приложение уезжает только результат, а не весь набор категории.
function top_products_by_category(category, limit)
local result = {}
local idx = box.space.products.index.category
for _, tuple in idx:pairs({ category }, { iterator = 'REQ' }) do
if tuple.category ~= category then break end
table.insert(result, { id = tuple.id, title = tuple.title, views = tuple.views })
if #result >= limit then break end
end
return result
endREQ — обратный обход индекса начиная с последнего совпадения ключа, то есть по убыванию views внутри категории без отдельной сортировки: индекс уже отсортирован, хранимка просто идёт по нему и останавливается на limit. Альтернативный, «наивный» путь — вычитать всю категорию клиенту и посчитать топ в приложении:
var wholeCategory []ProductTuple
err := conn.Do(tarantool.NewSelectRequest("products").
Index("category").
Iterator(tarantool.IterEq).
Key([]interface{}{category}).
Limit(0xFFFFFFFF),
).GetTyped(&wholeCategory)
// ... сортировка по views DESC, тай-брейк по id DESC, обрезка до limitОба пути на живом кластере (category=tools, датасет из 33 276 товаров) дают идентичный топ — сверено побитово, включая граничный случай с реальной ничьёй (limit=9: id=84 и id=85, оба views=93 — тай-брейк по PK-убыванию совпал у обеих реализаций). Разница не в результате, а в том, сколько объектов физически уехало по сети: клиент получает 33 276 кортежей (вся категория), хранимка возвращает 5 (запрошенный limit). Отношение — 6655,2 (33 276 / 5). Это не абсолютная константа хранимки, а прямое следствие формулы ratio = размер_категории / limit: при limit=10, как в сравнительном бенчмарке шестой статьи серии, то же свойство даёт 3327,6 — те же данные, другой лимит, не расхождение в измерении.
Число объектов — не то же самое, что байты трафика, и уж тем более не то же самое, что экономия по времени; серия не сглаживает ни одну из этих разниц: шестая статьяготовится, с 26 сентября прогоняет ту же самую хранимку в паре с аналогичным compute-переносом на Apache Ignite (на пробе limit=10) и показывает три числа раздельно — сколько объектов не уехало по сети, сколько это в реально измеренных байтах (сокетный счётчик поверх go-tarantool/v2, не выведено из числа объектов), и отдельно, во сколько раз быстрее оказался локальный путь. Забегая вперёд ровно настолько, чтобы не создать ложное впечатление: выигрыш по времени и выигрыш по трафику на двух разных системах серии оказались величинами разного порядка, а отношение байт не совпало и с отношением объектов — и это тоже часть цены переноса вычислений к данным, про которую честнее сказать в конце, с числами на руках, чем сейчас.
Persistence: WAL и snapshot
Резидентная память Tarantool по умолчанию энергозависима — durability обеспечивают два механизма поверх memtx: WAL (write-ahead log, поток операций на диск перед подтверждением) и periodic snapshot (полный слепок space на диск). Но «восстановление до последней подтверждённой операции» верно не для любого отказа одинаково — стоит разделить два разных сценария. box.cfg в tarantool/init.lua этого стенда не задаёт wal_mode явно, значит действует дефолт Tarantool — write: операция физически пишется в WAL-файл (проходит через write()), но fsync() на каждую транзакцию не форсируется, и до сброса на диск запись остаётся в page cache ядра ОС. Отсюда разница: при падении самого процесса Tarantool (крах приложения, SIGKILL, OOM-kill) записанное в WAL уже пережило процесс — оно в page cache ядра, а не в памяти упавшего процесса, — и рестарт корректно доигрывает WAL поверх snapshot до последней подтверждённой операции. При потере питания или падении самой ОС гарантия другая: содержимое page cache, ещё не сброшенное на физический диск, может быть потеряно, и часть операций, уже подтверждённых клиенту, в этом сценарии восстановить не удастся — при wal_mode = write (без fsync) это ожидаемое поведение, а не дефект. Тот же водораздел между «упал процесс» и «пропало питание» подробно разобран для Redis в статье про персистентность Redis: RDB, AOF и границы надёжности — стоит держать её рядом. Стенд tarantool этой статьи не воспроизводил вживую ни один из двух отказов (ни kill -9 процесса, ни отключение питания или падение ОС) — измерения раздела ограничены байт-ценой memtx/vinyl и побитовым совпадением хранимки; сама механика восстановления описана здесь как задокументированное поведение Tarantool, а не как то, что стенд проверил экспериментально. Тот же принцип восстановления, что и в PostgreSQL или MySQL, реализован другим набором файлов; устройство WAL и снапшота Tarantool в сравнении с другими СУБД — предмет отдельной серии, а не этой статьи: WAL и его аналоги: Tarantool WAL и snapshot. Здесь же снапшот появляется не только как механизм восстановления, а как рабочий инструмент измерения: именно явный box.snapshot() — тот момент, в который стенд выше форсированно переводит vinyl из переходной фазы (L0-буфер в памяти) в устойчивое состояние, чтобы получить воспроизводимое число 288 359 байт вместо зависящего от таймера регулятора.
Picodata: не обвес, а другой продукт
Про Picodata удобно думать как про «Tarantool, которому добавили кластер», и это первое, от чего стоит отказаться. Да, ядро — форк Tarantool, и оно честно видно в исходниках; но поверх него стоит собственный планировщик распределённых запросов на Rust, собственный кластер-менеджер с Raft, архитектура shard-per-core и, главное для повседневной работы, PostgreSQL-протокол как основной способ общения с базой. Расширяют её не Lua-хранимками, а плагинами на Rust со своим жизненным циклом, миграциями и версионированием. Сохранившийся внутри Tarantool — это фундамент и обратная совместимость, а не то, чем система является.
Насколько ядро осталось узнаваемым, видно по версиям: они живут отдельно и по тегу Picodata не выводятся. docker.binary.picodata.io/picodata:26.1.6 несёт внутри tarantool 2.11.8-371-g2d419bd21, а 26.1.3 — уже 2.11.8-356-gbf4844c5c. Это стоит держать в голове при чтении документации Tarantool применительно к Picodata.
Топология: почему в кластере четыре инстанса, а не три
Бриф стенда предполагал три инстанса. Живой прогон показал, что при факторе репликации 2 три инстанса структурно не дают шардирования: один репликасет получает двух членов и вес 1, второй остаётся с одним членом навсегда в состоянии weight=0, not-ready — vshard раздаёт бакеты репликасету только по достижении заданного фактора репликации, и это не гонка или задержка ребалансировки, а документированная механика vshard с версии Picodata 22.11.0, подтверждённая экспериментом на этом стенде, а не открытая им. Показательная деталь: узел, держащий на данный момент роль vshard-мастера соответствующего репликасета, в этот момент честно логирует «The cluster is balanced ok» — при том что ровно половина мощности кластера простаивает без единого бакета. Тег в логе — vshard.rebalancer/vshard.storage, не governor_loop; роль мастера не закреплена за конкретным контейнером (проверено на двух независимых пересозданиях кластера).
Рабочая топология стенда — 4 инстанса, 2 репликасета по 2. Raft-лидер кластерного governance согласован на всех четырёх (leader_id совпадает, raft_state=Leader ровно у одного) — статус снят через picodata admin и pico.raft_status(), потому что через SQL/PG-протокол в 26.1.6 состояние Raft-лидера кластера недоступно (_pico_instance его не содержит, _pico_replicaset.current_master_name — это другое понятие, мастер репликасета для записи vshard, а не Raft-лидер кластера).
Raft-лидер"] P2["picodata-2"] end subgraph RS2["Репликасет default_2 (100 224 кортежа)"] P3["picodata-3"] P4["picodata-4"] end P1 -- Raft --> P2 P1 -- Raft --> P3 P1 -- Raft --> P4 P2 -- vshard-репликация --- P1 P4 -- vshard-репликация --- P3
flowchart TB
subgraph RS1["Репликасет default_1 (99 776 кортежей)"]
P1["picodata-1
Raft-лидер"]
P2["picodata-2"]
end
subgraph RS2["Репликасет default_2 (100 224 кортежа)"]
P3["picodata-3"]
P4["picodata-4"]
end
P1 -- Raft --> P2
P1 -- Raft --> P3
P1 -- Raft --> P4
P2 -- vshard-репликация --- P1
P4 -- vshard-репликация --- P3
Шардирование: распределение по построению, не по удаче
CREATE TABLE ... DISTRIBUTED BY (id) шардирует по детерминированному хешу от ключа — при одном и том же датасете распределение обязано совпасть на каждом прогоне без пересоздания кластера, это гарантия конструкции, а не то, что стоит проверять повторными запусками. Содержательная проверка — независимое пересоздание кластера с нуля (down -v → up -d), где порядок присоединения инстансов не гарантирован заранее: на трёх таких пересозданиях распределение совпало побитово — 99 776 кортежей на одном репликасете, 100 224 на другом (сумма — все 200 000).
Распределённый SQL: агрегация сошлась с источником, но не сразу
Распределённая агрегация — SELECT category, count(*) FROM products GROUP BY category через SQL-роутер — сошлась с той же агрегацией в PostgreSQL по всем шести категориям: auto=33239, books=33550, garden=33495, kitchen=32944, sport=33496, tools=33276. Побитовое совпадение, стабильно на всех прогонах.
Но по умолчанию этот же самый запрос падает — на полном датасете кластерного лимита sql_vdbe_opcode_max (дефолт 45000) не хватает GROUP BY по 200 000 строкам. Бисекцией доказано, что порог зависит от числа строк, реально попадающих в GROUP BY, а не от размера таблицы (WHERE id<6500 проходит, WHERE id<7000 — падает, детерминированно и дважды подряд; count(*) без GROUP BY по всем 200 000 строкам на том же лимите проходит спокойно). Контринтуитивная деталь: CREATE INDEX ON products(category) не помогает, а делает хуже — с индексом тот же запрос падает уже на 9 строках. Рабочее значение, выставленное стендом, — 5 000 000 (найденный порог между 1 000 000 и 2 000 000, запас 2,5×).
Название лимита указывает на его природу точнее, чем кажется на первый взгляд: VDBE — виртуальная машина ядра Tarantool, и считаются именно её опкоды. Распределённую часть запроса планирует движок Picodata на Rust, а локальную, на каждом хранилище, исполняет ядро — там лимит и упирается. Проверить это удалось не рассуждением, а побочным результатом другого стенда: плагин на Rust, выполняющий запрос изнутри кластера, упирается в тот же самый лимит с тем же самым сообщением — Reached a limit on max executed vdbe opcodes. Limit: 45000. Если бы лимит принадлежал внешнему SQL-интерфейсу, код внутри кластера его бы не заметил. Подробный разбор устройства планировщика — в отдельной статье про распределённый SQL Picodataготовится, с 9 октября.
Picodata говорит на языке PostgreSQL
Самое практичное отличие Picodata от Tarantool не в кластере, а в том, чем вы к ней подключаетесь. Основной протокол общения — PostgreSQL wire protocol: инстанс поднимается с --pg-listen, и дальше с ним работают обычные клиенты PostgreSQL. Не «есть совместимый режим», а именно основной канал.
Проверяется это одной строчкой. Клиент psql из штатного образа postgres:18.4 подключается к кластеру Picodata и выполняет запросы без единого адаптера:
psql "postgres://admin:***@picodata-1:5432/picodata?sslmode=disable" \
-c "SELECT name, replicaset_name FROM _pico_instance"То же и для драйверов. Загрузчик стенда picodata перекачивает датасет из PostgreSQL в Picodata, и обе стороны обслуживает один и тот же pgx/v5 — отличается только строка подключения:
origin, err := pgxpool.New(ctx, *originDSN) // настоящий PostgreSQL
// ...
pico, err := pgxpool.New(ctx, *picoDSN) // Picodata, тот же самый клиентЗаливка датасета стенда — 200 000 строк партиями по 500 через обычный многострочный INSERT — заняла 1,54 с. Это длительность конкретного setup-прогона на одной машине, а не характеристика производительности Picodata: шестая статья серииготовится, с 26 сентября объясняет, почему абсолютные времена на Docker Desktop не публикуются как факт о системе. Здесь важно другое — что этот код вообще не пришлось переписывать под другую базу.
Дальше начинается честная часть, и обобщать её до «весь PostgreSQL-инструментарий просто работает» нельзя. Стенд проверил ровно два клиента: psql и pgx. Для DBeaver сама Picodata рекомендует не штатный PostgreSQL-драйвер, а собственный JDBC-драйвер с отдельным классом и своей схемой URL. Совместимость документирована как частичная: системные каталоги реализованы не полностью, из методов аутентификации по этому протоколу доступны только md5 и ldap.
Самое опасное ограничение — не то, что вызывает ошибку, а то, что её не вызывает. Команды управления транзакциями реализованы как заглушки, работа идёт в режиме autocommit. На стенде это выглядит так:
BEGIN;
INSERT INTO tx_probe VALUES (1);
ROLLBACK;
SELECT count(*) FROM tx_probe; -- 1, строка на местеНи одна команда не завершилась ошибкой, ROLLBACK отчитался успешно — а запись осталась. Приложение, полагающееся на транзакционную семантику, здесь не упадёт, а тихо получит другое поведение. По той же причине миграционные утилиты PostgreSQL нельзя считать переносимыми по умолчанию: они опираются и на транзакционный DDL, и на системные каталоги.
Есть и ограничения помельче, найденные стендом: состояние Raft-лидера через SQL в 26.1.6 недоступно, а LIMIT ? распределённый планировщик не принимает — параметр в WHERE биндится нормально, лимит же приходится подставлять в текст запроса. Полная карта того, где заканчивается PostgreSQL-совместимость, — предмет отдельной статьи серии про Picodataготовится, с 7 октября.
Наблюдаемость: дашборд и метрики из коробки
Отсутствие Raft-статуса в SQL звучит как приговор мониторингу — и им не является, потому что смотреть на кластер предполагается не через SQL. Один флаг --http-listen поднимает сразу две вещи: встроенный веб-интерфейс и экспорт метрик в формате Prometheus по /metrics. Ни отдельного экспортера, ни плагинов для этого не нужно — стенд проверил это на кластере, где установленных плагинов ровно ноль.
Отдаётся 144 семейства метрик. Крупно они делятся на три части: tnt_* — ядро (memtx, vinyl, сеть, CPU), lj_* — сборщик мусора LuaJIT, и pico_* — то, что добавила сама Picodata: состояние Raft и инстансов, счётчики PostgreSQL-протокола (соединения, подготовленные выражения, порталы), длительность и число распределённых SQL-запросов, попадания и вытеснения в кэше планов на роутере и на хранилищах, RPC-вызовы плагинов.
И то самое, чего не хватало в SQL, — здесь есть:
pico_raft_leader_id{instance_name="default_1_1"} 1
pico_raft_state{state="Leader",instance_name="default_1_1"} 1
pico_raft_term{instance_name="default_1_1"} 2
pico_raft_leader_id{instance_name="default_1_2"} 1
pico_raft_state{state="Follower",instance_name="default_1_2"} 1Часть метрик — кластерная: pico_instance_state перечисляет все четыре инстанса стенда с их состоянием Online и тиром, и эта картина одинакова, с какого узла её ни снимай. Но именно из этого не следует, что опрашивать можно один узел. Раскладка по памяти и движкам (tnt_*), сборщик мусора LuaJIT (lj_*), роль в Raft и счётчики запросов относятся к тому инстансу, который вы опросили: на лидере вы увидите pico_raft_state{state="Leader"}, на его соседе — Follower, и это разные измерения, а не разные представления одного. Документация Picodata говорит об этом прямо: под каждый инстанс нужен отдельный адрес сбора метрик, а пример конфигурации перечисляет все узлы кластера как отдельные цели.
Практический вывод для эксплуатации: _pico_instance и admin-консоль — инструменты разбора инцидента, а не мониторинга. Штатный путь наблюдения за Picodata — тот же, что и за остальной инфраструктурой: Prometheus снимает /metrics с каждого инстанса, дашборд собирается в Grafana. Опрос одного узла даст слепую зону ровно там, где обычно и случаются проблемы, — на остальных. Встроенный веб-интерфейс при этом полезен как быстрый взгляд на кластер, но полноценный мониторинг не заменяет.
Две модели расширения: Lua-хранимка и Rust-плагин
Теперь то, ради чего эти две системы стоят в одной статье. Идея одна — считать там, где лежат данные, — а воплощения разные, и разница глубже, чем «другой язык».
В Tarantool это хранимая процедура на Lua внутри процесса базы, показанная выше: несколько строк, никакой сборки, деплой — загрузить файл. В Picodata это плагин на Rust: отдельный проект, который компилируется в динамическую библиотеку, раскладывается по узлам, устанавливается в кластер и живёт по собственному жизненному циклу. Скелет генерирует утилита pike, а обработчик выглядит так — это тот же топ товаров категории, что считала Lua-хранимка:
pub fn top_products(request: &TopRequest) -> anyhow::Result<TopResponse> {
// LIMIT подставляется в текст: параметр в WHERE биндится, а `LIMIT ?`
// распределённый планировщик не принимает. Безопасно, потому что u32.
let sql = format!(
"SELECT id, title, views FROM products
WHERE category = ?
ORDER BY views DESC, id DESC
LIMIT {}",
request.limit
);
let rows: Vec<ProductRow> = query(&sql)
.bind(request.category.as_str())
.fetch::<ProductRow>()?;
Ok(TopResponse { items: rows, /* ... */ })
}Сверка на том же датасете: топ от плагина совпал с топом из PostgreSQL построчно при limit 5, 10 и 25, включая порядок строк с одинаковым числом просмотров. Наружу при limit=5 уходит 5 строк вместо 33 276 в категории — то же свойство и та же формула, что у хранимки.
Разница в цене. Lua-хранимку правят и перезагружают; плагин нужно собрать — причём на той же ОС, что и кластер: образ Picodata собран на AlmaLinux 8.10 с glibc 2.28, и библиотека, собранная на системе поновее, просто не загрузится. Зато взамен появляется то, чего у Lua-хранимки нет: у плагина есть версия, собственные миграции схемы с секциями отката, привязка к тиру кластера и штатное обновление, при котором две версии сосуществуют, а включена ровно одна.
Последнее работает не совсем так, как ожидаешь, и это стоит знать заранее. На стенде переход 0.1.0 → 0.2.0 прошёл штатно, RPC-эндпоинты переключились сразу — они версионированы, в журнале видно, как снимается ...:v0.1.0/top_products и регистрируется ...:v0.2.0/top_products. А вот HTTP-маршрут из шаблона pike версии не несёт, и запросы продолжал обслуживать обработчик старой версии, пока инстанс не перезапустили. Если обновление без рестарта принципиально — точкой входа должен быть RPC, а не HTTP. Разбор жизненного цикла плагина целиком — в статье про плагины на Rustготовится, с 12 октября.
Отдельная ветка той же мысли — совместимость не только с PostgreSQL. На том же ядре Picodata предлагает фронтенды, говорящие протоколами Redis (плагин Radix) и Cassandra (плагин Sirin): заявленный сценарий — заместить эти системы, не переписывая приложение. Проверить это на стенде нельзя — оба плагина коммерческие, в открытую часть не входят, поэтому здесь они упомянуты как заявленная возможность, а не как измеренный факт. Для читателя второй статьи серии, про Redis это, впрочем, важный поворот: кэш и основное хранилище в такой архитектуре могут оказаться одним кластером.
Чем платим
- Логика на Lua на сервере. Хранимая процедура — не просто SQL-запрос: это код на отдельном языке, который живёт внутри процесса базы, а не в привычном стеке приложения. Версионирование, отладка и тестирование этого кода — отдельная дисциплина, а связность между приложением и конкретной хранимкой в схеме — осознанный компромисс, а не бесплатное ускорение.
- Размер набора в резидентной памяти. Даже сравнительно компактные 15,13 МБ на 200 000 кортежей memtx — это RAM, которая масштабируется линейно с датасетом; vinyl меняет это соотношение (0,28 МБ резидентно), но платит диском и другой моделью записи через LSM.
- Операционная сложность и HA у Picodata. Одиночный Tarantool — один процесс. Picodata — уже распределённая система: Raft-топология, за которой нужно следить, фактор репликации, который нужно правильно посчитать против числа инстансов заранее (три инстанса при RF=2 — рабочий пример того, как легко получить формально «живой», но наполовину простаивающий кластер), и кластерные тюнаблы вроде
sql_vdbe_opcode_max, дефолт которых не рассчитан на агрегацию по сотням тысяч строк. - Плагин — это сборка, а не файл. Расширение на Rust даёт версионирование, миграции и штатное обновление, но платит за это цепочкой сборки: библиотеку нужно компилировать под ту же ОС и ту же версию glibc, что у кластера, а версия библиотеки
picodata-pluginобязана совпадать с версией самого кластера. Lua-хранимка Tarantool не требует ничего из этого — её правят и перезагружают. - Открытое ядро, коммерческая периферия. Ядро Picodata, PostgreSQL-протокол, распределённый SQL и механизм плагинов открыты. А совместимость с Redis и Cassandra и кросс-кластерная репликация поставляются коммерческими плагинами — при выборе это стоит учитывать заранее, а не после того, как архитектура выстроена вокруг обещания «заместим и то, и другое».
Что дальше
Следующая статья серии меняет модель хранения ещё раз — гибрид RAM и диска на масштабе, а не встроенный сервер приложений: Статья 4 — Aerospike и Apache Ignite: масштаб и гридготовится, с 24 сентября. Идея переноса вычислений к данным, начатая здесь Lua-хранимками Tarantool, там продолжается на распределённом кластере — collocated compute Apache Ignite отправляет задачу на узел, где физически лежит партиция, вместо того чтобы тянуть партицию к вычислению.
Picodata в этой статье показана ровно настолько, насколько она нужна для сравнения двух моделей вычислений рядом с данными. Всё остальное — устройство кластера и shard-per-core, границы PostgreSQL-совместимости, распределённый SQL, плагины на Rust и эксплуатация — вынесено в отдельную серию: Picodata: что это на самом делеготовится, с 5 октября, Где кончается совместимость с PostgreSQLготовится, с 7 октября, Плагины на Rust: pike, миграции, blue-greenготовится, с 12 октября.
Рядом с этой статьёй стоит держать первую статью серии «WAL и его аналоги» — про сам механизм write-ahead в общем виде, и Consensus Landscape — интерактивный тур по Raft и другим алгоритмам консенсуса, тому самому, что держит топологию кластера Picodata согласованной. Общая карта, где Tarantool и Picodata стоят рядом с остальными подходами к хранению данных, — в хабе «Данные: карта хранилищ и подходов». А честная разница между экономией по объёму данных и экономией по времени, которую этот текст только анонсировал на примере хранимки top_products_by_category, — с числами на руках, в шестой, заключительной статье серииготовится, с 26 сентября.
Комментарии