Как стать автором
Обновить
0
ex-Wargaming
Издатель и разработчик free-to-play MMO

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

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

Жил-был один проект. И была у него база данных. И была в базе таблица для хранения, ну, скажем, котов. Вот такая:
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 байта на хранение длины строки (подробнее тут www.postgresql.org/docs/current/static/datatype-character.html). В нашем случае это 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

Одна строка в таблице занимает:
  • 27 байт на заголовок строки,
  • 8 байт id,
  • 21 байт cname (считаем, что у всех котов имена по 20 символов),
  • 12 байт ctype

Итого: 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 Кб. У страниц есть свои заголовки и другая метаинформация. Да и значения в строках как-то выравниваются (подробнее тут www.postgresql.org/docs/9.5/static/storage-page-layout.html).

Но что у нас с индексами?
Функция pg_indexes_size показывает расход памяти суммарно по всем индексам, связанным с таблицей, а не по каждому отдельно. Но это не беда, мы можем вызвать ее до создания индекса по ctype и после. И тогда увидим, что индекс по 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);

И посмотрим, сколько места занимают таблица и индекс:
Тип данных
Байт на одно значение
Размер таблицы
Размер индекса
bool
1
18,128,896
11,255,808
smallint
2
18,128,896
11,255,808
int
4
18,128,896
11,255,808
bigint
8
18,128,896
11,255,808
char(7)
8
18,128,896
11,255,808
char(8)
9
22,142,976
15,794,176
char(9)
10
22,142,976
15,794,176
char(15)
16
22,142,976
15,794,176
char(16)
17
26,091,520
20,332,544
char(20)
21
26,091,520
20,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"

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

У нас есть еще парочка интересных историй про миграции, и про то, как трудно жить с большими таблицами. Расскажем об этом в следующий раз.

Юра Жлоба,
Веб-разработчик.
Теги:
Хабы:
Всего голосов 54: ↑51 и ↓3+48
Комментарии28

Публикации

Информация

Сайт
lesta.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия

Истории