Net-Base Журнал

06.06.2026

Высокопроизводительный REST сервер в Delphi: лимиты запросов, пул потоков и корректное поведение при перегрузке (фрагменты исходного кода)

Высокопроизводительный REST сервер в Delphi становится быстрым не только за счёт «быстрого JSON», но и благодаря контролируемой параллельности, жёстким таймаутам и корректному поведению при перегрузке. В этой статье показан практически применимый Concurrency-Gate на основе семафора с ответами 429/503...

06.06.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Почему «High Performance» при REST в Delphi часто терпит неудачу из‑за параллелизма

На практике High Performance REST Server Delphi редко ограничивается только временем CPU на запрос; причиной чаще всего является неконтролируемый параллелизм: слишком много одновременных запросов, слишком много одновременных обращений к базе данных или блокирующие операции I/O (файловые операции, сеть, база данных). Результат выглядит не как «немного медленнее», а как цепная реакция: больше потоков, больше очередей, коллапс connection‑пула, растущие задержки, таймауты на стороне клиента и в итоге сервер, который хоть ещё «жив», но не выдаёт стабильных ответов.

Противоядие — это не один трюк, а осознанное поведение при перегрузке: когда сервер достигает своих пределов, он должен рано и детерминированно отказывать (обычно HTTP 429 или 503), вместо того чтобы позволять запросам накапливаться в бесконечной очереди. Именно для этого предназначен этот фрагмент исходного кода: лёгкое Concurrency‑Gate (Semaphore) с таймаутами, которое можно интегрировать в существующие REST‑эндпойнты — независимо от того, используете ли вы Indy, WebBroker, Horse или собственный HTTP‑слой.

Архитектурная идея: Concurrency‑Gate перед «ресурсозатратной частью»

Базовая идея проста: перед ресурсозатратной частью (доступ к базе данных, сложные отчёты, большие JSON‑ответы) резервируется токен из семафора. Если свободного токена нет, возвращается немедленный контролируемый ответ. Важно: этот шлюз должен надёжно освобождаться (try/finally), и он должен находиться в том кодовом пути, который действительно ресурсозатратен — а не только в самом начале обработчика запроса, если после этого всё равно будут парсинг/роутинг/аутентификация.

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

Фрагмент исходного кода: Request‑Limiter с таймаутом, 429/503 и хуками телеметрии

Ниже приведён Delphi‑код, реализующий Concurrency‑Gate в виде класса TRestRequestGate. Он основан на TSemaphore (из System.SyncObjs; семафор — это счётчик для ограниченного количества одновременных доступов). Вызов шлюза возвращает либо «Lease»‑объект (аналогично RAII: освобождение в деструкторе), либо сразу выбирает ответ при перегрузке. Дополнительно предусмотрены хуки для логирования/мониторинга, чтобы вы в эксплуатации могли увидеть, почему запросы были отклонены.

Delphi
unit RESTRequestGate;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs,
  System.Diagnostics;

type
  // Минимальный контекст для логирования/трейсинга; может быть, например, расширен информацией о User/Route.
  TRESTGateContext = record
    RequestId: string;
    Route: string;
    RemoteIp: string;
  end;

  TRESTOverloadDecision = (odAccepted, odRejectedBusy, odRejectedTimeout);

  // Хук для эксплуатационной телеметрии (напр., в файл, Syslog, Prometheus-экспортёр и т.д.)
  TRESTGateEvent = reference to procedure(const Ctx: TRESTGateContext;
                                         Decision: TRESTOverloadDecision;
                                         WaitedMs: Integer;
                                         InFlight: Integer);

  // Объект Lease: освобождение токена в деструкторе.
  TRESTGateLease = class
  private
    FSemaphore: TSemaphore;
    FInFlightCounter: PInteger;
    FReleased: Boolean;
  public
    constructor Create(ASem: TSemaphore; ACounter: PInteger);
    destructor Destroy; override;
    procedure Release;
  end;

  TRESTRequestGate = class
  private
    FSem: TSemaphore;
    FMaxInFlight: Integer;
    FInFlight: Integer;
    FOnEvent: TRESTGateEvent;
  public
    constructor Create(AMaxInFlight: Integer);
    destructor Destroy; override;

    // TimeoutMs = 0: без ожидания, немедленно 429/503
    function TryAcquire(const Ctx: TRESTGateContext; TimeoutMs: Cardinal;
                        out Lease: TRESTGateLease;
                        out WaitedMs: Integer;
                        out Decision: TRESTOverloadDecision): Boolean;

    property OnEvent: TRESTGateEvent read FOnEvent write FOnEvent;
    property MaxInFlight: Integer read FMaxInFlight;
    function InFlight: Integer;
  end;

implementation

uses
  System.Math;

{ TRESTGateLease }

constructor TRESTGateLease.Create(ASem: TSemaphore; ACounter: PInteger);
begin
  inherited Create;
  FSemaphore := ASem;
  FInFlightCounter := ACounter;
  FReleased := False;
end;

destructor TRESTGateLease.Destroy;
begin
  Release;
  inherited;
end;

procedure TRESTGateLease.Release;
begin
  if FReleased then
    Exit;
  FReleased := True;

  // Сначала уменьшить счётчик, затем освободить семафор.
  TInterlocked.Decrement(FInFlightCounter^);
  FSemaphore.Release;
end;

{ TRESTRequestGate }

constructor TRESTRequestGate.Create(AMaxInFlight: Integer);
begin
  inherited Create;
  if AMaxInFlight <= 0 then
    raise EArgumentException.Create('AMaxInFlight должен быть > 0');

  FMaxInFlight := AMaxInFlight;
  FInFlight := 0;

  // InitialCount = MaxCount = AMaxInFlight
  FSem := TSemaphore.Create(nil, AMaxInFlight, AMaxInFlight, '');
end;

destructor TRESTRequestGate.Destroy;
begin
  FSem.Free;
  inherited;
end;

function TRESTRequestGate.InFlight: Integer;
begin
  Result := TInterlocked.CompareExchange(FInFlight, 0, 0);
end;

function TRESTRequestGate.TryAcquire(const Ctx: TRESTGateContext; TimeoutMs: Cardinal;
  out Lease: TRESTGateLease; out WaitedMs: Integer; out Decision: TRESTOverloadDecision): Boolean;
var
  Sw: TStopwatch;
  WaitRes: TWaitResult;
  CurrentInFlight: Integer;
begin
  Lease := nil;
  WaitedMs := 0;
  Decision := odRejectedBusy;

  Sw := TStopwatch.StartNew;
  if TimeoutMs = 0 then
    WaitRes := FSem.WaitFor(0)
  else
    WaitRes := FSem.WaitFor(TimeoutMs);

  WaitedMs := Integer(Min(Sw.ElapsedMilliseconds, High(Integer)));

  case WaitRes of
    wrSignaled:
      begin
        CurrentInFlight := TInterlocked.Increment(FInFlight);
        Lease := TRESTGateLease.Create(FSem, @FInFlight);
        Decision := odAccepted;
        Result := True;

        if Assigned(FOnEvent) then
          FOnEvent(Ctx, Decision, WaitedMs, CurrentInFlight);
      end;

    wrTimeout:
      begin
        // wrTimeout при TimeoutMs > 0: целевое ожидание, но с ограничением.
        Decision := odRejectedTimeout;
        Result := False;
        if Assigned(FOnEvent) then
          FOnEvent(Ctx, Decision, WaitedMs, InFlight);
      end;
  else
    begin
      // wrAbandoned/ошибочные случаи: консервативно отклонять
      Decision := odRejectedBusy;
      Result := False;
      if Assigned(FOnEvent) then
        FOnEvent(Ctx, Decision, WaitedMs, InFlight);
    end;
  end;
end;

end.

Цель: стабильность под нагрузкой вместо „всё одновременно“

С помощью MaxInFlight вы задаёте, сколько Requests одновременно могут попадать в «дорогую» часть. Это сознательно не «Anzahl CPU-Kerne», а эксплуатационная величина. Для эндпоинтов с высокой нагрузкой на базу данных часто целесообразно соотнести MaxInFlight с DB-Connection-Pool (например Pool = 20, MaxInFlight = 12 bis 16), чтобы не каждый Request блокировал подключение и не приводил к наращиванию дополнительных потоков.

Граничные условия и подводные камни

  • Try/Finally ist Pflicht: Der Lease muss garantiert freigegeben werden. Wenn Sie Exceptions im Endpoint haben, wird sonst das Gate „undicht“ und der Server bleibt dauerhaft auf „busy“.
  • Timeout sinnvoll wählen: TimeoutMs=0 ist ein hartes Limit (sofort abweisen). Ein kurzes Timeout (typisch 50 bis 150 ms) glättet Peaks, ohne echte Warteschlangen aufzubauen.
  • Gate nicht zu früh: Authentifizierung (zum Beispiel Bearer/JWT) oder Routing kann günstig sein; die Semaphore sollte vor dem wirklich teuren Abschnitt greifen. Umgekehrt: Wenn Auth teuer wird (z.B. gegen ein externes Identity-System), muss auch das begrenzt werden.
  • 429 vs 503: HTTP 429 („Too Many Requests“) passt gut, wenn Clients gezielt retryen sollen. 503 („Service Unavailable“) passt, wenn der Dienst temporär generell nicht in der Lage ist, Anfragen sinnvoll anzunehmen. In beiden Fällen ist ein Retry-After-Header empfehlenswert.

Интеграция в REST-обработчик: Indy/WebBroker/Horse прагматично

Der Snippet ist absichtlich framework-neutral. Sie brauchen nur einen Ort, an dem Requests „durchlaufen“. Typisch ist ein globales Singleton oder ein Gate pro Route-Gruppe (zum Beispiel „/reports“ kleiner, „/health“ ohne Gate). Beispielhaft die Einbindung als Muster:

  • Заполнить Kontext (RequestId, Route, RemoteIp)
  • TryAcquire mit kurzem Timeout
  • Bei Ablehnung sofort Response schreiben (429/503) und beenden
  • Lease lebt im Scope bis nach dem teuren Teil

In Horse (Middleware) liegt das Gate nahe an einer Route-Gruppe. In WebBroker können Sie im jeweiligen Action-Handler arbeiten. Bei Indy hängt es davon ab, ob Sie pro Request einen Thread haben; das Gate wirkt trotzdem, solange die teuren Abschnitte sauber begrenzt werden.

Высокопроизводительные REST Server Delphi: Overload-Antworten, die Clients nicht „vergiften“

Überlast-Antworten sind mehr als Statuscodes. Wenn Clients bei 429/503 aggressiv sofort erneut senden, haben Sie einen Retry-Sturm. In heterogenen Systemlandschaften (мобильные Anwendungen, C# Services, Legacy-Clients) hilft ein konsistentes Verhalten:

  • Retry-After: zum Beispiel 1 bis 3 Sekunden, je nach Endpoint. Das ist ein klarer Taktgeber.
  • Kurzer Body: Ein kleines JSON wie {"error":"server_busy","requestId":"..."} reicht. Große Error-Objekte kosten wieder CPU und Bandbreite.
  • Health-Endpoint ungedrosselt: Monitoring soll auch bei Last noch Aussagen liefern (ggf. mit „degraded“-Flag).

Wenn Sie einen Reverse Proxy wie nginx davor betreiben: Timeouts und Buffering dort abstimmen. Ein Proxy kann entlasten (TLS-Termination, Keep-Alive), aber auch Last verschieben (zum Beispiel große Request-Bodies puffern). Im Betrieb zählt, dass die Limits konsistent sind: Proxy-Timeout > App-Timeout, sonst sehen Clients „Gateway Timeout“, obwohl die App sauber abgewiesen hätte.

Threading, DB-Pools und Keep-Alive: Wo es in der Praxis kippt

Das Gate löst das „zu viele gleichzeitig“-Problem, aber es verhindert nicht automatisch, dass ein einzelner Request übermäßig viele Ressourcen bindet. Drei typische Kipp-Punkte aus Delphi-Projekten entstehen genau an den Schnittstellen zwischen Threading, Datenbank und HTTP-Verbindungen:

  • Ein Request blockiert mehrere knappe Ressourcen: Erst eine DB-Verbindung, dann ein externer HTTP-Call, dann ein Dateizugriff. Wenn das alles im selben Request-Thread passiert, multipliziert sich die Blockadezeit. Das Gate begrenzt dann zwar die Parallelität, aber die Durchsatzleistung sinkt drastisch. Hier lohnt es sich, die Abhängigkeiten zu entkoppeln (z.B. externe Calls asynchron, Vorberechnung per Job-Queue).
  • BDE-Ablosung mit nativer Anbindung-Pooling und Transaktionen: BDE-Ablosung mit nativer Anbindung kann Connections poolen, aber eine „lange“ Transaktion (z.B. weil JSON-Erstellung oder Business-Checks zwischen StartTransaction und Commit liegen) hält die Verbindung unnötig. Eine saubere Praxis ist, die Transaktion so eng wie möglich um die eigentlichen Statements zu legen und außerhalb der Transaktion zu serialisieren oder zu validieren, wenn es fachlich geht.
  • HTTP Keep-Alive als versteckter Speicherfresser: Keep-Alive reduziert Handshakes, kann aber bei vielen Leerlauf-Clients zu vielen offenen Sockets führen. Gerade bei Windows- und Linux-Services sieht man dann nicht „CPU hoch“, sondern „Handles/FDs voll“ oder RAM durch Puffer. Hier helfen klare Idle-Timeouts im Server und am Reverse Proxy sowie ein Limit pro Client-IP, wenn es die Umgebung erlaubt.

Die Konsequenz: MaxInFlight ist kein statischer Wert. Er hängt von Ihrer langsamsten, knappsten Ressource ab (DB, externe Systeme, Storage) und davon, wie gut ein Request diese Ressourcen „zusammenhält“.

Performance-Hebel neben dem Gate: JSON, DB und I/O nicht vermischen

Das Gate stabilisiert, aber es ersetzt keine saubere Endpoint-Ökonomie. Drei Bremsen in Delphi REST-Servern tauchen wiederholt auf:

  • JSON-Building mit unnötigen Zwischenstrings: Häufig entsteht Last durch viele temporäre Unicode-Strings. Wo möglich, streaming-orientiert bauen (Writer/Stream) statt riesige Zwischenobjekte, besonders bei Listen-Endpunkten.
  • Datenbankzugriff „pro Item“: N+1-Queries und per-Row Lookups sind der Klassiker. Besser: gezielte Joins, Batch-Queries, serverseitige Aggregation. Bei sehr großen Ergebnissen lohnt sich zusätzlich Pagination mit stabiler Sortierung (damit Seiten nicht „springen“).
  • Blockierende I/O im Request-Thread: Dateizugriffe oder externe HTTP-Calls sollten entweder strikt begrenzt oder in eine asynchrone Pipeline verlagert werden. Sonst blockieren Sie teure Threads für „Warten“.

Für gewachsene digitale Unternehmenslösungen ist das oft der Knackpunkt: Ein Endpoint wurde „mal schnell“ ergänzt und funktioniert, bis reale Last und Datenvolumina kommen. Dann zeigt sich, ob Architekturgrenzen sauber gezogen wurden (Datenzugriffsschicht, Caching, Bulk-Strategien, klare Timeouts).

Debugging und Betrieb: Was Sie messen sollten

Der Hook OnEvent ist bewusst simpel. In der Praxis sollten Sie mindestens folgende Werte erfassen:

  • InFlight (aktuelle Parallelität am Gate)
  • WaitedMs (wie viel „Queueing“ Sie zulassen)
  • Decision (accepted/busy/timeout)
  • Route/RemoteIp (грубый анализ причин, не пренебрегая защитой данных)

Это даст сигнал, слишком ли строги лимиты (слишком много 429) или слишком мягки (высокие WaitedMs, растущие задержки). И вы увидите, доминируют ли отдельные маршруты. Для Windows- и Linux-Services это в повседневной работе решающий момент: без телеметрии проблема с производительностью быстро превращается в угадывание между сетью, базой данных, прокси и приложением.

Необычно, но чрезвычайно полезно: „WaitedMs“ как индикатор раннего предупреждения

Многие команды смотрят только на Response-Time и CPU. WaitedMs часто является более информативным индикатором, потому что показывает, что запросы уже ожидают до начала фактической работы. Если WaitedMs растёт при умеренных значениях CPU, дефицитным ресурсом чаще всего является не CPU, а пул (подключения к DB), блокировка (Lock) в бизнес-логике или внешний downstream‑сервис. Это экономит время при анализе причин, потому что вы будете целенаправленно искать проблему в направлении «Pool/Lock/I/O», а не «оптимизация компилятора».

Варианты: Pro-Route-Gates, приоритеты и „Fast Lane“

Один Gate для всего прост в реализации, но не всегда оптимален. Практичные варианты:

  • Gate pro Route-Gruppe: „/reports“ строго, „/api/orders“ умеренно, „/health“ открыто. Так вы предотвращаете вытеснение ключевых процессов дорогими запросами отчётов.
  • Fast Lane für Admin/Monitoring: Отдельный Gate с небольшой параллельностью, чтобы операции эксплуатации были возможны даже при высокой нагрузке.
  • Budget-basierte Limits: Если размеры ответов сильно варьируются, дополнительно может помочь байт‑бюджет (например, максимум X MB одновременно при генерации). Это сложнее в реализации, но реалистично для больших загрузок.

Важно: приоритизация быстро становится политическим вопросом («мой Endpoint важнее»). Технически устойчивой она остаётся, если приоритеты привязаны к процессам (например, регистрация заказов перед reporting), а не к ролям или отделам.

Вывод: стоит ли Gate — и где подход даёт сбой?

Concurrency-Gate — прагматичный компонент для высокопроизводительного REST Server в Delphi, поскольку он делает перегрузки контролируемыми и поддерживает стабильность систем при пиковых нагрузках. Особенно это оправдано, если у вас есть зависящие от базы данных endpoints, если перед сервисом стоит Reverse Proxy или если несколько клиентов (Legacy, Portale, Services) генерируют нагрузку волнами.

Границы понятны: если реальная работа на запрос слишком затратна (неэффективные queries, большие JSON‑объекты, блокирующие внешние системы), Gate лишь маскирует симптомы. В таком случае необходимо дорабатывать доступ к данным, стратегии кэширования, таймауты и, при необходимости, внедрять асинхронную обработку (Queue/Job‑System). В роли страховочного пояса в эксплуатации Gate часто является разницей между «слегка медленным» и «полностью непригодным».

Если вы хотите внедрить поведение при перегрузке в существующий Delphi REST-API und REST-Server или аккуратно сбалансировать лимиты с таймаутами базы данных и прокси, обсудите проект или модернизацию с Net-Base.

В профессиональном контексте также важны Thread‑Pool Delphi и Http 429 Too Many Requests, когда интеграции, потоки данных и дальнейшее развитие должны работать слаженно.

Обсудить проект или модернизацию с Net-Base.

Следующий шаг

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

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

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

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.