DBA: хранение списков — таблица, массив, строка?

от автора

Достаточно часто при проектировании схемы БД возникает задача сохранить по основной сущности некоторый набор простых второстепенных данных.

Например, это могут быть ФИО сотрудников, принимающих участие во встрече, список приложенных к сообщению файлов или перечень отгружаемых по документу позиций.

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

Давайте посмотрим, какие варианты хранения таких данных мы можем использовать в PostgreSQL, и какой из них окажется в разы более эффективным.

Связанная таблица

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

Давайте условимся, что второстепенные данные в нашем примере будут представлены упорядоченным списком из нескольких uuid:

-- таблица основных данных CREATE TABLE tblpk(   id     serial       PRIMARY KEY );  -- таблица второстепенных данных CREATE TABLE tblfk(   id     integer       REFERENCES tblpk    -- эквивалентно tblpk(id), поскольку id - PK         ON DELETE CASCADE , ord                     -- порядок записи в списке     integer , data     uuid , PRIMARY KEY(id, ord)    -- используется и для FK(id) );

О некоторых проблемах, которые может вызывать подобная структура, если промахнуться с индексами, я уже рассказывал в «PostgreSQL Antipatterns: когда мешает внешний ключ». Но тут у нас все хорошо — на обеих таблицах пришлось создать по индексу (первичному ключу), чтобы функционал foreign keys работал нормально.

Создадим 100K основных сущностей и к ним 400K произвольно связанных случайных записей. Для этого воспользуемся расширением uuid-ossp:

CREATE EXTENSION "uuid-ossp";  INSERT INTO tblpk(id) SELECT generate_series(1, 1e5);  INSERT INTO tblfk SELECT   id , row_number() OVER(PARTITION BY id) ord -- фиксируем порядок записей в списке , data FROM   (     SELECT       (random() * (1e5 - 1))::integer + 1 id -- случайный ID из [1 .. 100K]     , uuid_generate_v4() "data"              -- случайный uuid     FROM       generate_series(1, 4e5)   ) T;  -- переупакуем таблицы полностью, чтобы сбалансировались индексы VACUUM (FULL, ANALYZE, VERBOSE) tblpk, tblfk;

Посмотрим, какой объем занимает такая структура на диске:

SELECT   relname , pg_total_relation_size(oid) sz -- этот размер включает и все индексы FROM   pg_class WHERE   relkind = 'r' AND   relname LIKE 'tbl%';
relname |    sz tblpk   |  5890048 --  5.6MB tblfk   | 29876224 -- 28.5MB

Итак, 34MB у нас заняло хранение в двух связанных таблицах. И вполне понятно почему — каждая запись из списка приносит с собой, как минимум, пару значений (id, ord) и индекс по ней.

Может, не стоит столько лишнего хранить?..

Массив

Что если весь список хранить прямо в основной таблице — массивом? Тогда ни id второй раз, ни ord хранить не придется, и второй индекс не нужен:

CREATE TABLE tblarr(   id     serial       PRIMARY KEY , list     uuid[] -- тот самый массив );

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

INSERT INTO tblarr SELECT   id , list FROM   tblpk -- полный список id LEFT JOIN   (     SELECT       id                                -- тут не окажется id без записей     , array_agg(data ORDER BY ord) list -- порядок в массиве соответствует исходному     FROM       tblfk     GROUP BY       1   ) T     USING(id);

Перепакуем и оценим:

relname |    sz tblarr  | 14729216 -- 14.0MB

Почти в 2.5 раза компактнее!

Но давайте оценим, сколько у нас занимает list-поле в первых 10 записях:

SELECT   id , array_length(list, 1) ln , pg_column_size(list) sz FROM   tblarr ORDER BY   id LIMIT 10;
id | ln | sz  1 |    |  2 |  3 |  69 = 21 + 3 * 16  3 |  6 | 117 = 21 + 6 * 16  4 |  2 |  53 = 21 + 2 * 16  5 |  2 |  53  6 |  2 |  53  7 |  3 |  69 = 21 + 3 * 16  8 |  2 |  53  9 |  4 |  85 = 21 + 4 * 16 10 |  2 |  53

Каждое uuid -значение занимает 16 байт, а 21 байт «сверху» добавляет заголовочная информация массива о количестве элементов и их типе.

Давайте попробуем убрать этот заголовок.

Строка (text/bytea)

Для этого сохраним весь массив в виде строки. Тут возможны варианты в зависимости от того, насколько сильно хочется упаковать, и насколько сложно потом придется доставать данные.

На примере списка [0, 1, 65535] мы можем его упаковать:

  • в строковое представление всего массива: '{0,1,65535}' - 11 байт

  • в строку с разделителями: '0,1,65535' - 9 байт

  • в двоичную строку: x'00000001FFFF' - 6 байт

Давайте попробуем применить наиболее компактный вариант упаковки в bytea:

CREATE TABLE tblstr(   id     serial       PRIMARY KEY , list     bytea );

Давайте попробуем «упаковать» наш массив в bytea. Для этого воспользуемся *_send-функцией, которая преобразует поле в его хранимое двоичное представление: uuid_send для uuid:

SELECT   id , array_to_string(ARRAY(     SELECT       uuid_send(data)     FROM       unnest(list) data   ), '')::bytea list FROM   tblarr;

Замечу, что чтобы была возможность поклеить bytea как строки, параметр bytea_output должен быть установлен в значение 'escape'.

Посмотрим, во что превратится запись с 2 uuid (для удобства разбил на блоки по 8 байт):

... 4 | \035J\366S\032\212D+    -- <     \241~\362f\3216\345\236 -- > uuid #1     \231\220Fc\266 H\177    -- <     \242f}\213\221\032v\025 -- > uuid #2

Такая же функция есть и для всего произвольного массива целиком, array_send :

SELECT   id , array_send(list) list FROM   tblarr;

Но она нам добавит тот самый префикс да еще и информацию о длине каждого поля, чего мы совсем не хотим:

... 4 | \000\000\000\001        -- <     \000\000\000\000     \000\000\013\206     \000\000\000\002     \000\000\000\001        -- > 16 байт префикса     \000\000\000\020        -- размер поля     \035J\366S\032\212D+    -- <     \241~\362f\3216\345\236 -- > uuid #1     \000\000\000\020        -- размер поля     \231\220Fc\266 H\177    -- <     \242f}\213\221\032v\025 -- > uuid #2

В общем, упаковываем в полу-ручном режиме:

INSERT INTO tblstr SELECT   id , array_to_string(ARRAY(     SELECT       uuid_send(data)     FROM       unnest(list) data   ), '')::bytea list FROM   tblarr;
relname |    sz tblstr  | 12337152 -- 11.8MB

Итого: почти в 3 раза компактнее исходного варианта! Отлично, а как оттуда теперь достать-то данные?

Для этого нам уже понадобится понимать, как представлены данные в нашем контейнере. array_send как раз это все и сохраняет, а мы на этом — сэкономили:

SELECT   id , ARRAY(     SELECT       encode(substr(list, pos, 16), 'hex')::uuid -- bytea -> hex -> uuid     FROM       generate_series(1, length(list), 16) pos -- 16 байт/uuid   ) uuids FROM   tblstr LIMIT 10;
... 4 | {1d4af653-1a8a-442b-a17e-f266d136e59e,99904663-b620-487f-a266-7d8b911a7615}

Перепроверим себя:

SELECT   * FROM   tblfk WHERE   id = 4 ORDER BY   ord;
id | ord | data  4 |   1 | 1d4af653-1a8a-442b-a17e-f266d136e59e  4 |   2 | 99904663-b620-487f-a266-7d8b911a7615

Все совпало, и мы молодцы!

Может показаться, что экономия всего в 3 раза не стоит таких сложностей. Но если вам тоже, как и нам, приходится писать в PostgreSQL терабайты данных, то даже «экономия на спичках» может принести существенное снижение не только хранимого объема, но и нагрузки на дисковую подсистему.


ссылка на оригинал статьи https://habr.com/ru/company/tensor/blog/704250/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *