Как стать автором
Обновить

Low-code без границ: 32 млрд квартетов и терабайты данных в конструкторе приложений

Уровень сложностиПростой
Время на прочтение19 мин
Количество просмотров8.6K

Бум No-code начался в 2022 году, и сейчас многие компании стараются так или иначе внедрить функционал «low-code» в свои продукты. У участников IT-индустрии пока нет согласия о границах применимости технологий «без кода», хотя адепты этих технологий обещают, что они позволят создавать практически любые приложения***.

В этой заметке мы рассмотрим один из основных аспектов создания приложений – его масштабируемость в средней и дальней перспективе. Для этого сам продукт под капотом должен быть построен на чем-то более мощном, чем MS Excel, Airtable, Notion и Make, и такие продукты уже есть на рынке.

Фатальные проблемы масштабируемости проявляются с ростом объемов данных и количества пользователей, которые с ними работают – с этого мы и начнём.

Для создания баз данных в конструкторах используются ORM разной степени унификации, и здесь будет рассмотрен случай предельной унификации данных, который ранее назывался квинтетной моделью данных. По сути это EAV с добавленным полем ID и тремя индексами, то есть, это даже не квинтет, а квартет. Для обозначения такой структуры также используется термин IDEAV. Сейчас ещё используется поле-рудимент – порядок среди равных, например, в конструкторе Интеграм.

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

Все составляющие конструктора на IDEAV есть в открытом доступе и их можно исследовать и использовать в разработке.

Что такое IDEAV

Чтобы не заставлять читателя отдельно изучать IDEAV, расскажем кратко в чем суть:

  1. Все данные – метаданные, данные, управляющие структуры, пользователи и вообще всё – хранятся в единой таблице из 4 полей

  2. В таблице описываются простые (базовые) типы: строка, число, дата и т.д.

  3. На основе простых типов описывается всё остальное – термины бизнеса, связи, роли и доступы, пользователи, запросы и другое

  4. Таблица объединяется сама с собой множество раз для десериализации всех этих данных в привычные пользователю широкие таблицы

  5. Три индекса обеспечивают быстрые точечные выборки и объединения данных в этой единой мега-таблице

  6. Логика работы приложения описывается терминами бизнеса, их связями и формулами, хранящимися всё в той же таблице

  7. 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);

В пользовательском интерфейсе конструктора эта таблица будет выглядеть примерно так:

Таблицы виртуальной БД собираются из записей в структуре IDEAV
Таблицы виртуальной БД собираются из записей в структуре IDEAV

Также у нас есть функции, которые управляют записями в этой виртуальной таблице транзакций. Записи создаются функцией 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;
Одна транзакция хранится в 45 записях IDEAV
Одна транзакция хранится в 45 записях IDEAV

Первый запрос - самый простой, выбор строки по совпадению значения за 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%';
Найдено 33 записи из ~700 млн за полторы секунды
Найдено 33 записи из ~700 млн за полторы секунды

Здесь выбраны данные, распределенные по всем 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;
Получилось 9 записей вместо 6, как в плане запроса, из-за замены условия
Получилось 9 записей вместо 6, как в плане запроса, из-за замены условия

Далее сделаем выборку всех реквизитов конкретной транзакции (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;
Первые попавшиеся 200 записей при низкой вариативности фильтруемого поля
Первые попавшиеся 200 записей при низкой вариативности фильтруемого поля

Работает очень быстро, что вполне объяснимо и ожидаемо.

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

Те же запросы на ненагруженной базе показывают заметно меньшее время выборки из-за отсутствия конкуренции при доступе к диску.

Выбор строки по совпадению значения 0.77 → 0.53 секунды:

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

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

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

ID — 25 млрд, разве это не круто?
ID — 25 млрд, разве это не круто?

Выборка всех реквизитов одной транзакции идет быстрее прочих запросов, потому что обычно все эти реквизиты находятся в одном блоке данных. 

Кстати, здесь мы видим решение задачи быстрой выборки с фильтром по любому из 45 полей таблицы на практически неограниченном объеме, что, в общем случае, считается нерешаемым. Спросите у опытных людей – есть ли иные способы?

В силу простоты организации структуры и правил работы с ней, разворачивать подобные инстансы с минимальным набором настроек будет достаточно несложно, и с ними можно работать «без кода», как нам обещают адепты No-code.

Итак, мы рассмотрели один из критических аспектов разработки ПО и убедились, что у no-code разработчиков есть как минимум один способ решить проблему масштабирования своих баз данных до размеров, достаточных для промышленного применения.

Если ваше корпоративное хранилище занимает в пределах 10ТБ или немного больше, то вам, вероятно, стоит присмотреться к такому подходу и протестировать его.

С учетом конкуренции, урезания бюджетов, геополитики, решения «без кода» будут рассматриваться всё чаще, а мы пока будем продолжать наблюдение.

Спасибо, что дочитали!

***

P.S. Не могу не отметить одну важную вещь. Многие адепты No-code утверждают, что разработка без кода позволит создавать приложения любому непрограммисту. При всем уважении, этого никогда не случится. Без архитектурного видения никакого приложения создать не получится, хотя любой непрограммист легко сможет сделать более мощную копию своего экселя.

Теги:
Хабы:
+24
Комментарии34

Публикации

Работа

Ближайшие события