На прошедшем PGConf.Russia был доклад про расширение MobilityDB, а Андрей Бородин предложил идею расширять методы индексов под задачу.


Продолжу тему с расширением Postgres под решаемую задачу на примере расширения сделанного в рамках HighLoad Cup 2018, код доступен на GithHub. На хабре уже есть статья от blackmaster. Расширение добавляет два типа с поддержкой btree и GIN индексов.


Начальная схема:


CREATE TABLE accounts
(
  id           INTEGER PRIMARY KEY,
  email        VARCHAR(100) UNIQUE,
  fname        SMALLINT,
  sname        VARCHAR(50),
  phone        VARCHAR(16) UNIQUE,
  birth        TIMESTAMP NOT NULL,
  country      SMALLINT,
  city         SMALLINT,
  email_domain SMALLINT,
  joined       TIMESTAMP NOT NULL,
  status       status,
  sex          sex       NOT NULL,
  interests    SMALLINT[],
  premium      tsrange,
  likes_ids    INTEGER[],
  likes_tss    INTEGER[],
  CHECK ( joined >= '01.01.2011'::timestamp ),
  CHECK ( joined <= '01.01.2018'::timestamp )
);

CREATE INDEX IF NOT EXISTS interests_ids_index ON accounts USING GIN(interests);

Количество записей 1 300 000, а размеры интересующих отношений:


relation size
accounts 598 MB
accounts_email_key 54 MB
accounts_phone_key 32 MB
accounts_pkey 28 MB
interests_ids_index 8072 kB

Конечная схема:


CREATE UNLOGGED TABLE accounts
(
  id           INTEGER PRIMARY KEY,
  email        VARCHAR(100) UNIQUE,
  fname        SMALLINT,
  sname        VARCHAR(50),
  phone        phone UNIQUE,
  birth        TIMESTAMP NOT NULL,
  country      SMALLINT,
  city         SMALLINT,
  email_domain SMALLINT,
  joined       TIMESTAMP NOT NULL,
  status       status,
  sex          sex       NOT NULL,
  interests    bit128,
  premium      tsrange,
  likes_ids    INTEGER[],
  likes_tss    INTEGER[],
  CHECK ( joined >= '01.01.2011'::timestamp ),
  CHECK ( joined <= '01.01.2018'::timestamp )
);

Размеры:


relation old size new size
accounts 598 MB 579 MB
accounts_phone_key 32 MB 28 MB
accounts_pkey 28 MB 28 MB
interests_ids_index 8072 kB 8072 kB

Были добавлены два типа: phone и bit128.


phone


Номер телефона имеет вид 8(929)5481819, восьмерку можно отбросить. Код оператора укладывается в 10 бит, номер 24 бит, получается нужно 5 байт. Не очень удобное число из-за выравнивания данных в памяти. На вопрос как лучше укладывать данные в 5, 6 или 8 байт, ответ может дать только тесты.


Если следовать примеру из документации, то нужно немного. Определить тип:


class Phone : public PAllocNew<Phone>
{
public:

    bool fromCString(char* str) noexcept;
    char* toCString() const noexcept;

    int code() const noexcept;

    bool operator==(const Phone& b) const noexcept;
    bool operator<(const Phone& b) const noexcept;
    bool operator<=(const Phone& b) const noexcept;
    bool operator>(const Phone& b) const noexcept;
    bool operator>=(const Phone& b) const noexcept;

private:

    uint64_t m_data = 0;
};

#define GETARG_PHONE(x) reinterpret_cast<Phone*>(PG_GETARG_POINTER(x))

PAllocNew вспомогательный класс перегружающий new:


template<typename T>
class PAllocNew
{
public:

    static void* operator new(std::size_t count, const std::nothrow_t& tag)
    {
        return palloc(count);
    }
};

Память, выделенная через palloc, освобождается при завершении транзакции. Добавить функции ввода/вывода:


Datum phone_in(PG_FUNCTION_ARGS)
{
    char* str = PG_GETARG_CSTRING(0);

    auto v = new(std::nothrow) Phone;
    if (!v->fromCString(str))
    {
        ereport(ERROR,
            (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
                errmsg(
                    "invalid input syntax for phone: \"%s\"",
                    str
                )));
    }

    PG_RETURN_POINTER(v);
}

Datum phone_out(PG_FUNCTION_ARGS)
{
    const auto phone = GETARG_PHONE(0);

    PG_RETURN_CSTRING(phone->toCString());
}

И регистрация типа:


CREATE TYPE phone;

CREATE OR REPLACE FUNCTION phone_in ( cstring )
RETURNS phone AS
'libhighloadcup'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE OR REPLACE FUNCTION phone_out ( phone )
RETURNS cstring AS
'libhighloadcup'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE TYPE phone (
   INTERNALLENGTH = 8,
   INPUT = phone_in,
   OUTPUT = phone_out,
   ALIGNMENT = int4,
   COLLATE TRUE
);

Т.к. новый тип имеет размер не более 8 байт, то можно передавать не по ссылке, а по значению. В этом случае в CREATE TYPE нужно указать флаг PASSEDBYVALUE. Или использовать LIKE:


CREATE TYPE phone (
   INPUT = phone_in,
   OUTPUT = phone_out,
   LIKE = int8,
   COLLATE TRUE
);

Тогда INTERNALLENGTH, ALIGNMENT и PASSEDBYVALUE унаследуются от int8.


btree индекс


Поддежка уникальности поля осуществляется созданием уникального индекса B-дерева. Делается это через классы операторов и стратегий.


Оператор Номер
меньше 1
меньше или равно 2
равно 3
больше или равно 4
больше 5

Функция Номер
равно 1

CREATE OPERATOR = (
   PROCEDURE = phone_equal,
   LEFTARG = phone,
   RIGHTARG = phone,
   COMMUTATOR = =,
   NEGATOR = !=
);

CREATE OPERATOR < (
  PROCEDURE = phone_lt,
  LEFTARG = phone,
  RIGHTARG = phone,
  NEGATOR = >
);

CREATE OPERATOR <= (
  PROCEDURE = phone_le,
  LEFTARG = phone,
  RIGHTARG = phone
);

CREATE OPERATOR >= (
  PROCEDURE = phone_ge,
  LEFTARG = phone,
  RIGHTARG = phone
);

CREATE OPERATOR > (
  PROCEDURE = phone_gt,
  LEFTARG = phone,
  RIGHTARG = phone,
  NEGATOR = <
);

CREATE OPERATOR CLASS btree_phone_ops
  DEFAULT FOR TYPE phone USING btree AS
   OPERATOR        1        <,
   OPERATOR        2        <=,
   OPERATOR        3        =,
   OPERATOR        4        >=,
   OPERATOR        5        >,
   FUNCTION        1       phone_equal_internal ( phone, phone );

Операторы возвращают bool, а phone_equal_internal int:


Datum phone_equal_internal(PG_FUNCTION_ARGS)
{
    const auto a = GETARG_PHONE(0);
    const auto b = GETARG_PHONE(1);
    if (*a < *b)
    {
        PG_RETURN_INT32(-1);
    }

    if (*a > *b)
    {
        PG_RETURN_INT32(1);
    }

    PG_RETURN_INT32(0);
}

Все это дало небольшое уменьшение данных:


relation size diff
accounts 595 MB 3 MB
accounts_phone_key 28 MB 4 MB

Количество аккаунтов с номерами 533 289, что составляет 41%. По крайней мере нет сравнения строк, при работе с индексом.


bit128


Захотелось иметь битовое поле с операциями пересечения(&&), вхождения(<@) и с поддержкой GIN. Достаточно было 96 бит, но пошел по простому пути и взял uint128.


class BitSet128: public PAllocNew<BitSet128>
{
public:

    bool haveIntersection(const BitSet128& b) const noexcept;
    bool contains(const BitSet128& b)  const noexcept;

    bool operator==(const BitSet128& b) const noexcept;
    bool operator<(const BitSet128& b) const noexcept;
    bool operator>(const BitSet128& b) const noexcept;

    bool fromCString(char* str) noexcept;
    char* toCString() const noexcept;

    std::vector<uint8_t> keys() const;

private:

    uint128 m_bits = 0;
};

#define GETARG_BIT128(x) reinterpret_cast<BitSet128*>(PG_GETARG_POINTER(x))

Регистрация типа и операции:


CREATE TYPE bit128 (
   INTERNALLENGTH = 16,
   INPUT = bit128_in,
   OUTPUT = bit128_out,
   ALIGNMENT = int4
);

CREATE OPERATOR = (
   PROCEDURE = bit128_equal,
   LEFTARG = bit128,
   RIGHTARG = bit128,
   COMMUTATOR = =,
   NEGATOR = !=
);

CREATE OPERATOR && (
   PROCEDURE = bit128_intersection,
   LEFTARG = bit128,
   RIGHTARG = bit128
);

CREATE OPERATOR @> (
   PROCEDURE = bit128_containts,
   LEFTARG = bit128,
   RIGHTARG = bit128
);

Generalized Inverted Index/GIN


Про GIN в Postgres хорошо написал Егор Рогов erogov в статье Индексы в PostgreSQL — 7, применяя к задаче полнотекстового поиска. Документация по расширяемости GIN говорит о том, что достаточно реализовать 4 функции:


Извлечение массива ключей из индексируемого объекта:


Datum *extractValue(Datum itemValue, int32 *nkeys, bool **nullFlags)

Извлекает ключи для запрашиваемого значения:


Datum *extractQuery(Datum query, int32 *nkeys, StrategyNumber n, bool **pmatch, Pointer **extra_data, bool **nullFlags, int32 *searchMode)

Например в запросе:


SELECT * FROM test WHERE a && ARRAY[1, 2, 3]

extractQuery применяется к массиву ARRAY[1, 2, 3], а ключами могут быть значения 1, 2, 3.
Проверяет удовлетворяет ли значение запросу:


bool consistent(bool check[], StrategyNumber n, Datum query, int32 nkeys, Pointer extra_data[], bool *recheck, Datum queryKeys[], bool nullFlags[])

Так как извлеченные ключи хранятся в дереве поиска, то нужна функция сравнения:


int compare(Datum a, Datum b)

API может показаться избыточным, но предоставляет все, что может пригодиться. Перейдем к реализации. Извлечение ключей:


Datum gin_extract_value_bit128(PG_FUNCTION_ARGS)
{
    auto bitset = GETARG_BIT128(0);
    const auto bits = bitset->keys();

    int32* nentries = (int32*) PG_GETARG_POINTER(1);
    *nentries = bits.size();
    Datum* entries = NULL;

    entries = (Datum*) palloc(sizeof(Datum) * bits.size());
    for (int i = 0; i < bits.size(); ++i)
    {
        entries[i] = DatumGetInt16(bits[i]);
    }

    PG_RETURN_POINTER(entries);
}

В выходной параметр nentries записываем количество ключей и возвращаем массив ключей entries. Сравнение ключей:


Datum bit128_cmp(PG_FUNCTION_ARGS)
{
    const auto a = PG_GETARG_INT16(0);
    const auto b = PG_GETARG_INT16(1);

    if (a < b)
    {
        PG_RETURN_INT32(-1);
    }

    if (a > b)
    {
        PG_RETURN_INT32(1);
    }

    PG_RETURN_INT32(0);
}

Этих двух функций достаточно для создания индекса. Остальные функции используются при поиске по индексу. Извлечение ключей из запроса:


Datum gin_extract_query_bit128(PG_FUNCTION_ARGS)
{
    const auto a = GETARG_BIT128(0);
    int32* nentries = (int32*) PG_GETARG_POINTER(1);
    StrategyNumber strategy = PG_GETARG_UINT16(2);
    int32* searchMode = (int32*) PG_GETARG_POINTER(6);

    Datum* res = nullptr;

    const auto keys = a->keys();

    *nentries = keys.size();
    res = (Datum*) palloc(sizeof(Datum) * keys.size());
    for (int i = 0; i < keys.size(); ++i)
    {
        res[i] = DatumGetInt16(keys[i]);
    }

    switch (strategy)
    {
        case RTOverlapStrategyNumber:  //  &&
            if (*nentries > 0)
            {
                *searchMode = GIN_SEARCH_MODE_DEFAULT;
            }
            else
            {
                *searchMode = GIN_SEARCH_MODE_ALL;
            }
            break;
        case RTContainsStrategyNumber: //  @>
            if (*nentries > 0)
            {
                *searchMode = GIN_SEARCH_MODE_DEFAULT;
            }
            else
            {
                *searchMode = GIN_SEARCH_MODE_ALL;
            }
            break;
    }

    PG_RETURN_POINTER(res);
}

Первым параметром передается значение справа от оператора, третий параметр отвечает за стратегию(оператор) по которой происходит поиск. Про режимы поска лучше ознакомится в документации. Функция соотвествия:


Datum gin_bit128_consistent(PG_FUNCTION_ARGS)
{
    bool* check = (bool*) PG_GETARG_POINTER(0);

    StrategyNumber strategy = PG_GETARG_UINT16(1);
    int32 nkeys = PG_GETARG_INT32(3);
    bool* recheck = (bool*) PG_GETARG_POINTER(5);
    bool res = true;
    switch (strategy)
    {
        case RTOverlapStrategyNumber:  //  &&
        {
            for (int i = 0; i < nkeys; ++i)
            {
                if (check[i])
                {
                    res = true;
                }
            };
            *recheck = false;
        }
            break;
        case RTContainsStrategyNumber: //  @>
        {
            for (int i = 0; i < nkeys; ++i)
            {
                if (check[i])
                {
                    res = true;
                }
            };
            *recheck = true;
        }
            break;
    }

    PG_RETURN_BOOL(res);
}

Возвращает true, если индексированный объект удовлетворяет оператору запроса с номером стратегии. Массив check имеет длину nkeys, что соотвествует числу ключей возвращенной gin_extract_query_bit128. Проверка check[i] = true означает, что i-ый ключ из gin_extract_query_bit128 присутствует в индексированном объекте. Этого достаточно, для пересечения, но т.к. в GIN мы не работаем с самими значениями, то для стратегии вхождения выставляем recheck в true. Это приведет к вызову соотвествующего оператора, для проверки результата. Класс оператора:


CREATE OR REPLACE FUNCTION gin_extract_value_bit128 ( bit128, internal, internal)
RETURNS internal AS
'libhighloadcup'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE OR REPLACE FUNCTION gin_extract_query_bit128(bit128, internal, int2, internal, internal)
RETURNS internal AS
'libhighloadcup'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE OR REPLACE FUNCTION gin_bit128_consistent(internal, int2, anyelement, int4, internal, internal)
RETURNS bool AS
'libhighloadcup'
LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;

CREATE OPERATOR CLASS bit128_ops
DEFAULT FOR TYPE bit128 USING gin
AS
    OPERATOR        3       &&,
    OPERATOR        6       =,
    OPERATOR        7       @>,
    FUNCTION        1       bit128_cmp (int2, int2 ),
    FUNCTION        2       gin_extract_value_bit128(bit128, internal, internal),
    FUNCTION        3       gin_extract_query_bit128(bit128, internal, int2, internal, internal),
    FUNCTION        4       gin_bit128_consistent(internal, int2, anyelement, int4, internal, internal),
STORAGE  int2;

Добавился STORAGE, он указывает какой тип данных истользуется для ключей. Какой результат стал по диску:


relation old size new size diff
accounts 595 MB 579 MB 16 MB
interests_ids_index 8072 kB 8072 kB

Интересный резльтат, потому что есть нюансы:


  • физическое хранение базы данных;
  • распределение данных.

Все созданные типы имеют фиксированный размер, поэтому для них выбирается храниение PLAIN. К ним не применяется сжатие и отдельное хранение. Массивы и строки как типы переменной длины проходят через TOAST. Да, там есть накладные расходы на хранение размера, но так же есть сжатие.


Распределение по интересам имеет следующий вид:


Количество интересов пользователей
1 174061
2 279744
3 317212
4 262313
5 128512
6 48099
7 12228
8 2232
9 250
null 75349

Честно не знаю как релизован SMALLINT[], но предположим что 4 байта на размер и 2 байта на значение. Тогда в 16 байт можно уместить массив из 6 элементов. И 98% элементов укладывались бы в 16 байт и разницы в 16 MB. Вроде бы тип bit128 избыточен, но и стандартный тип избыточен на этом наборе данных.

Комментарии (11)


  1. eefadeev
    04.03.2019 13:32

    Я так и не смог понять: для чего это всё? Ну, если не считать чистой и бескорыстной любви к C?


    1. RPG18 Автор
      04.03.2019 13:52

      Расширять типы Postgres под решаемую задачу. И Си я не люблю, код написан на C++.


      1. eefadeev
        04.03.2019 15:01

        Ок, я готов поверить что применение C++ кардинально отличает задачу от варианта с применением простого C (сделать это непросто, но я готов).
        Но я по прежнему не могу понять ответ на вопрос «Зачем?». Для решения какой прикладной задачи вы ввели эти типы? И почему вам было недостаточно тех типов, которые уже есть в PostgreSQL?


        1. RPG18 Автор
          04.03.2019 15:18
          +1

          Для решения какой прикладной задачи вы ввели эти типы?

          Там в самом начале указано в рамках Highload Cup 2018. А на PgConf.Russia рассказывали про MobilityDB.


          И почему вам было недостаточно тех типов, которые уже есть в PostgreSQL?

          Уменьшение размера данных. В статье указаны размеры до и после.


        1. puyol_dev2
          06.03.2019 15:27

          Очень похоже просто на рекламу — смотрите, мы так умеем. В реальных задачах практически никто не будет заморачиваться при выигрыше в частном случае в 2,6% (16 к 595Мб)


          1. RPG18 Автор
            06.03.2019 15:40

            Извиняюсь, что ответил с задержкой. Хочу процитировать Томаса Эдисона:


            Результаты? Ну, что ж, друг, у меня много результатов. Я знаю пятьдесят тысяч вещей, которые не будут работать.

            Не запротоколировав, не узнаешь. Да выигрыш не значительный, но статья задумывалась как продолжение Создание расширений в PostgreSQL, добавив информацию про индексы.


            В реальных задачах таки заморачиваются со своими типами: PostGIS, MobilityDB.


          1. eefadeev
            06.03.2019 15:56

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


            1. RPG18 Автор
              06.03.2019 16:03

              Я преследовал две цели:
              * изучить API Postgres;
              * получить футболку, если повезёт.

              К сожалению руки не дошли до реализации поддержки GiST, который дает поддержку поиск ближайших соседей.


              1. eefadeev
                06.03.2019 17:07

                Судя по всему вам удалось и то и другое. Не обижайтесь, я не имел в виду ничего плохого. Просто я неверно понял исходные условия. После их выяснения всё стало понятно и правильно.


  1. Nord001
    04.03.2019 13:43

    Спасибо за статью!

    Вы в статье упоминаете про выравнивание, а сами не совсем оптимально расположили поля, если я правильно понял:

    Убрал некоторые поля — но картину не должно изменить.
    Ваш вариант:

    SELECT pg_column_size(row(
      1::integer,                      --id
      'email@email.ru'::varchar(100),  --email
      1::smallint,                     --fname
      'sname'::varchar(100),           --sname
      now()::timestamp,                --birth
      1::smallint,                     --country
      1::smallint,                     --city
      1::smallint,                     --email_domain
      now()::timestamp,                --joined
      '[2010-01-01 14:30, 2010-01-01 15:30]'::tsrange, --premium
      '{1,2,3}'::integer[],                            --likes_ids
      '{1,2,3}'::integer[]                             --likes_tss
    ));
    168 байт
    


    Тоже самое но не фиксированные размеры в конце

    SELECT pg_column_size(row(
      1::integer,             --id
      1::smallint,            --fname
      1::smallint,            --country
      1::smallint,            --city
      1::smallint,            --email_domain
      '{1,2,3}'::integer[],   --likes_ids
      '{1,2,3}'::integer[],   --likes_tss
      now()::timestamp,       --birth
      now()::timestamp,       --joined
      '[2010-01-01 14:30, 2010-01-01 15:30]'::tsrange, --premium
      'email@email.ru'::varchar(100), --email
      'sname'::varchar(100)           --sname
    ));
    163 байта
    


    Разница в 5 байт на строку.


    1. RPG18 Автор
      04.03.2019 13:58

      Выравнивание нельзя было не упомянуть, т.к. оно может указываться при создании [типа](https://postgrespro.ru/docs/postgresql/11/sql-createtype), как понимаю оно может влиять на расположение данных в оперативной памяти.