Сила в количестве — ответил бы микросервис.
Вооружимся Visual Studio, .NET, Docker и прочими средствами и посмотрим так ли это.
Но с чего же начать? Для начала надо бы выбрать какую-то прикладную задачу, над реализацией которой и проводить эксперименты. Хотелось бы задачу не совсем оторванную от реальности и в то же время чтобы можно было реализовать за разумное время.
Я, не особо утруждая себя размышлениями, решил поэкспериментировать с задачей поиска товаров как на онлайн магазинах электроники. Такие, знаете, где в каждом разделе у товаров разные характеристики. К примеру, в разделе “Ноутбуки” там поиск по: ЦПУ, ОЗУ, ПЗУ и т.п. А в соседнем разделе, например, “Стиральные машины” там поиск по количеству оборотов и кг загрузки.

Подумалось, что с точки зрения реализации, там будет много интересных вариантов и по выбору способа хранения и по распределению нагрузки — как раз то что надо для экспериментов.
Мне привычнее начать с хранилища. Датабэйз-фёст-подход. Я давно посматривал на сайты электроники и размышлял — как бы я реализовал поиск по товарам если бы пришлось. Хранил бы я всё в объектной базе? Или в реляционной? А если в реляционной то как? Стал бы генерировать таблицы под каждую категорию или запихнул все атрибуты в одну таблицу?
Объектное хранилище — выглядит как-то просто и без челленджа, генерировать таблицы — нудно, выберу-ка я третий вариант. Решено!
Создал таблиц по минимуму. У меня же эксперимент и всё за разумное время. Помните же?
Значения атрибутов буду хранить в таблице вида:
С помощью скриптов икакой-то матери Excel напарсил товаров для пары категорий. Обилие ручных манипуляций мне быстро надоело и оставшиеся категории я заполнил рандомными данными по 7 тысяч товаров с 15 атрибутами каждый. Для глобального ретейлера еще маловато, для регионального магазинчика уже много. Для экспериментов, думаю, подойдёт. Суммарно получилось
Далее, создаю проект в студии. Стандартный вэб-апи. Подключаю Entity Framework. Генерирую классы по структуре базы используя EF Power Tools. Scaffold-ом создаю базовый CRUD, наверно он мне не понадобится. Но так проще стартовать, когда уже в проекте есть какая-то структура. Дальше придется дорабатывать напильником вручную.
Подготовим минимум необходимый для работы клиентской части.
Это просто:
Для каждой категории товаров нужен список атрибутов для фильтров и набор допустимых значений чтобы оформить в виде галочек. Это чуть посложнее, но по-прежнему можно обойтись одним запросом к базе:
Пришлось сканить таблицу чтобы DISTINCT-ом выбрать все допустимые значения для фильтра. Ох, думаю тяжелый будет запрос… Проанализировал выполнение — подкрутил индексы чтобы хоть как-то облегчить жизнь sql-серверу.
Раз сервер уже что-то возвращает — попробую изобразить подобие UI чтобы работа стала нагляднее. Уже вижу как фронтендеры кривят нос, мол “ну кто так пишет фронт” и “реакт ацтой”. Надеюсь они меня простят 🙂
И когда пользователь понажимает галочек разных — надо отфильтровать товары в соответствии с выбранными значениями атрибутов. Так как количество параметров и их значения заранее неизвестны — запрос придется строить динамически.
Я предполагал что это будет задача со звездочкой, но думал всё обойдётся добавлением в цикле по параметрам множественных условий WHERE к запросу. Но не тут то было. Покрутив варианты и так и сяк пришел к выводу что без Expressions не обойтись.
Для тех кто совсем не знаком с Expression class — вкратце, с его помощью можно динамически собирать .NET программу из набора операторов и потом прямо в рантайме выполнять. И основная фишка этих экспрешенов, что Linq и EF могут их принимать на вход. И если там выражения над объектами БД, то они будут сконвертированы в выражения sql и выполнены на сервере БД.
Для моего случая как на картинке выше
CPU Family=(‘Core i3’, ‘Xeon’)
Cores Total=(4, 6)
клиентское приложение высылает JSON вида
мне к запросу по таблице Product надо добавить два джойна чтобы получилось что-то такое:
Буду использовать следующие объекты:
Из них собираю результат. Вышло слегка громоздко ¯\_(ツ)_/¯.
Попробовал всё это вызывать с клиента. Вау! Оно даже работает!
(не с первого раза конечно же 🙂)
Посмотрим, можно ли такое выпускать в прод. Для проверки попробуем нагрузить приложение хорошенько.
Средства для этого существуют разные. Я взял Apache JMeter. Он хорош и главное доступен бесплатно, без СМС.
Создаю тестовый план в котором три запроса:
Эти запросы будут выполняться с разными параметрами. По кругу много раз. Настроил чтобы параметры подавались из CSV файла. Файлы нагенерил тысяч по 10 строк каждый.
Для начала настроил выполнение с нарастающим числом одновременных сессий. Т.е. запросы сначала выполняются в одном потоке (сессии), затем добавляется еще сессия и еще и еще (каждые 5 секунд).
Запускать буду на локальном докере. Поставил ограничение по одному процессору для sql и для web. Посмотрим что можно выжать в такой конфигурации.
Образы собрались, запустились, получилось так:
Результат выполнения теста на графике.


Да уж… 2 (джве!) секунды на ответ!!!111адын. В прод такое нельзя. Даже у нас “для экспериментов” — всё равно такое нельзя. И если запросы на поиск еще как-то работают (зеленый и красный), то подготовка фильтров (черный) — совсем никуда не годится. И манипуляции с индексами хоть и уменьшили сортировки, но глобально проблему не решили.
Надо с этим что-то делать.
Попробую добавить процессор на sql. Это тупиковый путь. Знаю. Процессоры не получится добавлять бесконечно. Просто для эксперимента добавлю.

Вот и подтверждение что это тупиковый путь. Да, показатели улучшились, но время ответа всё равно растет, и хоть и медленнее, но всё равно быстро.
Шутки в сторону. Закатываем рукава и берёмся за дело всерьёз!
(продолжение следует)
Вооружимся Visual Studio, .NET, Docker и прочими средствами и посмотрим так ли это.
Но с чего же начать? Для начала надо бы выбрать какую-то прикладную задачу, над реализацией которой и проводить эксперименты. Хотелось бы задачу не совсем оторванную от реальности и в то же время чтобы можно было реализовать за разумное время.
Я, не особо утруждая себя размышлениями, решил поэкспериментировать с задачей поиска товаров как на онлайн магазинах электроники. Такие, знаете, где в каждом разделе у товаров разные характеристики. К примеру, в разделе “Ноутбуки” там поиск по: ЦПУ, ОЗУ, ПЗУ и т.п. А в соседнем разделе, например, “Стиральные машины” там поиск по количеству оборотов и кг загрузки.

Подумалось, что с точки зрения реализации, там будет много интересных вариантов и по выбору способа хранения и по распределению нагрузки — как раз то что надо для экспериментов.
Итак, реализация
Мне привычнее начать с хранилища. Датабэйз-фёст-подход. Я давно посматривал на сайты электроники и размышлял — как бы я реализовал поиск по товарам если бы пришлось. Хранил бы я всё в объектной базе? Или в реляционной? А если в реляционной то как? Стал бы генерировать таблицы под каждую категорию или запихнул все атрибуты в одну таблицу?
Объектное хранилище — выглядит как-то просто и без челленджа, генерировать таблицы — нудно, выберу-ка я третий вариант. Решено!
Создал таблиц по минимуму. У меня же эксперимент и всё за разумное время. Помните же?
Значения атрибутов буду хранить в таблице вида:
- ИД товара,
- ИД атрибута,
- значение (три поля на всякий случай: строка, целочисленный, и нумерик)
Схема БД


С помощью скриптов и
- 12 категорий,
- 177 названий атрибутов,
- 70600 наименований товаров
- и ~1 млн значений атрибутов.
Пример данных
SELECT * FROM dbo.Catalog where CatalogId=1 CatalogId CatalogName ----------- ----------- 1 Notebooks SELECT * FROM dbo.Attribute a where a.AttributeId in (select ca.AttributeId from dbo.CatalogAttribute ca where ca.CatalogId=1) AttributeId AttributeName AttributeTypeId ----------- ------------------- --------------- 1 CPU 1 2 RAM, Gb 2 3 Screen Size 3 4 Storage Size, Gb 2 5 Cores 1 6 Screen Technology 1 7 Screen Pixels 1 8 Storage Type 1 9 Graphics 1 10 OS 1 SELECT top 10 * FROM dbo.Product p where p.CatalogId = 1 ProductId ProductName CatalogId BrandId price quantity ----------- -------------------------------------------------------- ----------- -------- --------- ----------- 1 Ноутбук MSI Titan 18 HX A14VIG-096RU черный 1 13 543999,00 5.000000 2 Ноутбук Razer Blade 18 черный 1 14 482999,00 5.000000 3 Ноутбук Apple MacBook Pro серебристый 1 4 454999,00 5.000000 4 Ноутбук ASUS ROG Zephyrus Duo 16 GX650PY-NM040W черный 1 6 447999,00 5.000000 5 Ноутбук Apple MacBook Pro серебристый 1 4 435999,00 5.000000 6 Ноутбук Apple MacBook Pro черный 1 4 435999,00 5.000000 7 Ноутбук MSI Raider GE78 HX 14VIG-801RU черный 1 13 433999,00 5.000000 8 Ноутбук AORUS 17X AZF черный 1 3 419999,00 5.000000 9 Ноутбук Apple MacBook Pro серебристый 1 4 402999,00 5.000000 10 Ноутбук Apple MacBook Pro черный 1 4 402999,00 5.000000 SELECT * FROM dbo.AttributeValue av where av.ProductId=1 order by av.AttributeId AttributeValueId AttributeId ProductId ValueString ValueInt ValueNumeric ---------------- ----------- ----------- ------------------------------------- --------- ------------- 919 1 1 Intel Core i9-14900HX NULL NULL 1531 2 1 NULL 32 NULL 1 3 1 NULL NULL 18.000000 2143 4 1 NULL 3000 NULL 1225 5 1 8 + 16 x 2.2 GHz + 1.6 GHz NULL NULL 613 6 1 mini-LED NULL NULL 307 7 1 3840x2400 (4K) NULL NULL 1837 8 1 SSD NULL NULL 2449 9 1 GeForce RTX 4090 для ноутбуков 16 Гб NULL NULL 2755 10 1 Windows 11 Home NULL NULL
Далее, создаю проект в студии. Стандартный вэб-апи. Подключаю Entity Framework. Генерирую классы по структуре базы используя EF Power Tools. Scaffold-ом создаю базовый CRUD, наверно он мне не понадобится. Но так проще стартовать, когда уже в проекте есть какая-то структура. Дальше придется дорабатывать напильником вручную.
WEB API
Подготовим минимум необходимый для работы клиентской части.
Список категорий
Это просто:
// GET: api/Catalogs [HttpGet] public async Task<ActionResult<IEnumerable<Catalog>>> GetCatalogs() { return await _context.Catalogs.ToListAsync(); }
Список фильтров
Для каждой категории товаров нужен список атрибутов для фильтров и набор допустимых значений чтобы оформить в виде галочек. Это чуть посложнее, но по-прежнему можно обойтись одним запросом к базе:
// GET: api/Attributes/5 [HttpGet("{catalogId}")] public async Task<ActionResult<IEnumerable>> GetAttribute(int catalogId) { return await _context.Catalogs.Where(c => c.CatalogId == catalogId).SelectMany(c=>c.Attributes).Select(a => new { Id = a.AttributeId, Name = a.AttributeName, TypeId = a.AttributeTypeId, Values = a.AttributeValues.Select(v => new { ValueInt = v.ValueInt, ValueNumeric = v.ValueNumeric, ValueString = v.ValueString }).Distinct().Select(v => a.AttributeTypeId == 1 ? v.ValueString : a.AttributeTypeId == 2 ? v.ValueInt.ToString() : v.ValueNumeric.ToString() ).ToArray() }).ToListAsync(); }
Пришлось сканить таблицу чтобы DISTINCT-ом выбрать все допустимые значения для фильтра. Ох, думаю тяжелый будет запрос… Проанализировал выполнение — подкрутил индексы чтобы хоть как-то облегчить жизнь sql-серверу.
UI на react
Раз сервер уже что-то возвращает — попробую изобразить подобие UI чтобы работа стала нагляднее. Уже вижу как фронтендеры кривят нос, мол “ну кто так пишет фронт” и “реакт ацтой”. Надеюсь они меня простят 🙂
UI на react


Сервис фильтрации по товарам
И когда пользователь понажимает галочек разных — надо отфильтровать товары в соответствии с выбранными значениями атрибутов. Так как количество параметров и их значения заранее неизвестны — запрос придется строить динамически.
Я предполагал что это будет задача со звездочкой, но думал всё обойдётся добавлением в цикле по параметрам множественных условий WHERE к запросу. Но не тут то было. Покрутив варианты и так и сяк пришел к выводу что без Expressions не обойтись.
Для тех кто совсем не знаком с Expression class — вкратце, с его помощью можно динамически собирать .NET программу из набора операторов и потом прямо в рантайме выполнять. И основная фишка этих экспрешенов, что Linq и EF могут их принимать на вход. И если там выражения над объектами БД, то они будут сконвертированы в выражения sql и выполнены на сервере БД.
Для моего случая как на картинке выше
CPU Family=(‘Core i3’, ‘Xeon’)
Cores Total=(4, 6)
клиентское приложение высылает JSON вида
{ "catalogId":"4", "filters":{ "11":["Core i3","Xeon"], "12":["4","6"] } }
мне к запросу по таблице Product надо добавить два джойна чтобы получилось что-то такое:
select p.* from Product p inner join AttributeValue av1 on p.ProductId = av1.ProductId and (attributeId=11 AND (value = ’Core i3’ OR value = ‘Xeon’)) inner join AttributeValue av2 on p.ProductId = av2.ProductId and (attributeId=12 AND (value=4 OR value=6)) where p.catalogId=4
Буду использовать следующие объекты:
- Expression.OrElse
- Expression.AndElse
- Expression.Equal
- Expression.Constant
- Expression.Property
- и т.д
Из них собираю результат. Вышло слегка громоздко ¯\_(ツ)_/¯.
Спрячу под спойлер чтобы не позориться
// POST: api/ProductFilter [HttpPost] public async Task<ActionResult<IEnumerable<Product>>> FilterProducts(ProductFilter f) { // list of AttributeIds in the filter int[] atrIds = f.filters.Keys.ToArray(); // prepare AttributeTypeIds (required to convert strings to int/numeric) Dictionary<int, byte> atrTypes = _context.Attributes .Where(a => atrIds.Contains(a.AttributeId)) .Select(a => new { a.AttributeId, a.AttributeTypeId }) .ToDictionary(a => a.AttributeId, a => a.AttributeTypeId); // base query for Product table. we will expand it later var query = from p in _context.Products where p.CatalogId == f.catalogId select p; foreach (var filter in f.filters) { // prepare additional filter for each attribute Expression<Func<AttributeValue, bool>>? exprFilters = BuildFilter(filter, atrTypes); if (exprFilters == null) continue; IQueryable<AttributeValue> v_query = _context.AttributeValues.Where(exprFilters); // expand the query by adding additional filters query = from p in query join v in v_query on p.ProductId equals v.ProductId select p; } return await query.ToListAsync(); } private static string[] fieldNames = { "", "ValueString", "ValueInt", "ValueNumeric" }; private static Type[] fieldTypes = { typeof(object), typeof(string), typeof(Nullable<int>), typeof(Nullable<decimal>) }; private static Expression<Func<AttributeValue, bool>>? BuildFilter(KeyValuePair<int, string[]> filter, Dictionary<int, byte> atrTypes) { if (filter.Value == null || filter.Value.Length == 0) return null; byte atrType = atrTypes[filter.Key]; object? compareTo = null; Expression checkValues = null; var param = Expression.Parameter(typeof(AttributeValue)); var fieldToCompare = Expression.Property(param, fieldNames[atrType]); foreach (string item in filter.Value) { compareTo = parseString(atrType, item); Expression check = Expression.Equal( fieldToCompare, Expression.Constant(compareTo, fieldTypes[atrType]) ); if (checkValues == null) checkValues = check; else checkValues = Expression.OrElse(checkValues, check); } Expression checkAttributeId = Expression.Equal( Expression.Property(param, "AttributeId"), Expression.Constant(filter.Key) ); Expression allChecks = Expression.AndAlso(checkAttributeId, checkValues); return Expression.Lambda<Func<AttributeValue, bool>>(allChecks, param); }
Попробовал всё это вызывать с клиента. Вау! Оно даже работает!
(не с первого раза конечно же 🙂)
Поддадим жару
Посмотрим, можно ли такое выпускать в прод. Для проверки попробуем нагрузить приложение хорошенько.
Средства для этого существуют разные. Я взял Apache JMeter. Он хорош и главное доступен бесплатно, без СМС.
Создаю тестовый план в котором три запроса:
- Attributes (черный на графиках) — список атрибутов и допустимых значений для построения UI фильтров.
- Filter1 (красный) — фильтр продуктов по одному атрибуту и двум значениям.
- Filter2 (зеленый) — более сложный фильтр по нескольким атрибутам, каждый с несколькими выбранными значениями.
Как это выглядит в JMeter


Эти запросы будут выполняться с разными параметрами. По кругу много раз. Настроил чтобы параметры подавались из CSV файла. Файлы нагенерил тысяч по 10 строк каждый.
Для начала настроил выполнение с нарастающим числом одновременных сессий. Т.е. запросы сначала выполняются в одном потоке (сессии), затем добавляется еще сессия и еще и еще (каждые 5 секунд).
Запускать буду на локальном докере. Поставил ограничение по одному процессору для sql и для web. Посмотрим что можно выжать в такой конфигурации.
docker-compose.yml
version: '3.4' networks: mikesshopnetwork: external: false name: mikesshopnetwork volumes: sql1data: services: mikesshop.web: image: ${DOCKER_REGISTRY-}mikesshopweb cpuset: "0" networks: mikesshopnetwork: {} ports: - "51387:8080" build: context: . dockerfile: MikesShop.Web/Dockerfile sql1: image: mcr.microsoft.com/mssql/server:2022-latest container_name: sql1 hostname: sql1 cpuset: "2" environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: 12QWas!@ volumes: - sql1data:/var/opt/mssql - c:\src\sql\backup:/var/tmp/backup ports: - "11433:1433" networks: mikesshopnetwork: {}
Образы собрались, запустились, получилось так:
умиротворяющий вид из окна docker-а


Результат выполнения теста на графике.


Да уж… 2 (джве!) секунды на ответ!!!111адын. В прод такое нельзя. Даже у нас “для экспериментов” — всё равно такое нельзя. И если запросы на поиск еще как-то работают (зеленый и красный), то подготовка фильтров (черный) — совсем никуда не годится. И манипуляции с индексами хоть и уменьшили сортировки, но глобально проблему не решили.
Надо с этим что-то делать.
Попробую добавить процессор на sql. Это тупиковый путь. Знаю. Процессоры не получится добавлять бесконечно. Просто для эксперимента добавлю.

Вот и подтверждение что это тупиковый путь. Да, показатели улучшились, но время ответа всё равно растет, и хоть и медленнее, но всё равно быстро.
Шутки в сторону. Закатываем рукава и берёмся за дело всерьёз!
(продолжение следует)
