Сказ о том, как нормализация данных завела производительность many-to-many в Postgres в тупик, как это зло было повержено, и как тут нам помог Clickhouse. Порой бывают ситуации, когда стоит посмотреть на задачу будто с нуля и отбросить предыдущий опыт и best practices. Подумать на несколько шагов вперёд. И лучше до того, когда уже вышли из SLA или нахватали негатива от клиентов или бизнеса. Об одной такой задаче и стандартном решении, которое пришлось больно редизайнить, хочется и поделиться с сообществом в этой статье. Сразу оговорюсь: я - приверженец писать меньше кода, когда это возможно. Лучший код - это его отсутствие. Лучшее решение - стандартное, максимально покрываемое мануалом. Особенно сегодня, когда всё уже придумано и сделано, осталось лишь правильно настроить и использовать.  Что может быть лучше для Symfony-приложения чем создание сущностей и схемы таблиц с помощью maker-бандла, просто указывая названия свойств и выбирая связи? Но иногда жизнь работа заставляет взглянуть под другим углом на привычные вещи. Итак, задача довольно простая: есть финансовые операции - транзакции, которые помечаются тегами ключ-значение, например: merchant: someMerhcant, provider: somePrivider, customer: someCustomercard: someCardToken,country: RUS Требуется уметь настраивать ограничительные лимиты в любых разрезах, например: сумма операций более 5млн.руб по someProvider в месяц, количество операций от одного плательщика у someMerchant не более 10 в день, сумма операций по someCardToken извне России не более 500тыс.р. в неделю по anotherMerchant, и т.п. Стек стандартный: PHP + Symfony/Doctrine. На нём схема стандартно сводится к такой связи много-ко-многим: И к такому описанию сущностей в Symfony: Схема немного осложняется тем, что теги - это не просто строковое значение, а пара ключ-значение. Поэтому, например, подсчёт лимита операций по провайдеру someProvider от одного уникального плательщика у someMerchant будет выглядеть так: Время выполнения: ~1.5 сек.  А если в агрегации будет участвовать не 3, а 4 тега, то джойнов будет соответственно 8, и так далее. А если проверка складывается из нескольких последовательных таких запросов, нетрудно посчитать общее время её выполнения. Конечно ни про какие SLA в таком случае и говорить не приходится. Почему-то мы сразу были убеждены, что упёрлись в производительность самого Postgres, и после быстрых изысканий было решено попробовать перейти на что-то кардинально более быстрое, особенно с учетом быстро растущего объём данных.  Критерии выбора были следующие: Поддержка SQL или его подобия, чтобы переписать только слой работы с базой, минимально затрагивая бизнес-логику. Выполнение аналитических запросов и агрегации больших объемов данных, т.к. именно это составляет львиную долю нагрузки. Наличие надёжного клиента для php. Недорогая цена. Развитое сообщество и условно недорогая поддержка. Nosql-хранилища отпали сразу на пункте 1, Oracle не подошёл по пунктам 4 и 5. В итоге выбор пал на Clickhouse. Он славится своим быстродействием на больших объёмах, к тому же бесплатный, да и вендор его вызывает доверие. Итак, решено.  Синтаксис создания таблицы в Clickhouse похож на MySQL: Особенность Кликхауса в том, что он силён в денормализованных данных. Фтопку джойны!Есть такой тип Nested, где описывается вложенная таблица целиком. То есть внутри каждой строки-операции существует своё множество тегов ключ : значение. И тогда в Clickhouse запрос с агрегацией суммы стал выглядеть так: Запускаем запрос... и... время выполнения: 0.4сек! Причем с любым количеством тегов, которые при many-to-many превратились бы в смертельные джойны. Замечательно. Но вместе с тем Кликхаус привнёс и некоторые неудобства. Во-первых, он хорош для чтения, но не очень эффективен при записи, и есть официальная рекомендация вставлять данные пачками от 1000 строк. Можно конечно ею пренебречь, но экспериментировать не хотелось. Можно и ждать, пока накопится такая пачка, но в некоторых случаях отсутствие вовремя вставленной строки может быть критично, повлечь race condition и т.д. Попробовали гибридный вариант - оставлять самую свежую оперативную часть (скажем, операции за последний час) в Postgre, а остальной объём считать в Clickhouse, и затем складывать. Эффект был, но кардинально картину это не изменило, ведь дорогие джойны Postgres даже на малом объёме всё равно остаются дорогими джойнами. А что если попробовать такую вложенность реализовать в самом Postgres? Он же обладает большими возможностями для работы с json. Сказано - сделано. Добавляем столбец tags_nested типа jsonb, а также GIN-индекс. В нашем случае поиск будет производиться по плоским массивам данных, но если предполагаются древовидные иерархии, лучше подойдёт GIST-индекс. Попробуем выполнить: Время выполнения с указанием любого количества тегов не превышает 0.3сек! А что, так можно было?  Здесь для поиска значений, содержащих все указанные теги, хорошо подходит оператор вложенности массивов @>.  Однако это, конечно, и иллюстрирует недостаток такой схемы и подхода в целом - мы привязали бизнес-логику к реализации конкретной СУБД. Например, в мускуле его нужно будет переписать на JSON_CONTAINS().  Но поняв, что базу мы будем менять скорее всего только вместе со всей инфраструктурой и кодовой базой в некоем абстрактном светлом будущем, решили, что это не стоп-фактор. Вот так на графике выглядел момент перехода: Заключение. Clickhouse решили приберечь до лучших времён, когда Postgres перестанет справляться с нагрузкой и объёмами.  Spoiler: сейчас, спустя 2 года, это время настало, что и побудило описать этот опыт в статье. Прямо сейчас актуализируем Clickhouse и адаптируем к текущим требованиям. И хорошо, что из кода его не выпиливали, а аккуратно отключили.  Планами запроса решил не перегружать статью, да и сильно упростил примеры - они c костами были бы не совсем релевантны. Всем peace! Пишите, у кого был подобный опыт, буду рад. Спасибо, что дочитали.