All streams
Search
Write a publication
Pull to refresh
59
28
Стас Выщепан @gandjustas

Умею оптимизировать программы

Send message

Во-первых в статье написать можно любую цифру

Во-вторых у меня код выдает 600 заказов в секунду и 300+ обновлений остатков в секунду на каждый товар. Я могу сделать по 100 товаров и 100 складов, распределив равномерно запросы между нему, тогда количество запросов в секунду будет около 2000, а количество броней на один товар в секунду - меньше 100.

В-третьих у меня код работает на одной машине, в docker в wsl вместе со средой разработки и тестирования. Мы же не знаем какое железо было в оригинальной статье.

Нет, конечно.

Это вы о чем?

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

Что значит "загнали систему в пик производительности" ? В коде из статьи быстродействие НЕ упирается в железо если что. Железо выдерживает в 5,5 раз больше.

И не надо писать, что надо заранее железо увеличить, не поможет, надо будет шардировать, отчего вся опитимизация пойдет погулять

Чему именно помешает шардирование?

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

Что делать если вы превысили страховой остаток? Да и в целом сам маркетплейс остатком не управляет, продавец отправляет на склад по мере готовности и спрос может превысить запасы.

Значит заказ отменится. Цель - что бы такое было гораздо реже чем покупка.

Если вы заказываете что-то в магазине, а ваш заказ отменяется, то будете ли вы еще что-то в нем заказывать?

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

Если вы всю обработку делаете в фоне, то количество таких причин конечно увеличится.

Защитой является тот самый страховой остаток, поэтому все эти приседания - бессмыслены и даже вредны.

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

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

Rate Limiter же обычно 429  возвращает, а не ждет выполнения. Непонятно насколько это лучше, чем клиент просто подождет

Вы наверное не до конца поняли проблему.

Чтобы сделать резервацию надо :

  1. Найти строку в бд

  2. Дождаться снятия блокировки обновления

  3. Повесить свою блокировку обновления

  4. Записать новую строку

И повторить это от 1 до 10 раз (сколько строк в заказе). И только в самом конце снимаются блокировки.

То есть следующая транзакция скорее всего зависнет на строке 2 на относительно долгое время. И транзакция за ней тоже будет висеть.

В тесте, когда выпадали дедлоки, я видел циклы ожиданий, которые состояли из 9 транзакций.

Блокировка происходит не на диапазонах, не на очереди записи на диск, а просто на ожидании снятия блокировки ОДНОЙ строки в один момент времени. Ни партицирование, ни шардирование тут не поможет.

Такова цена ACID гарантий при высокой конкурентности. И это самый лучший вариант на самом деле, все альтернативы еще более медленные, как в примере с однопоточной очередью.

Именно с ним, без repeateable read получаю потерю обновлений при конкурентных обновлениях.

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

Попытка впихнуть весь поток изменений товаров в одну БД это конечно страшно.

А что страшного? Нагрузка на железо небольшая, упирается все в блокировки.

Если мы точно знаем, что у нас на складах в разы больше товаров чем дневной заказ все что описано выше ведёт к затратам ресурсов совершенно бессмысленным

Какие траты вы называете бессмысленными?

  • Обновление резервов? Его в любом случае надо делать и в любом случае оно будет тормозить

  • Проверка остатков? Это же простое сравнение, если его отключить, то разница незаметна

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

Гораздо дешевле разгребать очередь в фоне мелкими воркерами

Если если вдруг окажется, что резерв в очереди не может быть сделан - не осталось запасов на складе, тогда что? А вы уже деньги у клиента списали и он ждет что его заказ скоро приедет.

Непонятно что вы сэконосить пытаетесь, если вы сделаете очередь и все запросы последовательно будете обрабатывать, то у вас пропускная способность (от вызова метода клиента до бронирования товара на складе) упадет раза в 4 по сравнению с параллельным случаем. Вы сможете с меньшими ресурсами принимать заказы, но без обновления остатков какой в этом смысл?

Увы даже такой вариант у меня теряет обновления при использовании UNNEST. Я все что можно перепробовал.

А во всех остальных случаях код с ORDER BY ... FOR UPDATE работает не быстрее триггеров. Но FOR UPDATE нельзя написать на EF, только SQL.

Посмотрел ещё раз запрос. Он покрутит счётчик зарезервированного, проигнорировав товарное наличие.

Я что-то пропустил этот момент...
1) Вы можете в таблице добавить check constraint
2) Вы можете после запроса обновления проверять что резервы не превышают запасы и откатывать транзакцию в противном случае

Вычисления должны проходить на приложении, на БД рассматривать в нашем кейсе - смысла нет.

Как так вышло и почему:

Мы упирались в диск на БД при вставке резервов (неожиданно самая дорогая и долгая операция в обработке).

Как это связано с остатками? ну даже если вы храните сами заказы вообще в другом хранилище, просто сгенерируйте в программе запрос вида:

UPDATE stock as s
SET reserved = s.reserved - l.quantity
FROM unnest(ARRAY[1,2], ARRAY[1,2] , ARRAY[10,5]) 
  as l(item_id, warehouse_id, quantity)
where s.item_id = l.item_id and s.warehouse_id = l.warehouse_id;

Массивы можно передать параметрами. Такой запрос вам консистентно и атомарно обновит остатки на уровне изоляции read commited. Это даст ОЧЕНЬ высокую степень конкурентности. Вам не придется вешать pg_advisory_xact_lock. Запросы не будут ждать в очереди на блокировку и будут быстрее освобождать соединения.

Проведя множество экспериментов, нашли неочевидное решение: товары резерва лучше всего сериализовать в proto и класть в резерв как поле с типом bytea (сейчас резерв в БД это 1 запись вида {id uuid, … , items bytea }). С этим не удобно работать вне кода, но скорость перекрыла минусы.

Исходя из примера выше вы можете эти три массива хранить как массивы в заказе и работать с ними из запросов Postgres. Я не думаю что это будет занимать больше места, чем сериализованные в protobuf структуры.

Важный момент - клиенты сами генерируют ID резерва (uuid v7) перед походом в нас, иначе скорость вставки/чтения будет заоблачной.

Это как раз дает возможность ВСЮ логику, в том числе идемпотентность, выполнить за один запрос к СУБД с уровнем блокировки read commited.

Просто нагенерируйте запрос вида:

WITH 
ord AS (
    INSERT INTO orders(id, items, wharehouses, quantities) 
    VALUES ($1,$2,$3,$4) 
    ON CONFLICT DO NOTHING
    RETURNING *
)
UPDATE stock as s
SET reserved = s.reserved - l.quantity
FROM (
  select l.* FROM ord, LATERAL (
    unnest(items, wharehouses, quantities) 
      as l(item_id, warehouse_id, quantity)
  ) l 
where s.item_id = l.item_id and s.warehouse_id = l.warehouse_id;
-- $1 = 'f32048c5-2e69-441d-bd4b-08fa3c413c46'
-- $2 = ARRAY[1,2], --item_id
-- $3 = ARRAY[1,2], --quantity
-- $4 = ARRAY[10,5], --quantity

Интересующие куски кода из сэмпла

Я несколько раз прочитал ваш пример и не понял чем оно лучше, тем то что я привел. Просто вы собираете "буфер" до отправки в ченнел, а я предложил делать это после чтения из ченнела.

  • После чтения всегда можно узнать есть ли еще элементы в ченнеле

  • Как только вы выбрали все что есть в ченнеле в текущий момент вы это можете в одном соединении с БД обработать

  • Не нужно локи вешать руками

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

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

  • Я уже второй коммент подряд пишу запросы, которые не требуют никаких ретраев

  • Я уже третий коммент пытаюсь "на пальцах" показать что ваш "буффер" - лишняя сущность.

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

У вас в статье про счетчик написано. Для счетчика вам не нужна пессимистичная блокировка на advisory lock, там read commited достаточно. Для остальной работы тоже более чем достаточно read commited, так как конкуренции там нет.

Для принятия решения о каждом резерве - требуется

1. Знание о его существовании в БД
2. Наличие свободного стока для всех позиций резерва

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

WITH 
ord AS (
    INSERT INTO orders VALUES ('f32048c5-2e69-441d-bd4b-08fa3c413c46') 
    ON CONFLICT DO NOTHING
    RETURNING *
),
lines AS ( --тут можно использовать ord
    INSERT INTO order_lines 
    VALUES 
    ('f32048c5-2e69-441d-bd4b-08fa3c413c46', 1, 1, 10),
    ('f32048c5-2e69-441d-bd4b-08fa3c413c46', 2, 2, 5) 
    ON CONFLICT DO NOTHING 
    RETURNING *
)
UPDATE stock as s
SET reserved = s.reserved - l.quantity
FROM lines l
where s.item_id = l.item_id and s.warehouse_id = l.warehouse_id;

т.к. буферизация под каждую схему(шард) отдельная - сервис/клиент может попробовать создать резерв в ту схему, где нет "популярных товаров" и отвалится по таймауту.Мы не наберём там больше 1 запроса за требуемое время и клиент отменит запрос.

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

Еслиб можно было крутить только счётчик без всей дополнительной работы - было бы значительно проще :)

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

Теперь понятно откуда такие проблемы с перфомансом. Задача по обновлению остатков при поступлении заказа в postgres решается на уровне Read Commited одним запросом, а вы на advisory сделали медленный вариант Serializable в SQL Server\MySQL и кучу раз бегаете в базу.

  1. Приходит запрос -> кладём его в активный буфер (массив)

  2. Добавляем буфер в очередь на обработку (Channel), если его ещё там нет

  3. …В буфер продолжают добавляться новые запросы

Вам тогда не нужен буфер. Ченнел - уже очередь (LIFO буфер). Каждый запрос пишет в writer, а на стороне reader делаете:

while(await reader.WaitToReadAsync())
{
    List<T> batch = new();
    while(batch.Count < MAX_ITEMS_IN_BATCH 
          && reader.TryRead(out var item)) batch.Add(item);  
    await ProcessBatch(batch);    
}

Если убрать промежуточное звено в виде буфера и класть запросы напрямую в Channel, то на стороне workerа надо будет как-то набирать нужное количество запросов, появятся какой-то искусственный delay при слабом трафике (если трафика мало, то запросы могут уходить в работу слишком поздно, потому кроме счётчика требуется и дедлайн на время накопления)

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

ИМХО потратив время на оптимизацию работы с базой вместо изобретения "буферов" вы бы добились лучших показателей, да еще и нагрузку на железо бы сократили.

Сделаете пример с транзакционным orleans с 100+ RPS. Даже не на товар, а всего на систему, которая запускается на локальной машине и может масштабироваться?

Single-threaded execution within a grain

У вас больше одного сервера, где выполняются ваши grains

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

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

Там (В методе BookItems у объекта ShopItem) в транзакции

В транзакции база сама проверяет ограничения ПОСЛЕ записи - уникальность (при обновлении индекса), внешние ключи, и прочие check constraints. Вам не нужно придумывать транзакционность в приложении

https://learn.microsoft.com/ru-ru/dotnet/orleans/grains/transactions

А вы сами читали? Транзакционность в Orleans реализована за счет сохранения состояния в транзакционном хранилище, то есть в БД.

То есть вы этим буфером просто батчите обновления БД, чтобы было не одно подключение к базе на каждый запрос пользователя?

Почему тогда не воспользоваться стандартными System.Threading.Channels? Делаете консьюмеров для него столько, сколько вы хотите максимум соединений открывать. Можно даже написать свой монитор количества элементов в канале и спавнить новые задачи для открытия.

При любой деградации БД мы упрёмся в пул подключений на приложении / пуллере БД

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

В БД вместо 1 записи будет постоянно удалятся и добавляться новые вместо обновления одного поля (IO)

Вы же в курсе, что в postgres обновление строки = пометка удаления (запись xmax) + создание новой строки?

Как вы добьетесь того, чтобы stock >= reserved для всех записей всегда? Если правила нарушается, то резерв надо отменить, а пользователю сказать "простите, товар закончился на складе"?

Я так и не понял: буфер это что? Где он хранится?

Как работает со стороны пользователя? Он кликает оплатить и идет курить пока буфер заполнится?

А если за время заполнения буфера клиент запрос отменит?

1
23 ...

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