Бум No-code начался в 2022 году, и сейчас многие компании стараются так или иначе внедрить функционал «low-code» в свои продукты. У участников IT-индустрии пока нет согласия о границах применимости технологий «без кода», хотя адепты этих технологий обещают, что они позволят создавать практически любые приложения***.
В этой заметке мы рассмотрим один из основных аспектов создания приложений – его масштабируемость в средней и дальней перспективе. Для этого сам продукт под капотом должен быть построен на чем-то более мощном, чем MS Excel, Airtable, Notion и Make, и такие продукты уже есть на рынке.
Фатальные проблемы масштабируемости проявляются с ростом объемов данных и количества пользователей, которые с ними работают – с этого мы и начнём.

Для создания баз данных в конструкторах используются ORM разной степени унификации, и здесь будет рассмотрен случай предельной унификации данных, который ранее назывался квинтетной моделью данных. По сути это EAV с добавленным полем ID и тремя индексами, то есть, это даже не квинтет, а квартет. Для обозначения такой структуры также используется термин IDEAV. Сейчас ещё используется поле-рудимент – порядок среди равных, например, в конструкторе Интеграм.
Такая модель данных позволяет описать реляционную базу данных неограниченной сложности и загрузить в неё данные любого объема, по заявлениям её авторов. Подход используется достаточно давно в различных проектах и даже тестируется в корпорациях, поэтому становится особенно интересно, как работает гипотеза про любой объем.
Все составляющие конструктора на IDEAV есть в открытом доступе и их можно исследовать и использовать в разработке.
Что такое IDEAV
Чтобы не заставлять читателя отдельно изучать IDEAV, расскажем кратко в чем суть:
Все данные – метаданные, данные, управляющие структуры, пользователи и вообще всё – хранятся в единой таблице из 4 полей
В таблице описываются простые (базовые) типы: строка, число, дата и т.д.
На основе простых типов описывается всё остальное – термины бизнеса, связи, роли и доступы, пользователи, запросы и другое
Таблица объединяется сама с собой множество раз для десериализации всех этих данных в привычные пользователю широкие таблицы
Три индекса обеспечивают быстрые точечные выборки и объединения данных в этой единой мега-таблице
Логика работы приложения описывается терминами бизнеса, их связями и формулами, хранящимися всё в той же таблице
IDEAV используется в конструкторах баз данных и приложений в виде простых и универсальных помощников (импорт, навигация, редактирование, отчеты, формы, дэшборды и прочее)
Очевидно, что с ростом таблицы IDEAV быстродействие её будет деградировать, влияя на работоспособность приложения, и эту проблему надо как-то решить. Вариант, выбранный для проверки гипотезы, заключается в создании партиций по id записей:

Запросы к этим партициям могут выполняться параллельно, а индексы будут содержать приемлемое и ограниченное количество уровней ветвления. Таким образом, на любом объеме данных быстродействие не будет снижаться сколько-нибудь заметно. Если партиции расположить в табличных пространствах (tablespace) на разных физических дисках, то параллельность работы с ними будет эффективнее.

Ниже будет описано, как мы пришли к решению с партициями, а сейчас рассмотрим конфигурацию и наполнение таблицы.
В качестве стенда будем использовать облачный сервер – кластер PostgreSQL, параметры которого такие:
16 vCPU, 32 ГБ RAM
Также мы запасемся хранилищем на 8ТБ с такими параметрами:
Макс. IOPS: Чтение 20000, Запись 40000
Макс. Bandwidth: Чтение 450 МБ/с, Запись 450 МБ/с
Здесь мы создадим таблицу для хранения виртуальной базы данных, построенной на IDEAV:
CREATE TABLE ntt (
id bigserial NOT NULL,
up int8 NOT NULL,
t int8 NOT NULL,
val text NULL,
CONSTRAINT ntt_pk PRIMARY KEY (id)
)
PARTITION BY RANGE (id);
SELECT setval('ntt', 100);
Создадим партиции по id для диапазонов по 1 млрд записей с необходимыми индексами.
Нулевая партиция совсем небольшая, она служит для хранения кэша метаданных и хранит отрицательные id.
CREATE TABLE ntt_0 PARTITION OF ntt FOR VALUES FROM (-9223372036854775806) TO (0);
Вот это первая партиция, а остальные – аналогичные, с шагом диапазона в 1 млрд:
CREATE TABLE ntt_1 PARTITION OF ntt FOR VALUES FROM (1) TO (1073741824);
CREATE INDEX ntt_1_tval_idx ON ntt_1 USING btree (t, lower("left"(val, 127)));
CREATE INDEX ntt_1_upt_idx ON ntt_1 USING btree (up, t);
Далее наполним таблицу базовыми типами, метаданными и данными. Для управления виртуальной базой данных мы используем функции PostgreSQL. В результате в нашей виртуальной БД будут созданы основные сущности, нужные в любом проекте.
Функции управления метаданными
-- OUT res='1' in case of success, otherwise - error or warning message
-- Create the term with the given base type
CREATE OR REPLACE FUNCTION public.post_terms(db text, value text, base int8, mods json default '{}', OUT newid int8, OUT res TEXT)
AS $$
DECLARE i record;
m int8;
BEGIN
EXECUTE format('SELECT obj.id FROM %s obj, %s base WHERE obj.val ILIKE ''%s'' AND obj.up=0 AND base.t=obj.t AND base.id=base.t', db, db, replace(value, '''', '''''')) into newid;
IF newid IS NOT NULL THEN
res := 'warn_term_exists';
ELSE
EXECUTE format('insert into %s (up, t, val) values (0, %s, ''%s'') RETURNING id', db, base, replace(value, '''', '''''')) into newid;
res := '1';
FOR i IN (SELECT * FROM json_each(mods)) LOOP
EXECUTE format('SELECT id FROM %s WHERE t=0 AND up=0 AND val ILIKE ''%s'' LIMIT 1', db, i.key) INTO m;
IF m IS NOT NULL THEN
EXECUTE format('INSERT INTO %s (up, t, val) VALUES (%s, %s, ''%s'')', db, newid, m, trim('"' FROM i.value::text));
END IF;
END LOOP;
END IF;
END;
$$ LANGUAGE plpgsql;
-- Add a requisite to a term
CREATE OR REPLACE FUNCTION public.post_requisites(db text, term_id int8, req_id int8, mods json default '{}', OUT newid int8, OUT res TEXT)
AS $$
DECLARE ord int8; i record; m int8;
BEGIN
EXECUTE format('SELECT obj.id, newreq.id, max(concat(''0'', reqs.val)::NUMERIC), max(CASE WHEN reqs.t=newreq.id THEN reqs.id ELSE 0 END)
FROM %s obj LEFT JOIN %s newreq ON newreq.id=%s AND newreq.up=obj.up AND newreq.t!=newreq.id
LEFT JOIN (%s reqs CROSS JOIN %s defs) ON reqs.up=obj.id AND defs.id=reqs.t AND defs.t!=0
WHERE obj.id=%s AND obj.up=0 AND obj.t!=obj.id
GROUP BY 1, 2', db, db, req_id, db, db, term_id) into term_id, req_id, ord, newid;
IF term_id IS NULL THEN res := 'err_term_not_found';
ELSIF req_id IS NULL THEN res := 'err_req_not_found';
ELSIF newid!=0 THEN res := 'warn_req_exists';
ELSE
EXECUTE format('INSERT INTO %s (up, t, val) VALUES (%s, %s, %s) RETURNING id', db, term_id, req_id, ord+1) into newid;
FOR i IN (SELECT * FROM json_each(mods)) LOOP
EXECUTE format('SELECT id FROM %s WHERE t=0 AND up=0 AND val ILIKE ''%s'' LIMIT 1', db, i.key) INTO m;
IF m IS NOT NULL THEN
EXECUTE format('INSERT INTO %s (up, t, val) VALUES (%s, %s, ''%s'')', db, newid, m, trim('"' FROM i.value::text));
END IF;
END LOOP;
res := '1';
END IF;
END;
$$ LANGUAGE plpgsql;
-- Create a reference to a term
CREATE OR REPLACE FUNCTION public.post_references(db text, term_id int8, OUT newid int8, OUT res TEXT)
AS $$
declare i int;
BEGIN
EXECUTE format('SELECT obj.id, ref.id FROM %s obj LEFT JOIN %s ref ON ref.up=0 AND ref.t=obj.id AND ref.val IS null
WHERE obj.id=%s AND obj.id!=obj.t AND obj.up=0', db, db, term_id) into term_id, newid;
IF term_id IS NULL THEN res := 'err_incorrect_term';
ELSIF newid IS NOT NULL THEN res := 'warn_ref_exists';
ELSE
EXECUTE format('INSERT INTO %s (up, t, val) VALUES (0, %s, NULL) RETURNING id', db, term_id) into newid;
res := '1';
END IF;
END;
$$ LANGUAGE plpgsql;
Скрипт для создания таблицы и начального наполнения
CREATE OR REPLACE PROCEDURE public.create_public_table(IN dbname text)
LANGUAGE plpgsql
AS $procedure$
DECLARE
bgi int8 := 2^30;
i int := 2;
BEGIN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I (
id bigserial NOT NULL,
up int8 NOT NULL,
t int8 NOT NULL,
val text NULL,
CONSTRAINT %I_pk PRIMARY KEY (id)
)
PARTITION BY RANGE (id);', dbname, dbname
);
EXECUTE format('SELECT setval(''%I_id_seq'', 100);', dbname);
-- Partition for cached metadata
EXECUTE format('CREATE TABLE IF NOT EXISTS %I_0 PARTITION OF %I FOR VALUES FROM (-9223372036854775806) TO (0);', dbname, dbname, bgi);
-- First partition
EXECUTE format('CREATE TABLE IF NOT EXISTS %I_1 PARTITION OF %I FOR VALUES FROM (1) TO (%s);', dbname, dbname, bgi);
EXECUTE format('CREATE INDEX IF NOT EXISTS %I_1_upt_idx ON %I_1 USING btree (up, t);', dbname, dbname);
EXECUTE format('CREATE INDEX IF NOT EXISTS %I_1_tval_idx ON %I_1 USING btree (t, lower(left(val, 127)));', dbname, dbname);
-- The rest of partitions
WHILE i < 30 LOOP
EXECUTE format('CREATE TABLE IF NOT EXISTS %s_%s PARTITION OF %s FOR VALUES FROM (%s) TO (%s)', dbname, i, dbname, (i-1)*bgi, i*bgi);
EXECUTE format('CREATE INDEX IF NOT EXISTS %s_%s_upt_idx ON %s_%s USING btree (up, t)', dbname, i, dbname, i);
EXECUTE format('CREATE INDEX IF NOT EXISTS %s_%s_tval_idx ON %s_%s USING btree (t, lower(left(val, 127)))', dbname, i, dbname, i);
i := i + 1;
END LOOP;
-- Initial types, tables, and records
EXECUTE format(
'INSERT INTO %I (id, up, t, val) VALUES
(1, 1, 1, ''ROOT''),
(2, 0, 2, ''HTML''),
(3, 0, 3, ''CHARS''),
(4, 0, 4, ''DATETIME''),
(5, 0, 5, ''TERM''),
(6, 0, 6, ''PWD''),
(7, 0, 7, ''BUTTON''),
(8, 0, 8, ''MEDIA''),
(9, 0, 9, ''DATE''),
(10, 0, 10, ''FILE''),
(11, 0, 11, ''BOOLEAN''),
(12, 0, 12, ''MEMO''),
(13, 0, 13, ''NUMBER''),
(14, 0, 14, ''FLOAT''),
(15, 0, 15, ''CALCULATABLE''),
(16, 0, 16, ''UUID''),
(17, 0, 17, ''PATH''),
(30, 0, 0, ''NOT NULL''),
(31, 0, 0, ''ALIAS''),
(32, 0, 0, ''MULTIPLE''),
(33, 0, 0, ''ORDER''),
(34, 0, 0, ''UNIQUE''),
(35, 0, 0, ''SIZE''),
(36, 0, 0, ''VALIDATION''),
(37, 0, 0, ''SHARD''),
(64, 0, 3, ''Пользователь''),
(65, 0, 3, ''Имя''),
(66, 0, 3, ''email''),
(67, 0, 3, ''Телефон''),
(68, 0, 6, ''token''),
(69, 0, 6, ''xsrf''),
(70, 0, 4, ''Activity''),
(71, 0, 3, ''Роль''),
(72, 0, 5, ''Объект''),
(73, 0, 3, ''Меню''),
(74, 0, 3, ''Адрес''),
(75, 0, 3, ''Доступ''),
(76, 0, 11, ''EXPORT''),
(77, 0, 11, ''DELETE''),
(78, 0, 11, ''IMPORT''),
(79, 0, 11, ''HREF''),
(81, 0, 12, ''Примечание''),
(82, 0, 3, ''Функция''),
(83, 0, 8, ''Формат''),
(84, 0, 3, ''Итог''),
(85, 0, 12, ''Формула''),
(86, 0, 3, ''Запрос''),
(87, 0, 5, ''Колонка запроса''),
(88, 0, 9, ''Дата''),
(89, 0, 10, ''Фото''),
(90, 0, 3, ''Secret''),
(91, 0, 13, ''Limit''),
(92, 0, 6, ''Пароль''),
(93, 0, 12, ''Фильтр''),
(94, 0, 3, ''SET''),
(95, 0, 11, ''Скрыть''),
(96, 0, 3, ''Маска''),
(97, 0, 11, ''Интерактивный''),
(98, 0, 3, ''URL''),
(99, 0, 11, ''EXECUTE''),
(100, 0, 12, ''Alias'');', dbname
);
END
$procedure$
;
CALL create_public_table('ntt');
Для нагрузочного тестирования мы выберем бизнес-сущность в виде банковской транзакции, которая хранится в таблице из более 50 полей, из которых 45 мы заполним случайно сгенерированными данными. Эту модель данных мы должны также загрузить в таблицу.
Скрипт метаданных транзакций
SELECT post_terms('ntt', 'Transaction', 3, '{"UNIQUE":"1"}'::json);
SELECT post_terms('ntt','pan', 3);
SELECT post_terms('ntt','code', 3);
SELECT post_terms('ntt','amount', 14);
SELECT post_terms('ntt','settlement', 14);
SELECT post_terms('ntt','billing', 14);
SELECT post_terms('ntt','DT', 4);
SELECT post_terms('ntt','billing fee', 14);
SELECT post_terms('ntt','rate', 14);
SELECT post_terms('ntt','rate billing', 14);
SELECT post_terms('ntt','audit no', 13);
SELECT post_terms('ntt','time', 3);
SELECT post_terms('ntt','TX DT', 9);
SELECT post_terms('ntt','DTexpire', 9);
SELECT post_terms('ntt','DTsettle', 9);
SELECT post_terms('ntt','DTconvert', 9);
SELECT post_terms('ntt','DTcapture', 9);
SELECT post_terms('ntt','Merchant Type', 3);
SELECT post_terms('ntt','ACC', 3);
SELECT post_terms('ntt','Pext', 3);
SELECT post_terms('ntt','ICC', 3);
SELECT post_terms('ntt','POS mode', 3);
SELECT post_terms('ntt','APAN', 3);
SELECT post_terms('ntt','ISO', 3);
SELECT post_terms('ntt','PC', 3);
SELECT post_terms('ntt','CC', 3);
SELECT post_terms('ntt','R', 3);
SELECT post_terms('ntt','TR fee', 3);
SELECT post_terms('ntt','S fee', 14);
SELECT post_terms('ntt','TP fee', 3);
SELECT post_terms('ntt','SP fee', 14);
SELECT post_terms('ntt','Acq IIC', 3);
SELECT post_terms('ntt','Fwd IIC', 3);
SELECT post_terms('ntt','PAN extended', 3);
SELECT post_terms('ntt','T2', 3);
SELECT post_terms('ntt','T3', 3);
SELECT post_terms('ntt','RRN', 3);
SELECT post_terms('ntt','Auth', 3);
SELECT post_terms('ntt','RC', 3);
SELECT post_terms('ntt','SRC', 3);
SELECT post_terms('ntt','CATI', 3);
SELECT post_terms('ntt','CAIC', 3);
SELECT post_terms('ntt','CAN', 3);
SELECT post_terms('ntt','ARD', 3);
SELECT post_terms('ntt','T1', 3);
SELECT post_terms('ntt','Ext1', 3);
SELECT post_terms('ntt','Ext2', 3);
SELECT post_terms('ntt','Ext3', 12);
SELECT post_terms('ntt','CCY', 3);
SELECT post_terms('ntt','CCYs', 3);
SELECT post_terms('ntt','CCYb', 3);
SELECT post_terms('ntt','PIN hash', 3);
SELECT post_terms('ntt','SRCI', 3);
SELECT post_terms('ntt','toExport', 11);
SELECT post_terms('ntt','Gosb', 3);
SELECT post_terms('ntt','Memorial', 12);
SELECT post_terms('ntt','Status', 3);
SELECT post_references('ntt', 158);
SELECT post_requisites('ntt', 101, 103);
SELECT post_requisites('ntt', 101, 104);
SELECT post_requisites('ntt', 101, 105);
SELECT post_requisites('ntt', 101, 106);
SELECT post_requisites('ntt', 101, 107);
SELECT post_requisites('ntt', 101, 108);
SELECT post_requisites('ntt', 101, 109);
SELECT post_requisites('ntt', 101, 110);
SELECT post_requisites('ntt', 101, 111);
SELECT post_requisites('ntt', 101, 112);
SELECT post_requisites('ntt', 101, 113);
SELECT post_requisites('ntt', 101, 114);
SELECT post_requisites('ntt', 101, 115);
SELECT post_requisites('ntt', 101, 116);
SELECT post_requisites('ntt', 101, 117);
SELECT post_requisites('ntt', 101, 118);
SELECT post_requisites('ntt', 101, 119);
SELECT post_requisites('ntt', 101, 120);
SELECT post_requisites('ntt', 101, 121);
SELECT post_requisites('ntt', 101, 122);
SELECT post_requisites('ntt', 101, 123);
SELECT post_requisites('ntt', 101, 124);
SELECT post_requisites('ntt', 101, 125);
SELECT post_requisites('ntt', 101, 126);
SELECT post_requisites('ntt', 101, 127);
SELECT post_requisites('ntt', 101, 128);
SELECT post_requisites('ntt', 101, 129);
SELECT post_requisites('ntt', 101, 130);
SELECT post_requisites('ntt', 101, 131);
SELECT post_requisites('ntt', 101, 132);
SELECT post_requisites('ntt', 101, 133);
SELECT post_requisites('ntt', 101, 134);
SELECT post_requisites('ntt', 101, 135);
SELECT post_requisites('ntt', 101, 136);
SELECT post_requisites('ntt', 101, 137);
SELECT post_requisites('ntt', 101, 138);
SELECT post_requisites('ntt', 101, 139);
SELECT post_requisites('ntt', 101, 140);
SELECT post_requisites('ntt', 101, 141);
SELECT post_requisites('ntt', 101, 142);
SELECT post_requisites('ntt', 101, 143);
SELECT post_requisites('ntt', 101, 144);
SELECT post_requisites('ntt', 101, 145);
SELECT post_requisites('ntt', 101, 146);
SELECT post_requisites('ntt', 101, 147);
SELECT post_requisites('ntt', 101, 148);
SELECT post_requisites('ntt', 101, 149);
SELECT post_requisites('ntt', 101, 150);
SELECT post_requisites('ntt', 101, 151);
SELECT post_requisites('ntt', 101, 152);
SELECT post_requisites('ntt', 101, 153);
SELECT post_requisites('ntt', 101, 154);
SELECT post_requisites('ntt', 101, 159);
SELECT post_requisites('ntt', 101, 155);
SELECT post_requisites('ntt', 101, 156);
SELECT post_requisites('ntt', 101, 157);
В пользовательском интерфейсе конструктора эта таблица будет выглядеть примерно так:

Также у нас есть функции, которые управляют записями в этой виртуальной таблице транзакций. Записи создаются функцией post_objects().
Функция post_objects()
CREATE OR REPLACE FUNCTION public.post_objects(db text, up bigint, type bigint, attrs json, OUT newid bigint, OUT res text)
RETURNS record
LANGUAGE plpgsql
AS $function$
DECLARE settings json;
mods json;
i record;
reqs json;
val text;
values_to_insert TEXT := '';
BEGIN
val := COALESCE(replace(attrs->>('t'||type), '''', ''''''), ''); -- Object value
IF val='' THEN
res := 'err_empty_val';
RETURN;
END IF;
-- Validate the type and get its modifiers and requisites
EXECUTE format('SELECT val::json FROM %s WHERE id=-%s', db, type) INTO settings;
IF settings IS NULL THEN
-- Validate the type
EXECUTE format('SELECT json_object_agg(trim(''"'' FROM def.val), mods.val)::text mods'
||' FROM %s base, %s obj LEFT JOIN (%s mods CROSS JOIN %s def) ON mods.up=obj.id AND def.id=mods.t AND def.up=0 AND def.t=0'
||' WHERE obj.id=%s AND obj.up=0 AND base.id=obj.t AND base.t=base.id', db, db, db, db, type) into mods;
IF mods IS NULL THEN
res := 'err_type_not_found';
RETURN;
END IF;
-- Get the requisites
EXECUTE format('SELECT json_object_agg(req.id,(''{"ord":"''||req.val||''","t":''||req.t||'',"base":''||base.t
||'',"ref":''||CASE WHEN base.id=base.t THEN 0 ELSE typ.t END||''}'')::json)
FROM %s req JOIN %s typ ON typ.id=req.t JOIN %s base ON base.id=typ.t
WHERE req.up=%s', db, db, db, type) INTO reqs;
settings := ('{"mods":'||mods||',"reqs":'||reqs||'}')::json;
EXECUTE format('INSERT INTO %s (id,up,t,val) values(-%s,0,0,''%s'') ON CONFLICT DO NOTHING', db, type, replace(settings #>> '{}', '''', ''''''));
ELSE
mods := settings->'mods';
reqs := settings->'reqs';
END IF;
IF up!=1 THEN
EXECUTE format('SELECT id FROM %s WHERE id=%s', db, up) into up;
IF up IS NULL THEN
res := 'err_parent_not_found';
RETURN;
END IF;
END IF;
-- Check if the value must be unique
IF mods->'UNIQUE' IS NOT NULL THEN
EXECUTE format('SELECT id FROM %s WHERE t=%s AND lower(left(val, 127))=lower(left(''%s'', 127)) AND up=%s LIMIT 1', db, type, val, up) into newid;
IF newid IS NOT NULL THEN
res := 'err_non_unique_val';
RETURN;
END IF;
END IF;
EXECUTE format('INSERT INTO %s(up,t,val)VALUES(%s,%s,''%s'')RETURNING id', db, up, type, val) into newid;
-- Create the requisites
FOR i IN (SELECT * FROM json_each(reqs)) LOOP
val := COALESCE(replace(attrs->>('t'||i.key), '''', ''''''), '');
IF val='' THEN -- Empty val, skip it
CONTINUE;
ELSIF (reqs->i.key->>'ref')::int=0 THEN -- it's not a Ref
values_to_insert:=values_to_insert||',('||newid||','||i.key||','''||val||''')';
ELSE -- This is a ref - check if the referenced id is valid
EXECUTE format('SELECT id FROM %s WHERE t=%s AND id=%s', db, reqs->i.key->'ref', val) into val;
IF val IS NULL THEN
res := 'err_invalid_ref '||val;
RETURN;
END IF;
-- Reference value is stored as Typ, while Type goes to Value
values_to_insert:=values_to_insert||',('||newid||','||val||','||i.key||')';
END IF;
END LOOP;
IF values_to_insert != '' THEN
EXECUTE format('INSERT INTO %I(up,t,val)VALUES%s', db, substring(values_to_insert, 2));
END IF;
res := '1';
END;
$function$
;
Мы будем грузить данные в несколько потоков, подбирая нагрузку так, чтобы процессор был загружен под 80%.

Мы перебрали несколько конфигураций железа, чтобы посмотреть зависимость скорости загрузки от количества процессоров, и выбрали 16 ядер и 32ГБ. Из тестов, на 32 ядрах скорость загрузки составляет около 5000 транзакций в секунду.
Конфигурация в 16 ядер вполне доступна по цене бизнесу, у которого база в несколько терабайт с нагрузкой в 2000-3000 обращений в секунду. При более скромных запросах будет достаточно 2 или 4 ядер.
На небольшом отрезке график роста базы кажется линейным, но если его приблизить, то видно, что он дугообразный из-за небольшой деградации скорости в рамках каждой партиции:

При подготовке этой статьи мы проверили, что будет, если продолжать грузить данные в таблицу: скорость, хоть и не пропорционально, но заметно снижается с ростом размера этой таблицы. Если же ограничить размер партиций, из которых состоит таблица, то деградации практически не будет.

Скорость роста БД до применения партиций – красным, это 3 попытки заполнить пустую таблицу с нуля, и видно замедление скорости с ростом объема. Также виден результат с партициями – зеленым, скорость практически постоянна, при том, что заполняется одна и та же таблица, состоящая из партиций.
Теперь следует проверить основные типы запросов к данным, посмотрев заодно их план выполнения. Запускать их будем на нагруженной базе – данные продолжают грузиться всё так же.
На момент тестов в базе больше 703 млн транзакций в 31 млрд квартетов, она занимает больше 4.5ТБ.
SELECT max(id) quintets, max(id)/45 transactions FROM ntt;

Первый запрос - самый простой, выбор строки по совпадению значения за 0.77 секунды:
SELECT * FROM ntt WHERE t=101 AND lower(left(val, 127))='98635dbc4383dc17';

В плане запроса видно, что идёт поиск по всем партициям, и это может делаться параллельно, в несколько потоков:

Время выборки для нагруженной базы в 5ТБ вполне адекватное.
Полный текст плана запроса
Append (cost=0.00..1924.37 rows=1794 width=32)
-> Seq Scan on ntt_0 ntt_1 (cost=0.00..29.40 rows=1 width=56)
Filter: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_1_tval_idx on ntt_1 ntt_2 (cost=0.58..58.68 rows=56 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_2_tval_idx on ntt_2 ntt_3 (cost=0.57..58.68 rows=56 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_3_tval_idx on ntt_3 ntt_4 (cost=0.57..59.70 rows=57 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_4_tval_idx on ntt_4 ntt_5 (cost=0.58..58.68 rows=56 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_5_tval_idx on ntt_5 ntt_6 (cost=0.57..58.68 rows=56 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_6_tval_idx on ntt_6 ntt_7 (cost=0.58..58.68 rows=56 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_7_tval_idx on ntt_7 ntt_8 (cost=0.58..57.66 rows=55 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_8_tval_idx on ntt_8 ntt_9 (cost=0.57..59.70 rows=57 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_9_tval_idx on ntt_9 ntt_10 (cost=0.57..60.72 rows=58 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_10_tval_idx on ntt_10 ntt_11 (cost=0.58..59.70 rows=57 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_11_tval_idx on ntt_11 ntt_12 (cost=0.57..62.76 rows=60 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_12_tval_idx on ntt_12 ntt_13 (cost=0.58..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_13_tval_idx on ntt_13 ntt_14 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_14_tval_idx on ntt_14 ntt_15 (cost=0.57..65.82 rows=63 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_15_tval_idx on ntt_15 ntt_16 (cost=0.57..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_16_tval_idx on ntt_16 ntt_17 (cost=0.57..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_17_tval_idx on ntt_17 ntt_18 (cost=0.57..62.76 rows=60 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_18_tval_idx on ntt_18 ntt_19 (cost=0.57..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_19_tval_idx on ntt_19 ntt_20 (cost=0.58..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_20_tval_idx on ntt_20 ntt_21 (cost=0.57..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_21_tval_idx on ntt_21 ntt_22 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_22_tval_idx on ntt_22 ntt_23 (cost=0.58..64.80 rows=62 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_23_tval_idx on ntt_23 ntt_24 (cost=0.57..65.82 rows=63 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_24_tval_idx on ntt_24 ntt_25 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_25_tval_idx on ntt_25 ntt_26 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_26_tval_idx on ntt_26 ntt_27 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_27_tval_idx on ntt_27 ntt_28 (cost=0.57..63.78 rows=61 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_28_tval_idx on ntt_28 ntt_29 (cost=0.57..65.82 rows=63 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_29_tval_idx on ntt_29 ntt_30 (cost=0.57..62.76 rows=60 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_30_tval_idx on ntt_30 ntt_31 (cost=0.57..54.60 rows=52 width=32)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_31_tval_idx on ntt_31 ntt_32 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_32_tval_idx on ntt_32 ntt_33 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_33_tval_idx on ntt_33 ntt_34 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_34_tval_idx on ntt_34 ntt_35 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_35_tval_idx on ntt_35 ntt_36 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_36_tval_idx on ntt_36 ntt_37 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_37_tval_idx on ntt_37 ntt_38 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_38_tval_idx on ntt_38 ntt_39 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
-> Index Scan using ntt_39_tval_idx on ntt_39 ntt_40 (cost=0.15..2.17 rows=1 width=56)
Index Cond: ((t = 101) AND (lower("left"(val, 127)) = '98635dbc4383dc17'::text))
Теперь чуть сложнее: выбор по маске – первым 6 символам уникальных значений (1.57 секунды):
SELECT * FROM ntt WHERE t=101 AND lower(left(val, 127)) LIKE 'cabba5%';

Здесь выбраны данные, распределенные по всем 5ТБ дискового пространства. Используется индекс t_val, который построен специально для поиска без учета регистра.
Теперь ещё сложнее – будем искать транзакции по условию на значение в одной из колонок нашей таблицы транзакций. EXPLAIN, опираясь на статистику, прогнозирует выборку за 50-900 миллисекунд, и сейчас мы его проверим:

Как видно в плане запроса, планировщик начинает с тех выборок, где может максимально сократить количество выбираемых записей. Далее он присоединяет к ним все остальные.
Результат выборки хуже предсказанного планировщиком, потому что база серьезно нагружена и мы поменяли условие, чтобы БД не использовала уже загруженные блоки с индексом и данными (1.28 секунды):
SELECT * FROM ntt, ntt ntr
WHERE ntt.t=101 AND lower(left(ntr.val, 127))='57777777'
AND ntr.up=ntt.id AND ntr.t=199;

Далее сделаем выборку всех реквизитов конкретной транзакции (0.95 секунды):
SELECT ntt.id, ntt.val, ntm.val
FROM ntt LEFT join ntt ntm ON ntm.up=ntt.id
WHERE ntt.up=19780167935;

Такой запрос нужен для вывода полей на форму редактирования записи о транзакции, наподобие такой:

Теперь проведем поиск по колонке с низкой вариативностью – в таблице всего 5-7 вариантов валют, поэтому подходящие значения находятся в первых же блоках индекса (0.05 секунды):
SELECT * FROM ntt, ntt ntr
WHERE ntt.t=101 AND lower(left(ntr.val, 127))='rub'
AND ntr.up=ntt.id AND ntr.t=209;

Работает очень быстро, что вполне объяснимо и ожидаемо.
Мы не тестируем запросы на изменение и удаление, потому что основная их часть выполняется по id, что сужает область поиска до 1 партиции, при этом сама партиция уже не растет по количеству записей.
Те же запросы на ненагруженной базе показывают заметно меньшее время выборки из-за отсутствия конкуренции при доступе к диску.
Выбор строки по совпадению значения 0.77 → 0.53 секунды:

Выбор по маске 1.57 → 0.56 секунды:

По условию на значение в одной из колонок 1.28 → 0.41 секунды:

Выборка всех реквизитов 1.28 → 0.04 секунды:

Выборка всех реквизитов одной транзакции идет быстрее прочих запросов, потому что обычно все эти реквизиты находятся в одном блоке данных.
Кстати, здесь мы видим решение задачи быстрой выборки с фильтром по любому из 45 полей таблицы на практически неограниченном объеме, что, в общем случае, считается нерешаемым. Спросите у опытных людей – есть ли иные способы?
В силу простоты организации структуры и правил работы с ней, разворачивать подобные инстансы с минимальным набором настроек будет достаточно несложно, и с ними можно работать «без кода», как нам обещают адепты No-code.
Итак, мы рассмотрели один из критических аспектов разработки ПО и убедились, что у no-code разработчиков есть как минимум один способ решить проблему масштабирования своих баз данных до размеров, достаточных для промышленного применения.
Если ваше корпоративное хранилище занимает в пределах 10ТБ или немного больше, то вам, вероятно, стоит присмотреться к такому подходу и протестировать его.
С учетом конкуренции, урезания бюджетов, геополитики, решения «без кода» будут рассматриваться всё чаще, а мы пока будем продолжать наблюдение.
Спасибо, что дочитали!
***
P.S. Не могу не отметить одну важную вещь. Многие адепты No-code утверждают, что разработка без кода позволит создавать приложения любому непрограммисту. При всем уважении, этого никогда не случится. Без архитектурного видения никакого приложения создать не получится, хотя любой непрограммист легко сможет сделать более мощную копию своего экселя.