Сила в количестве — ответил бы микросервис.
Вооружимся 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. Это тупиковый путь. Знаю. Процессоры не получится добавлять бесконечно. Просто для эксперимента добавлю.

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