Стас Выщепан @gandjustas
Умею оптимизировать программы
Information
- Rating
- 271-st
- Location
- Москва, Москва и Московская обл., Россия
- Date of birth
- Registered
- Activity
Specialization
Software Architect, Delivery Manager
Lead
C#
.NET Core
Entity Framework
ASP.Net
Database
High-loaded systems
Designing application architecture
Git
PostgreSQL
Docker
А вы статью прочитали?
Во-первых в статье написать можно любую цифру
Во-вторых у меня код выдает 600 заказов в секунду и 300+ обновлений остатков в секунду на каждый товар. Я могу сделать по 100 товаров и 100 складов, распределив равномерно запросы между нему, тогда количество запросов в секунду будет около 2000, а количество броней на один товар в секунду - меньше 100.
В-третьих у меня код работает на одной машине, в docker в wsl вместе со средой разработки и тестирования. Мы же не знаем какое железо было в оригинальной статье.
Это вы о чем?
Что значит "загнали систему в пик производительности" ? В коде из статьи быстродействие НЕ упирается в железо если что. Железо выдерживает в 5,5 раз больше.
Чему именно помешает шардирование?
Что делать если вы превысили страховой остаток? Да и в целом сам маркетплейс остатком не управляет, продавец отправляет на склад по мере готовности и спрос может превысить запасы.
Если вы заказываете что-то в магазине, а ваш заказ отменяется, то будете ли вы еще что-то в нем заказывать?
Если вы всю обработку делаете в фоне, то количество таких причин конечно увеличится.
Проблема в том, что в пики распродаж от момента поступления заказа до момента накладывания резерва могут заказать количество товаров в 10 раз превышающее страховой остаток. Вы же заранее не знаете как долго у вас будет очередь обрабатываться при нагрузке и как часто будут приходить заказы.
И еще раз напомню, что в случае маркетплейса сам маркетплейс пополняет остатки на складе, это делает только продавец. Поэтому у вас может просто не быть страхового остатка.
Rate Limiter же обычно
429
возвращает, а не ждет выполнения. Непонятно насколько это лучше, чем клиент просто подождетВы наверное не до конца поняли проблему.
Чтобы сделать резервацию надо :
Найти строку в бд
Дождаться снятия блокировки обновления
Повесить свою блокировку обновления
Записать новую строку
И повторить это от 1 до 10 раз (сколько строк в заказе). И только в самом конце снимаются блокировки.
То есть следующая транзакция скорее всего зависнет на строке 2 на относительно долгое время. И транзакция за ней тоже будет висеть.
В тесте, когда выпадали дедлоки, я видел циклы ожиданий, которые состояли из 9 транзакций.
Блокировка происходит не на диапазонах, не на очереди записи на диск, а просто на ожидании снятия блокировки ОДНОЙ строки в один момент времени. Ни партицирование, ни шардирование тут не поможет.
Такова цена ACID гарантий при высокой конкурентности. И это самый лучший вариант на самом деле, все альтернативы еще более медленные, как в примере с однопоточной очередью.
Именно с ним, без repeateable read получаю потерю обновлений при конкурентных обновлениях.
Проблема только в том, что между сравнением и записью "точно хватает" может превратиться в "точно не хватает".
А что страшного? Нагрузка на железо небольшая, упирается все в блокировки.
Какие траты вы называете бессмысленными?
Обновление резервов? Его в любом случае надо делать и в любом случае оно будет тормозить
Проверка остатков? Это же простое сравнение, если его отключить, то разница незаметна
Это я в рамках теста знаю, что все запросы выполнятся успешно, а в жизни вы такого не можете гарантировать. Код корректно отработает в любом случае.
Если если вдруг окажется, что резерв в очереди не может быть сделан - не осталось запасов на складе, тогда что? А вы уже деньги у клиента списали и он ждет что его заказ скоро приедет.
Непонятно что вы сэконосить пытаетесь, если вы сделаете очередь и все запросы последовательно будете обрабатывать, то у вас пропускная способность (от вызова метода клиента до бронирования товара на складе) упадет раза в 4 по сравнению с параллельным случаем. Вы сможете с меньшими ресурсами принимать заказы, но без обновления остатков какой в этом смысл?
Увы даже такой вариант у меня теряет обновления при использовании
UNNEST
. Я все что можно перепробовал.А во всех остальных случаях код с
ORDER BY ... FOR UPDATE
работает не быстрее триггеров. НоFOR UPDATE
нельзя написать на EF, только SQL.Я что-то пропустил этот момент...
1) Вы можете в таблице добавить check constraint
2) Вы можете после запроса обновления проверять что резервы не превышают запасы и откатывать транзакцию в противном случае
Как это связано с остатками? ну даже если вы храните сами заказы вообще в другом хранилище, просто сгенерируйте в программе запрос вида:
Массивы можно передать параметрами. Такой запрос вам консистентно и атомарно обновит остатки на уровне изоляции read commited. Это даст ОЧЕНЬ высокую степень конкурентности. Вам не придется вешать
pg_advisory_xact_lock
. Запросы не будут ждать в очереди на блокировку и будут быстрее освобождать соединения.Исходя из примера выше вы можете эти три массива хранить как массивы в заказе и работать с ними из запросов Postgres. Я не думаю что это будет занимать больше места, чем сериализованные в protobuf структуры.
Это как раз дает возможность ВСЮ логику, в том числе идемпотентность, выполнить за один запрос к СУБД с уровнем блокировки read commited.
Просто нагенерируйте запрос вида:
Я несколько раз прочитал ваш пример и не понял чем оно лучше, тем то что я привел. Просто вы собираете "буфер" до отправки в ченнел, а я предложил делать это после чтения из ченнела.
После чтения всегда можно узнать есть ли еще элементы в ченнеле
Как только вы выбрали все что есть в ченнеле в текущий момент вы это можете в одном соединении с БД обработать
Не нужно локи вешать руками
Вы, похоже, очень невнимательно читали что я пишу, особенно примеры кода.
Я уже второй коммент подряд пишу запросы, которые не требуют никаких ретраев
Я уже третий коммент пытаюсь "на пальцах" показать что ваш "буффер" - лишняя сущность.
del
У вас в статье про счетчик написано. Для счетчика вам не нужна пессимистичная блокировка на advisory lock, там read commited достаточно. Для остальной работы тоже более чем достаточно read commited, так как конкуренции там нет.
Вы усложняете сильно. Вот пример запроса, который идемпотентно в базу вставляет заказ, позиции и сражу же обновляет stock:
То есть клиент таки ждет пока не заполнится буфер, даже если нагрузки на базу нет и вы можете сразу обработать его запрос? Тогда ваш буфер выглядит еще более сомнительно.
Есть у меня подозрение, что сложность вызвана как раз тем, что вы сами накрутили, а не условиями задачи. Не говоря уже о том, что вы в посте про эти условия не упомянули.
Теперь понятно откуда такие проблемы с перфомансом. Задача по обновлению остатков при поступлении заказа в postgres решается на уровне Read Commited одним запросом, а вы на advisory сделали медленный вариант Serializable в SQL Server\MySQL и кучу раз бегаете в базу.
Вам тогда не нужен буфер. Ченнел - уже очередь (LIFO буфер). Каждый запрос пишет в
writer
, а на сторонеreader
делаете:В том-то и дело, что нет. Код выше выбирает просто все запросы, которые есть сейчас в очереди. Он конечно дает задержку, пока вы наберете свои 50, или сколько там у вас запросов, но она на три порядка меньше чем один раунд-трип в базу.
ИМХО потратив время на оптимизацию работы с базой вместо изобретения "буферов" вы бы добились лучших показателей, да еще и нагрузку на железо бы сократили.
Сделаете пример с транзакционным orleans с 100+ RPS. Даже не на товар, а всего на систему, которая запускается на локальной машине и может масштабироваться?
У вас больше одного сервера, где выполняются ваши grains
Ну конечно могу. Более того, так и должен делать. Иначе придется или блокировки навешивать или вы обязательно зарегистрируете больше, чем есть на складе.
В транзакции база сама проверяет ограничения ПОСЛЕ записи - уникальность (при обновлении индекса), внешние ключи, и прочие check constraints. Вам не нужно придумывать транзакционность в приложении
А вы сами читали? Транзакционность в Orleans реализована за счет сохранения состояния в транзакционном хранилище, то есть в БД.
То есть вы этим буфером просто батчите обновления БД, чтобы было не одно подключение к базе на каждый запрос пользователя?
Почему тогда не воспользоваться стандартными
System.Threading.Channels
? Делаете консьюмеров для него столько, сколько вы хотите максимум соединений открывать. Можно даже написать свой монитор количества элементов в канале и спавнить новые задачи для открытия.Так вы в любом случае упретесь, не? У вас же несколько инстансов делают одно и то же и все они сходятся только в СУБД
Вы же в курсе, что в postgres обновление строки = пометка удаления (запись xmax) + создание новой строки?
Как вы добьетесь того, чтобы
stock >= reserved
для всех записей всегда? Если правила нарушается, то резерв надо отменить, а пользователю сказать "простите, товар закончился на складе"?Я так и не понял: буфер это что? Где он хранится?
Как работает со стороны пользователя? Он кликает оплатить и идет курить пока буфер заполнится?
А если за время заполнения буфера клиент запрос отменит?