内存泄漏之谜:一个Lambda如何拖垮了我们的Kubernetes服务

作者:微信公众号:【架构师老卢】
7-1 8:56
16

这次事故并非始于崩溃,而是源于一条线——我们某个.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快照:我们发现了什么?

我们在负载测试期间使用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));

同时增加了重试次数的监控,以便检测异常模式。

作用域 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“天然”避免内存泄漏,但闭包(尤其是持久化队列或事件订阅中的闭包)会形成强大的引用链。而当这些链包裹作用域服务时,它们会拖垮整个应用。

我们修复了代码,但更重要的是,我们修复了系统
从此,我们不再天真地看待闭包——

相关留言评论
昵称:
邮箱:
阅读排行