这次事故并非始于崩溃,而是源于一条线——我们某个.NET 8服务(运行在Kubernetes上的后台订单处理系统)内存图中一条悄然攀升的曲线。
起初,我们并未在意。或许只是GC的小波动。但一周又一周,这条曲线持续攀升。最终,容器因内存压力开始频繁重启。
我多希望我们能迅速定位问题。但事实上,我们花了六周时间、两次紧急补丁尝试,外加一个痛苦的性能分析周末,才揪出罪魁祸首:一个Lambda表达式。
我们构建了一个后台队列来处理异步任务,采用了一种常见模式:将Func<Task>
类型的Lambda表达式入队,并逐个执行。
public class BackgroundTaskQueue
{
private readonly 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 class OrderScheduler
{
private readonly IUserContext _userContext; // Scoped
private readonly 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捕获了内存快照。在“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 class SafeQueueWorker
{
private readonly Channel<OrderMessage> _channel = Channel.CreateUnbounded<OrderMessage>();
public void Enqueue(OrderMessage msg) => _channel.Writer.TryWrite(msg);
public async Task StartAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _channel.Reader.ReadAllAsync(stoppingToken))
{
using var 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));
同时增加了重试次数的监控,以便检测异常模式。
真正的根源在于在单例生命周期中捕获作用域服务。我们在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“天然”避免内存泄漏,但闭包(尤其是持久化队列或事件订阅中的闭包)会形成强大的引用链。而当这些链包裹作用域服务时,它们会拖垮整个应用。
我们修复了代码,但更重要的是,我们修复了系统。
从此,我们不再天真地看待闭包——