Сила в количестве — ответил бы микросервис.

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



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

Итак, реализация


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

Создал таблиц по минимуму. У меня же эксперимент и всё за разумное время. Помните же?
Значения атрибутов буду хранить в таблице вида:

  • ИД товара,
  • ИД атрибута,
  • значение (три поля на всякий случай: строка, целочисленный, и нумерик)

Схема БД


С помощью скриптов и какой-то матери Excel напарсил товаров для пары категорий. Обилие ручных манипуляций мне быстро надоело и оставшиеся категории я заполнил рандомными данными по 7 тысяч товаров с 15 атрибутами каждый. Для глобального ретейлера еще маловато, для регионального магазинчика уже много. Для экспериментов, думаю, подойдёт. Суммарно получилось

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



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

Шутки в сторону. Закатываем рукава и берёмся за дело всерьёз!

(продолжение следует)

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


  1. HyperWin
    19.08.2024 17:20
    +1

    Интересная статья. Раньше писал на C#, и прошёл курс по SQL, так что в принципе все понятно. Жду продолжения


  1. Proscrito
    19.08.2024 17:20
    +2

    Очень хочется все денормализовать к такой-то бабушке. И каталог сделать атрибутом. Вот пятой точкой чувствую, что один и тот же продукт захочет быть в разных категориях рано или поздно. Типа как и "азиатки" и "худые" одновременно. Или это не про продукты? Энивей, когда найденное решение содержит жонглирование экспрешенами, очень хочется вернуться к архитектуре как таковой и что-нибудь с ней сделать, чтобы не трогать экспрешены.

    А на том месте где выбираются категории и атрибуты для фильтров, сразу хочется найти альтернативы. Какой-нибудь материалайзед вью, или хотя-бы кэш результатов запроса. И если уж EF, то EF.CompileAsyncQuery в этом месте. И кэш. Иначе что там лоад тестировать, если при проектировании производительность даже вскользь не фигурировала.

    А в целом упражнение хорошее. Но для реалистичности все же нужно еще какие-то нон-фанкшинал требования принять во внимание и уделить им немножко времени.


  1. AcidWave
    19.08.2024 17:20
    +1

    Готов к куче минусов. Мелкософт уходит от нас но появляется все больше статей как писать на .NET как пользовать их технологии. Не надоело!


    1. vvdev
      19.08.2024 17:20
      +2

      Чисто формально - .НЕТ открытый проект, куда бы МС не ушёл, на возможность пользоваться .НЕТом это не повлияет.

      Дальше, .НЕТ - от рантайма-джита, через базовую библиотеку и до дизайна языка и компилятора - громадный и интереснейший кусок знаний (и опыта), развивающийся у нас на глазах.

      Так что хотя бы с этой точки зрения должно быть познавательно.

      Ну и возвращаясь к уходу МС - было бы, конечно, здорово и интересно всё это импортозаместить - да хотя-бы даже реализовать рантайм-джит для Эльбруса или только MAUI для авроры (или как там её?), но я/ты/он/она такое организовать не потянем, а заинтересованности у тех, кто мог бы, к сожалению, не заметно.
      А могло бы стать шагом к какой-нибудь православной хармони-ос.


  1. kimisa
    19.08.2024 17:20
    +1

    Сейчас довольно не плохой поиск по json полям идет. И если запихнуть туда поля может быть вполне не плохо. Но мне кажется для этого бд лучше взять Mongo.


  1. TIEugene
    19.08.2024 17:20
    +1

    Выбранная структура данных идеально вписывается в semantic web.
    Но оно не в тренде, к сожалению.


  1. Stems
    19.08.2024 17:20
    +1

    В эластик все запихать или кликхаус.


    1. kimisa
      19.08.2024 17:20
      +2

      Это не статика, а постоянно изменяющая информация. Что-то нужно удалить, что-то добавить. Кликхаус и эластик не для этого.


      1. notffirk
        19.08.2024 17:20
        +1

        Эластик отлично сюда ложится - построенный на Lucene прямо из коробки будет фасетный поиск. Кликхаус да, для другого.


        1. kimisa
          19.08.2024 17:20

          С эластиком +/-. Вроде хорош, но мне кажется что не совсем тут идеально ложится.


  1. vitaly_il1
    19.08.2024 17:20
    +1

    1) Сорри, не понял графика - при скольки запросах в секунду началось замедление?
    2) Про базу
    2.1) Postgres намного популярнее и неспроста
    2.2) хорошо бы проверить план исполнения запросов


    1. Gromilo
      19.08.2024 17:20
      +1

      На двух :) через 4 секунды график пошёл вверх. Шучу, на графике нет RPS.

      JMetter работает так: у нас есть некое количество тредов, каждый из которых в цикле выполняет свой план. Если тред ждёт ответа 2 секунды, значит он будет давать нагрузку в пол запроса в секунду. Если сервер быстро отвечает, один тред и 100 запросов может сделать. Когда сервер не вывозит, запросы становятся в очередь, а линия начинает линейно расти от количества ожидающих.

      Можно прикинуть, что на 1 запрос сервер тратит 85мс и вывозит где-то 12 запросов в секунду.


      1. vitaly_il1
        19.08.2024 17:20

        12 запросов в секунду выглядит нормально для "игрушечного" окружения, без настройки базы и т.п.
        Впрочем, интересно посмотреть весь сериал - я понял, что вы готовите несколько постов.


        1. Gromilo
          19.08.2024 17:20

          Если что, я не автор, просто нагрузочные когда-то делал :)


          1. vitaly_il1
            19.08.2024 17:20

            Дошло!
            (Нагрузочное я тоже иногда делаю, просто давно перешел от JMetter на всякие облачные сервисы )


  1. thekingoftheworld
    19.08.2024 17:20
    +1

    Да прибудет с Вами фасетный поиск


  1. OldNileCrocodile
    19.08.2024 17:20

    Expressions медленные как ни крути.
    Expression.Lambda - тяжёлая вещь. Попробуйте хотя бы начать замену с неё.


    1. vvdev
      19.08.2024 17:20

      Expressions медленные
      Expression.Lambda - тяжёлая вещь.

      Не настолько.
      Плюс ламбда в EF не компилируется.

      Да и мусор от них вряд ли переживёт ген0 при таком использовании.


  1. vvdev
    19.08.2024 17:20

    дел


  1. abratko
    19.08.2024 17:20

    Если посмотреть на Вашу схему, то это похоже на EAV модель.
    Если цель эксперимента - реализация фасетного поиска, то это тупиковый путь, потому что там есть нюансики.
    Лучше смотреть в сторону Эластика, Сфинкса и т.п.
    Кроме этого, есть определенный алгоритм построения запроса. Как вариант, можно почитать здесь https://habr.com/ru/articles/517074/


    1. Gromilo
      19.08.2024 17:20

      Спасибо тебе добрый человек, никак не мог вспомнить и нагуглить EAV-модель.