Необходимо реализовать возможность приёма транзакций от разных клиентов для процессинга и дальнейшей отправки в разные банки. Клиенты могут присылать как равномерно (например, по 1шт в секунду), так и большими пачками (1000шт каждый час или 10000 раз в день), банки обрабатывают транзакции последовательно.

Для простоты интеграции необходимо использовать REST API для приёма транзакций

Необходимо исключить ситуации, когда:

Тысячи клиентов по несколько транзакции ожидают двух, у которых по 10000 транзакций

Несколько клиентов по 10000 ждут тысячи клиентов по несколько транзакций.


Ок, нужно реализовать сервис который обрабатывает транзакции

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

[Route("api/[controller]")]
[ApiController]
public class ProcessController : ControllerBase
{
    [HttpPost("Queue")]
    public async Task<IActionResult> Queue([FromBody] RequestDTO request)
    {
        // Что-то вроде балансировки нагрузки
        var requestUri = request.TransactionsCount < 100
            ? $"http://instance1:81/api/Process/Queue"
            : $"http://instance2:81/api/Process/Queue";

        HttpClient client = new HttpClient();
        var result = await client.PostAsJsonAsync(requestUri, request); 
        var content = await result.Content.ReadAsStringAsync();
        return Ok(content);
    }
}

Да, но в случае, когда есть 3 клиента (Клиент 1: 10000 транзакций), (Клиент 2: 1000 транзакций), (Клиент 3: 100 транзакций) третий клиент будет ожидать пока выполнятся все 11000 транзакций первых двух клиентов

Может обрабатывать сперва запросы у которых меньше транзакций?

Тогда в случае, когда 1000 клиентов (Клиент 1: 10000 транзакций), (Клиент 2-500: 1000 транзакций), (Клиент 501 - 1000: 100 транзакций), Клиент 1 будет ожидать пока обработаются все мелкие запросы. Вариант увеличить мощность машины в котором инстанс для больших запросов, масштабировав вертикально тоже отметается.

Тогда придется добавить все запросы в очередь (или группировать по клиентам, или по другому параметру) и выполнять транзакции по очереди с каждого запроса. Это, по моему мнению, самый справедливый вариант для клиентов, так как клиент не просто ждет пока дойдет его очередь, а постепенно получает callback от выполненных транзакций

if (Requests.Any())
{
    // С каждого запроса выполняем одну транзакцию
    List<Task> tasks = new List<Task>();
    foreach(var request in Requests.ToList())
    {
        // имитируем обработку транзакции
        tasks.Add(TransactionProcess());
        request.TransactionsCount--;
        // имитируем callback
        logger.LogCritical($"[Client: {request.Client}] left {request.TransactionsCount} transactions");
        if(request.TransactionsCount == 0)
        {
            // Если все транзакции клиента выполнены, удаляем запрос из очереди
            Requests.Remove(request);
        }
    }
    // Выполняем транзакции одного цикла параллельно
    await Task.WhenAll(tasks);
}
3 запроса по 1, 3 и 6 транзакций и порядок их выполнения
3 запроса по 1, 3 и 6 транзакций и порядок их выполнения

А потом пакуем приложение с помощью docker compose. Результат тут

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

3 клиента по 10000, 1000 и 500 транзакций в одном запросе в контейнере instance2-1
3 клиента по 10000, 1000 и 500 транзакций в одном запросе в контейнере instance2-1

А тут, то что "Load balancer" перенаправляет запросы с менее 100 транзакциями в первый инстанс:

Запрос четвертого клиента, у которого всего 90 транзакций (меньше 100) обрабатывается в отдельном контейнере instance1-1
Запрос четвертого клиента, у которого всего 90 транзакций (меньше 100) обрабатывается в отдельном контейнере instance1-1

Предлагайте как еще можно улучшить решение. Также предлагайте другие задачи на тему микросервисов

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


  1. zubrbonasus
    21.10.2023 07:42

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

    Решение немного обработать напильником и можно пилить код.


    1. Amangeldi Автор
      21.10.2023 07:42

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

      … результат обработки записывают в таблицу истории обработки транзакций…

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


      1. koridoc
        21.10.2023 07:42

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

        А вообще рекомендую почитать какое-нибудь пособие по теории массового обслуживания (кстати, у вас почти классическая задача одного станка) и потыкать палкой GPSS.


        1. Amangeldi Автор
          21.10.2023 07:42

          Есть большое отличие от задачи одного станка. В нашем случае не учитываются такие значение как: «Время исполнения транзакции», «значение накладных ресурсов, для исполнения транзакции», «Важность транзакции». Все транзакции для системы условно равны.

          Что значит время ожидания должно быть справедливым?

          Критерии «справедливости» я описал в самом начале задачи:

          Необходимо исключить ситуации, когда:

          Тысячи клиентов по несколько транзакции ожидают двух, у которых по 10000 транзакций

          Несколько клиентов по 10000 ждут тысячи клиентов по несколько транзакций.


          1. zubrbonasus
            21.10.2023 07:42

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


          1. ultrinfaern
            21.10.2023 07:42

            А как насчет ситуации, когда из за постоянного наплыва мелких транзакций, большая никогда не выполнится?


            1. zubrbonasus
              21.10.2023 07:42

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


            1. Amangeldi Автор
              21.10.2023 07:42

              Такой сценарий невозможен. Если речь про запросов с менее 100 транзакций то они выполняются в отдельном инстансе (он может быть в другом сервере…).

              Допустим, у нас 10 тысяч запросов с 100-300 транзакциями в запросе, и несколько запросов с 1000 транзакциями. Тогда получается, мы объединяем транзакции в пачки из 10 тысяч транзакций, по одной транзакции с каждого запроса. И одна транзакция из большого запроса выполнится раз из 10к транзакций. Да, большому запросу придется ожидать дольше, так как каждая транзакция выполняется в цикле из 10к транзакций (пока не закончатся пачки из мелких транзакций), но ресурсы ограничены, по другому нельзя. Возможно, я не прав, можете предложить более релевантный сценарий.

              При желании, можно указать в docker compose, что для транзакций с 100-300 транзакциями в запросе, нужно выделить отдельный инстанс, как для запросов с менее 100 транзакций

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


              1. zubrbonasus
                21.10.2023 07:42
                +1

                В конкретном виде моё предложение выглядит так: существует оркестратор (приложение управляющее инстансами обработчиков). Оркестратор порождает консьюмер (инстанс который обрабатывает входящие запросы клиентов). Итак приходит запрос от клиента, оркестратор порождает инстанс который занимался транзакции. Приходит ещё запрос - ещё один порождает. Когда инстанс освободился - он уничтожается или берет следующий поток, а уничтожается когда появляется простой. Таким образом все инстансы будут работать независимо друг от друга и новые запросы не будут мешать долгим запросам. Создавать лоадбалансер и разделять запросы по количеству транзакций - похоже на костыль.


              1. propell-ant
                21.10.2023 07:42
                +1

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

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

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

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


                1. Amangeldi Автор
                  21.10.2023 07:42

                  Да, именно. Одна выполненная транзакция запроса – точка синхронизации, после которой переходим на выполнение транзакции со следующего запроса.


                  1. propell-ant
                    21.10.2023 07:42

                    Перечитал статью, посмотрел исходники:
                    когда вы в промежуточном сервисе ProcessExecutor создали список запросов - вы создали у своего сервиса state, он как-бы перестал быть "чисто-рестовым".
                    public static List<RequestDTO> Requests { get; set; } = new List<RequestDTO>();
                    Теперь при отключении инстанса (а при масштабировании на это надо закладываться) этот state потеряется. Клиенты должны будут самостоятельно решать, какие транзакции отправлять повторно, какие признать выполненными.

                    Как вы будете бороться с этим тонким местом?

                    Мелочи не трогаю.


  1. gandjustas
    21.10.2023 07:42
    +4

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

    Кстати вполне практическое приложение: я заехал в мак вкусно-и-точка взять кофе. Но в ресторане было много заказов и кофе я дожидался минут 15. Хотя сама операция наливания кофе занимает от силы минуту, но передо мной было много "семейных" заказов, которые не умещались на один поднос.

    Очевидно в маке (буду так называть) используется обычная FIFO очередь. Без потери общности можно считать, что обработчик очереди тоже один. Чтобы улучшить обслуживание они могли бы каждому заказу назначать некоторый вес Wi. В случае мака это время приготовления (они могут вычислить), а в случае топикстартера - количество транзакций в запросе.

    • Далее надо ввести функцию долга D(w), такую что D(Wi) < Wi. Для примера D(w) = 0.5 * w

    • После обработки каждого заказа считать значение долга d = (d - Wi + D(Wi)) max 0.

    • Следующим заказом брать первым из очереди у которого Wi <= d, или первый из очереди если под условие ни один не подходит, в начале обработки d = 0

    Если обработчиков очереди больше одного, то параметр долга должен быть общий на всех.


    1. zubrbonasus
      21.10.2023 07:42

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

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

      Что касается функции долга: если 2 посетителя заказали по три семейных комбо, а третий посетитель только колу, тогда при распределенной работе множество людей будут готовить компоненты семейного комбо, а для третьего посетителя один человек нальет колу и он уйдёт. А если оценивать вес заказа, тогда семейное комбо будет весомее с точки зрения бизнеса, чем посетитель заказавший колу. Однако распределение ресурсов позволяет максимально быстро удовлетворить потребности всех посетителей.


      1. gandjustas
        21.10.2023 07:42

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


      1. gandjustas
        21.10.2023 07:42

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

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

        Если будет действовать по моему алгоритму, то после первого крупного заказа появится "долг" в размере Х, а кола гарантированно будет иметь вес меньше Х (если нет, то неправильно функцию долга подобрали). И вы сразу после обработки крупного заказа её получите.

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

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

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


    1. boov
      21.10.2023 07:42
      +1

      Не могли бы пояснить, пожалуйста. Выглядит так, что d <= 0, а Wi > 0. Условие Wi <= d никогда не выполнится.


      1. gandjustas
        21.10.2023 07:42
        +1

        Неверно формулу записал, надо было так: d = ((d - Wi) max 0) + D(Wi)

        Спасибо.


    1. Vitimbo
      21.10.2023 07:42

      Разве у них все так и не устроено? В БК, во всяком случае, если кто-то перет просто лимонад, то ему наливают почти без очереди.


  1. gandjustas
    21.10.2023 07:42

    Что касается кода из статьи, то автор не учел, что когда у всех клиентов будет меньшее 100 транзакций в заказе, то второй инстанс будет отдыхать. Или наоборот, все элементы в очереди окажутся чуть больше порога. Этот алгоритм хоть и "справедливый", но нифига не эффективный.


    1. Amangeldi Автор
      21.10.2023 07:42

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

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

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


      1. gandjustas
        21.10.2023 07:42

        Почему не использует? Вы заранее знаете что у вас все инстансы на одной физической машине развернуты? Масштабировать не собираетесь?

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


  1. Arm79
    21.10.2023 07:42
    +1

    Я может чего не понял в постановке, но что мешает получить сообщение с транзакциями (без учёта их количества) и по одной записывать в ту же кафку? Тогда сообщения от всех клиентов будут равномерно перемешаны с учётом времени прихода. И также горизонтально масштабируется путем создания нужных партий и увеличения консьюмеров

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

    Грубо говоря - получили 10к транзакций. Присваиваем каждой из них случайное число в диапазоне 0..100к и пишем их в БД.

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

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

    Метод работает хорошо, если скорость исполнения транзакций значительно превышает скорость их прихода.

    PS только обратил внимание, что gandjustas предложил в принципе то же самое со своей функцией долга


    1. Amangeldi Автор
      21.10.2023 07:42

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

      Генератор случайных чисел не подходит, так как кукушата, скорее всего, будут равномерно распределены не в начале ранга.

      Вы предлагаете запросу, с одной транзакцией случайно сгенерировать ранг 7539 и ждать, пока 7538 транзакций отработают, пока кукушонку не дойдет очередь. Согласитесь, мой вариант справедливее.

      Метод работает хорошо, если скорость исполнения транзакций значительно превышает скорость их прихода.

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


      1. Arm79
        21.10.2023 07:42

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

        Конечно, если всего 2 клиента, по 1 транзакции и 10к транзакций, то такие коллизии возможны. Но когда одиночных транзакций становится побольше, среднее время ожидания уменьшается

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

        Вариант 3 - это вариация варианта 2: оценивать при вычитывании размер очереди. Чем больше размер очереди, тем больше у этой очереди приоритет вычитывания транзакций. И после каждого вычитывания пересчитывать приоритеты

        Получится примерно так-2 клиента: 10к транзакций и 100 транзакций. И на каждую 1000 транзакций из первой очереди считаем 10 транзакций из второй. Этот вариант позволяет уровнять время обслуживания всех клиентов.


        1. Amangeldi Автор
          21.10.2023 07:42

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

          Я разве не реализовал тоже самое?


          1. Arm79
            21.10.2023 07:42

            Кажется, не то же самое. Отдельный инстанс на мелкие транзакции неравно отдельному инстансу на клиента


  1. dabrahabra
    21.10.2023 07:42

    Рашили похожую задачу используя hash exchange который равномерно размазывает пользователей на N очередей и сервис обработчики с подпиской на все очереди и лимитом в количество обрабатываемых сообщений из каждой очереди (грубо говоря до 8 потоков вообще и не более двух потоков на одну очередь).

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


  1. Anarchist
    21.10.2023 07:42

    Придумать функцию двух параметров: время прихода сообщения и количество транзакций. Фунция монотонно убывает от времени и растёт от количества транзакций. Сообщения балансировщих скидывает в кучу. На вершине всегда будет наиболее приоритетное, его балансировщих и будет скармливать обработчикам. Осталось поковыряться с арифметикой и придумать функцию.


  1. Kahelman
    21.10.2023 07:42

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

    если у вас N клиентом которые шлют транзакции, назовём нормальными словами хотят провести платежи через M банков.

    При этом в Росси около 200 банков, т.е. N >> M.

    Очередь должна быть на каждый банк, так как это API банка который будет тормозить.

    Далее не ясно мы списываем деньги или зачисляем.

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

    ещё одна проблема- если не прошла 1001 -Аня транзакция на 1000000 млн рублей, то 1002 на 1 рубль может пройти. Скорее всего данных от остатках на счете у клиента у вас нет, так что в случае получения отказа на на транзакцию -надо попытаться пропихнуть транзакции с меньшей суммой.

    соответственно вы не можете просто перемешать платежи клиента с платежами других клиентов.

    В общем- надо глубже вникать в бизнес процесс а не в микросервисы.

    И последнее: закон Мура - экспоненциальный , а микросервисы масштабируются линейно, т.е. выгоднее купить более производительный Помпею но и не париться с микросервисами.


  1. Sealkeen
    21.10.2023 07:42

    Я сейчас могу ошибаться, но, по-моему, в официальной документации написано, что нужно иметь только 1 инстанс http клиента, а не множить их по кучи штук, чтобы не было ситуации, когда в системе закончились сокеты.

    ```C#

    //No

    HttpClient client = new HttpClient();```

    //Yes

    public void ctor()

    {

    _client = new HttpClient();

    }