Вооружимся 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 чтобы работа стала нагляднее. Уже вижу как фронтендеры кривят нос, мол “ну кто так пишет фронт” и “реакт ацтой”. Надеюсь они меня простят ?
Сервис фильтрации по товарам
И когда пользователь понажимает галочек разных — надо отфильтровать товары в соответствии с выбранными значениями атрибутов. Так как количество параметров и их значения заранее неизвестны — запрос придется строить динамически.
Я предполагал что это будет задача со звездочкой, но думал всё обойдётся добавлением в цикле по параметрам множественных условий 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 (зеленый) — более сложный фильтр по нескольким атрибутам, каждый с несколькими выбранными значениями.
Эти запросы будут выполняться с разными параметрами. По кругу много раз. Настроил чтобы параметры подавались из CSV файла. Файлы нагенерил тысяч по 10 строк каждый.
Для начала настроил выполнение с нарастающим числом одновременных сессий. Т.е. запросы сначала выполняются в одном потоке (сессии), затем добавляется еще сессия и еще и еще (каждые 5 секунд).
Запускать буду на локальном докере. Поставил ограничение по одному процессору для sql и для web. Посмотрим что можно выжать в такой конфигурации.
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: {}
Образы собрались, запустились, получилось так:
Результат выполнения теста на графике.
Да уж… 2 (джве!) секунды на ответ!!!111адын. В прод такое нельзя. Даже у нас “для экспериментов” — всё равно такое нельзя. И если запросы на поиск еще как-то работают (зеленый и красный), то подготовка фильтров (черный) — совсем никуда не годится. И манипуляции с индексами хоть и уменьшили сортировки, но глобально проблему не решили.
Надо с этим что-то делать.
Попробую добавить процессор на sql. Это тупиковый путь. Знаю. Процессоры не получится добавлять бесконечно. Просто для эксперимента добавлю.
Вот и подтверждение что это тупиковый путь. Да, показатели улучшились, но время ответа всё равно растет, и хоть и медленнее, но всё равно быстро.
Шутки в сторону. Закатываем рукава и берёмся за дело всерьёз!
(продолжение следует)
Комментарии (24)
Proscrito
19.08.2024 17:20+2Очень хочется все денормализовать к такой-то бабушке. И каталог сделать атрибутом. Вот пятой точкой чувствую, что один и тот же продукт захочет быть в разных категориях рано или поздно. Типа как и "азиатки" и "худые" одновременно. Или это не про продукты? Энивей, когда найденное решение содержит жонглирование экспрешенами, очень хочется вернуться к архитектуре как таковой и что-нибудь с ней сделать, чтобы не трогать экспрешены.
А на том месте где выбираются категории и атрибуты для фильтров, сразу хочется найти альтернативы. Какой-нибудь материалайзед вью, или хотя-бы кэш результатов запроса. И если уж EF, то EF.CompileAsyncQuery в этом месте. И кэш. Иначе что там лоад тестировать, если при проектировании производительность даже вскользь не фигурировала.
А в целом упражнение хорошее. Но для реалистичности все же нужно еще какие-то нон-фанкшинал требования принять во внимание и уделить им немножко времени.
AcidWave
19.08.2024 17:20+1Готов к куче минусов. Мелкософт уходит от нас но появляется все больше статей как писать на .NET как пользовать их технологии. Не надоело!
vvdev
19.08.2024 17:20+2Чисто формально - .НЕТ открытый проект, куда бы МС не ушёл, на возможность пользоваться .НЕТом это не повлияет.
Дальше, .НЕТ - от рантайма-джита, через базовую библиотеку и до дизайна языка и компилятора - громадный и интереснейший кусок знаний (и опыта), развивающийся у нас на глазах.
Так что хотя бы с этой точки зрения должно быть познавательно.
Ну и возвращаясь к уходу МС - было бы, конечно, здорово и интересно всё это импортозаместить - да хотя-бы даже реализовать рантайм-джит для Эльбруса или только MAUI для авроры (или как там её?), но я/ты/он/она такое организовать не потянем, а заинтересованности у тех, кто мог бы, к сожалению, не заметно.
А могло бы стать шагом к какой-нибудь православной хармони-ос.
kimisa
19.08.2024 17:20+1Сейчас довольно не плохой поиск по json полям идет. И если запихнуть туда поля может быть вполне не плохо. Но мне кажется для этого бд лучше взять Mongo.
dph
19.08.2024 17:20Хм, а как в задаче поиска по множеству категорий поможет Mongo? Там для этого вообще нет никаких инструментов.
Можно идти или в сторону специализированных поисков или таки по таблице на категорию или играть с селективностью запросов и дальше уже отбирать на уровне приложения.
Впрочем, подход EAV (описанный в статье) вообще непригоден для подобных задач, он крайне неэффективен.
TIEugene
19.08.2024 17:20+1Выбранная структура данных идеально вписывается в semantic web.
Но оно не в тренде, к сожалению.
Stems
19.08.2024 17:20+1В эластик все запихать или кликхаус.
kimisa
19.08.2024 17:20+2Это не статика, а постоянно изменяющая информация. Что-то нужно удалить, что-то добавить. Кликхаус и эластик не для этого.
notffirk
19.08.2024 17:20+1Эластик отлично сюда ложится - построенный на Lucene прямо из коробки будет фасетный поиск. Кликхаус да, для другого.
kimisa
19.08.2024 17:20+1С эластиком +/-. Вроде хорош, но мне кажется что не совсем тут идеально ложится.
dph
19.08.2024 17:20Ну, оперативное изменение будет довольно дорогим и на большом объеме будет сильно тормозить или требовать очень много ресурсов. Но для небольших объемов решение на эластике или солре вполне допустимо.
CH, увы, потянет, но до 10-100rps, дальше уже будет невыгодно.
В общем, как всегда, нужно сначала смотреть на ФТ/НТФ, а потом уже решение выбирать.
vitaly_il1
19.08.2024 17:20+11) Сорри, не понял графика - при скольки запросах в секунду началось замедление?
2) Про базу
2.1) Postgres намного популярнее и неспроста
2.2) хорошо бы проверить план исполнения запросовGromilo
19.08.2024 17:20+1На двух :) через 4 секунды график пошёл вверх. Шучу, на графике нет RPS.
JMetter работает так: у нас есть некое количество тредов, каждый из которых в цикле выполняет свой план. Если тред ждёт ответа 2 секунды, значит он будет давать нагрузку в пол запроса в секунду. Если сервер быстро отвечает, один тред и 100 запросов может сделать. Когда сервер не вывозит, запросы становятся в очередь, а линия начинает линейно расти от количества ожидающих.
Можно прикинуть, что на 1 запрос сервер тратит 85мс и вывозит где-то 12 запросов в секунду.
vitaly_il1
19.08.2024 17:20+112 запросов в секунду выглядит нормально для "игрушечного" окружения, без настройки базы и т.п.
Впрочем, интересно посмотреть весь сериал - я понял, что вы готовите несколько постов.Gromilo
19.08.2024 17:20+1Если что, я не автор, просто нагрузочные когда-то делал :)
vitaly_il1
19.08.2024 17:20+2Дошло!
(Нагрузочное я тоже иногда делаю, просто давно перешел от JMetter на всякие облачные сервисы )
OldNileCrocodile
19.08.2024 17:20+1Expressions медленные как ни крути.
Expression.Lambda
- тяжёлая вещь. Попробуйте хотя бы начать замену с неё.vvdev
19.08.2024 17:20+1Expressions медленные
Expression.Lambda
- тяжёлая вещь.Не настолько.
Плюс ламбда в EF не компилируется.Да и мусор от них вряд ли переживёт ген0 при таком использовании.
abratko
19.08.2024 17:20+1Если посмотреть на Вашу схему, то это похоже на EAV модель.
Если цель эксперимента - реализация фасетного поиска, то это тупиковый путь, потому что там есть нюансики.
Лучше смотреть в сторону Эластика, Сфинкса и т.п.
Кроме этого, есть определенный алгоритм построения запроса. Как вариант, можно почитать здесь https://habr.com/ru/articles/517074/Gromilo
19.08.2024 17:20+1Спасибо тебе добрый человек, никак не мог вспомнить и нагуглить EAV-модель.
HyperWin
Интересная статья. Раньше писал на C#, и прошёл курс по SQL, так что в принципе все понятно. Жду продолжения