В Tarantool вся пользовательская модель данных держится на трёх сущностях: спейс (space) -> кортеж (tuple) -> поля (fields) с их системой типов. Если в реляционной СУБД вы думаете "таблица -> строка -> колонка", то здесь аналогия почти прямая, но с важными отличиями: у полей может не быть имён, поле может быть составной структурой (map или array), а сам кортеж физически хранится не как набор колонок, а как один компактный MsgPack-массив.
Эта механика одинакова и в классическом треке (box.cfg + box.schema), и в декларативном Tarantool 3.x: YAML-конфиг описывает топологию и инстансы, но сами спейсы и форматы создаются тем же box.schema API, обычно в скрипте приложения. Поэтому понимание этого уровня переносится между версиями без изменений.Спейс - контейнер. Кортеж - запись внутри него (MsgPack-массив). Поле - отдельное значение кортежа, адресуемое по номеру (с 1 в Lua) или по имени, если задан формат.
Как это устроено внутри
Спейс - это первичный контейнер хранения. У него есть уникальное имя, числовой id (обычно назначается автоматически как "последний id + 1") и движок: memtx (в памяти, по умолчанию) или vinyl (на диске для больших объёмов). Чтобы со спейсом можно было работать, ему нужен хотя бы первичный индекс. Сам список всех спейсов лежит в системном спейсе box.space._space - это не магия, а обычная таблица метаданных, которую можно прочитать select-ом.
Кортеж хранится как MsgPack-массив - бинарный формат с переменной длиной значений. Это ключевая деталь: маленькое число занимает 1 байт, самое большое целое - 9 байт, строка - длину плюс заголовок. Никакого выравнивания по колонкам нет, поэтому "пропущенные" поля в хвосте просто отсутствуют в массиве и не занимают места. Когда вы получаете кортеж в Lua, это не Lua-таблица, а лёгкая cdata-ссылка на MsgPack в памяти базы - конвертация в таблицу не происходит, пока вы явно не обратитесь к полю. Отсюда скорость доступа.
Поле адресуется номером (в Lua с 1, в C/PHP с 0) или именем. Имя появляется только если задан формат спейса. Формат - это необязательный, но крайне желательный список описаний полей: имя, тип, признак nullable, значение по умолчанию. Формат не меняет физическое хранение (это всё тот же MsgPack-массив), он добавляет слой валидации и именования поверх.
Два набора типов. Разработчик всегда живёт между типами языка (Lua) и типами хранилища (MsgPack). Это источник большинства недоразумений. Lua-число - всегда double-точности с плавающей точкой, а в MsgPack оно может лечь как integer или как float64 - Tarantool решает по значению: если есть десятичная точка или число больше 1e14, оно идёт как float, иначе как integer.

Спейс -> кортеж -> поля и система типов
Система типов полей
Tarantool различает скалярные и составные типы. Скалярные: unsigned, integer, number, double, decimal, string, varbinary, boolean, uuid, datetime, interval. Составные: array, map. Плюс два "обёрточных": scalar (любой скаляр, но не array/map/tuple) и any (вообще всё).
Код: Выделить всё
тип диапазон / суть хранение MsgPack
--------- ----------------------------------- ----------------------
unsigned 0 .. 18446744073709551615 int (positive)
integer -2^63 .. 2^64-1 int
number integer ИЛИ float int или float64
double ровно float64, всегда 9 байт MP_DOUBLE
decimal точное, до 38 значащих цифр ext (extension)
string байтовая строка переменной длины str
varbinary бинарные данные bin
boolean true / false bool
uuid 128-битный идентификатор ext
datetime момент времени (since 2.10) ext
interval период времени (since 2.10) ext
array [1,2,3] array
map {a=5, b=6} map (строковые ключи)
Код: Выделить всё
local decimal = require('decimal')
local uuid = require('uuid')
local datetime = require('datetime')
box.cfg{}
local s = box.schema.space.create('events', {if_not_exists = true})
s:format({
{name = 'id', type = 'uuid'},
{name = 'amount', type = 'decimal'},
{name = 'ts', type = 'datetime'},
{name = 'note', type = 'string', is_nullable = true},
})
s:create_index('pk', {parts = {{field = 'id', type = 'uuid'}}})
s:insert({uuid.new(), decimal.new('19.99'), datetime.new{year=2026}, box.NULL})
Формат и nullable. По умолчанию is_nullable = false, и положить box.NULL в такое поле нельзя. Поле, объявленное nullable, может отсутствовать в хвосте кортежа - тогда при чтении оно отдаётся как null. Это и есть приём для "узких" кортежей: необязательные поля держат в конце.
Частые заблуждения и грабли
- integer против unsigned. Если индекс или поле объявлены unsigned, отрицательное число туда не вставить - получите ошибку. unsigned - это подмножество integer, выбирайте integer, если значения могут быть отрицательными.
- number против double. number в Lua коварен: 1 ляжет как integer, а 1.0 как integer тоже (нет дробной части), но 1.5 как float. Если нужен строго float64 (например, для совместимости с SQL DOUBLE), используйте тип double и ffi.cast('double', x) при вставке и поиске, иначе сравнение по индексу может не найти значение.
- Кортеж - это не Lua-таблица. Результат select - это cdata-ссылка. Прямое сравнение двух кортежей через == сравнивает ссылки, а не содержимое.
- nil в таблицах. Lua nil нельзя положить в Lua-таблицу (он обрывает массив). Для пропуска поля используют box.NULL: nil == box.NULL даёт true, но box.NULL безопасно лежит в таблице.
- Обновление поля после nullable-дыр. Если nullable-поля в хвосте физически отсутствуют, update по номеру поля, который "за дырой", может вернуть ошибку "Field N was not found in the tuple". Заполняйте такие поля явно или работайте по именам.
- Строки сравниваются побайтово. Без коллации '2345' меньше '500' (сравнение по первому байту), а unicode-порядок включается только явной коллацией в индексе.
- field_count. Если задать space_opts.field_count = 5, вставка кортежа не ровно из 5 полей запрещена. По умолчанию 0 - длина не фиксирована.
Создайте спейс catalog с форматом из четырёх полей: id (unsigned), sku (string), price (decimal), tags (array, nullable). Сделайте первичный индекс по id. Вставьте две записи (вторую - без tags, через box.NULL или просто опустив поле). Затем сделайте select и убедитесь, что у второй записи tags вернулся как null.
Код: Выделить всё
local decimal = require('decimal')
box.cfg{}
local c = box.schema.space.create('catalog', {if_not_exists = true})
c:format({
{name='id', type='unsigned'},
{name='sku', type='string'},
{name='price', type='decimal'},
{name='tags', type='array', is_nullable=true},
})
c:create_index('pk', {parts={{field='id', type='unsigned'}}})
c:insert({1, 'A-100', decimal.new('9.90'), {'new','sale'}})
c:insert({2, 'B-200', decimal.new('14.50')}) -- tags опущен
c:select()
-- проверьте: попытка c:insert({-1, ...}) упадёт (unsigned),
-- а c.index.pk:get({2}).tags вернёт nil/null
- В каком физическом формате хранится кортеж и почему пропущенные хвостовые поля не занимают места?
- Чем отличаются типы number и double, и когда обязателен ffi.cast('double', ...)?
- Почему decimal, uuid и datetime требуют require отдельного модуля, и как это связано с MsgPack ext?
- Что произойдёт при вставке отрицательного значения в поле типа unsigned и при попытке положить box.NULL в поле с is_nullable=false?