Как я бэкенд для интернет-магазина пилил…
Привет, читатель! Это моя самая первая статья на тему программирования, на написание которой меня побудил интерес к микросервисной архитектуре.
Первые строки кода..
Для начала я решил написать всё в монолитной архитектуре так как в силу своего опыта не имел дело с микросервисами и выбрал следующий стек технологий:
Python
FastAPI
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 эндпоинта.
"Получить все товары." На вход принимает структуру фильтрации товаров с числовыми и текстовыми характеристиками, строкой поиска и прочими фильтрующими параметрами. На выходе выдаёт список товаров.
"Применить фильтры". На вход получает всё ту же структуру фильтрации товаров, что и первый эндпоинт, а на выходе даёт не товары, а ту же структуру фильтрации, что могут принять на вход эти два эндпоинта, но с неким логическим значением "Вот эти характеристики и значения ты можешь использовать для ДАЛЬНЕЙШЕЙ фильтрации"
Логика реализации фасетного поиска со стороны фронтенда будет выглядеть примерно так:
Юзер перешёл в список товаров категории "Электроинструменты"
Отправляются параллельно 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 так как есть удобная штука с автогенерацией документации. Далее тот монолит, что описывал выше я решил разбить на микросервис "Каталог" и микросервис "Склады", в каталоге остаются категории, товары, характеристики, а в "Склады" переезжает промежуточная таблицы "товар-склад" и "склад" (Сервиса склада пока нет, но он будет) )
Сервис "каталог" использует следующий стек технологий:
Golang
PostgreSQL
GRPC
Redis
testify (Библиотека тестирования)
Этот сервис выделен для любых взаимодейстивий с товарами, категориями, характеристиками. Фасетный поиск товаров в нём реализован теми же двумя эндпоинтами, но поиск выполняется при помощи ElasticSearch. Далее расскажу более подробно об этом..
Сервис "индексатор" использует следующий стек технологий:
Golang
testify
Этот сервис подключается напрямую к БД каждого из микросервисов для того чтобы агрегировать информацию и при помощи брокера сообщений доставлять их в ElasticSearch
Где мой товар??
Теперь товары ищутся не в PostgreSQL, а в ElasticSearch. И начать стоит с загрузки товаров в него. Это происходит так: Товар добавляется синхронно через GRPC в БД сервиса "Каталог", далее сервис "Индексатор" раз в час получает товары из БД и при необходимости дополняет информацию о них из других сервисов, скажем добавляет в структуру товара дополнительное поле с информацией о наличии его на складах, далее дополненные информацией товары передаются в брокер сообщений Kafka. На этом работа сервиса "Индексатор" закончена.
Следом идёт работа LogStash, который по сути выгребает данные из топика Kafka и пушит их в ElasticSearch.
Таким образом у нас есть индекс (aka таблица в PostgreSQL) products в ElasticSearch, который содержит в себе следующего вида товары:
Таким образом запрос Юзера на поиск товаров с использованием того механизма, что я описывал выше будет выглядеть примерно так: Запрос прилетел на микросервис каталога, далее конструируется и отправляется запрос на поиск товаров в ElasticSearch, а те ID товаров, что он вернул используются для поиска в PostgreSQL. Запрос же на получение доступных фильтров так же отправляется в ElasticSearch напрямую возвращается обратно без использования PostgreSQL.
Сравнение скорости..
Есть два варианта:
Монолит с поиском чисто в БД по триграммам с индексацией
Микросервисы с поиском товаров сначала в 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. Он более рационально относится к скорости поиска и аггрегации.
Что дальше...
В этой статье я слишком поверхностно разобрал нюансы разработки приложения, но оставляю надежду, что ты, читатель нашёл для себя что-то нужное, что сможешь использовать и в своих проектах :-)
Так как проект только на стадии активной разработки, думаю напишу вторую и очередные части с подробным разбором технологий и архитектуры :-)