Привет, читатель! Это моя самая первая статья на тему программирования, на написание которой меня побудил интерес к микросервисной архитектуре.

Первые строки кода..

Для начала я решил написать всё в монолитной архитектуре так как в силу своего опыта не имел дело с микросервисами и выбрал следующий стек технологий:

  1. Python

  2. FastAPI

  3. PostgreSQL

Примерная схема БД
Примерная схема БД

На моё удивление я быстро написал методы API к такой структуре БД, даже успел накинуть тесты) Эта схема выглядит вполне расширяемой в случае если потребовалось бы добавить какую-либо другую таблицу. Но есть как минимум 1 существенный недостаток, такой как категории и разделы. В такой реализации уровень вложенности у товаров достигает 2-х уровней, ни меньше, ни больше. К тому же если приглядеться, то категория и раздел по сути описывают примерно одинаковый набор полей.

Переработка категорий
Переработка категорий

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

Что там по фильтрации и поиску товаров?

К слову о поиске: Обнаружил на сайтах по схожей тематике поиск по характеристикам товаров, такой поиск называется "Фасетный", когда например мы указываем, что хотим видеть список товаров, у которых "Цвет красный или зелёный, а так же цена от 100 до 450 рублей"

Фильтр по характеристикам в одном из интернет-магазинов
Фильтр по характеристикам в одном из интернет-магазинов

Пример части фасетного поиска по характеристикам товаров. Обратить можно внимание на следующие немаловажные вещи: Логика при указании двух и более характеристик одновременно "И", а при указании значений в одной из характеристик "ИЛИ". Есть характеристики с числовым диапазоном значений и характеристики с выбором нескольких из доступных значений. Так же если поиграться с этими фильтрами - можно заметить такую интересную вещь, как если нажимать на характеристики, то они имеют свойство становиться серыми, говоря о том, что при текущем наборе характеристик и их значений, указание серых будет бесполезным в виду того, что в выбранных товарах путём фильтрации их попросту нет.

БД с характеристиками
БД с характеристиками

Теперь в БД есть рекуррентные категории и характеристики, хоть сейчас в продакшн отправляй) поле "values_type" у характеристики имеет enum поле с одним из значений ("numeric", "text")

SELECT
     min(rp.price) min_product_price,
     max(rp.price) max_product_price,
     pc.characteristic_id,
     string_agg(DISTINCT pc.text_value::TEXT, ','),
     min(pc.numeric_value),
     max(pc.numeric_value)
FROM (
     SELECT *
     FROM product AS p
     WHERE (

         p.category_id = 'd3659c13-3a5e-4f42-b068-3a91936ad3fb'
         AND
         p.price BETWEEN 0 AND 120
         AND
         p.id = ANY (
             SELECT product_id
             FROM product_characteristic AS pc
             WHERE pc.characteristic_id = 'a47667b0-39f3-40e8-bbc0-4374ac78f6d6' AND pc.text_value IN ('Красный', 'Жёлтый')
         )
         AND
         p.id = ANY (
             SELECT product_id
             FROM product_characteristic AS pc
             WHERE pc.characteristic_id = 'd3cf68ca-0aa6-48fe-ab7e-3e59748cf87c' AND pc.numeric_value BETWEEN 25 AND 1000
         )
     )
) AS rp
 LEFT JOIN product_characteristic AS pc ON rp.id = pc.product_id
 GROUP BY characteristic_id

SQL - запрос выше - получение агрегированной информации для каждой id характеристики в т.ч. и NULL о том какие минимальные и максимальные цены товаров есть какие уникальные текстовые значения есть у товаров этой характеристики, а так же минимальное и максимальное числовое значение для этой характеристики. Если во внешнем SELECT агрегируется информация по характеристикам товаров, то во внутреннем SELECT фильтруются сами товары. PS: в примере в качестве id используются UUID вместо числовых.

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

  1. "Получить все товары." На вход принимает структуру фильтрации товаров с числовыми и текстовыми характеристиками, строкой поиска и прочими фильтрующими параметрами. На выходе выдаёт список товаров.

  2. "Применить фильтры". На вход получает всё ту же структуру фильтрации товаров, что и первый эндпоинт, а на выходе даёт не товары, а ту же структуру фильтрации, что могут принять на вход эти два эндпоинта, но с неким логическим значением "Вот эти характеристики и значения ты можешь использовать для ДАЛЬНЕЙШЕЙ фильтрации"

Логика реализации фасетного поиска со стороны фронтенда будет выглядеть примерно так:

Юзер перешёл в список товаров категории "Электроинструменты"

Отправляются параллельно 2 запроса на "Получить все товары." и "Применить фильтры"

// Request "Получить все товары."
{
  "limit": 10,
  "offset": 0,
  "category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv"
}

// Response "Получить все товары."
[
  {
    "id": "...",
    "name": "...",
    ...
  },
  ...
]

// Request "Применить фильтры"
{
  "limit": 10,  // Здесь лимит и оффсет игнорируются
  "offset": 0,
  "category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv"
}

// Response "Применить фильтры"
{
  "price": {
    "from": 12.34,
    "to": 43354.25
  },
  "characteristics": {
    "numeric_values": [
      {
        "id": "...",  // id характеристики "Вес товара, кг"
        "values": {
          "from": 1,
          "to": 45
        }
      },
      ...
    ],
    "text_values": [
      {
        "id": "...",  // id характеристики "Цвет"
        "values": ["Красный", "Синий"]
      },
      ...
    ]
  }
}

Ок, на этом этапе юзер получил и список товаров и список доступных для фильтрации характеристик. Теперь если он захочет уточнить свой поиск и указать, что хочет видеть товары с ценой от 0 до 500 рублей и у которых цвет красный и у которых вес до 30 кг, то отправит очередные 2 параллельных запроса на эти 2 эндпоинта со сделующим содержанием:

// Request "Применить фильтры" и "Получить все товары."
{
  "limit": 10,  // Здесь лимит и оффсет игнорируются
  "offset": 0,
  "category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv",
  "price": {
    "from": 0,
    "to": 500
  },
  "characteristics": {
    "numeric_values": [
      {
        "id": "...",  // id характеристики "Вес товара, кг"
        "values": {
          "to": 30
        }
      }
    ],
    "text_values": [
      {
        "id": "...",  // id характеристики "Цвет"
        "values": ["Красный"]
      }
    ]
  }
}

// Response для краткости описывать не буду, "Получить все товары." выплюнет товары,
// а "Применить фильтры" выплюнет фильтры, но в меньшем кольчестве, чем при первом запросе

Что там по скорости?

По скорости у меня есть несколько замечаний к реализации ранее.

Первое - это монолит на Python, что само по себе вызовет замыкание процессора на том сервере, где будет находиться этот код. Именно поэтому я решил переписать всю эту кухню на Golang в микросервисной архитектуре. Golang более рационально использует процессорное время и заточен под лёгкую асинхронно-параллельную работу, вдобавок, что меня лично сильно радует, так это строгая типизация в нём, это позволяет не переживать о том, что попадёт в аргументы функции или переменной.

Второе - это конечно скорость поиска. В тестовую БД я залил порядка 45_000 товаров и по хардкору решил применить фасетный и полнотекстовый поиск к ним. Результаты ожидаемо были фатальными. Один лишь поиск по триграммам с индексацией заставлял ждать доооолгое время. Не говоря уже о эндпоинте применения фильтров. PS если упустить такой нюанс как скорость полнотекстового поиска в БД, то тут есть проблема с его неполноценностью...

Ура! Ура! Микросервисы

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

Пожалуй сразу предоставлю схему той архитектуры, что получилась

Текущая архитектура
Текущая архитектура

Итак... Для микросервсисов характерен такой паттерн, как API-Gateway, его я решил написать на Python + FastAPI так как есть удобная штука с автогенерацией документации. Далее тот монолит, что описывал выше я решил разбить на микросервис "Каталог" и микросервис "Склады", в каталоге остаются категории, товары, характеристики, а в "Склады" переезжает промежуточная таблицы "товар-склад" и "склад" (Сервиса склада пока нет, но он будет) )

Сервис "каталог" использует следующий стек технологий:

  1. Golang

  2. PostgreSQL

  3. GRPC

  4. Redis

  5. testify (Библиотека тестирования)

Этот сервис выделен для любых взаимодейстивий с товарами, категориями, характеристиками. Фасетный поиск товаров в нём реализован теми же двумя эндпоинтами, но поиск выполняется при помощи ElasticSearch. Далее расскажу более подробно об этом..

Сервис "индексатор" использует следующий стек технологий:

  1. Golang

  2. testify

Этот сервис подключается напрямую к БД каждого из микросервисов для того чтобы агрегировать информацию и при помощи брокера сообщений доставлять их в ElasticSearch

Где мой товар??

Теперь товары ищутся не в PostgreSQL, а в ElasticSearch. И начать стоит с загрузки товаров в него. Это происходит так: Товар добавляется синхронно через GRPC в БД сервиса "Каталог", далее сервис "Индексатор" раз в час получает товары из БД и при необходимости дополняет информацию о них из других сервисов, скажем добавляет в структуру товара дополнительное поле с информацией о наличии его на складах, далее дополненные информацией товары передаются в брокер сообщений Kafka. На этом работа сервиса "Индексатор" закончена.

Следом идёт работа LogStash, который по сути выгребает данные из топика Kafka и пушит их в ElasticSearch.

Таким образом у нас есть индекс (aka таблица в PostgreSQL) products в ElasticSearch, который содержит в себе следующего вида товары:

Пример товара в ElasticSearch
Пример товара в ElasticSearch

Таким образом запрос Юзера на поиск товаров с использованием того механизма, что я описывал выше будет выглядеть примерно так: Запрос прилетел на микросервис каталога, далее конструируется и отправляется запрос на поиск товаров в ElasticSearch, а те ID товаров, что он вернул используются для поиска в PostgreSQL. Запрос же на получение доступных фильтров так же отправляется в ElasticSearch напрямую возвращается обратно без использования PostgreSQL.

Сравнение скорости..

Есть два варианта:

  1. Монолит с поиском чисто в БД по триграммам с индексацией

  2. Микросервисы с поиском товаров сначала в Elasticsearch, а следом по выданным IDs в PostgreSQL

Время замеряю окончательное (на стороне фронта). Машина на которой всё находится имеет параметры хуже чем на моём ноутбуке, а именно: 4гб оперативы и 8 ядер. Кэширование не делал, измеряю по хардкору. Товаров 45_000, у каждого имеется по 7 характеристик в среднем. Названия у товаров, по которым идёт поиск длиной в среднем 200 символов. Результаты для кейсов приведены средние за 5 запросов подряд. Лимит по товарам - 20. Запрос и сервак в рамках одной локальной сети.

ПФ - применить фильтры; ПТ - получить товары.

Кейс #1: Только лимит и оффсет. Применение фильтров по всему набору данных, что по сути довольно тяжко агрегировать.

1) ПТ - 0.03s; ПФ - 1.550s

2) ПТ - 0.35s; ПФ - 0.360s

Кейс #2: Здесь нагружается поиск по полной, используется относительно длинная строка поиска

1) ПТ - 0.150s, ПФ - 0.620s

2) ПТ - 0.08ms, ПФ - 0.252s

Ограничусь на этом. Здесь можно отдать явное предпочтение Elasticsearch. Он более рационально относится к скорости поиска и аггрегации.

Что дальше...

В этой статье я слишком поверхностно разобрал нюансы разработки приложения, но оставляю надежду, что ты, читатель нашёл для себя что-то нужное, что сможешь использовать и в своих проектах :-)

Так как проект только на стадии активной разработки, думаю напишу вторую и очередные части с подробным разбором технологий и архитектуры :-)

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


  1. Pacika
    20.05.2024 08:58
    +3

    Отличная статя! Хотелось бы узнать разницу в скорости запроса, между монoлитом и микросервисом!


  1. hardtop
    20.05.2024 08:58
    +5

    В постгес тоже можно положить данные в jsonb, настроить индексы и сделать фасетный поиск там. Если данные влезают в toast ~2kb, то поиск довольно резвый.


    1. Tem_Ku Автор
      20.05.2024 08:58

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


  1. kimisa
    20.05.2024 08:58
    +9

    Так и не увидела аргументации за микросервисы. Кеш и поиск в эластике так же можно сделать и в монолите. А переписывание под девизом - это сейчас модно, ну такое себе.


    1. vlom88
      20.05.2024 08:58
      +2

      А какая может быть аргументация?
      Компания в которой есть свой it отдел, по какой-то причине нанимает человека после курсов и даёт ему сразу переписывать всё и вся без тз, которое он должен сформулировать на своё усмотрение, остальная же команда занимается правками багов, звиздежом попахивает.

      У меня к автору несколько вопросов

      • кто в компании отвечает за конечный результат

      • есть ли хоть один сотрудник который имеет экспертизу и понимает каков должен быть конечный результат, понимает качество написаного когда и его масштабируемость после написания MVP?

      • кто общается с бизнесом выявляя потребности, боли и хотелки текущего состояния проекта? При этом проводя попутную аналитику?

      • насколько крупен проект, сколько транзакций (продаж) в месяц через ИМ, насколько крупен каталог? нужен ли вообще компании интернет магазин или он как во времена доткомов лишь бы был?


      1. k0rinf
        20.05.2024 08:58

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


        1. Tem_Ku Автор
          20.05.2024 08:58

          У статьи нет цели донести плюсы и минусы. Есть цель рассказать о этапах развития. PS добавил раздел со сравнением скорости поиска


      1. Tem_Ku Автор
        20.05.2024 08:58

        Компания имеет уже готовую реализацию проекта. Но поскольку стек технологий хоть работает и медленно, но их устраивает. Меня наняли для того чтобы я задал вектор разработки. + в старой архитектуре большая беда с обновлением товаров и хранением их в БД. PS из статьи убрал часть о компании так как даёт повод для вопросов и не относится к теме.


  1. MyraJKee
    20.05.2024 08:58
    +1

    Вопросов больше чем ответов... Я например даже представить себе не могу чтобы мне в компании дали возможность написать монолит, а потом взять и переписать его на микросервисы с другим стеком технологий. Звучит как треш. :))


    1. VanKrock
      20.05.2024 08:58

      Вопрос во времени написания, если вы за день написали 1 вариант, а на второй день 2 вариант, то вам никто слова не скажет, но вот если первый вариант занял пол года или год, то это другое дело


      1. MyraJKee
        20.05.2024 08:58
        +1

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


    1. GospodinKolhoznik
      20.05.2024 08:58

      Что позволено небольшому стартапу, не позволено энтерпрайз гиганту.