用 ASP.NET Core Web API 實作排隊系統

在高併發的 Web API 環境中,瞬間湧入的請求就像演唱會開賣時的粉絲潮水,沒有控管就會把系統直接「擠爆」。
這篇文章要分享一個我在 ASP.NET Core 9 專案中實作的 限流 + 排隊機制,它不只保護後端服務,還能讓用戶感覺到「公平」。

畫一張,有關排隊、限制流量的圖片

開發環境

  • Windows 11 + WSL2 Ubuntu
  • ASP.NET Core 9
  • Rider 2025.2

系統架構設計

核心業務流程

graph TD
    A[使用者請求 POST /api/commands] --> B[前置條件檢查]

    B --> C{檢查佇列是否已滿<br/>_commandQueue.IsQueueFull}
    C -->|佇列已滿| D[加入佇列並回傳 429<br/>Reason: QueueFull]

    C -->|佇列未滿| E{檢查限流<br/>!_rateLimiter.IsAllowed}
    E -->|限流失敗| F[加入佇列並回傳 429<br/>Reason: Rate Limit Exceeded]

    E -->|限流通過| G[記錄請求<br/>_rateLimiter.RecordRequest]
    G --> H[直接執行業務邏輯]
    H --> I[回傳 200 OK]

    D --> J[客戶端根據 RetryAfterSeconds 等待重試]
    F --> J
    J --> A

https://www.mermaidchart.com/app/projects/88128347-98b5-4d8a-86e6-9f09f70b6a6a/diagrams/f7eebb47-33c4-4eb0-8691-ebed29655723/version/v0.1/edit

 

狀態機

stateDiagram-v2
    [*] --> Queued : POST /api/commands<br/>(超過限流)

    Queued --> Ready : 背景服務<br/>許可管理
    Queued --> Failed : 系統錯誤<br/>或取消

    Ready --> Processing : 調用 /wait 端點<br/>開始執行
    Ready --> Failed : 執行失敗<br/>或超時

    Processing --> Finished : 執行完成<br/>移出佇列
    Processing --> Failed : 執行過程<br/>發生錯誤

    Failed --> [*] : 清理失敗記錄
    Finished --> [*] : 記錄到<br/>cleanup-summary

https://www.mermaidchart.com/app/projects/88128347-98b5-4d8a-86e6-9f09f70b6a6a/diagrams/f7eebb47-33c4-4eb0-8691-ebed29655723/version/v0.1/edit

 

 

關鍵技術實作

限流演算

這是整個系統的核心保護機制,使用 ConcurrentQueue<DateTime> 實作 SlidingWindowRateLimiter:

public class SlidingWindowRateLimiter : IRateLimiter
{
    private readonly ConcurrentQueue<DateTime> _requestTimestamps = new();
    private readonly int _maxRequests;
    private readonly TimeSpan _timeWindow;
    private readonly object _lock = new();

    public bool IsAllowed()
    {
        lock (_lock)
        {
            CleanupOldRequests(); // 清理過期的時間戳
            return _requestTimestamps.Count < _maxRequests;
        }
    }

    public void RecordRequest()
    {
        lock (_lock)
        {
            _requestTimestamps.Enqueue(DateTime.UtcNow);
        }
    }

    private void CleanupOldRequests()
    {
        var cutoffTime = DateTime.UtcNow - _timeWindow;

        while (_requestTimestamps.TryPeek(out var timestamp) && timestamp < cutoffTime)
        {
            _requestTimestamps.TryDequeue(out _);
        }
    }
}

 

Channel 佇列管理

使用 .NET 的 Channel<T> 實作生產者-消費者佇列

public class ChannelCommandQueueProvider : ICommandQueueProvider
{
    private readonly Channel<QueuedContext> _channel;
    private readonly ConcurrentDictionary<string, QueuedContext> _pendingRequests = new();
    private readonly int _maxCapacity;

    public ChannelCommandQueueProvider(int capacity = 100)
    {
        _maxCapacity = capacity;
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = false,
            SingleWriter = false
        };

        _channel = Channel.CreateBounded<QueuedContext>(options);
    }

    public async Task<string> EnqueueCommandAsync(object requestData, CancellationToken cancel = default)
    {
        var queuedRequest = new QueuedContext
        {
            RequestData = requestData
        };

        _pendingRequests[queuedRequest.Id] = queuedRequest;
        await _writer.WriteAsync(queuedRequest, cancel);

        return queuedRequest.Id;
    }
}

 

把列隊內的狀態壓成 Ready,不執行業務邏輯

public class ReadyCommandQueueService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var queuedRequest = await _commandQueue.GetNextQueuedRequestAsync(stoppingToken);

            if (queuedRequest == null)
            {
                await Task.Delay(_emptyQueueDelay, stoppingToken);
                continue;
            }

            // 等待直到可以許可請求(遵守限流規則)
            while (!_rateLimiter.IsAllowed())
            {
                var retryAfter = _rateLimiter.GetRetryAfter();
                await Task.Delay(retryAfter.Add(TimeSpan.FromMilliseconds(100)), stoppingToken);
            }

            // 只記錄許可並更新狀態為 Ready,不執行業務邏輯
            _rateLimiter.RecordRequest();
            await _commandQueue.MarkRequestAsReadyAsync(queuedRequest.Id, stoppingToken);
        }
    }
}

 

定期清理超過指定時間且未被取得結果的過期請求

為了避免卡了太多未處理的請求,這個背景服務專門用來清理門戶


public class ExpiredRequestCleanupService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("ExpiredRequestCleanupService started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_cleanupInterval, stoppingToken);

                var cleanedCount = _queueProvider.CleanupExpiredRequests(_maxRequestAge);

                if (cleanedCount > 0)
                {
                    _logger.LogInformation(
                        "Cleaned up {CleanedCount} expired requests older than {MaxRequestAge}",
                        cleanedCount,
                        _maxRequestAge);
                }
                else
                {
                    _logger.LogDebug("No expired requests found during cleanup cycle");
                }
            }
        }
    }
}

 

API 端點設計

主要端點

POST /api/commands

[HttpPost("commands")]
public async Task<IActionResult> CreateCommandAsync([FromBody] CreateCommandRequest request)
{
    // 前置條件檢查
    var isQueueFull = _commandQueue.IsQueueFull();
    if (isQueueFull)
    {
        var requestId = await _commandQueue.EnqueueCommandAsync(request.Data, cancel);
        return StatusCode(429, new
        {
            Message = "Too many requests. Queue is full, please retry after the specified time.",
            RequestId = requestId,
            Reason = "QueueFull"
        });
    }

    var isRateLimitExceeded = !_rateLimiter.IsAllowed();
    if (isRateLimitExceeded)
    {
        var requestId = await _commandQueue.EnqueueCommandAsync(request.Data, cancel);
        return StatusCode(429, new
        {
            RequestId = requestId,
            Reason = "Rate Limit Exceeded"
        });
    }

    // 通過所有檢查,直接執行
    _rateLimiter.RecordRequest();
    var response = await ExecuteBusinessLogicAsync(request, cancel);
    return Ok(response);
}

 

GET /api/commands/{requestId}/wait

[HttpGet("commands/{requestId}/wait")]
public async Task<IActionResult> WaitForCommandAsync(string requestId)
{
    var queuedRequest = await _commandQueue.ExecuteReadyRequestAsync(requestId, cancel);

    if (queuedRequest?.Status == QueuedCommandStatus.Processing)
    {
        var response = await ExecuteBusinessLogicAsync(queuedRequest, cancel);
        await _commandQueue.FinishAndRemoveRequestAsync(queuedRequest, response, cancel);
        return Ok(response);
    }

    return StatusCode(429, new { Message = "Request is not ready for execution" });
}

系統配置

在 Program.cs 中的關鍵配置:

// 限流器設定(10秒內最多2個請求)
builder.Services.AddSingleton<IRateLimiter>(provider =>
    new SlidingWindowRateLimiter(maxRequests: 2, timeWindow: TimeSpan.FromSeconds(10)));

// 佇列提供者(容量100)
builder.Services.AddSingleton<ICommandQueueProvider>(provider =>
    new ChannelCommandQueueProvider(capacity: 100));

// 每 5 秒掃描一次,超過一分鐘未處理的,則拋棄
builder.Services.AddHostedService<ExpiredRequestCleanupService>(provider =>
    new ExpiredRequestCleanupService(
        provider.GetRequiredService<ICommandQueueProvider>(),
        provider.GetRequiredService<ILogger<ExpiredRequestCleanupService>>(),
        TimeSpan.FromMinutes(1),
        TimeSpan.FromSeconds(5)));

 

實際測試驗證

正常流量

# 第1個請求:直接執行
curl -X POST https://localhost:7131/api/commands -d '{"data":"user1_request"}'
# 回應:200 OK

# 第2個請求:直接執行
curl -X POST https://localhost:7131/api/commands -d '{"data":"user2_request"}'
# 回應:200 OK

 

觸發限流

# 第3個請求:觸發限流,進入佇列
curl -X POST https://localhost:7131/api/commands -d '{"data":"user3_request"}'
# 回應:429 Too Many Requests
# {
#   "RequestId": "abc123",
#   "Reason": "Rate Limit Exceeded",
#   "RetryAfterSeconds": 8
# }

# 等待背景服務處理...
sleep 10


# 主動執行
curl https://localhost:7131/api/commands/abc123/wait
# 回應:200 OK(業務邏輯執行結果)

 

搶票系統應用場景

這個系統非常適合搶票類應用:

演唱會售票流程

  1. 開搶瞬間:大量使用者同時請求
  2. 系統保護:前N個直接處理,其餘進入佇列
  3. 排隊等待:使用者收到排隊位置和預估等待時間
  4. 主動搶票:輪到使用者時,主動執行搶票動作
  5. 完成記錄:所有操作都有完整的歷史記錄

關鍵優勢

  • 系統穩定:限流保護避免服務崩潰
  • 用戶體驗:透明的排隊狀態和主動控制
  • 公平機制:先到先服務的排隊邏輯
  • 擴展性:分離架構易於水平擴展

總結

這個範例展示了如何使用 ASP.NET Core 構建一個既能保護系統又能提供良好用戶體驗的解決方案。透過限流 + Channel 佇列管理和背景許可服務的結合,完成了現流 + 排隊的功能

在實際專案中,你可以根據業務需求調整限流參數、佇列容量和處理邏輯,這個範例只是提供一個思路,千萬不要照抄。

範例位置

https://github.com/yaochangyu/sample.dotblog/tree/master/Rate%20Limit/Lab.QueueApi

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo