В предыдущей статье я разобрал что умеет 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 dbtype = 'String' THEN _String ... — один проход по строкам, никаких самоджойнов.
Разница принципиальная:
|
|
EAV |
REDB ( |
|---|---|---|
|
Тип поля |
строка в |
FK → |
|
Хранение значения |
всё в одну |
в типизированную колонку ( |
|
Фильтр |
|
|
|
Индекс по значению |
по строке, cast на лету |
по типизированной колонке (partial index |
|
Поддержка массивов |
дополнительная таблица / JSON |
|
Обзор таблиц
Полная схема — 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. Примеры:
|
|
|
|
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(массив) —collectiontype = Array_ID -
Для
HomeAddress(вложенный класс) —idtype = 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);
Несколько нетривиальных вещей:
Дерево через idparent — 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 имеют idparent в structures, а их values имеют arrayparent_id указывающий на маркер-строку Address-поля.
Три уникальных индекса в _values обеспечивают консистентность всех этих случаев:
-
UIX__values__structure_object— WHEREarrayindex IS NULL AND arrayparent_id IS NULL -
UIX__values__structure_object_parent— WHEREarrayindex IS NULL AND arrayparent_id IS NOT NULL -
UIX__values__structure_object_array_index— WHEREarrayindex 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 уровней в поисках ближайшего предка с явно заданными правами. idref = 0 — глобальный fallback (права на всё). Приоритет: user > role, конкретный объект > глобальные.
Есть автотриггер: при создании дочернего объекта — если у родителя ещё нет собственных прав — триггер автоматически создаёт ему запись, копируя права ближайшего предка. Это сокращает глубину рекурсии при каждом чтении прав.
schememetadata_cache — зачем нужен кэш
Каждый запрос к данным требует знать: для объекта с idscheme = X, какие поля (_structures) существуют и какого типа? Это JOIN structures → types, который повторяется на каждом обращении.
Решение — денормализованная таблица schememetadata_cache. Она содержит все колонки structures + typename, 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 — плоский список всех полей с именем схемы, именем поля, типом и значением.
-- PostgresSELECT 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_idFROM _values vJOIN _structures st ON st._id = v._id_structureJOIN _schemes s ON s._id = st._id_schemeJOIN _types t ON t._id = st._id_typeWHERE v._id_object = :object_id -- подставить нужный IDORDER 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_idFROM [dbo].[_values] vJOIN [dbo].[_structures] st ON st._id = v._id_structureJOIN [dbo].[_schemes] s ON s._id = st._id_schemeJOIN [dbo].[_types] t ON t._id = st._id_typeWHERE v._id_object = @object_idORDER BY st._order, v._array_index;
Этот запрос — диагностический. Он даёт полную картину: что именно хранится для объекта, в каком слоте, с каким типом. Маркерные строки массивов (_array_parent_id IS NULL AND arrayindex IS NULL) тоже видны — именно их наличие/отсутствие отличает null от [].
Запрос 2 — через schememetadata_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._orderFROM _values vJOIN _scheme_metadata_cache c ON c._structure_id = v._id_structureJOIN _objects o ON o._id = v._id_objectWHERE v._id_object = :object_idORDER 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] vJOIN [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_idORDER 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 + _structuresawait redb.InitializeAsync(typeof(EmployeeProps).Assembly);// SaveAsync → строка в _objects + строки значений в _valuesvar 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_optimizedvar loaded = await redb.LoadAsync<EmployeeProps>(id);
SaveAsync обращается к schememetadata_cache чтобы узнать структуру полей, генерирует ключи через sequence global_identity, кладёт строку в objects и строки значений в values. LoadAsync читает objects, затем все values одним SELECT в массив values[], рекурсивно собирает иерархию через buildhierarchical_properties_optimized — исключительно через unnest() в памяти, без повторных обращений к таблице.
Почему именно так
Несколько решений, которые не очевидны с первого взгляда:
Отрицательные константы для системных ID — гарантия, что пользовательские данные (через global_identity, который стартует с 1_000_000) никогда не пересекутся с системными типами/схемами/пользователями.
structurehash в schemes — позволяет не пересчитывать кэш при каждом запросе. Хеш меняется только при изменении полей схемы. Триггер на schemes инвалидирует кэш точечно, без перестройки всего.
Маркерные строки в values — нетривиальный выбор. Для коллекций нет отдельной таблицы. Маркер (array_parent_id = NULL, arrayindex = 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,structurehash, автоматический онбординг -
Статья 3 — CRUD: SaveAsync и LoadAsync: change tracking (TreeDiff), bulk insert через COPY protocol, lazy loading
-
Статья 4 — LINQ-запросы: как
Where(x => x.Salary > 80000)превращается в SQL сCASE WHEN idstructure = X THEN _Long END > 80000 -
Статья 5 — Дерево объектов: LoadTreeAsync, GetDescendantsAsync, WhereHasAncestor
-
Статья 6 — Оконные функции:
Win.RowNumber(),Win.Rank(),PartitionBy/OrderByповерх REDB-объектов
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/1043790/