.NET sh blog
73 subscribers
7 photos
1 file
68 links
Блог про .NET

Тут различные заметки и все что покажется интересным для обсуждения

https://github.com/mt89vein
Download Telegram
А вы знали что можно распространять NuGet пакет чисто с исходниками, вместо заранее прекомпилированных бинарников (dll)?
Оказывается так можно сделать, хоть это и не афишируется официально.

Andrew Lock об этом довольно подробно рассказывает с примерами как такой пакет можно сделать.

Плюсы распространения библиотек в виде исходного кода:

- отсутствие зависимости: все исходники в пакете как будто написаны вами и компилируются вместе с вашим кодом (собственно так обычно работает npm, когда подгружает node_modules)
- есть возможность использовать несколько разных версий таких пакетов в одном проекте. В случае с готовыми dll всегда может быть только одна версия
- отсутствие ошибок в рантайме из-за того что кто-то например ожидает версию 5, а в верхних слоях установлена 7ая и в финальную сборку попала 7ая версия, а часть кода пытается работать с API от 5ой, которой уже нет. Это довольно частая проблема из-за транзитивных зависимостей.

Минусы:

- данный код необходимо компилировать. Если все пакеты делать таким образом, время билда может вырасти
- фича неофициальная и может измениться в будущем

Я еще не пробовал, но вероятно, такой пакет подойдет для различных утилит, атрибутов и прочего, что обычно копируется из проекта в проект :)
Одним из важных атрибутов качества ПО является надежность. Повысить надежность помогает качественная обработка ошибок и их мониторинг.
Для того чтобы мониторинг был полезен, имел низкий показатель ложных срабатываний подозрений на инциденты, нужна стандартизация ответов API. Этой самой стандартизацией может быть использование подхода с кодами ошибок, суть которого я раскрою позднее.

Больше всего стандартизации ответов API нуждаются ответы по различным ошибкам.

Если мало времени уделено обработке ошибок или если разработчику захотелось воспользоваться всеми доступными ему HTTP статус кодами, то на стороне потребителя этого API будет крайне не сладко :)
Мониторить такое API тоже может быть довольно затруднительно.

Поэтому ответы по ошибкам должны тщательно дизайниться и просто так необдуманно возвращать 400 или 500 "что-то пошло не так", это не есть хорошо.

Ошибки я делю на 3 типа:

1. Ошибка использования API

Согласно RFC, общепринятой практикой является возврат 400 Bad Request в ответ на некорректно сформированные запросы.

В идеале 400ые могут возникать, только когда кто-то извне экспериментирует с нашим API (т.е. шлет неправильно сформированные запросы), и крайне редко или почти никогда - для наших пользователей, которые работают через UI, который мы разрабатываем (Web/Mobile etc) и там некорректно сформированных запросов быть вообще не должно.

При таком подходе, количество 400ых ответов в нормальное время будет равно нулю, и мы можем настроить алерт с небольшим порогом срабатывания, и по малейшему всплеску 400ых, сможем быстро обнаруживать проблему.

Также полезно раздельно мониторить запросы по API от интеграций и запросы от UI, так как у них обычно разные уровни обслуживания и разные потребители, а в идеале и разные API эндпойнты.

Как можно понять кто вызывает наше API?

В этом нам поможет аутентификация (идентификация).
Как альтернатива - ваш UI также может отправлять метку во все запросы, а те, что без меток - считать их внешними запросами к нам.
Получив алерт по ошибкам от UI, можно понять, в каком API разъехались контракты, и оперативно их поправить.
Если же алерт по внешним запросам, то либо зарелизили несовместимые изменения, либо кто-то некорректно вызывает наше API и мы можем им об этом сообщить.

2. Внутренние ошибки (свои и от внутренних сервисов)

Этот тип ошибок используется, когда мы не можем из-за некоторых причин выполнить как ожидалось запрос.
Например: сервис перегружен, переполнена очередь, недоступна внешняя зависимость, без которого запрос не выполнить.
Количество таких ошибок (которые получит пользователь) можно снизить автоматическими повторами запросов, фолбеками на другие сервера, но с повторами нужно быть аккуратней.

В нормальное время таких ошибок быть не должно. Любое повышение кол-ва таких ошибок может говорить об инциденте.

3. Псевдо-ошибки

Это сценарии, которые по своей сути не являются ошибочными, но API спроектировано таким образом (или просто так сложилось исторически), что возвращает для них ответы 4xx/5xx.
Например, пользователь не имея денег на счете пытается оплатить покупку. Является ли это ошибкой системы? Нужно ли вернуть 400 или 500?
Я считаю что это не всегда ошибка системы, но важно помнить, что источником этих ошибок может быть релиз багов в сервисе баланса и он просто стал возвращать некорректный баланс.
Тогда фон таких ошибок будет выше обычного: это может быть всплеском или еле заметным отклонением в зависимости от размера сегмента пострадавших пользователей. Иначе говоря это является аномалией, которую нужно мониторить отдельными инструментами более детально.

Продолжение следует
HTTP as a transport

Это достаточно холиварная тема, но всё же своё мнение выскажу. Я не претендую на истину которой следует строго придерживаться :)

Так вот, HTTP мы используем в качестве транспорта для передачи данных и вместо HTTP у нас могло быть что-нибудь другое (RPC, AMQP, TCP, UDP) или RPC поверх HTTP: gRPC, SOAP и т.п.

Поэтому важно HTTP протокол воспринимать в первую очередь как транспорт и не пытаться выразить ошибку уровня приложения/домена в статус коде HTTP.
Иными словами, бизнесовые ошибки лучше возвращать в теле ответа, а HTTP статусы применять по их назначению, чтобы все были счастливы - и мониторинги и балансеры и клиенты.
Много API из того что я видел пытаются натянуть бизнес код ошибки на http и потом вечно спорят, лучше 400 или 422, а может вообще 500 и как это потом мониторить.


Как я писал в посте выше, ошибки с кодами 4xx должны быть крайне редки, а такое возможно, только если мы используем 4хх коды по их назначению.
Коды ошибок и их описание есть в rfc9110.

Здесь я не предлагаю использовать только 200 или 500, как это обычно делают в RPC фреймворках, тем не менее не нужно бояться использовать 200ые для возврата ошибок, считая что они только успешных бизнес операций.
На самом деле это далеко не так, если обратимся к RFC по статус кодам HTTP ответов. Но обо всем по порядку, разберемся с HTTP статусами подробней.

5xx Server Error

Используем этот диапазон кодов для того чтобы клиенту сообщить, что проблема на сервере.

Ответы с этим статус кодом мы можем дополнить заголовками, чтобы было проще потребителю API:

X-Retry-After - сообщаем клиенту когда (или через сколько) можно повторить запрос.
X-Retryable - сообщаем есть ли смысл инициировать повторный запрос.

4xx Client Error

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

Помимо этого я обычно 404 Not Found возвращаю только если эндпойнта не существует в принципе (т.е. только как автоматический ответ от сервера).
Так нам будет проще мониторить и находить ошибки конфигурации. Если пошел всплеск 404ых, то значит где-то мы прописали некорректные URI и нужно поправить конфигурацию или сервер, или возможно внешние потребители просто стучатся не туда.

Если сущности в бд с таким идентификатором не нашлось (или оно отмечено удаленным) отдаем 200 с контентом в формате ProblemDetails и специальным кодом ошибки уровня приложения.

3xx Redirection

Используем для редиректов и для проверок кэшей (304 Not Modified).

2xx Successful

Используем этот диапазон кодов для того чтобы сообщить клиенту, что запрос успешно получен, понят и выполнен.

Что говорит нам RFC по 200 статус коду?

"The 200 (OK) status code indicates that the request has succeeded"

Дословно: "200 статус означает что запрос выполнен успешно"

HTTP запрос как транспорт успешно выполнен: все данные получены, ответ клиенту передан.
Но сама бизнес операция необязательно должна быть выполнена успешно.
К примеру если вы получили 400 - как транспорт он тоже вполне сработал, так как вы что-то отправили, и в ответ получили валидный ответ от сервера.
В качестве вывода: гораздо лучше в разработке и поддержке, когда мы четко разделяем протоколы (separation of protocols).
Пусть HTTP сервер и веб-браузер работают по общей спецификации, а приложение (backend & frontend) использует его как транспорт и реализует свою логику как запрашивать данные,
как интерпретировать ответы поверх него, которые к слову могут быть гораздо сложнее, а еще сложнее будет выразить это статусами HTTP, что конечно делать не стоит.

В будущем при необходимости можно будет перейти или выставить альтернативный API, написав для этого адаптер. Например на grpc
Пример псевдо-ошибки

А теперь давайте представим ситуацию: пользователь заходит в UI нашего приложения через веб-браузер.
Клиент (наш frontend UI) должен уметь работать с нашим API. Под "уметь работать" означает не только формировать корректные запросы, но также и корректно обрабатывать ответы от сервера, обрабатывать ошибки и т.п.
И здесь важно различать два вида ошибок - ошибка пользователя и ошибка клиента.

Вернемся к примеру:
Пользователь хочет оплатить покупку. Денег на счете недостаточно. Клиент отправляет HTTP request, сервер возвращает HTTP response, в котором поясняется что не может провести эту операцию - HTTP запрос обработан.
Теперь поразмышляем, какой статус код должен вернуть сервер на такую ситуацию.
Технически, клиент выполнил всё как полагается: сформировал запрос корректно (синтаксически и семантически верно), значит отпадают все 4xx. Вот только мы всё равно провести эту операцию не можем и необработанной ошибки на сервере не было, значит и не 5xx. Поясню за каждый код ответа:
400 - если мы вернем этот код, то сообщим клиенту о проблеме в формировании запроса, но это не так. Более того, запросы, на которые получили 4xx повторять нельзя, а значит если пользователь попытается еще раз (после того как в мобильном приложении закинул деньги на счет), то клиент в идеале должен попросить пользователя поменять данные т.к. согласно протоколу, он должен получить 400, если снова отправит такой же запрос.
403 - наша цель не заблочить пользователя, или сказать, что прав недостаточно, а сообщить, что сейчас операцию выполнить нельзя.
422 - семантически у нас запрос верен, тоже не подходит.
500 - этот статус должен появляться только в крайних случаях, когда сервер столкнулся с неожиданной ошибкой. Ситуация что денег нет на счете не должно быть неожиданностью - этот сценарий должен прорабатываться как unhappy path и возвращаться ответ явно с бизнес ошибкой, пояснением. Если вернем 500, то клиент скорее всего автоматически сделает ретрай, что в целом то бессмысленно в данном случае.
200 - этот статус, на первый взгляд, совершенно не подходит, так как он должен означать успешный успех. Но на самом деле мы должны рассматривать HTTP как транспорт и как транспорт он свою работу сделал превосходно - клиент всё что нужно переслал, сервер обработал запрос. В процессе, сервер, как и ожидалось, проверил, что денег недостаточно на счете и вернул информацию об этом клиенту.
Чтобы убрать сомнения, давайте еще раз обратимся к RFC по 200 статусу:
для POST/PUT запросов: "Returns the status of, or results obtained from, the action.". А статус таков, что сейчас операцию выполнить нельзя т.к. не хватает денег.
Выходит, что в HTTP 200 OK не всегда должен означать, что всё выполнено как запрошено, мы можем варьировать ответы в зависимости от ситуации, например, в случае успеха и ошибки (ниже), он мог бы быть таким:

HTTP/1.1 201 Created
Location: https://myproject.ru/orders/efdc93a1-04fb-4cc8-8d2c-e693972f7429


Или в случае ошибки выше:

HTTP/1.1 200 OK
Content-Type: application/problem+json

{
"type": "https://help.myproject.ru/error-codes/pay10001",
  "title": "Недостаточно денег на счете",
  "status": 200,
  "detail": "Ваш текущий баланс 300. Стоимость покупки 500.",
  "code": "pay10001",
  "id": "8273f48b-d8d8-4e0e-9711-d52e3b7ff8b8",
  "userId": "aabe69e9-bb0a-47c3-8b82-890785b9da7a",
  "orderId": "efdc93a1-04fb-4cc8-8d2c-e693972f7429",
"cost": 500,
"balance": 300
}


Со стороны потребителя такого API нужно просто смотреть на тип контента.
Если там application/problem+json, то это означает, что операция не была выполнена.
Вычитываем тело ответа как ProblemDetails и смотрим на код ошибки и принимаем дальше решение что с этим делать (например, показываем пользователю)
Коды ошибок

Для возврата ошибок, используем стандарт ProblemDetails:
HTTP/1.1 200 OK
Content-Type: application/problem+json

{
"type": "https://help.myproject.ru/error-codes/pay10001",
  "title": "Недостаточно денег на счете",
  "status": 200,
  "detail": "Ваш текущий баланс 300. Стоимость покупки 500.",
  "code": "pay10001",
  "id": "8273f48b-d8d8-4e0e-9711-d52e3b7ff8b8",
  "userId": "aabe69e9-bb0a-47c3-8b82-890785b9da7a",
  "orderId": "efdc93a1-04fb-4cc8-8d2c-e693972f7429",
"cost": 500,
"balance": 300
}


Пройдемся по каждому полю.
type - Ссылка на документацию по коду ошибки
status - Статус код HTTP ответа, повторяет заголовок. Добавлен для удобства клиента
title - Короткое и понятное человеку описание проблемы. Оно должно быть статичным и зависит только от кода ошибки
detail - Детальное описание ошибки, которое должно помочь устранить проблему

Расширение спецификации для всех ответов:

id - Уникальный идентификатор ошибки. Он же должен присутствовать в логах, чтобы найти информацию о проблеме.
code - Код ошибки.

Остальные поля в примере - это расширение под конкретный ответ, они добавляются как key-value в корневую ноду.
Зачем нужны коды ошибок?

- Мы предоставляем API, чтобы оказать некую услугу, если у нас по какой-либо причине это сделать не удается, мы должны сообщить потребителю API, что же пошло не так, и помочь достичь желаемого, например, показав ссылку на документацию, где будут описаны случаи, когда такая ошибка возникает, и что можно предпринять на своей стороне чтобы эту ошибку устранить. Как итог – разработчики реже обращаются к команде с вопросами и все счастливы.
- Также это помогает с мониторингом - собираем в виде метрики все коды ошибок и смотрим, каких кодов ошибок больше всего. Каких-то кодов ошибок в нормальное время быть не должно вообще, какие-то могут быть в незначительном объёме. А вот любые всплески могут говорить о том, что у нас не все хорошо. Вероятно плохой релиз, баги.. иными словами инцидент. Даже если всплесков нет, можно завести техдолг задачу или баг на исследование причины этих ошибок и устранение.
- Коды ошибок можно записывать в скоупы лога, чтобы смотреть флоу который привел к ошибке, искать пострадавших и т.п.

Для упрощения внедрения кодов ошибок в приложение, у меня есть библиотека на гитхабе с подробным описанием, которую я использую на работе во всех сервисах.

Коды ошибок, которые может вернуть API можно документировать в такой таблице (см в комментариях)

Чтобы проще ориентироваться в источнике ошибки, каждому сервису (домену) необходимо завести свой префикс 2-3 символа.
Нумерацию начинаем с нуля, дополняя до 5 цифр (будет всего 7-8 символов на код ошибки).
Таким образом каждый сервис сможет завести до 100к ошибок. Что вполне достаточно чтобы заводить код ошибки на каждый if

В проекте book library применяются коды ошибок, можно посмотреть как это работает
Партиционирование (секционирование) таблиц в Postgres

В целом идея партиционирования очень схожа между разными СУБД, но буду рассказывать на примере Postgres.

Партиционирование - это разбиение одной таблицы на более маленькие отдельные физические таблицы по какому-нибудь ключу партиционирования внутри одной СУБД,
с возможностью работать с ней как с одной логической таблицей.

Таким образом вставку, обновление, удаление и выборку можно делать как через исходную родительскую таблицу так и через сами партиции.
Важно не путать партиционирование с шардингом, где используют несколько СУБД и данные распределяют уже между разными серверами.

Для того чтобы распределять данные по партициям нужно явно задать одно или несколько полей таблицы в качестве ключа партиционирования.
Партиционировать таблицу можно разными способами.
- по диапазону значений (0 - 10 000, 10 001 - 20 000
- по диапазону дат: по дням, месяцам, кварталам, годам и т.п.
- по списку значений
- хэшу полей и т.п.

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

Партиционирование применяется в качестве одного из способов оптимизаций в следующих случаях:

- Приложение чаще всего использует данные только одной или нескольких партиций. Например данные за последний месяц. Для этого в подавляющем количестве запросов должен участвовать ключ партиционирования, поэтому СУБД будет использовать только одну или несколько партиций.
- Таблица большая и не помещается полностью в память СУБД.
- Требуется периодическое удаление старых данных.

Подробнее о партиционировании можете почитать в документации Postgres или например в блоге компании Percona.
В библиотеке Sstv.Outbox я реализовал как для EF с Npgsql так и без EF на чистом Npgsql несколько outbox воркеров под разные сценарии использования:

- одиночный клиентский обработчик записей с сохранением и без сохранения порядка обработки.
- пакетный клиентский обработчик записей с сохранением и без сохранения порядка обработки.

При этом можно поднимать сколько угодно инстансов приложения т.к. синхронизация идет за счёт explicit row lock в Postgres:
- for update nowait для гарантии порядка обработки (strict ordering)
- for update skip locked, где этого не требуется (competing)

В первом случае единовременно будет работать только один инстанс который успел взять лок, все остальные воркеры уснут. Любой инстанс может взять в работу следующую пачку данных, если первым захватит лок.

Во втором случае каждый инстанс возьмет N различных записей и будут обрабатывать их параллельно. Поэтому здесь гарантии очередности обработки никакой нет.

Помимо всего того что описал выше, начиная с версии 2.0, для любого из них можно сделать таблицу партиционированной, чтобы сэкономить на удалении большого количества записей.
Библиотека сама будет заранее создавать новые и постепенно удалять старые партиции. Сообщения обрабатываются из основной таблицы.

Если партиционирование не используется, то записи удаляются сразу после обработки. При включенной - по мере обработки будут переводиться в статус обработано, а удаление будет происходить вместе с партицией.

Так как поле статус не имеет индексов и это единственное поле которое обновляется, то может быть задействована оптимизация "HOT-update", тем самым минимизируя генерацию WAL и сводится на нет доп. работу для автовакуума, экономя драгоценное CPU и IO.

По-умолчанию Postgres старается заполнить страницы данных полностью (fillfactor = 100), не оставляя место для апдейтов.
Чтобы HOT-update происходил и происходил часто, нужно чтобы было место куда записать апдейт там же где лежит обновляемый tuple.

На моих тестах количество HOT-update достигает внушительных 90%+ при fillfactor таблицы равной 90.
К счастью, здесь Вам ничего тюнить не нужно, fillfactor для партиций выставляется автоматически при их создании самой библиотекой.

Про то что такое fillfactor и как оно влияет на перформанс Postgresql есть статья от Kaarel Moppel.
В качестве первичного ключа в outbox таблицах я использую uuid v7, про который я писал в этом посте.
Сам uuid v7 проектировался таким образом, чтобы базе данных было удобно раскладывать у себя данные, минимизируя фрагментацию и кол-во перестроений кластеризованного индекса. Все это благодаря тому что он бинарно сортируем, поэтому легко определить что было раньше, а что позже.

Так вот, я решил сделать партиционирование по uuid v7, так как это сильно упрощает код, убирает необходимость в дополнительном поле с меткой времени, не нужно метку времени явно задавать чтобы искало только в одной партиции при запросе данных по id и т.п.
Для начала давайте сперва посмотрим что говорит нам спецификация uuid v7.

В пункте 6.12 явно написано, что в общем случае не рекомендуется парсить UUID и считать её просто неким атомарным значением, так как это сделает приложения более простыми и надежными.

Это логичное предостережение, ведь uuid v7 хоть и стандарт, но позволяет много вольностей.

Например, позволяет самим разработчикам выбирать точность встраиваемой в uuid метки времени, а так же при необходимости можно добавлять сдвиг метки времени относительно реальной даты на любую величину, но стандарт не допускает заполнения метки времени данными, не зависящими от текущего времени, чтобы обеспечить монотонность и защиту от коллизий.

Поэтому если uuid v7 генерируется много кем (например внешними приложениями, которые вы не контролируете), то есть риск занести себе баг из-за парсинга меток времени или любой другой информации из uuid v7.

Но мы парсить timestamp из uuid и не собираемся.

В спецификации написано следующее:
The first 48 bits are a big-endian unsigned number of milliseconds since the Unix epoch. The next four bits are the version bits (0111), followed by 12 bits of pseudo-random data.

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

Допустим мы хотим сделать партицию от 2024-08-18T00:00:00.000Z до 2024-08-18T23:59:59.999Z

Алгоритм вычисления uuid v7 для них простой: берем из даты unix epoch с точностью до миллисекунды, конвертируем в hex (16система в BigEndian), не забываем про значащие нули и проставляем дефисы и остальное заполняем нулями.

2024-08-18T00:00:00.000Z -> 1723939200000 -> 019162C89C00 -> 019162c8-9c00-0000-0000-000000000000
2024-08-18T23:59:59.999Z -> 1724025599000 -> 019167EEF418 -> 019167ee-f418-0000-0000-000000000000

И создаем партицию:
CREATE TABLE public.partitioned_table_p20240818 
PARTITION OF public.partitioned_table
FOR VALUES
FROM ('019162c8-9c00-0000-0000-000000000000')
TO ('019167ee-f7ff-0000-0000-000000000000')


Это было бы тоже самое, если бы партиционировали по дате:
CREATE TABLE public.partitioned_table_p20240818 
PARTITION OF public.partitioned_table
FOR VALUES
FROM ('2024-08-18T00:00:00.000Z')
TO ('2024-08-18T23:59:59.999Z');


Благодаря тому что Postgres изначально хранит uuid в бинарном виде и сравнивает их таковыми без преобразований, то uuid v7 здесь прекрасно работает без дополнительных накладных расходов.

Теперь при запросе по id, постгрес сам своими внутренними механизмами будет просто сравнивать uuid и границы партиций и определять в какую партицию ему нужно обращаться.
Это тоже самое если бы сделали партиционирование по long полю и сделали range от 0 до 10 тыс, от 10 тыс до 20 тыс и тп. Но более удобно, так как мы привязаны к дате и времени :)

upd. примеры запросов и их разбор закинул в комментарии
Какую СУБД используете в работе?
Anonymous Poll
89%
Postgres
0%
Oracle
26%
Microsoft SQL Server
7%
MySQL
7%
MongoDB
4%
Cassandra/Scylla DB
7%
Другое
Как анализировать план выполнения запроса в Postgres?

Сперва нужно получить сам план запроса с помощью explain, для этого добавляем перед SQL запросом эти 2 строчки
SET track_io_timing = TRUE;
explain (analyze, buffers)

Например:
SET track_io_timing = TRUE;
explain (analyze, buffers)
SELECT *
FROM public.partitioned_ef_outbox_items
WHERE status <> 3
ORDER BY id ASC;


Важно: если просто указать explain (без analyze), то он выдаст примерный или ранее закэшированный план запроса без фактического выполнения, а добавив в него параметр analyze, будет выполнен реальный запрос. А buffers даст дополнительный инсайт по кол-ву прочитанных данных с диска.
Поэтому если хотите сделать explain с analyze на DELETE/UPDATE/INSERT, то обязательно оборачивайте в транзакцию и делайте rollback
BEGIN;
SET track_io_timing = TRUE;
explain (analyze, buffers)
*your query
ROLLBACK;


Дальше копируем полученный план запроса и вставляем в одно из приложений:

https://explain.tensor.ru/
https://explain.dalibo.com/

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

Еще они пытаются в рекомендации, но не следуйте им бездумно, обязательно проверяйте эффект :)

Еще важно планы запросов снимать +- с того же набора данных, что будет на проде. Потому что планы запросов
могут и будут отличаться и вы будете пытаться оптимизировать не то.

Чтобы глубже понимать план запроса, рекомендую посмотреть 4 и 5 лекцию из этого плейлиста.

В комментарии добавлю пару скриншотов планов
Какой режим GC выбрать?

Согласно документации garbage collector в .NET адаптивный т.е. способен самостоятельно тюнить себя в зависимости от ситуации и доступных ресурсов в запускаемом окружении.

При этом долгое время .NET приложение (и не только), размещалось на выделенных больших серверах и львиная доля оптимизаций были под именно такие сценарии.
Если это ваш случай, то здесь выбор очевиден - нужно обязательно ставить "Server GC".

Помимо больших серверов еще есть клиентские приложения (winforms, wpf) и под них предусмотрен режим "Workstation".

Отличия между Server GC и Workstation можно почитать здесь.

Но далеко не всегда GC знает где размещен и сколько реально доступно ему ресурсов.

Представьте, что Вы деплоите в k8s, а .NET считает что ему доступны все ресурсы хоста, например все 8 CPU и 16 RAM.

Это проблема не только в .NET, во времена .NET Core 2.0 вышло много статей и багфиксов связанных с этим.
С тех пор команда .NET сделала довольно много фиксов и оптимизаций для работы в таких условиях. Например DATAS.

Помимо всего хорошей практикой в k8s является явное указание request и limit как по CPU, так и по RAM для каждого контейнера:

spec:
containers:
- name: net-core-app
image: net-core-image
resources:
requests:
memory: 100Mi
cpu: 100m
limits:
memory: 256Mi
cpu: 500m


Это делает работу k8s шедулера более адекватной: он будет знать сколько нужно ресурсов и определять где лучше поднять новый под (в какой ноде в кластере)

А еще это также защищает от контейнеров, которые в случае чего готовы съесть все доступные ресурсы и тем самым уронить всю k8s ноду где он поднят.

В k8s обычно выделяют ресурсы на контейнер довольно небольшими (к примеру < 1 CPU и < 1GB RAM).
Это обусловлено общим объёмом ресурсов в одной k8s ноде, а это обычно небольшие виртуалки (c4r8 или c8r16), так как это дешевле, чем иметь большие.
А виртуалок надо много (минимум 3 мастер ноды, 3 воркер ноды для HA).

Поэтому если если Вы деплоите .NET приложение в k8s, то рассмотрите вариант переключения режима сборки мусора на "Workstation", чтобы оно работало лучше в условиях жестких ограничений.
Workstation режим работает лучше серверного если выделено < 1 CPU. К тому же общее потребление памяти будет намного ниже так как будет чаще её возвращать в OS, в отличие от серверной.

Принудительно переключить на Workstation GC можно через переменную окружения:

DOTNET_gcServer = 0 (начиная с .NET 6)

Все варианты настройки этого параметра описаны здесь.

В .NET 8 у нас появился DATAS, а с .NET 9 он будет включен по-умолчанию для всех, что должно еще улучшить размещение приложений в сильно ограниченных окружениях.
Про модели на разных слоях

Если у вас слоёная архитектура, то на каждом слое нужно иметь собственные изолированные модели, с которым он будет работать.

Это позволит Вам независимо развивать каждый слой, не боясь случайно повлиять на остальные, а так же скрыть детали реализации.

Например не стоит модели БД "как есть" сериализовывать и отдавать в ответ по API. Любое переименование полей в бд, будет означать изменение контракта API.
А если используете ORM и не дай бог подключен Lazy loading, то можно одним запросом положить весь сервер из-за out of memory exception, так как в процессе сериализации может быть вытянута вся база данных в память через навигационные свойства :)
Но хуже всего когда случайно отдают по API чувствительные данные.

Какие же модели нужны в стандартном Web API приложении?

В слое API нужны модели для биндинга данных из HTTP запроса (body, headers, route params, query params, authentication data), а также модель для ответа (DTO).

Это должен быть самый стабильный контракт, за которым нужно тщательно следить. В идеале контрактным тестированием или через verify.http (golden|snapshot тесты).

Эти модели располагаются в сборке API и нужны исключительно для десериализации данных и первичной валидации корректности значений полей. А в слой ниже передаем только через маппинг в Application модели. Мы ведь не хотим заносить в Application слой "знание" того что есть HTTP API? Ведь приложение может запускаться разными способами.

Модели в слое Application это command/query, которые используются в качестве аргументов для UseCase/MediatR/whatever. А также выходные модели в виде результата команды или ответ на Query.

Эти модели как правило чуть шире чем модели для HTTP body, потому что к ним добавляются данные пользователя из аутентификационных данных и прочее собранное из других частей HTTP запроса.

Модели слоя Application маппятся в доменные: создаются ValueObjects, агрегаты и сущности и/или выполняется доменная логика на полученных из бд.

В слое Domain - доменные модели, где в идеале должна быть реализована бизнес логика.

В Infrastructure - могут быть свои модели для маппинга в систему хранения данных, если не маппите их сразу в доменные модели.

Теперь посчитаем кол-во моделей для модифицирующего HTTP запроса:

HTTP request -> API model -> Application command -> Persistence model -> Domain model -> Application DTO -> API DTO -> HTTP response

Теперь посчитаем кол-во моделей для читающего HTTP запроса:

HTTP request -> API model -> Application query -> Persistence model -> Domain model -> Application DTO -> API DTO -> HTTP response

К слову, здесь мы можем опустить Domain model и маппить Persistence model сразу в Application DTO.

Выходит что нужно аж 6 моделей чтобы обработать запрос. И на каждом этапе могут быть ошибки маппинга, плюс дополнительные затраты памяти и CPU (gc).

В своих проектах я редко делаю API DTO. Обычно сразу отдаю Application DTO, а в отдельных Persistence model еще не было необходимости,поэтому просто маплю EntityFrameworkCore сразу на Domain model.

С отдельным Persistence model есть как плюсы так и минусы

плюсы:
- чистые доменные модели, которые не зависят вообще ни от чего
- за это топят различные блогеры
- нет приседаний с маппингом в ORM

минусы:
- теряем встроенный ChangeTracker: надо самому сделать обратный маппинг изменений из доменной модели в persistence модели (вручную или через доменные события) чтобы сохранить внесенные изменения
- чем сильнее отличия хранения данных от доменной модели, тем больше ошибок может появиться.
Please open Telegram to view this post
VIEW IN TELEGRAM
Представьте что в Вашем приложении требуется отображать различные счетчики.

Стандартным решением будет просто сделать count запрос в бд по некоторым условиям, а в случае проблем с производительностью придумать как это закэшировать (с учетом различных параметров).

Это вполне неплохо работает в большинстве случаев, но иногда нужно что-нибудь более быстрое :)

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

Почему так?

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

Если же мы все нормализуем, допустим до 2-3 НФ, то нам становится очень просто редактировать данные.
Это потому что мы уменьшили избыточность данных, а следовательно избавились от аномалий и к примеру справочные данные лежат в отдельных таблицах и их изменение влияет на все записи которые на них ссылаются, как бы их много не было.
Консистентность при этом высочайшая, но скорость чтения сильно падает из-за необходимости все это джойнить.

В проекте BookLibrary исключительно в обучающих целях я решил добавить пример как можно сделать счетчики количества доступных и взятых книг с помощью Sstv.Outbox.

Для этого я завел таблицу book_stats:
CREATE TABLE book_library.book_stats (
isbn text NOT NULL,
publication_date date NOT NULL,
title text NOT NULL,
authors text NOT NULL,
available_count int4 NOT NULL,
borrowed_count int4 NOT NULL,
CONSTRAINT pk_book_stats PRIMARY KEY (isbn, publication_date)
);
CREATE INDEX ix_book_stats_authors ON book_library.book_stats USING gin (authors gin_trgm_ops);
CREATE INDEX ix_book_stats_title ON book_library.book_stats USING gin (title gin_trgm_ops);


В которую просто буду записывать информацию о книге и 2 счетчика: количество доступных на текущий момент и количество взятых на чтение.
Этой таблицы будет достаточно чтобы отобразить информацию обо всех книгах в библиотеке. Объем записей не ожидается очень большим, в отличие от таблицы books, где каждая запись это конкретный экземпляр книги и у каждого из них есть информация о том кто взял эту книгу и когда должен вернуть.

Самый простой способ обновлять счетчики это делать +1/-1 при взятии или возврате книги, но под большой нагрузкой это не будет работать, либо будет работать медленно.
Поэтому хочется показать альтернативный вариант, который может Вам пригодиться.

Стандартной практикой управления конкурентными обновлениями записей на уровне СУБД является использование либо пессимистичной блокировки (select for update), либо оптимистичной (update ... where id = $id and version = $version)
При этом пессимистичную блокировку сделать намного проще: везде где вычитываются записи для обновлений добавляем в запрос "for update".

Но если использовать пессимистичную блокировку не следя за порядком их получения на разных записях/сущностях, а в данном случае блокировка будет еще неявно браться самим UPDATE на записи в book_stat, то риски дедлоков при обновлении счетчиков через +1/-1 будут намного выше.

И как раз чтобы не ждать на локах (пока другая транзакция меняет счетчик) и не ловить дедлоки из-за массовых обновлений лучше всего обновление счетчиков делать в отдельной транзакции. Поэтому без Outbox здесь не обойтись.

Как сделать через Outbox?

Просто записывать все изменения счетчиков в таблицу book_stat_changes в виде +1/-1 и потом батчем вычитывать, группировать, суммировать значения и применять обновления в book_stats.

Таким образом если возьмут одну и ту же книгу 1000 раз за секунду, то его счетчик может быть обновлен одной операцией.

В комментариях ссылочки на конкретный код
В одном канале про C# выложили пример юнит теста с использованием паттерна Builder.

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

Так вот, представьте что у Вас большое количество тестов, допустим > 1000.
И тесты написаны таким образом, что в секции Arrange создается объект с нуля используя его конструктор или object initializer.
Если тестов мало такой подход работает неплохо, когда тестов много это становится проблемой, потому что логика создания объектов начинает дублироваться повсюду.
И чем сложнее эти объекты, тем больше бойлерплейт кода и сложнее это все поддерживать.
Если придет задача что надо добавить новое поле, придется пройтись по всем тестам чтобы понять какое там теперь должно быть значение и вообще может ли оно повлиять на результаты теста.

Использование паттерна Builder помогает в таком случае двумя вещами:

1. Логика создания объекта и приведения его в определенное состояние сосредотачивается в одном месте. При этом названия методов билдера помогут сделать все еще читабельней.
2. Можно легко сделать обязательный параметр в конструкторе класса не исправляя потом в 1000 мест создания класса в тестах. Достаточно будет поправить билдер и запустить тесты.

Таким образом мы упрощаем себе поддержку тестов, а билдер помогает избавиться от дублирования знания о том, как именно привести тестируемый объект в определенное состояние.
.NET sh blog
В одном канале про C# выложили пример юнит теста с использованием паттерна Builder. В комментариях к нему возникло много вопросов зачем это все, т.к. это лишний код который надо тоже тестировать и т.п. Проблема в том что пример там был очень простой и билдер…
Давайте теперь рассмотрим пример использования паттерна Builder в тестах проекта BookLibrary:

Там есть основной агрегат Book и написан тест на то, что книгу нельзя взять, пока другой абонент взявший её не вернет в библиотеку.
Чтобы это поведение зафиксировать тестом, нужно:
- создать книгу
- сделать так чтобы она была взята каким-нибудь абонентом
- сделать попытку взятия книги другим абонентом
- ожидать что эта операция не будет выполнена с соответствующей ошибкой.

Как тест выглядит без билдера:
[Test]
public void Should_throw_exception_when_try_to_borrow_book_that_was_already_borrowed_by_another_abonent()
{
// arrange
var book = CreateBook(clearDomainEvents: true); // приватный метод в тестов классе который просто создает книгу
var firstAbonentId = new AbonentId(Guid.NewGuid());
var secondAbonentId = new AbonentId(Guid.NewGuid());

book.Borrow(
abonement: new Abonement(firstAbonentId, 0),
borrowedAt: DateTimeOffset.UtcNow,
returnBefore: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(5))
);

// act
void Act()
{
book.Borrow(
abonement: new Abonement(secondAbonentId, 0),
borrowedAt: DateTimeOffset.UtcNow,
returnBefore: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(10))
);
}

// assert
Assert.That(Act, ErrorCodes.BookAlreadyBorrowed.Expect());
}

И с билдером:
[Test]
public void Should_throw_exception_when_try_to_borrow_book_that_was_already_borrowed_by_another_abonent()
{
// arrange
var firstAbonentId = new AbonentId(Guid.NewGuid());
var secondAbonentId = new AbonentId(Guid.NewGuid());

var book = new BookBuilder().SetBorrowedBy(firstAbonentId).Build();

// act
void Act()
{
book.Borrow(
abonement: new Abonement(secondAbonentId, 0),
borrowedAt: DateTimeOffset.UtcNow,
returnBefore: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(10))
);
}

// assert
Assert.That(Act, ErrorCodes.BookAlreadyBorrowed.Expect());
}

В примере без билдера есть 2 проблемы:
1. Нет возможности создать книгу не с дефолтными параметрами. Если такое будет где-нибудь нужно, то придется в теле теста самому создать Book или добавлять параметры в метод CreateBook.
2. Два вызова Borrow может быть неочевидным. Тут помогает только понятное название теста и то что один расположен в секции Arrange, другой в Act.
Но они оба вызываются почти с одинаковыми параметрами, отличие только в том кто берет книгу и на сколько.
Но важным параметром является только идентификатор абонента, все остальное в данном тесте не участвует и лишь мешает.

В тоже время пример с билдером позволяет через FluentAPI создать книгу с любыми данными, а также привести в состояние что книга взята таким то абонентом одним простым понятным методом, указав только те данные которые важны для этого теста.

Помимо прочего, можно внутри билдера использовать Faker или Bogus или любой другой генератор тестовых данных для того чтобы вручную их не заполнять.

Diff с рефакторингом для применения паттерна билдер.
.NET sh blog
Представьте что в Вашем приложении требуется отображать различные счетчики. Стандартным решением будет просто сделать count запрос в бд по некоторым условиям, а в случае проблем с производительностью придумать как это закэшировать (с учетом различных параметров).…
Давайте теперь рассмотрим различия в перформансе между прямым обновлением счетчиков и при использовании Outbox таблицы с асинхронным обновлением.

Как делать через Outbox таблицу мы уже рассмотрели выше, посмотрим как делать это напрямую.

Чтобы правильно измерить перформанс нужно поднимать инстанс приложения через командную строку dotnet run -c Release, для того чтобы дебаггер IDE не замедлял работу приложения, плюс применены оптимизации которых нет в отладочной сборке.

Для подачи нагрузки буду использовать популярный инструмент для нагрузочного тестирования k6, который просто будет вызывать API создания книги. Каждый запрос будет создавать 10 книг в бд. Код бенчмарка.

Перформанс прямого обновления счетчиков, очевидно, будет зависеть от сценариев использования API, давайте их рассмотрим.

Сценарий 1.

Много пользователей работают над вашей системой конкурентно, изменения вносят небольшими порциями, поэтому счетчики как правило меняются по одному +1/-1 и часто.

Реализация этого сценария выложена на GitHub.

Для прямого обновления счетчиков я взял linq2db.EntityFrameworkCore, потому что он позволяет очень просто сделать upsert через merge.

Вызов linq2Db MergeAsync сразу отправляет запрос на сервер (так же как и другие bulk операции типа ExecuteUpdate/ExecuteDelete в EF), поэтому нам нужно самим оборачивать их в транзакцию и коммитить.

В данном случае исходная архитектура BookLibrary не менялась и просто вместо записи в Outbox делаем операцию по обновлению счетчика. Поэтому обновление счетчиков делается по 1 на каждую новую книгу.

Результат бенчмарка: RPS=21.83 RPS, median=313ms, p95=1.15s

Сценарий 2.
Выполняются преимущественно массовые вставки или обновления. В таком случае счетчики можно обновлять одной операцией MERGE сгруппировав и посчитав в памяти необходимые изменения.

Реализация этого сценария выложена на GitHub.

Ради оптимизации здесь больше не используется обработчик события создания книги. Вместо этого сразу считаем как должны измениться счетчики и обновляем одним запросом.

Результат бенчмарка: RPS=228.5, median=33ms, p95=106ms

Перформанс сильно вырос, аж в 10 раз, и как раз в 10 раз меньше делаем запросов на обновление счетчиков :)

Бенчмарк для Outbox: RPS=625.7, median=10ms, p95=16ms

Перформанс вырос еще почти в 3 раза и больше нет риска поймать дедлок, но счетчики обновляются асинхронно.

Отчёты бенчмарков от k6 в комментариях.

P.S. здесь тестируется худший случай - максимальная конкурентность обновлений: меняется только один счетчик. При хорошем распределении данных, перформанс может быть значительно лучше у сценариев 1 и 2, но всё равно надо быть аккуратным при массовых обновлениях чтобы не словить дедлок.