內存泄漏之謎:一個Lambda如何拖垮了我們的Kubernetes服務
這次事故并非始于崩潰,而是源于一條線——我們某個.NET 8服務(運行在Kubernetes上的后臺訂單處理系統)內存圖中一條悄然攀升的曲線。
起初,我們并未在意。或許只是GC的小波動。但一周又一周,這條曲線持續攀升。最終,容器因內存壓力開始頻繁重啟。
我多希望我們能迅速定位問題。但事實上,我們花了六周時間、兩次緊急補丁嘗試,外加一個痛苦的性能分析周末,才揪出罪魁禍首:一個Lambda表達式。
問題根源:內存泄漏是如何引入的?
我們構建了一個后臺隊列來處理異步任務,采用了一種常見模式:將Func<Task>
類型的Lambda表達式入隊,并逐個執行。
public classBackgroundTaskQueue
{
privatereadonly BlockingCollection<Func<CancellationToken, Task>> _queue = new();
public void Enqueue(Func<CancellationToken, Task> task)
{
_queue.Add(task);
}
public async Task ProcessQueueAsync(CancellationToken stoppingToken)
{
foreach (var work in _queue.GetConsumingEnumerable(stoppingToken))
{
try
{
await work(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Background task failed.");
}
}
}
}
而它的調用方式如下:
public classOrderScheduler
{
privatereadonly IUserContext _userContext; // Scoped
privatereadonly BackgroundTaskQueue _queue;
public OrderScheduler(IUserContext userContext, BackgroundTaskQueue queue)
{
_userContext = userContext;
_queue = queue;
}
public void ScheduleOrder(Order order)
{
_queue.Enqueue(async token =>
{
await Process(order, _userContext.UserId, token);
});
}
private Task Process(Order order, string userId, CancellationToken token)
{
// Actual logic
return Task.CompletedTask;
}
}
問題在于,這個異步Lambda捕獲了作用域服務_userContext
,而Lambda本身卻被存儲在一個單例隊列中。這意味著:每次請求執行這段代碼時,都會泄漏一個HttpContext
、DI作用域及其相關對象。
DotMemory快照:我們發現了什么?
我們在負載測試期間使用dotMemory捕獲了內存快照。在“Retention Paths”視圖中,通過篩選CancellationTokenSource
,我們發現:
GC Root -> BackgroundTaskQueue
-> BlockingCollection<Func<CancellationToken, Task>>
-> Func<>
-> Closure
-> OrderScheduler
-> IUserContext
-> HttpContext
-> ClaimsPrincipal
-> MemoryStream (from request body)
?? 一個閉包讓所有對象都無法釋放!
我們還觀察到對象計數的異常模式:
(圖表略)
誤導性的修復嘗試
我們嘗試了限制內存、限制隊列大小、手動觸發GC,但都無效。
GC.Collect();
GC.WaitForPendingFinalizers();
?? 毫無作用——閉包仍然被強引用。
接著,我們嘗試清空隊列:
while (_queue.TryTake(out var task))
{
// drain
}
依然無效,因為閉包通過重試循環重新入隊了自己:
try
{
await ProcessOrder();
}
catch
{
_queue.Enqueue(...); // 遞歸泄漏!
}
最終修復方案:純數據消息 + 作用域解析
我們棄用了Func<Task>
,改用基于消息的處理器:
public record OrderMessage(Guid OrderId, string InitiatedBy);
public interface IOrderHandler
{
Task HandleAsync(OrderMessage message, CancellationToken token);
}
新隊列實現如下:
public classSafeQueueWorker
{
privatereadonly Channel<OrderMessage> _channel = Channel.CreateUnbounded<OrderMessage>();
public void Enqueue(OrderMessage msg) => _channel.Writer.TryWrite(msg);
public async Task StartAsync(CancellationToken stoppingToken)
{
awaitforeach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
usingvar scope = _provider.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();
await handler.HandleAsync(msg, stoppingToken);
}
}
}
不再有閉包,不再捕獲服務,每個任務都會創建新的DI作用域。
額外優化:限制重試次數 + 退避策略
此前,我們的重試邏輯是無限制的:
catch (Exception)
{
_queue.Enqueue(() => Retry()); // 無限循環!
}
現在改用Polly實現:
await Policy
.Handle<Exception>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)))
.ExecuteAsync(() => handler.HandleAsync(msg, token));
同時增加了重試次數的監控,以便檢測異常模式。
作用域 vs 單例:陷阱可視化
真正的根源在于在單例生命周期中捕獲作用域服務。我們在CI中增加了單元測試來檢測此類問題:
[Fact]
public void Capturing_Scoped_Service_In_Closure_Should_Be_Detected()
{
var userContext = new FakeUserContext(); // 模擬HttpContext
var task = new Func<Task>(() =>
{
var id = userContext.UserId;
return Task.CompletedTask;
});
GC.Collect();
GC.WaitForPendingFinalizers();
var weakRef = new WeakReference(userContext);
userContext = null;
GC.Collect();
Assert.False(weakRef.IsAlive, "Scoped service is still rooted via closure");
}
未來的預防措施
我們增加了以下防護機制:
? Roslyn分析器:檢測單例類中訪問作用域服務的Lambda
? CI集成dotMemoryUnit快照
? 定期負載測試 + GC.GetTotalMemory()
日志
示例監控代碼:
_logger.LogInformation("Memory: {0} MB | Gen2 collections: {1}",
GC.GetTotalMemory(false) / 1024 / 1024,
GC.CollectionCount(2));
修復前后對比數據
圖片
是什么導致了泄漏? 一個Lambda。
是什么讓問題惡化? 捕獲上下文、無限重試、靜態隊列、審查疏漏。
我們常以為.NET“天然”避免內存泄漏,但閉包(尤其是持久化隊列或事件訂閱中的閉包)會形成強大的引用鏈。而當這些鏈包裹作用域服務時,它們會拖垮整個應用。
我們修復了代碼,但更重要的是,我們修復了系統。
從此,我們不再天真地看待閉包。