Сказ о том, как нормализация данных завела производительность many-to-many в Postgres в тупик, как это зло было повержено, и как тут нам помог Clickhouse.

Порой бывают ситуации, когда стоит посмотреть на задачу будто с нуля и отбросить предыдущий опыт и best practices. Подумать на несколько шагов вперёд. И лучше до того, когда уже вышли из SLA или нахватали негатива от клиентов или бизнеса. Об одной такой задаче и стандартном решении, которое пришлось больно редизайнить, хочется и поделиться с сообществом в этой статье.

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

Что может быть лучше для Symfony-приложения чем создание сущностей и схемы таблиц с помощью maker-бандла, просто указывая названия свойств и выбирая связи?

Но иногда жизнь работа заставляет взглянуть под другим углом на привычные вещи.

Итак, задача довольно простая: есть финансовые операции - транзакции, которые помечаются тегами ключ-значение, например:

merchant: someMerhcant,
provider: somePrivider,
customer: someCustomer
card: someCardToken,
country: RUS

Требуется уметь настраивать ограничительные лимиты в любых разрезах, например:

  • сумма операций более 5млн.руб по someProvider в месяц,

  • количество операций от одного плательщика у someMerchant не более 10 в день,

  • сумма операций по someCardToken извне России не более 500тыс.р. в неделю по anotherMerchant,

  • и т.п.

Стек стандартный: PHP + Symfony/Doctrine. На нём схема стандартно сводится к такой связи много-ко-многим:

И к такому описанию сущностей в Symfony:

/**
 * @ORM\Table(name="operation")
 * @ORM\Entity()
 */
class Operation
{
    // ......

    /**
     * @var ArrayCollection|Tag[]
     *
     * @ORM\ManyToMany(targetEntity="Tag")
     * @ORM\JoinTable(
     *     name="operation_tag",
     *     joinColumns={@ORM\JoinColumn(name="operation_id", referencedColumnName="operation_id")},
     *     inverseJoinColumns={@ORM\JoinColumn(name="tag_id", referencedColumnName="tag_id")}
     * )
     */
    private $tags;

    // ......
}


/**
 * @ORM\Table(name="tag")
 * @ORM\Entity()
 */
class Tag
{
    /**
     * @ORM\Column(name="tag_id", type="bigint", nullable=false)
     * @ORM\Id
     * ...
     */
    private int $tagId;

    /**
     * @ORM\Column(name="name", type="string", length=255, nullable=false)
     */
    private string $name;

    /**
     * @ORM\Column(name="value", type="string", length=255, nullable=false)
     */
    private string $value;

    // ...
}

Схема немного осложняется тем, что теги - это не просто строковое значение, а пара ключ-значение.

Поэтому, например, подсчёт лимита операций по провайдеру someProvider от одного уникального плательщика у someMerchant будет выглядеть так:

-- подсчёт лимита операций для someProvider от одного плательщика у someMerchant 

SELECT
    SUM(o.amount)
FROM
    operation o
INNER JOIN
    operation_tag ot1 ON ot1.operation_id = o.operation_id
INNER JOIN
    tags t1 ON ot1.tag_id = t1.tag_id
INNER JOIN
    operation_tag ot2 ON ot2.operation_id = o.operation_id
INNER JOIN
    tags t2 ON ot2.tag_id = t2.tag_id
INNER JOIN
    operation_tag ot3 ON ot3.operation_id = o.operation_id
INNER JOIN
    tags t3 ON ot3.tag_id = t3.tag_id
WHERE
    t1.name = 'provider'
    AND t1.value = 'someProvider'
    AND t2.name = 'merchant'
    AND t2.value = 'someMerchant'
    AND t3.name = 'customer_id'
    AND t3.value = 'someUniqueCustomerId';
    AND o.created_at BETWEEN '2023-08-01' AND '2023-08-31'

Время выполнения: ~1.5 сек.

А если в агрегации будет участвовать не 3, а 4 тега, то джойнов будет соответственно 8, и так далее.

А если проверка складывается из нескольких последовательных таких запросов, нетрудно посчитать общее время её выполнения.

Конечно ни про какие SLA в таком случае и говорить не приходится.

Почему-то мы сразу были убеждены, что упёрлись в производительность самого Postgres, и после быстрых изысканий было решено попробовать перейти на что-то кардинально более быстрое, особенно с учетом быстро растущего объём данных.

Критерии выбора были следующие:

  1. Поддержка SQL или его подобия, чтобы переписать только слой работы с базой, минимально затрагивая бизнес-логику.

  2. Выполнение аналитических запросов и агрегации больших объемов данных, т.к. именно это составляет львиную долю нагрузки.

  3. Наличие надёжного клиента для php.

  4. Недорогая цена.

  5. Развитое сообщество и условно недорогая поддержка.

Nosql-хранилища отпали сразу на пункте 1, Oracle не подошёл по пунктам 4 и 5. В итоге выбор пал на Clickhouse. Он славится своим быстродействием на больших объёмах, к тому же бесплатный, да и вендор его вызывает доверие.

Итак, решено.

Синтаксис создания таблицы в Clickhouse похож на MySQL:

CREATE TABLE IF NOT EXISTS ourdb.operation
(
    ...
    amount UInt64,
    tags Nested (
        name String,
        value String
    )
)

Особенность Кликхауса в том, что он силён в денормализованных данных. Фтопку джойны!
Есть такой тип Nested, где описывается вложенная таблица целиком. То есть внутри каждой строки-операции существует своё множество тегов ключ : значение.

И тогда в Clickhouse запрос с агрегацией суммы стал выглядеть так:

SELECT SUM(amount) AS result
FROM operation
WHERE created_at >= '2023-08-01 00:00:00' AND created_at <= '2023-08-31 23:59:59'
 AND tags.value[indexOf(tags.name, 'provider')] = 'someProvider'
 AND tags.value[indexOf(tags.name, 'merchant')] = 'someMerchant'
 AND tags.value[indexOf(tags.name, 'customer_id')] = 'someUniqueCustomerId'

Запускаем запрос... и... время выполнения: 0.4сек! Причем с любым количеством тегов, которые при many-to-many превратились бы в смертельные джойны.

Замечательно. Но вместе с тем Кликхаус привнёс и некоторые неудобства. Во-первых, он хорош для чтения, но не очень эффективен при записи, и есть официальная рекомендация вставлять данные пачками от 1000 строк. Можно конечно ею пренебречь, но экспериментировать не хотелось. Можно и ждать, пока накопится такая пачка, но в некоторых случаях отсутствие вовремя вставленной строки может быть критично, повлечь race condition и т.д. Попробовали гибридный вариант - оставлять самую свежую оперативную часть (скажем, операции за последний час) в Postgre, а остальной объём считать в Clickhouse, и затем складывать. Эффект был, но кардинально картину это не изменило, ведь дорогие джойны Postgres даже на малом объёме всё равно остаются дорогими джойнами.

А что если попробовать такую вложенность реализовать в самом Postgres? Он же обладает большими возможностями для работы с json. Сказано - сделано.

Добавляем столбец tags_nested типа jsonb, а также GIN-индекс. В нашем случае поиск будет производиться по плоским массивам данных, но если предполагаются древовидные иерархии, лучше подойдёт GIST-индекс.

ALTER TABLE operation
    ADD tags_nested JSONB DEFAULT '{}' NOT NULL;

CREATE INDEX ix_operation_tags_nested ON operation
    USING gin(tags_nested jsonb_path_ops);

Попробуем выполнить:

SELECT
    SUM(o.amount)
FROM
    operation o
WHERE
    o.tags_nested::jsonb @> '[{"provider":"someProvider"},{"merchant":"someMerchant"},{"customer_id":"someUniqueCustomerId"}]'
    AND o.created_at BETWEEN '2023-08-01' AND '2023-08-31'

Время выполнения с указанием любого количества тегов не превышает 0.3сек! А что, так можно было?

Здесь для поиска значений, содержащих все указанные теги, хорошо подходит оператор вложенности массивов @>.

Однако это, конечно, и иллюстрирует недостаток такой схемы и подхода в целом - мы привязали бизнес-логику к реализации конкретной СУБД. Например, в мускуле его нужно будет переписать на JSON_CONTAINS().

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

Вот так на графике выглядел момент перехода:

Заключение. Clickhouse решили приберечь до лучших времён, когда Postgres перестанет справляться с нагрузкой и объёмами.

Spoiler: сейчас, спустя 2 года, это время настало, что и побудило описать этот опыт в статье. Прямо сейчас актуализируем Clickhouse и адаптируем к текущим требованиям. И хорошо, что из кода его не выпиливали, а аккуратно отключили.

Планами запроса решил не перегружать статью, да и сильно упростил примеры - они c костами были бы не совсем релевантны.

Всем peace! Пишите, у кого был подобный опыт, буду рад.

Спасибо, что дочитали.

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


  1. ggo
    13.09.2023 07:24
    +3

    Вы приятно удивитесь, если в Postgres создадите структуры аналогичные CH.


    1. alexrozen Автор
      13.09.2023 07:24

      Возможно. Я описал конкретный кейс с приложением Symfony+Doctrine, где Many-to-many связь является стандартом из коробки


  1. igor_suhorukov
    13.09.2023 07:24
    +1

    Почему-то мы сразу были убеждены, что упёрлись в производительность самого Postgres

    Забавно, что не начали с денормализации данных в PostgreSQL и решили сразу переехать на другую базу. Можно было точно так же собрать Many to Many из исходной схемы в одну запись.


    1. alexrozen Автор
      13.09.2023 07:24

      Угу, тогда не было очевидно, что это может дать такой буст в постгресе


  1. SergeyProkhorenko
    13.09.2023 07:24
    +5

    Разработчик пытается костылями исправить ошибку аналитика. В методологии Data Vault операция - это не сущность (hub). Операция - это связь (link). Поэтому все ID тегов должны быть в одной таблице связей Операция, а ER-диаграмма кардинально перерисована. В фильтрах нужно использовать не сами теги, а их ID. То есть, джойны вообще не нужны. Вдобавок inner join работает медленнее, чем left join. И не надо обвинять ни в чем не повинные PostgreSQL и нормализацию данных.


  1. mentin
    13.09.2023 07:24
    +1

    Но зачем сразу JSON, когда в Postgres есть те же вложенные таблицы, что в CH, разве что в документации их зовут массивами.


  1. geher
    13.09.2023 07:24
    +1

    Nosql-хранилища отпали сразу на пункте 1,

    Я понимаю, что оно отвалилось бы позже, там же, где и Oracle, но есть nosql решения с поддержкой самого что ни на есть SQL. Например, Iris от Intersystems (раньше оно звалось Caché). Весьма приятная штука, только стоит дорого. Есть бесплатная версия для ознакомления, но она сильно ограничена в возможностях (сервер с хорошей нагрузкой не организуешь).


  1. IVNSTN
    13.09.2023 07:24

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

    EAV не тождественно равен нормализации, применен явно не самым удачным образом. Через восемь джойнов фильтр по восьми тегам делать вовсе не нужно (распростаренная задачка на базовые возможности SQL), в самом по-себе M2M никаких заложенных природой проблем нет. Аналоги предложенного решения, как уже упомянули, есть в самом Postgres. Планы выполнения и прочие факты при анализе производительности как раз имеют первоочередное значение, а сравнение 1.5 сек с 0.3 сек при очевидно неодинаковых условиях это как раз вообще не интересно.

    Почему-то мы сразу были убеждены, что упёрлись в производительность самого Postgres

    при many-to-many превратились бы в смертельные джойны

    Это довольно смелые утверждения.


  1. michael_v89
    13.09.2023 07:24

    Выглядит так, как будто теги здесь это неправильное решение, а нормализация ни при чем.


    Если известно, что все операции это финансовые транзакции, то надо сделать merchant/provider/customer/country полями operation.
    Если не все, то либо делаем поля null, либо отдельные таблицы для каждого типа операции со своим набором полей.


    У вас получился EAV со всеми его недостатками. Причем value обычно делают в той таблице, которая у вас называется operation_tag. Отсутствие избыточности в результате нормализации лишь кажется на первый взгляд, на самом деле она ушла в таблицу tag, где теперь находятся все возможные комбинации ('merchant', merchant_id), ('customer', customer_id), ..., то есть значение 'merchant' повторяется для многих строк таблицы. Логично его вынести в отдельную таблицу с id, тогда можно будет указывать этот id в operation_tag, и там же рядом писать value. Так было бы меньше джойнов.


    С JSON у вас получился видоизмененный вариант "делаем поля null". Раз в операции вряд ли могут быть указаны 2 разных customer_id, то нет смысла делать массив значений {key: value}, можно сделать один объект {key1: value1, key2: value2}. Или можно сделать не JSON-объект с полями, а нормальные поля.


  1. Portnov
    13.09.2023 07:24
    +1

    В таблице tag, name и value образуют очевидный естественный первичный ключ. Зачем там tag_id? Убираем, первичным ключом делаем (name, value). Тогда operation_tag должна ссылаться на тег по (name, value). Как-то странно, получается дублирование информации зачем-то. Выкидываем таблицу tag за ненадобностью.

    И вот, не отходя далеко от вашего EAV, выбираем нужные строки за 20 мс: https://gist.github.com/portnov/0f8ec7c651316b5d5d1deffd2f14a2bf .

    Но EAV обычно применяются во всяких DWH или OLAP-системах. А у вас, судя по упоминанию каких-то лимитов — OLTP. Для OLTP обычно делают "нормальную" нормализацию: из operation ссылки на provider, merchant, customer и всё что надо. И вот уже селектим за 4 мс: https://gist.github.com/portnov/dbc3fc94e69fc72f0bf70d3976e92052.