PostgreSQL. Как правильно хранить котов, история одной миграции.

17 октября 2016

История взята из реального проекта. Но поскольку реальный проект слишком скучный (и под DNA), то в этой статье используется упрощенный пример.

Жил-был один проект. И была у него база данных. И была в базе таблица для хранения, ну, скажем, котов. Вот такая:

    CREATE TABLE cats (
        id serial,
        cname varchar(20),
        ctype varchar(20),
        primary key(id)
    );

Все довольно просто: у каждого кота есть id, имя, и некий тип.

Конечно, у нас были бизнес требования к котам и их типам. Например, мы точно знали, что у нас есть типы big furry, neko и sudden danger. И предполагали, что еще могут быть типы long tail и sleeper-eater. Но мы ожидали, что требования будут меняться. И пока не известно, какие в итоге понадобятся типы. Поэтому использовали тип данных varchar(20).

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

Мы решали эту проблему не так старательно и педантично, как описано в данной статье. Но давайте притворимся, что именно так все и делалось в реальном проекте :)

Итак, применим инженерный подход:

Строим теорию

Создадим ENUM-тип данных и перечислим в нем допустимые значения. Затем выполним миграцию:

    CREATE TYPE cat_type AS ENUM ('big furry', 'small red', 'long tail', 'crafty hunter', 'sudden danger', 'sleeper-eater');
    ALTER TABLE cats ALTER COLUMN ctype TYPE cat_type USING ctype::cat_type;

Мы еще не знаем, что в таком виде миграция не сработает. Забыли про уже существующие в таблице недопустимые значения. Узнаем об этом позже, когда попробуем применить миграцию :)

Таким образом мы запретим создание котов с недопустимым значением типа. А еще мы уменьшим размер таблицы и размер индекса по полю ctype. Размер таблицы не так уж и важен, а вот уменьшение размера индекса – это хорошо. Мы уже имели дело с индексами, которые не помещаются в оперативной памяти. И это не очень полезные индексы :)

Давайте оценим, какого выигрыша по памяти можно ожидать.

Для хранения значения типа varchar выделяется 1-4 байта на символ (в зависимости от кодировки) и еще 1 или 4 байта на хранение длины строки (подробнее тут). В нашем случае это 1 байт на символ (utf8, латинские буквы) и 1 байт на длину строки. Строки длиной 9-14 символов. Будем считать, что в среднем у нас 12 байт на одно значение.

    > select pg_column_size('big furry');
    10
    > select pg_column_size('sleeper-eater');
    14

Известно, что значения enum занимают 4 байта независимо от их длины:

    > select pg_column_size('big furry'::cat_type);
    4
    > select pg_column_size('sleeper-eater'::cat_type);
    4

Одна строка в таблице занимает:

Итого: 68 байт.

После миграции будет 27 + 8 + 21 + 4 = 60 байт. Разница небольшая, но для 50 млн строк суммарный выигрыш должен быть существенным.

У нас 2 индекса, по id и по ctype. Индекс по id не изменится. Индекс по ctype должен уменьшится. Как устроена память индекса мы не знаем, но ожидаем, что если одно значение уменьшилось в 3 раза, то и индекс уменьшится в 2-3 раза.

Эксперимент №1

Для эксперимента создадим две таблицы:

    CREATE TABLE cats1 (
        id serial,
        name varchar(20),
        type varchar(20),
        primary key(id)
    );

    CREATE TYPE cat_type AS ENUM ('big furry', 'small red', 'long tail',
                                  'crafty hunter', 'sudden danger', 'sleeper eater');
    CREATE TABLE cats2 (
        id serial,
        name varchar(20),
        type cat_type,
        primary key(id)
    );

Заполним их тестовыми данными:

    CREATE SEQUENCE ss;

    INSERT INTO cats1 (name, type)
        SELECT
            substring(md5(random()::text), 0, 20),
            (ARRAY['big furry', 'small red', 'long tail',
                   'crafty hunter', 'sudden danger', 'sleeper eater'])
                [nextval('ss') % 5 + 1]
        FROM
            generate_series(1, 500000);

    INSERT INTO cats2 (name, type)
        SELECT
            substring(md5(random()::text), 0, 20),
            ((ARRAY['big furry', 'small red', 'long tail',
                    'crafty hunter', 'sudden danger', 'sleeper eater'])
                [nextval('ss') % 5 + 1])::cat_type
        FROM
            generate_series(1, 500000);

Да, имена у наших котов довольно странные. Но для эксперимента годятся :)

Создадим индексы:

    CREATE INDEX cats1_index ON cats1(type);
    CREATE INDEX cats2_index ON cats2(type);

И посмотрим, сколько памяти они заняли:

    SELECT pg_relation_size('cats1') AS table_size,
           pg_indexes_size('cats1') AS indexes_size;
    SELECT pg_relation_size('cats2') AS table_size,
           pg_indexes_size('cats2') AS indexes_size;

Теоретически строки в первой таблице занимают 68 * 500,000 = 34,000,000 байт, во второй таблице 60 * 500,000 = 30,000,000 байт. На практике видим 34,136,064 и 30,121,984 байт. Цифры получились близкие.

Понятно, что таблица устроена сложнее, чем просто 500,000 строк равномерно одна за другой. Там выделяются страницы памяти по 8 Кб. У страниц есть свои заголовки и другая метаинформация. Да и значения в строках как-то выравниваются (подробнее тут).

Но что у нас с индексами?

Функция pg_indexes_size показывает расход памяти суммарно по всем индексам, связанным с таблицей, а не по каждому отдельно. Но это не беда, мы можем вызвать ее до создания индекса по ctype и после. psql есть команда \diS+ index_name, которая показывает размер конкретного индекса. Но на момент написания статьи я об этом не знал.) И тогда увидим, что индекс по id занимает 11,255,808 байт, а индексы по ctype для первой таблицы – 15,794,176 байт, а для второй таблицы – 11,255,808 байт.

Заметно меньше, но не в 2-3 раза, как мы ожидали. Почему так?

Эксперимент №2

Создадим несколько простых таблиц, содержащих только один столбец:

    CREATE TABLE t_bool (f bool);
    CREATE TABLE t_sint (f smallint);
    CREATE TABLE t_int (f int);
    CREATE TABLE t_bint (f bigint);
    CREATE TABLE t_c7 (f char(7));
    CREATE TABLE t_c8 (f char(8));
    CREATE TABLE t_c9 (f char(9));
    CREATE TABLE t_c15 (f char(15));
    CREATE TABLE t_c16 (f char(16));
    CREATE TABLE t_c20 (f char(20));

Заполним их данными:

    INSERT INTO t_bool (f)
        SELECT true FROM generate_series(1, 500000);
    INSERT INTO t_sint (f)
        SELECT 1 FROM generate_series(1, 500000);
    ...
    INSERT INTO t_c7 (f)
        SELECT 'abcdefg' FROM generate_series(1, 500000);
    ...
    INSERT INTO t_c20 (f)
        SELECT 'abcd efgh abcd efgh ' FROM generate_series(1, 500000);

Создадим индексы:

    CREATE INDEX ON t_bool(f);
    CREATE INDEX ON t_sint(f);
    ...
    CREATE INDEX ON t_c20(f);

И посмотрим, сколько места занимают таблица и индекс:
Тип данных Байт на одно значение Размер таблицы Размер индекса
bool118,128,89611,255,808
smallint218,128,89611,255,808
int418,128,89611,255,808
bigint818,128,89611,255,808
char(7)818,128,89611,255,808
char(8)922,142,97615,794,176
char(9)1022,142,97615,794,176
char(15)1622,142,97615,794,176
char(16)1726,091,52020,332,544
char(20)2126,091,52020,332,544

Видим, что размеры таблицы и индекса одинаковые в диапазонах значений 1-8 байт, 9-16 байт и больше 16 байт.

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

Замена varchar на enum дает выигрыш, если varchar значения в среднем больше 8 байт (длинее 7-ми символов).

Разрабатываем практическое решение

Теперь мы знаем, что ожидать на практике, и готовы реализовать нашу миграцию.

Возвращаемся к нашим котам:

    CREATE TABLE cats (
        id serial,
        cname varchar(20),
        ctype varchar(20),
        primary key(id)
    );
    CREATE INDEX c1 ON cats(ctype);

Наполняем таблицу данными так, чтобы в ней были невалидные и NULL значения.

    CREATE SEQUENCE ss;

    INSERT INTO cats (cname, ctype)
        SELECT
            substring(md5(random()::text), 0, 20),
            (ARRAY['big furry', 'small red', 'long tail', 'crafty hunter', 'sudden danger',
                   'sleeper-eater', 'black eye', 'sharp claw', 'neko', NULL])
                [nextval('ss') % 10 + 1]
        FROM
            generate_series(1, 500000);

Пробуем мигрирововать:

    CREATE TYPE cat_type AS ENUM ('big furry', 'small red', 'long tail',
                                  'crafty hunter', 'sudden danger', 'sleeper-eater');
    ALTER TABLE cats ALTER COLUMN ctype TYPE cat_type USING ctype::cat_type;

И выясняем, что наш наивный ALTER TABLE не работает:

    ERROR:  invalid input value for enum cat_type: "black eye"

И нужно писать функцию для преобразования типа:

    CREATE OR REPLACE FUNCTION cast_to_cat_type(ctype varchar) RETURNS cat_type AS
    $$
    DECLARE res cat_type;
    BEGIN
        CASE ctype
            WHEN 'big furry' THEN res := 'big furry';
            WHEN 'small red' THEN res := 'small red';
            WHEN 'long tail' THEN res := 'long tail';
            WHEN 'crafty hunter' THEN res := 'crafty hunter';
            WHEN 'sudden danger' THEN res := 'sudden danger';
            WHEN 'sleeper-eater' THEN res := 'sleeper-eater';
            ELSE res := NULL;
        END CASE;
        RETURN res;
    END
    $$
    LANGUAGE plpgsql;

И пробуем еще раз:

    ALTER TABLE cats ALTER COLUMN ctype TYPE cat_type USING cast_to_cat_type(ctype);

На этот раз сработало.

Только показывать кому-то такую функцию стыдно. Ой, кажется я только что выдал свою тайную склонность к копипасте. Тсс, давайте притворимся, что я этого не писал, а вы этого не видели, ок? А я напишу по-другому:

    CREATE OR REPLACE FUNCTION cast_to_cat_type(ctype varchar) RETURNS cat_type AS
    $$
    DECLARE res cat_type;
    BEGIN
        BEGIN
            res := ctype::cat_type;
        EXCEPTION WHEN others THEN
            res := NULL;
        END;
        RETURN res;
    END
    $$
    LANGUAGE plpgsql;

Вот это можно смело отправлять на code review.

Оцениваем результат

Что же у нас получилось в итоге? Размеры таблицы и индексов до миграции: 33,038,336 и 26,140,672 байт. После миграции: 28,581,888 и 22,511,616 байт. Учитывая, что в реальной таблице у нас не 500 тыс записей, а 50 миллионов, выигрыш будет существенный.

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

Используем частичный индекс:

    CREATE index CONCURRENTLY c2 ON cats(ctype) WHERE ctype IS NOT NULL;
    DROP index c1;

И теперь размер индексов 18,014,208 байт. Тут, конечно, все будет зависеть от того, как много котов у нас оказались неправильными.

Любопытный вопрос, что дальше делать с неправильными котами. Но это уже вопрос к бизнесу, не к разработчику :)

Осталось убедиться, что правильные значения в таблицу вставляются, а неправильные не вставляются:

    > INSERT INTO cats (cname, ctype) VALUES ('Murzik', 'crafty hunter'), ('Vasjka', 'sleeper-eater');
    INSERT 0 2
    > INSERT INTO cats (cname, ctype) VALUES ('Sharik', 'big dog');
    ERROR:  invalid input value for enum cat_type: "big dog"

Все работает как надо.

comments powered by Disqus