PostgreSQL — Мечтаем про String Interning

На этой неделе на работе мы часто обсуждали Enum-ы, а также их хранение в БД PostgreSQL-а. После этого мне захотелось порассуждать о том, как было бы здорово, если бы PostgreSQL поддерживал умное хранение строк, применяя оптимизацию String Interning (другие названия — Пул строк, String Compaction).

Итак, какие вообще есть варианты хранения Enum значений в реляционной базе данных. На самом деле, есть как минимум 3 способа хранения значений:

  1. Можно хранить текстовое представление Enum-значения. То есть, если у нас есть Enum, обозначающий цвета, например —
    enum Color {
            blue,
            red,
            black
    }

    то можно хранить в базе данных именно эти значения — строки blue, red, black. Плюсы — простая и однозначная сериализация и десериализация. Кроме того, смотря в непосредственно БД, сразу становится понятно, что тут за значение. Минусы — это дорого по объему занимаемой памяти.

  2. Можно хранить числовые коды Enum-значений. Почти во всех языках программирования, каждому значению Enum сопоставляются неявно числа — 0, 1, 2. Давайте хранить именно их. Плюсы — это дешево по памяти. Минусы — нужно писать более сложный код сериализации и десериализации. Кроме того, смотря в базу данных, не понятно, что тут за значения.
  3. Можно, если Ваша база данных поддерживает это, использовать для хранения Enum непосредственно объект базы данных — Enum. Например, в PostgreSQL это есть — https://www.postgresql.org/docs/current/static/datatype-enum.html. Плюсы — вы получаете контроль со стороны Базы данных за этим объектом. Это будет ещё одно ваше ограничение целостности. Минусы — огромная сложность добавления нового значения Enum. Кроме того, vendor lock на базу данных. В общем, так себе вариант.

А давайте вообще посмотрим, о каких объемах памяти идёт речь. Может быть, это оптимизация на спичках. Может быть, нужно просто хранить строки в случае Enum-значений, и не париться.

Создадим первую тестовую таблицу. Она будет содержать 1 миллион значений — по примерно 10% строк: category1, category2, category3, category4, category5, category6, category7, category8, category9. То есть, у нас миллион значений, где всего 10 уникальных значений Enum.

CREATE TABLE BLOG(
   CATEGORY CHAR(10)
);

А теперь заполним эту таблицу тестовыми данными:

CREATE TABLE TMP_STRINGS(
   id SERIAL PRIMARY KEY,
   CATEGORY CHAR(50)
);

insert into TMP_STRINGS values (1, 'category1'), (2, 'category2'), (3, 'category3'), (4, 'category4'), (5, 'category5'), (6, 'category6'), (7, 'category7'), (8, 'category8'), (9, 'category9');

insert into blog(category)
SELECT category FROM (SELECT floor(random() * 9 + 1)::int as n from generate_series(1,1000000)) AS T INNER JOIN tmp_strings ON T.n = tmp_strings.id;

Получим статистику по этой таблице:

SELECT pg_size_pretty(pg_total_relation_size('"blog"'));

SELECT octet_length(t.*::text) FROM blog AS t WHERE category='category1' LIMIT 1;

SELECT l.what, l.nr AS "bytes/ct"
     , CASE WHEN is_size THEN pg_size_pretty(nr) END AS bytes_pretty
     , CASE WHEN is_size THEN nr / x.ct END          AS bytes_per_row
FROM  (
   SELECT min(tableoid)        AS tbl      -- same as 'public.tbl'::regclass::oid
        , count(*)             AS ct
        , sum(length(t::text)) AS txt_len  -- length in characters
   FROM blog t  -- provide table name *once*
   ) x
 , LATERAL (
   VALUES
      (true , 'core_relation_size'               , pg_relation_size(tbl))
    , (true , 'visibility_map'                   , pg_relation_size(tbl, 'vm'))
    , (true , 'free_space_map'                   , pg_relation_size(tbl, 'fsm'))
    , (true , 'table_size_incl_toast'            , pg_table_size(tbl))
    , (true , 'indexes_size'                     , pg_indexes_size(tbl))
    , (true , 'total_size_incl_toast_and_indexes', pg_total_relation_size(tbl))
    , (true , 'live_rows_in_text_representation' , txt_len)
    , (false, '------------------------------'   , NULL)
    , (false, 'row_count'                        , ct)
    , (false, 'live_tuples'                      , pg_stat_get_live_tuples(tbl))
    , (false, 'dead_tuples'                      , pg_stat_get_dead_tuples(tbl))
   ) l(is_size, what, nr);

Итог. Таблица занимает 42 MB места. В среднем, одна строка занимает 14 байт.

Теперь давайте создадим вторую таблицу. Она будет абстолютно такая же, как и первая — только хранится будут SMALLINT значения, а не строки.

CREATE TABLE BLOG(
   CATEGORY SMALLINT
);

Заполним и эту таблицу миллионом чисел от 1 до 9 включительно (аналогично категориям из первого примера):

insert into blog(category)
SELECT floor(random() * 9 + 1)::int as n from generate_series(1,1000000);

И получим опять аналогично статистику (запросы не копирую сюда — они аналогичные).

Результаты. Таблица занимает 35 MB места. В среднем, одна строка занимает 3 байта.

С первого взгляда может показаться, что разница — не существенная. Речь идёт примерно о 20% памяти. Однако тут есть несколько Но:

  • Мы использовали в примере Тип CHAR(10). В реальной жизни строки могут быть на много больше. Следовательно, и разница потребления памяти изменится.
  • Есть некоторые общие расходы на таблицу. Если у нас будет не 1 миллион записей, как в примере, а 100 миллионов записей, выигрышь по памяти будет ещё больше, в случае хранения чисел, а не строк.

На данный момент в PostgreSQL не реализована оптимизация String Interning. На самом деле, если немного подумать, то сразу становится понятно, что не всё так однозначно, и эта оптимизация может здорово наломать дров:

  • String Interning улучшит Хранение данных, но нанесет вред параметру CPU consumption. Ведь, хоть у нас и биективное отображение строк в числа, в некоторых участак Базы Данных придётся выполнять достаточно много преобразований, что может значительно ухудшить общую производительность БД.
  • Наивная реализация String Interning может сломать некоторые свойства некоторых Индексов. Например, если взять дефолтный B-tree индекс, который обладает свойством отсортированности данных, то в случае замены строк на числа, сортировка сломается.

Возможно, кто-нибудь со стороны и запилит модуль, в котором будет реализован String Interning. И тогда мы уже сможем проверить на реальных данных, есть ли польза от такой оптимизации.

Категории: Программирование