Многие приходят в Tarantool как в NoSQL-хранилище: спейсы, таплы, box.space:select. Но внутри того же процесса живёт полноценный SQL-движок. SQL появился в Tarantool 2.1 и с тех пор имеет статус release: есть JOIN-ы, подзапросы, VIEW, триггеры, ограничения (constraints), транзакции и совместимость с большей частью обязательных требований стандарта SQL:2016.
Ключевая идея урока: SQL в Tarantool - это не отдельная база данных и не отдельный движок хранения. Это транслятор поверх того же box-движка. Команда CREATE TABLE создаёт обычный спейс, строка превращается в тапл, а SELECT под капотом дёргает те же индексы memtx или vinyl, что и box.space:select. SQL и NoSQL смотрят на одни и те же данные с двух сторон.
Механика: как SQL ложится на boxБаза данных Tarantool SQL и база данных Tarantool NoSQL - это одно и то же. Но часть операций доступна только в SQL, а часть - только в NoSQL. Смешивать SQL-запросы и NoSQL-вызовы в одной программе разрешено.
Точка входа в SQL - Lua-функция box.execute(string). Никакого отдельного сетевого порта или процесса у SQL нет: вы исполняете SQL прямо в Lua-сессии или через коннектор по бинарному протоколу iproto. Что происходит, когда вы вызываете box.execute:
- Парсер разбирает текст SQL в дерево разбора.
- Планировщик строит план: какие спейсы читать, какие индексы использовать, в каком порядке делать JOIN.
- План компилируется в байт-код виртуальной машины (наследие SQLite-движка, на котором построен SQL Tarantool).
- Эта VM исполняет байт-код, но вместо собственного движка хранения вызывает публичный API box: чтение по индексу, вставку таплов, открытие транзакции.
Самое наглядное доказательство общего слоя - имена. Создайте таблицу:
Код: Выделить всё
box.cfg{}
box.execute([[CREATE TABLE things (id INTEGER PRIMARY KEY, remark STRING);]])
box.execute([[INSERT INTO things VALUES (55, 'Hello SQL world!');]])
Код: Выделить всё
tarantool> box.space.THINGS
---
- engine: memtx
field_count: 2
index:
0:
type: TREE
name: pk_unnamed_THINGS_1
parts: [{type: integer, fieldno: 1, ...}]
name: THINGS
id: 520
Код: Выделить всё
box.space.THINGS:insert{56, 'from NoSQL'}
box.execute([[SELECT * FROM things WHERE id > 0;]])

SQL-фронтенд транслируется в общий API box
Результат box.execute
Для SELECT и PRAGMA box.execute возвращает таблицу с двумя полями: metadata (имена колонок и их NoSQL-типы) и rows (содержимое строк). Для INSERT/UPDATE/DELETE возвращается row_count. Важно: типы в metadata - это типы Tarantool/NoSQL (integer, string, scalar, double, decimal), а не абстрактные SQL-типы. Это ещё одно напоминание, что под SQL лежит box.
Код: Выделить всё
tarantool> box.execute([[VALUES ('hello');]])
---
- metadata:
- name: COLUMN_1
type: string
rows:
- ['hello']
...
Чтобы не парсить и не планировать один и тот же запрос много раз, используйте box.prepare(). Он один раз компилирует байт-код и возвращает stmt_id, после чего вы исполняете запрос с разными параметрами через :execute. Параметры подставляются плейсхолдерами ? или :name - это и быстрее, и защищает от SQL-инъекций.
Код: Выделить всё
local p = box.prepare([[SELECT * FROM things WHERE id = ?;]])
p:execute({55})
p:execute({56})
p:unprepare()
EXPLAIN показывает тот самый байт-код VM, а EXPLAIN QUERY PLAN - выбранные индексы и порядок обхода. Это главный инструмент, чтобы убедиться, что планировщик использует ваш TREE-индекс, а не сканирует весь спейс.
Частые заблуждения и грабли
- "SQL - это отдельный движок, он медленнее". Нет. Слой хранения общий. SQL добавляет накладные расходы на парсинг и планирование, но сами чтения/записи идут через тот же box. Для повторяющихся запросов эти расходы убирает box.prepare.
- Регистр имён. CREATE TABLE things даёт спейс THINGS. Если потом искать box.space.things - получите nil. Имена без кавычек всегда в верхнем регистре.
- Не все спейсы видны в SQL. Поля NoSQL-спейса доступны в SQL, только если они скалярные и описаны во format(). Индекс используется планировщиком SQL, только если это TREE-индекс. HASH/BITSET/RTREE для SQL невидимы, а MAP- и ARRAY-поля при SELECT * могут давать ошибку.
- Целостность можно обойти снизу. Foreign key и CHECK, заданные в SQL, проверяет SQL-фронтенд. Если писать в спейс напрямую через box.space:insert, эти ограничения не проверяются - NoSQL-вызов может нарушить FK.
- SQL-триггеры не срабатывают на NoSQL-операции. Триггер, созданный через CREATE TRIGGER, отрабатывает только при SQL-запросах. Запись через box.space:replace его не запустит (для этого есть отдельные NoSQL-триггеры on_replace).
- Часть DDL только через NoSQL. Нестандартные опции индекса (тип HASH, особый id, последовательности) задаются box.space...:create_index, а не SQL. Проверка типов в SQL мягче, чем в классических СУБД.
Цель: увидеть, что SQL и box - один слой хранения.
- Запустите tarantool, выполните box.cfg{}.
- Создайте таблицу: box.execute([[CREATE TABLE users (id INTEGER PRIMARY KEY, name STRING);]]).
- Вставьте строку через SQL: box.execute([[INSERT INTO users VALUES (1, 'Ann');]]).
- Посмотрите спейс глазами NoSQL: выведите box.space.USERS и его :format(). Найдите движок (engine) и тип индекса.
- Вставьте строку через NoSQL: box.space.USERS:insert{2, 'Bob'}.
- Прочитайте обе строки через SQL: box.execute([[SELECT * FROM users ORDER BY id;]]) - убедитесь, что видны и Ann, и Bob.
- Запустите box.execute([[EXPLAIN QUERY PLAN SELECT * FROM users WHERE id = 1;]]) и найдите, что используется первичный TREE-индекс.
Контрольные вопросы
- Что физически создаёт команда CREATE TABLE в терминах box: новый движок, новый процесс или обычный спейс? Под каким именем таблица things будет видна в box.space?
- Через какую Lua-функцию исполняется SQL и что лежит в полях metadata и rows ответа на SELECT? Почему типы в metadata - это типы NoSQL, а не SQL?
- Почему запись через box.space:insert может нарушить foreign key, заданный в SQL, и почему SQL-триггер не сработает на такую запись?
- При каких условиях поле и индекс NoSQL-спейса будут доступны планировщику SQL? Что даёт box.prepare по сравнению с обычным box.execute?