Blazor Server 長時間運行 CPU 飆升到 99%?你可能忽略了這些細節

| .NET | 26 Reads

主題:Blazor Server 長時間運行導致 CPU 飆升的根本原因與解法

在使用 Blazor Server 架構開發網站時,我發現一個非常詭異的現象:

網站沒幾個訪客,卻在三天左右 CPU 緩慢飆升到 99%,只能靠重啟救急。

這個問題一直讓我懷疑是不是被攻擊,但檢查系統日誌、SSH 連線紀錄、.bash_history 等一切正常,最終才發現——是 Blazor Server 的「慢性資源洩漏」在搞鬼

這篇文章總結了我實際經歷的幾個坑與完整解法,希望能幫助遇到相同問題的你。


問題症狀

  • 部署的是 Blazor Server 應用(非 WASM)

  • 平常訪客很少,但 CPU 長時間運行會從 2% 緩慢升到 99%

  • 每次只能靠 reboot 解決,重啟後又回到 2%,然後又開始慢慢上升


常見誤解:SignalR 的 Timeout 就夠了?

我原本在 Program.cs 裡設定過:

builder.Services.Configure<HubOptions>(options =>
{
    options.ClientTimeoutInterval     = TimeSpan.FromSeconds(30);
    options.KeepAliveInterval         = TimeSpan.FromSeconds(15);
    options.HandshakeTimeout          = TimeSpan.FromSeconds(10);
});

這確實能讓斷線後 30 秒就關閉 SignalR 連線,但這「只處理網路層」,Blazor Server 會默默地把那個使用者的狀態(Circuit)保留 3 分鐘

這段時間內,使用者頁面裡的元件、事件訂閱、計時器還在跑!


真正的核心問題

1. Circuit 沒及時清理 → 內存與 Timer 不斷累積

Blazor Server 的 Circuit 預設會在斷線後保留 3 分鐘。如果使用者不會 reconnect(例如已經關掉頁面),那這些元件就會變成「僵屍」。

2. 元件訂閱事件後沒取消 → 無法 GC

我有一個全局系統監控服務 SystemMetricsService,每秒發送 CPU/RAM 使用率,元件透過事件訂閱接收:

MetricsService.OnMetricsUpdated += MetricsUpdated;

但沒有在元件銷毀時取消訂閱:

// 應該加上這段
public void Dispose()
{
    MetricsService.OnMetricsUpdated -= MetricsUpdated;
}

這會讓 GC 認為該元件還在使用中,永遠無法回收,Component 越堆越多,CPU 越來越高。


解法總結

✅ 1. 正確配置 CircuitOptions

Program.cs 加上:

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(options =>
    {
        options.DetailedErrors = builder.Configuration.GetValue<bool>("DetailedErrors");

        // 設定 Circuit 斷線 30 秒內未恢復,就清理
        options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromSeconds(30);

        // JS 端調用失敗不再卡住整個執行緒
        options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(5);
    });

這段才是真正讓伺服器「丟掉使用者狀態」的關鍵!


✅ 2. 每個訂閱事件的元件都必須實作 IDisposable

@implements IDisposable

@code {
    protected override void OnInitialized()
    {
        MetricsService.OnMetricsUpdated += Handle;
    }

    public void Dispose()
    {
        MetricsService.OnMetricsUpdated -= Handle;
    }
}

不這樣做,Blazor 離開頁面後事件還是引用著整個元件,無法 GC,長時間下來必定造成資源壅塞。


✅ 3. 若使用 JSInterop + DotNetObjectReference,記得釋放

private DotNetObjectReference<MyComponent>? _ref;

protected override void OnAfterRender(bool firstRender)
{
    if (firstRender)
    {
        _ref = DotNetObjectReference.Create(this);
        JSRuntime.InvokeVoidAsync("yourInterop.init", _ref);
    }
}

public void Dispose()
{
    _ref?.Dispose(); // 🔥 這一行非常關鍵!
}

不 Dispose 的話,Blazor 還會一直記著這些 JS 端回傳 handle。


✅ 4. 可選補強:Circuit 活躍監控端點

加個 /metrics/circuits 端點,讓你在 production 可即時查看目前有多少活躍 Circuit:

public class TrackingCircuitHandler : CircuitHandler
{
    private int _count;
    public int CurrentCount => _count;

    public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct)
    {
        Interlocked.Increment(ref _count);
        return Task.CompletedTask;
    }

    public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken ct)
    {
        Interlocked.Decrement(ref _count);
        return Task.CompletedTask;
    }
}

註冊並加入:

builder.Services.AddSingleton<CircuitHandler, TrackingCircuitHandler>();
app.MapGet("/metrics/circuits", (TrackingCircuitHandler h) => new { active = h.CurrentCount });

成效觀察

我部署這些修改後,CPU 再也沒有出現「3 天慢慢爬到 99%」的情況,整體資源使用明顯下降,也不再需要定時重啟來“洗掉垃圾”。


總結

問題原因 解法
Circuit 過久未釋放 DisconnectedCircuitRetentionPeriod 設短一點
Component 訂閱事件未解除 Dispose() 中解除事件
DotNetObjectReference 泄漏 手動呼叫 .Dispose()
無法追蹤 Circuit 狀態 實作 TrackingCircuitHandler 曝露 /metrics/circuits

Blazor Server 是強大但對資源非常敏感的架構,尤其要注意元件生命週期與服務註冊方式。如果你也遇到 CPU 飆高、記憶體膨脹的問題,不妨檢查一下上面幾個點,也許你會發現根本原因。

This article was last edited at