Tarantool - это не "Lua с диском". Внутри ядра (то, что мы зовём box - движок хранения, WAL, индексы, репликация, сетевой протокол iproto) данные живут не как Lua-таблицы, а как MsgPack - компактный бинарный формат. Каждый кортеж (tuple) в спейсе физически хранится как MsgPack-массив байт. Lua же оперирует своими типами: number (double), string, table, cdata, userdata. Значит на каждой границе Lua<->box происходит перевод: при insert/replace Lua-значения кодируются в MsgPack, при select/get байты декодируются обратно. Понимание этого перевода - ключ к тому, почему 0.1 ведёт себя не как decimal, почему uuid это cdata, и почему строка и varbinary кодируются по-разному.
Механика: базовый MsgPack плюс расширенияГлавная мысль урока: box не "понимает" Lua-типы напрямую. Он понимает MsgPack. Все типы Tarantool - это либо базовые типы MsgPack (int, float, str, bin, array, map, bool, nil), либо расширения MP_EXT.
MsgPack кодирует скаляр одним-двумя ведущими байтами (тип и размер), дальше идут данные. Примеры из спецификации: число 127 это один байт 7f (positive fixint), 65535 это cd ff ff (uint16), false это c2, nil это c0, строка 'a' это a1 61 (fixstr длины 1 плюс байт 'a'), массив {} это 90, дробь 1.5 это float64 cb 3f f8 ... . Tarantool выбирает самое короткое представление автоматически - целые ужимаются до fixint, дроби идут как float64.
Но decimal, uuid, datetime, interval, error - этого в базовом MsgPack нет. Tarantool вводит их через механизм MP_EXT (extension): заголовок ext несёт байт-тип расширения и длину, дальше упакованная полезная нагрузка. Коды расширений фиксированы в ядре:
Код: Выделить всё
тип расширения код что хранит
MP_DECIMAL 1 BCD-упакованное точное число
MP_UUID 2 16 байт RFC 4122 v4
MP_ERROR 3 объект ошибки box.error
MP_DATETIME 4 epoch-секунды + nsec + tzoffset/tzindex
MP_COMPRESSION 5 сжатый блок
MP_INTERVAL 6 map полей (year..nsec) + adjust
Разделение строка/бинарь тоже идёт здесь. Lua-string кодируется как MsgPack str (MP_STR), а varbinary (с версии 3.0) - как MP_BIN. Это разные типы на уровне байт: '\xFF\xFE' строкой даёт a2 ff fe, а varbinary.new('\xFF\xFE') даёт c4 02 ff fe (bin8, длина 2). С 3.0 декодер по умолчанию разворачивает MP_BIN обратно в varbinary-объект, а не в строку (старое поведение возвращается опцией compat binary_data_decoding).

Перевод типов на границе Lua и box через MsgPack
Ключевые команды и короткие примеры
Сырой MsgPack руками. Модуль msgpack даёт прямой доступ к кодеку - это лучший способ "потрогать" границу.
Код: Выделить всё
local msgpack = require('msgpack')
local s = msgpack.encode({'a', 1, true}) -- Lua -> сырой MsgPack
print(string.hex(s)) -- 93 a1 61 01 c3
local t, next_pos = msgpack.decode(s) -- обратно в Lua-таблицу
-- t = {'a', 1, true}; next_pos = #s + 1
Decimal - точные числа. Lua number это double (15-16 значащих цифр), поэтому деньги и счётчики держим в decimal.
Код: Выделить всё
local decimal = require('decimal')
local x = decimal.new('0.1') + decimal.new('0.2') -- ровно 0.3
print(decimal.new('0.16666666666667') * 6) -- 1.00000000000002
print(decimal.scale(decimal.new('123.4560'))) -- 4 (знаков после точки)
UUID.
Код: Выделить всё
local uuid = require('uuid')
local u = uuid.new() -- cdata, тип uuid
print(u:str()) -- 36-символьный текст
print(#u:bin()) -- 16 (бинарь для MP_UUID)
print(uuid.is_uuid(u)) -- true
Код: Выделить всё
local datetime = require('datetime')
local d = datetime.new{year=2026, month=6, day=13, tz='Europe/Moscow'}
local iv = datetime.interval.new{month = 1}
d:add(iv) -- сдвиг на месяц с учётом длины месяца
print((d - datetime.now()):totable().sec) -- разница как interval
Код: Выделить всё
box.schema.space.create('t')
box.space.t:format({{name='id', type='uuid'},
{name='price', type='decimal'},
{name='ts', type='datetime'}})
box.space.t:create_index('pk', {parts={{1, 'uuid'}}})
box.space.t:insert{uuid.new(), decimal.new('19.99'), datetime.now()}
- Lua-число != decimal. Если вставить 19.99 как обычный number, в кортеж уедет float64 со всеми артефактами double. Тип поля decimal в format не "чинит" это магически - подавать надо именно decimal.new(...).
- Переполнение точности молча режется. Исторически Tarantool не всегда даёт ошибку при кодировании числа с точностью выше предела - лишние цифры могут округлиться. Не полагайтесь на decimal как на bignum без проверок scale/precision.
- Строка против varbinary. До 3.0 бинарные данные приходили строкой, с 3.0 MP_BIN декодируется в varbinary-объект. Код, который ждал string и делал с ней string-операции, может сломаться после апгрейда - смотрите compat binary_data_decoding.
- tz перебивает tzoffset. В datetime.new, если заданы и tz, и tzoffset, побеждает tz; tzoffset игнорируется. tzoffset хранится в минутах (диапазон -720..840), поэтому на древних датах возможна потеря точности зоны.
- Арифметика месяцев неоднозначна. Прибавление interval с month/year зависит от поля adjust (по умолчанию усечение к концу месяца): 31 января + 1 месяц это не "1 марта".
- NaN/Inf. По умолчанию они кодируются, но msgpack.cfg{encode_invalid_numbers=false} это запретит - удобно ловить мусор на входе.
- uuid и datetime это cdata. Сравнивать их через == корректно (метаметоды есть), но не пытайтесь сериализовать произвольное cdata/userdata - получите ошибку unsupported Lua type, если не включён encode_use_tostring.
Запустите tarantool (интерактивно). Возьмите msgpack и в одну строку сравните кодирование строки и varbinary одних и тех же байт: посчитайте и распечатайте в hex msgpack.encode('\xFF\xFE') и msgpack.encode(require('varbinary').new('\xFF\xFE')). Убедитесь, что ведущий байт отличается (a2 против c4 02). Затем создайте decimal.new('0.1') и обычный 0.1, сложите каждый сам с собой трижды и сравните tostring результата. Объясните себе, почему decimal даёт ровно 0.3, а double - нет.
Контрольные вопросы
- Почему decimal, uuid и datetime кодируются как MP_EXT, а не как базовые типы MsgPack, и что это даёт коннекторам на других языках?
- Чем на уровне ведущих байт MsgPack отличается Lua-строка от varbinary-объекта, и что изменилось при декодировании в Tarantool 3.0?
- Что произойдёт с точностью, если создать decimal через decimal.new(0.1) (число), а не decimal.new('0.1') (строка), и почему?
- В datetime.new заданы одновременно tz='Europe/Moscow' и tzoffset=60. Какое значение зоны окажется в объекте и почему?