Skip to main content

Command Palette

Search for a command to run...

Leaky bucket w .NET – jak wyrównać przepływ zdarzeń

Updated
5 min readView as Markdown
Leaky bucket w .NET – jak wyrównać przepływ zdarzeń
B

.NET Developer | I build applications from concept to production. On my blog, I share practical examples (.NET “by example”) and thoughts on software architecture and bridging technology with business.

Wprowadzenie

W poprzednim artykule o Jitter poznaliśmy, jak możemy uniknąć nagłego skoku obciążenia, gdy wiele procesów startuje i pobiera te same dane w jednym momencie.

Tym razem rozważymy, co zrobić, gdy zdarzenia z natury napływają nierównomiernie, a mimo to aplikacja musi przetwarzać je w stałym, przewidywalnym tempie.

Pomyślmy o systemie opartym na zdarzeniach, który generuje korespondencję w aplikacji finansowej. Inne aplikacje przekazują do niego komendy tworzenia i wysłania listów PDF do wskazanych odbiorców. Wszystko działa sprawnie, dopóki nie nadejdzie termin korespondencji okresowej. Firma jest zobowiązana dostarczyć ją klientom w konkretnym dniu. Wtedy w systemie, poza codziennymi zapytaniami, pojawiają się dodatkowe polecenia generowania listów, załóżmy dla 50 000 użytkowników naraz. Umówmy się, że nasz generator PDF-ów ma ograniczoną przepustowość. Potrafi przetworzyć maksymalnie 100 dokumentów na minutę. Gdy przekroczymy limit, zapytania zaczną być odrzucane, a w systemie pojawią się błędy oraz automatyczne ponowienia, które tylko pogłębią chaos. Potrzebujemy mechanizmu, który przyjmie wszystkie polecenia i odda je w tempie, które faktycznie jesteśmy w stanie obsłużyć.

Idea leaky bucket

Żeby obsłużyć gwałtowne obciążenie i nie przekroczyć limitu przepustowości naszego systemu, skorzystamy z algorytmu leaky bucket. Jego nazwa pochodzi od prostego obrazu – wiadra z dziurą w dnie. Gdy wlewamy do niego wodę, niezależnie od tempa, z dziury wypływa ona tym samym, stabilnym strumieniem.

W naszym przypadku „wodą" będą polecenia generowania korespondencji. Nie ma znaczenia, czy do systemu trafi ich 10, czy 50 000 w jednym momencie – mechanizm generowania PDF-ów i tak przetworzy je w tempie maksymalnie 100 na minutę.

Wiadro ma jednak swoją pojemność. Jeśli wlewamy wodę szybciej, niż zdąża ona wyciekać, w pewnym momencie pojemnik się przepełni, a nadmiar po prostu się rozleje. To istotne ograniczenie, które zagwarantuje, że przy trwale zbyt dużym napływie nasz system nie zostanie przeciążony.

Leaky bucket zakłada więc dwie rzeczy do ustalenia: tempo przetwarzania oraz pojemność wiadra. W naszym przypadku pierwsze z nich jest proste do wyznaczenia i wynika wprost z limitu tempa mechanizmu generowania PDF-ów. Pojemność to już decyzja biznesowa, która może wynikać z SLA - ustalenia w jakim maksymalnie czasie powinien zostać wygenerowany list.

Leaky bucket w praktyce – kontrola tempa generowania dokumentów

Żeby zademonstrować działanie leaky bucket, zasymulujemy przypadek generowania korespondencji na przykładzie w .NET. Kod będzie dostępny w repozytorium serii.

Limit mechanizmu generowania PDF-ów

Na początek modelujemy wymaganie stałego czasu przetwarzania - generator PDF-ów, który akceptuje co najwyżej 10 zapytań na sekundę. Skala jest celowo mniejsza niż scenariusz 50 000 listów i 100 PDF-ów na minutę. Dzięki temu możemy sprawdzić działanie algorytmu w rozsądnym czasie. Gdy limit generowania zostanie przekroczony, pojawi się wyjątek - w prawdziwym systemie mógłby to być status code 429 - throttling z zewnętrznego API.

public void GeneratePDF(CorrespondenceDocument _, CancellationToken cancellationToken = default)
{
    generateSemaphore.Wait(cancellationToken);
    try
    {
        var now = DateTime.UtcNow;
        var windowStart = now.AddSeconds(-1);

        while (callTimestamps.Count > 0 && callTimestamps.Peek() < windowStart)
        {
            callTimestamps.Dequeue();
        }

        if (callTimestamps.Count >= maxPerSecond)
        {
            throw PDFGenerationThroughputExceededException.PerSecondExceeded(maxPerSecond);
        }

        callTimestamps.Enqueue(now);
    }
    finally
    {
        generateSemaphore.Release();
    }
}

W przykładzie ustawiamy maxPerSecond: 10. To wygodniejszy do demonstracji odpowiednik limitu „100 na minutę” z wprowadzenia.

Dwa źródła ruchu: codzienność i nagły skok obciążeń

Utwórzmy dwa źródła ruchu. Pierwsze - codzienne nazywane „Steady", w którym klasa Publisher będzie wysyłać żądanie wygenerowania pojedynczego dokumentu co 250 ms, imitując codzienne obciążenie. Drugie - „Burst" - odpowiada korespondencji okresowej i dostarcza 8 dokumentów co 2 sekundy, tworząc nagły skok obciążeń ponad limit.

var steadyPublisher = new Publisher<CorrespondenceDocument>(
    [correspondenceRef],
    tickDelay: TimeSpan.FromMilliseconds(250));

var burstPublisher = new Publisher<CorrespondenceDocument>(
    [correspondenceRef],
    tickDelay: TimeSpan.FromSeconds(2),
    batchSize: 8);

Generowanie PDF-ów bez leaky bucket

Jeśli oba źródła ruchu podłączymy wprost do GeneratePDF, nastąpi chwilowe przekroczenie przepustowości. W naszym wypadku prowadzi to do błędu PDFGenerationThroughputExceededException i kończy działanie programu. W scenariuszu produkcyjnym nieudane próby prawdopodobnie skutkowałyby powtarzaniem, które dodatkowo pogorszyłoby sytuację.

steadyPublisher.MessageStream.Subscribe(doc =>
{
    pdfGenerator.GeneratePDF(doc, cts.Token);
});

burstPublisher.MessageStream.Subscribe(doc =>
{
    pdfGenerator.GeneratePDF(doc, cts.Token);
});

var publishing = await Task.WhenAny(
    steadyPublisher.StartPublishingAsync(cts.Token),
    burstPublisher.StartPublishingAsync(cts.Token));

cts.Cancel();
await publishing; // PDFGenerationThroughputExceededException

Leaky bucket do kontrolowania tempa przepływu

By ograniczyć przepustowość tak, żeby nie przekraczała 10 zapytań na sekundę, wprowadzimy leaky bucket:

public void AddToBucket(CorrespondenceDocument document)
{
    if (completed)
    {
        throw new InvalidOperationException("Cannot add to a completed leaky bucket.");
    }

    var newCount = queue.Count + 1;
    if (newCount > capacity)
    {
        throw BucketOverflowException.CapacityExceeded(newCount, capacity);
    }

    queue.Enqueue(document);
    signal.Release();
}

public async Task StartLeakingAsync()
{
    while (true)
    {
        if (queue.TryDequeue(out var document))
        {
            subject.OnNext(document);
            await Task.Delay(leakInterval);
        }
        else if (completed)
        {
            subject.OnCompleted();
            return;
        }
        else
        {
            await signal.WaitAsync();
        }
    }
}

AddToBucket odpowiada wlewaniu wody do wiadra – dodaje dokument do kolejki, o ile starczy w niej miejsca. Kontrolę przepływu realizuje StartLeakingAsync, czyli nasza „dziura w dnie": co leakInterval (u nas 100 ms, czyli maksymalnie 10 dokumentów na sekundę) wypuszcza jeden dokument do strumienia LeakyStream.

Teraz oba źródła ruchu podłączamy do wiadra, a generator PDF-ów – już tylko do strumienia wycieku:

var bucket = new CorrespondenceLeakyBucket(leakInterval: TimeSpan.FromMilliseconds(100), capacity: 100);

steadyPublisher.MessageStream.Subscribe(bucket.AddToBucket);
burstPublisher.MessageStream.Subscribe(bucket.AddToBucket);
bucket.LeakyStream.Subscribe(doc => pdfGenerator.GeneratePDF(doc));

var leaking = bucket.StartLeakingAsync();

await Task.WhenAll(
    steadyPublisher.StartPublishingAsync(cts.Token),
    burstPublisher.StartPublishingAsync(cts.Token));

bucket.Complete();
await leaking; // bez wyjątku – tempo trzyma się limitu generatora

Burst nadal pojawia się w systemie gwałtownie, ale trafia do kolejki bucketa. Do PDF-ów dociera już równym strumieniem dopasowanym do limitu.

Podsumowanie

Leaky bucket sprowadza się do jednej zasady: przyjmij dowolny napływ zdarzeń, który mieści się w pojemności wiadra, i oddawaj go dalej w stałym, przewidywalnym tempie – niezależnie od tego, jak nierówno trafiają na wejście. W naszym przykładzie burst nałożony na stały ruch nie zachwiał systemem – trafiał do generatora PDF-ów w rytmie, jaki ten był w stanie faktycznie obsłużyć.

Ten artykuł jest częścią serii. Pełny kod przykładu znajdziesz w repozytorium: useful-async-algorithms.

Użyteczne algorytmy w systemach opartych na zdarzeniach

Part 1 of 4

W świecie .NET tworzymy systemy oparte na zdarzeniach. Reagujemy na pojawiające się informacje, przetwarzamy je asynchronicznie. W naszych rozwiązaniach często wracają te same problemy. Seria pokazuje praktyczne wzorce, które pomagają je rozwiązywać.

Up next

Double-checked locking w .NET – jak zatrzymać pędzące stado

Wprowadzenie W systemach opartych na zdarzeniach oraz w aplikacjach wielowątkowych często pojawia się problem nagłego wzrostu obciążenia w momencie, gdy wiele wątków jednocześnie próbuje uzyskać dostę