REDB изнутри, статья 1: 13 таблиц, на которых работает всё
В предыдущей статье я разобрал что умеет REDB на практике: code-first схемы, LoadAsync без Include, LINQ с оконными функциями, деревья, redb.Route и redb.Tsak. Если не читали — начните с неё, она даёт общую картину.
Теперь начинаю цикл «REDB изнутри» — серия статей про устройство хранилища: как хранятся объекты и свойства, как работают схемы и кэш структур, как LINQ превращается в SQL, как устроены деревья, права, оконные функции. Всё с реальным SQL под капотом.
Первая статья цикла — про саму базу данных. Без понимания схемы таблиц всё остальное будет висеть в воздухе.
Вся магия REDB делается в 13 таблицах. Никакого JSON-блоба, никакого NVARCHAR(MAX) для всего подряд — каждый тип данных в своей типизированной колонке. Разберём как это работает и почему это не классический EAV.
Почему не «EAV»
Прежде чем смотреть на таблицы — важное терминологическое замечание.
EAV (Entity–Attribute–Value) — это паттерн хранения данных, при котором строки таблицы выглядят так:
object_id | attribute_name | value
----------|----------------|-------
42 | "FirstName" | "Alice"
42 | "Age" | "28"
42 | "Salary" | "85000"
Всё в одной таблице, типы стёрты, имена атрибутов — просто строки. Любой JOIN адский. Запрос «найди сотрудников с зарплатой > 80000» — это самоджойн или pivot. EXPLAIN выдаёт Seq Scan размером с диссертацию.
REDB использует другую схему. В _values — не имена строками, а ссылки на _structures (_id_structure bigint NOT NULL). Тип значения известен в момент записи. Для каждого C#-типа — своя колонка:
_String text
_Long bigint
_Guid uuid
_Double float
_DateTimeOffset timestamptz
_Boolean boolean
_ByteArray bytea
_Numeric NUMERIC(38, 18)
_ListItem bigint -- FK → _list_items
_Object bigint -- FK → _objects (ссылка на объект)
Это ближе к RTTI (Run-Time Type Information): тип поля известен из _structures._id_type, и значение кладётся в правильную колонку. При чтении — CASE WHEN db_type = 'Long' THEN _Long WHEN db_type = 'String' THEN _String ... — один проход по строкам, никаких самоджойнов.
Разница принципиальная:
| EAV | REDB (_values) |
|
|---|---|---|
| Тип поля | строка в attribute_name |
FK → _structures → _types |
| Хранение значения | всё в одну value text колонку |
в типизированную колонку (_Long, _String, ...) |
Фильтр Salary > 80000 |
WHERE attribute_name='Salary' AND value::int > 80000 |
WHERE _id_structure = @struct_id AND _Long > 80000 |
| Индекс по значению | по строке, cast на лету | по типизированной колонке (partial index WHERE _Long IS NOT NULL) |
| Поддержка массивов | дополнительная таблица / JSON | _array_parent_id + _array_index в той же таблице |
Обзор таблиц
Полная схема — 13 таблиц + 2 служебных, которые живут в отдельных SQL-файлах.
_types — справочник типов (~37 записей)
_schemes — схемы (C# классы → строки в БД)
_structures — поля схем (с поддержкой вложенности и коллекций)
_objects — объекты (строки данных, дерево через self-FK)
_values — значения полей (RTTI-хранение + коллекции)
_lists — справочники
_list_items — элементы справочников
_users — пользователи
_roles — роли
_users_roles — M2M: пользователь ↔ роль
_permissions — права на объекты (с наследованием по дереву)
_links — M2M связи между объектами
_functions — хранимые выражения для схем
_dependencies — межсхемные зависимости
---
_scheme_metadata_cache — денормализованный кэш structures+types
_migrations — история миграций Props
Разберём послойно.
Слой типов: _types
CREATE TABLE _types (
_id bigint NOT NULL,
_name text NOT NULL UNIQUE,
_db_type text NULL, -- колонка в _values: 'Long', 'String', 'Guid', ...
_type text NULL, -- C#-тип: 'long', 'string', 'Guid', ...
CONSTRAINT PK__types PRIMARY KEY (_id)
);
Все ID — отрицательные константы из диапазона около Long.MinValue. Примеры:
_id |
_name |
_db_type |
C# тип |
|---|---|---|---|
| -9223372036854775709 | Boolean | Boolean | bool |
| -9223372036854775708 | DateTime | DateTimeOffset | DateTime |
| -9223372036854775704 | Long | Long | long |
| -9223372036854775700 | String | String | string |
| -9223372036854775695 | Decimal | Numeric | decimal |
| -9223372036854775675 | Class | — | вложенный класс (маркер) |
| -9223372036854775668 | Array | — | T[] / List<T> (маркер) |
| -9223372036854775667 | Dictionary | — | Dictionary<K,V> (маркер) |
Числа выглядят страшно — это просто константы, гарантирующие, что системные типы никогда не пересекутся с пользовательскими данными. У Class/Array/Dictionary нет своей колонки в _values — это маркерные типы, значения хранятся в дочерних строках.
Всего ~37 типов: числовые (Int/Short/Byte/Float → хранятся в Long/Double), строковые (Email/Url/Phone → String), временны́е (DateOnly/TimeOnly/TimeSpan), географические (Latitude/Longitude), файловые (FilePath/MimeType), Enum/EnumInt, коллекционные (Array/Dictionary/JsonDocument/XDocument).
Слой схем: _schemes + _structures
CREATE TABLE _schemes (
_id bigint NOT NULL,
_id_parent bigint NULL, -- для пространств имён
_name text NOT NULL UNIQUE, -- полное имя: 'MyApp.Models.EmployeeProps'
_alias text NULL,
_structure_hash uuid NULL, -- hash полей для быстрой проверки изменений
_type bigint NOT NULL -- FK → _types (Class по умолчанию)
);
CREATE TABLE _structures (
_id bigint NOT NULL,
_id_parent bigint NULL, -- вложенный Props-класс
_id_scheme bigint NOT NULL, -- FK → _schemes
_id_type bigint NOT NULL, -- FK → _types
_id_list bigint NULL, -- FK → _lists (для ListItem-полей)
_name text NOT NULL, -- имя поля (C# property name)
_alias text NULL,
_order bigint NULL,
_collection_type bigint NULL, -- NULL=скаляр, Array_ID / Dictionary_ID
_key_type bigint NULL, -- тип ключа для Dictionary<K,V>
_readonly boolean NULL,
_allow_not_null boolean NULL,
_is_compress boolean NULL,
_store_null boolean NULL,
_default_value bytea NULL
);
Как схемы связаны с C#
Когда вы пишете:
[RedbScheme("Сотрудник")]
public class EmployeeProps
{
public string FirstName { get; set; }
public int Age { get; set; }
public decimal Salary { get; set; }
public string[] Skills { get; set; }
public Address HomeAddress { get; set; } // вложенный класс
}
И вызываете await redb.SyncSchemeAsync<EmployeeProps>(), система:
- Создаёт строку в
_schemesс именемMyApp.EmployeeProps - Создаёт строки в
_structuresдля каждого поля - Для
Skills(массив) —_collection_type = Array_ID - Для
HomeAddress(вложенный класс) —_id_type = Class_ID, и рекурсивно создаёт дочерние_structuresс_id_parent = HomeAddress._structure_id
Триггер на уровне БД проверяет имена полей: запрещены системные (_id, _name, _date_create и т.д.), C#-зарезервированные (class, int, string...), имена не должны начинаться с цифры.
Слой объектов: _objects
CREATE TABLE _objects (
_id bigint NOT NULL,
_id_parent bigint NULL, -- родитель в дереве (self-FK)
_id_scheme bigint NOT NULL, -- FK → _schemes
_id_owner bigint NOT NULL, -- FK → _users
_id_who_change bigint NOT NULL, -- FK → _users
_date_create timestamptz NOT NULL,
_date_modify timestamptz NOT NULL,
_date_begin timestamptz NULL,
_date_complete timestamptz NULL,
_key bigint NULL,
_name text NULL,
_note text NULL,
_hash uuid NULL,
-- value-колонки для RedbPrimitive<T>
_value_long bigint NULL,
_value_string text NULL,
_value_guid uuid NULL,
_value_bool boolean NULL,
_value_double float NULL,
_value_numeric NUMERIC(38, 18) NULL,
_value_datetime timestamptz NULL,
_value_bytes bytea NULL
);
Несколько нетривиальных вещей:
Дерево через _id_parent — ON DELETE CASCADE. Удаление объекта автоматически удаляет всё поддерево, любой глубины. Это основная структура для организации данных в REDB: разделы, категории, папки, оргструктура — всё одним механизмом.
_value_* колонки — для RedbPrimitive<T>. Когда объект сам является примитивным значением (например, RedbObject<long> для счётчика или RedbObject<string> для токена), значение хранится прямо в строке объекта, без _values. Это 8 колонок — по одной на каждый db-тип.
Мягкое удаление — специальная схема @@__deleted (_id = -10). Функция mark_for_deletion() рекурсивно находит все потомки через CTE и атомарно перемещает их под TRASH-контейнер (_id_scheme = -10). Физическое удаление — отдельная purge_trash() батч-операция.
Слой значений: _values
Это самая насыщенная таблица. Здесь живут все значения всех полей всех объектов.
CREATE TABLE _values (
_id bigint NOT NULL,
_id_structure bigint NOT NULL, -- FK → _structures
_id_object bigint NOT NULL, -- FK → _objects
-- типизированные value-колонки
_String text NULL,
_Long bigint NULL,
_Guid uuid NULL,
_Double float NULL,
_DateTimeOffset timestamptz NULL,
_Boolean boolean NULL,
_ByteArray bytea NULL,
_Numeric NUMERIC(38, 18) NULL,
_ListItem bigint NULL, -- FK → _list_items
_Object bigint NULL, -- FK → _objects (ссылка на другой объект)
-- поля для реляционного хранения коллекций
_array_parent_id bigint NULL, -- FK → _values (родительский элемент)
_array_index text NULL -- '0','1','2' для массивов, ключ для словарей
);
Как хранятся коллекции
Это самое нетривиальное место. Коллекции хранятся реляционно прямо в _values, без дополнительных таблиц:
Скалярное поле (например, Age = 28):
_id_structure=struct_Age _id_object=42 _Long=28 _array_parent_id=NULL _array_index=NULL
Массив строк (Skills = ["C#", "SQL", "React"]):
-- маркер: "массив присутствует" (без него свойство = null, а не [])
_id=100 _id_structure=struct_Skills _id_object=42 _array_index=NULL _array_parent_id=NULL
-- элементы с индексами
_id=101 _id_structure=struct_Skills _id_object=42 _String="C#" _array_index='0' _array_parent_id=100
_id=102 _id_structure=struct_Skills _id_object=42 _String="SQL" _array_index='1' _array_parent_id=100
_id=103 _id_structure=struct_Skills _id_object=42 _String="React" _array_index='2' _array_parent_id=100
Словарь (PhoneDir = {"home": "+7...", "work": "+7..."}):
-- маркер словаря
_id=200 _id_structure=struct_PhoneDir _id_object=42 _array_index=NULL _array_parent_id=NULL
-- записи с строковыми ключами
_id=201 _id_structure=struct_PhoneDir _id_object=42 _String="+7 999..." _array_index='home' _array_parent_id=200
_id=202 _id_structure=struct_PhoneDir _id_object=42 _String="+7 495..." _array_index='work' _array_parent_id=200
Вложенный класс (Address.City = "Москва") — дочерние поля Address имеют _id_parent в _structures, а их _values имеют _array_parent_id указывающий на маркер-строку Address-поля.
Три уникальных индекса в _values обеспечивают консистентность всех этих случаев:
UIX__values__structure_object— WHERE_array_index IS NULL AND _array_parent_id IS NULLUIX__values__structure_object_parent— WHERE_array_index IS NULL AND _array_parent_id IS NOT NULLUIX__values__structure_object_array_index— WHERE_array_index IS NOT NULL
Права: _permissions
CREATE TABLE _permissions (
_id bigint NOT NULL,
_id_role bigint NULL, -- XOR с _id_user (CHECK constraint)
_id_user bigint NULL,
_id_ref bigint NOT NULL, -- 0 = глобальные права, иначе ID объекта
_select boolean NULL,
_insert boolean NULL,
_update boolean NULL,
_delete boolean NULL
);
Права наследуются по дереву объектов — рекурсивный CTE идёт вверх до 50 уровней в поисках ближайшего предка с явно заданными правами. _id_ref = 0 — глобальный fallback (права на всё). Приоритет: user > role, конкретный объект > глобальные.
Есть автотриггер: при создании дочернего объекта — если у родителя ещё нет собственных прав — триггер автоматически создаёт ему запись, копируя права ближайшего предка. Это сокращает глубину рекурсии при каждом чтении прав.
_scheme_metadata_cache — зачем нужен кэш
Каждый запрос к данным требует знать: для объекта с _id_scheme = X, какие поля (_structures) существуют и какого типа? Это JOIN _structures → _types, который повторяется на каждом обращении.
Решение — денормализованная таблица _scheme_metadata_cache. Она содержит все колонки _structures + type_name, db_type, type_semantic, collection_type_name из _types — плоской строкой, без джойна.
CREATE TABLE _scheme_metadata_cache (
_scheme_id bigint NOT NULL,
_structure_id bigint NOT NULL,
_parent_structure_id bigint,
_name text NOT NULL,
type_name text NOT NULL, -- 'Long', 'String', 'Guid', ...
db_type text NOT NULL, -- 'Long', 'String', 'Guid', ...
type_semantic text NOT NULL, -- 'Object', '_RObject', 'Array', ...
_collection_type bigint,
collection_type_name text,
_key_type bigint,
key_type_name text,
-- ... остальные атрибуты _structures
);
Кэш автоматически обновляется триггером при изменении _schemes._structure_hash. При запросе к схеме, чьего кэша нет — вызывается sync_metadata_cache_for_scheme(scheme_id). Вся функция build_hierarchical_properties_optimized() (которая собирает JSON объекта) работает исключительно через этот кэш.
Два SQL-запроса: заглянуть внутрь объекта
Запрос 1 — без кэша (прямые join)
Для объекта с заданным ID — плоский список всех полей с именем схемы, именем поля, типом и значением.
-- Postgres
SELECT
s._name AS scheme_name,
st._name AS field_name,
t._name AS type_name,
CASE t._db_type
WHEN 'String' THEN v._String
WHEN 'Long' THEN v._Long::text
WHEN 'Guid' THEN v._Guid::text
WHEN 'Double' THEN v._Double::text
WHEN 'Boolean' THEN v._Boolean::text
WHEN 'DateTimeOffset' THEN v._DateTimeOffset::text
WHEN 'Numeric' THEN v._Numeric::text
WHEN 'ListItem' THEN v._ListItem::text
WHEN 'Object' THEN v._Object::text
WHEN 'ByteArray' THEN encode(v._ByteArray, 'base64')
ELSE NULL
END AS value_text,
CASE
WHEN v._array_parent_id IS NULL
AND v._array_index IS NULL
AND t._name IN ('Array','Dictionary','Class') THEN 'collection_marker'
WHEN v._array_parent_id IS NULL
AND v._array_index IS NULL THEN 'scalar'
WHEN v._array_parent_id IS NOT NULL THEN 'element[' || v._array_index || ']'
ELSE 'scalar'
END AS slot,
v._array_index,
v._array_parent_id
FROM _values v
JOIN _structures st ON st._id = v._id_structure
JOIN _schemes s ON s._id = st._id_scheme
JOIN _types t ON t._id = st._id_type
WHERE v._id_object = :object_id -- подставить нужный ID
ORDER BY st._order NULLS LAST, v._array_index NULLS FIRST;
-- MS SQL (те же данные, синтаксис параметра @object_id)
SELECT
s._name AS scheme_name,
st._name AS field_name,
t._name AS type_name,
CASE t._db_type
WHEN 'String' THEN v._String
WHEN 'Long' THEN CAST(v._Long AS nvarchar(MAX))
WHEN 'Guid' THEN CAST(v._Guid AS nvarchar(MAX))
WHEN 'Double' THEN CAST(v._Double AS nvarchar(MAX))
WHEN 'Boolean' THEN CASE v._Boolean WHEN 1 THEN 'true' WHEN 0 THEN 'false' END
WHEN 'DateTimeOffset' THEN CAST(v._DateTimeOffset AS nvarchar(MAX))
WHEN 'Numeric' THEN CAST(v._Numeric AS nvarchar(MAX))
WHEN 'ListItem' THEN CAST(v._ListItem AS nvarchar(MAX))
WHEN 'Object' THEN CAST(v._Object AS nvarchar(MAX))
WHEN 'ByteArray' THEN CAST('' AS nvarchar(MAX)) -- base64 через приложение
ELSE NULL
END AS value_text,
CASE
WHEN v._array_parent_id IS NULL AND v._array_index IS NULL THEN 'scalar'
WHEN v._array_parent_id IS NOT NULL THEN 'element[' + ISNULL(v._array_index,'') + ']'
ELSE 'scalar'
END AS slot,
v._array_index,
v._array_parent_id
FROM [dbo].[_values] v
JOIN [dbo].[_structures] st ON st._id = v._id_structure
JOIN [dbo].[_schemes] s ON s._id = st._id_scheme
JOIN [dbo].[_types] t ON t._id = st._id_type
WHERE v._id_object = @object_id
ORDER BY st._order, v._array_index;
Этот запрос — диагностический. Он даёт полную картину: что именно хранится для объекта, в каком слоте, с каким типом. Маркерные строки массивов (_array_parent_id IS NULL AND _array_index IS NULL) тоже видны — именно их наличие/отсутствие отличает null от [].
Запрос 2 — через _scheme_metadata_cache
Это то, что в реальности делает движок при чтении. Кэш уже содержит денормализованные type_name/db_type, поэтому JOIN с _types и _structures не нужен:
-- Postgres (через _scheme_metadata_cache)
SELECT
c._scheme_id AS scheme_id,
c._name AS field_name,
c.type_name AS type_name,
c.db_type AS db_type,
c.collection_type_name AS collection_type,
CASE c.db_type
WHEN 'String' THEN v._String
WHEN 'Long' THEN v._Long::text
WHEN 'Guid' THEN v._Guid::text
WHEN 'Double' THEN v._Double::text
WHEN 'Boolean' THEN v._Boolean::text
WHEN 'DateTimeOffset' THEN v._DateTimeOffset::text
WHEN 'Numeric' THEN v._Numeric::text
WHEN 'ListItem' THEN v._ListItem::text
WHEN 'Object' THEN v._Object::text
WHEN 'ByteArray' THEN encode(v._ByteArray, 'base64')
ELSE NULL
END AS value_text,
v._array_index,
v._array_parent_id,
c._order
FROM _values v
JOIN _scheme_metadata_cache c ON c._structure_id = v._id_structure
JOIN _objects o ON o._id = v._id_object
WHERE v._id_object = :object_id
ORDER BY c._order NULLS LAST, v._array_index NULLS FIRST;
-- MS SQL (через _scheme_metadata_cache)
SELECT
c.[_scheme_id] AS scheme_id,
c.[_name] AS field_name,
c.[type_name] AS type_name,
c.[db_type] AS db_type,
c.[collection_type_name] AS collection_type,
CASE c.[db_type]
WHEN 'String' THEN v._String
WHEN 'Long' THEN CAST(v._Long AS nvarchar(MAX))
WHEN 'Guid' THEN CAST(v._Guid AS nvarchar(MAX))
WHEN 'Double' THEN CAST(v._Double AS nvarchar(MAX))
WHEN 'Boolean' THEN CASE v._Boolean WHEN 1 THEN 'true' WHEN 0 THEN 'false' END
WHEN 'DateTimeOffset' THEN CAST(v._DateTimeOffset AS nvarchar(MAX))
WHEN 'Numeric' THEN CAST(v._Numeric AS nvarchar(MAX))
WHEN 'ListItem' THEN CAST(v._ListItem AS nvarchar(MAX))
WHEN 'Object' THEN CAST(v._Object AS nvarchar(MAX))
WHEN 'ByteArray' THEN N'<binary>'
ELSE NULL
END AS value_text,
v._array_index,
v._array_parent_id,
c.[_order]
FROM [dbo].[_values] v
JOIN [dbo].[_scheme_metadata_cache] c ON c.[_structure_id] = v.[_id_structure]
JOIN [dbo].[_objects] o ON o.[_id] = v.[_id_object]
WHERE v.[_id_object] = @object_id
ORDER BY c.[_order], v.[_array_index];
Чем второй лучше первого в продакшне:
- Нет JOIN на
_structuresи_types— два тяжёлых джойна убраны - Кэш уже отсортирован по
_order+ проиндексирован по_structure_id - Сам кэш заполняется один раз при изменении схемы (
_structure_hash), а не при каждом запросе - Функция
build_hierarchical_properties_optimizedидёт ещё дальше: она загружает все_valuesобъекта в массив_values[]одним запросом и потом работает только с памятью черезunnest()— ни одного повторного обращения к таблице
Как это связано с C#
Для полноты картины — что происходит за каждым вызовом. Полные примеры кода были в предыдущей статье, здесь только связка с таблицами:
[RedbScheme("Сотрудник")]
public class EmployeeProps
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public decimal Salary { get; set; }
public string[] Skills { get; set; }
}
// InitializeAsync сканирует сборку → создаёт строки в _schemes + _structures
await redb.InitializeAsync(typeof(EmployeeProps).Assembly);
// SaveAsync → строка в _objects + строки значений в _values
var employee = new RedbObject<EmployeeProps>
{
name = "Алиса Иванова",
Props = new EmployeeProps
{
FirstName = "Алиса",
LastName = "Иванова",
Age = 28,
Salary = 120_000m,
Skills = ["C#", "PostgreSQL", "Redis"] // → маркер + 3 строки в _values
}
};
long id = await redb.SaveAsync(employee);
// LoadAsync → SELECT _objects + SELECT _values WHERE _id_object = id
// → рекурсивная сборка через build_hierarchical_properties_optimized
var loaded = await redb.LoadAsync<EmployeeProps>(id);
SaveAsync обращается к _scheme_metadata_cache чтобы узнать структуру полей, генерирует ключи через sequence global_identity, кладёт строку в _objects и строки значений в _values. LoadAsync читает _objects, затем все _values одним SELECT в массив _values[], рекурсивно собирает иерархию через build_hierarchical_properties_optimized — исключительно через unnest() в памяти, без повторных обращений к таблице.
Почему именно так
Несколько решений, которые не очевидны с первого взгляда:
Отрицательные константы для системных ID — гарантия, что пользовательские данные (через global_identity, который стартует с 1_000_000) никогда не пересекутся с системными типами/схемами/пользователями.
_structure_hash в _schemes — позволяет не пересчитывать кэш при каждом запросе. Хеш меняется только при изменении полей схемы. Триггер на _schemes инвалидирует кэш точечно, без перестройки всего.
Маркерные строки в _values — нетривиальный выбор. Для коллекций нет отдельной таблицы. Маркер (_array_parent_id = NULL, _array_index = NULL) отличает null (нет маркера) от [] (маркер есть, но дочерних элементов нет). Это позволяет хранить произвольно вложенные структуры без дополнительных таблиц.
_Numeric NUMERIC(38, 18) — сознательный выбор точности для финансовых расчётов. double для денег не подходит из-за накапливаемой ошибки с плавающей точкой.
Postgres vs MSSQL — реальное отличие в cascade delete: в Postgres _values имеет ON DELETE CASCADE по FK на _structures. В MSSQL это невозможно (ограничение "multiple cascade paths"), поэтому вместо FK — INSTEAD OF DELETE триггер, который делает то же самое вручную.
Цикл «REDB изнутри»
- Вводная статья — что умеет REDB: LINQ, деревья, redb.Route, redb.Tsak, production cases (уже опубликована)
- Статья 1 (эта) — структура БД: 13 таблиц, RTTI vs EAV,
_values, коллекции,_scheme_metadata_cache, SQL под капотом - Статья 2 — Code-first схемы: как
SyncSchemeAsync<T>преобразует C#-класс в строки_schemes+_structures,_structure_hash, автоматический онбординг - Статья 3 — CRUD: SaveAsync и LoadAsync: change tracking (TreeDiff), bulk insert через COPY protocol, lazy loading
- Статья 4 — LINQ-запросы: как
Where(x => x.Salary > 80000)превращается в SQL сCASE WHEN _id_structure = X THEN _Long END > 80000 - Статья 5 — Дерево объектов: LoadTreeAsync, GetDescendantsAsync, WhereHasAncestor
- Статья 6 — Оконные функции:
Win.RowNumber(),Win.Rank(),PartitionBy/OrderByповерх REDB-объектов