血泪复盘:一次由 HttpContext.Current 引发的 CPU 100% 惨案

做.NET开发的同学,对"CPU飙高"一定不陌生。很多时候,重启能解决一时,但解决不了一世。今天分享一个真实案例:一个看似无害的 TraceId 中间件,是如何通过 HttpContext.Current 和 log4net 联手,把 CPU 打到 994%,让线程池彻底瘫痪的。


一、 案发现场:CPU 突然"起飞"

MES业务系统的某个核心应用节点突然告警:CPU 使用率逼近 100%。

第一反应是重启,先恢复生产,重启后 CPU 确实回落了。但没过半小时,曲线再次陡峭上升,接口响应全线超时。

这种"重启缓解、随后复发"的模式,说明进程内部一定有持续消耗 CPU 资源 的逻辑在作祟。靠重启只能止血,不能根治。我们紧急对进程抓取了 Dump 文件,准备用 WinDbg​ 验尸。


二、 抽丝剥茧:WinDbg 三板斧

拿到 Dump,打开 WinDbg,加载 SOS 扩展。分析 CPU 飙高,我通常只用三板斧:

第一斧:确认 CPU 水位

执行 !tp 查看线程池状态:

解读 :CPU 直接干到 994%(多核总和),更可怕的是 182 个线程正在 Running。正常情况下大部分线程应该 Idle,这说明大量线程卡在了同一个地方疯狂自旋或切换上下文。


第二斧:查看线程状态

执行 ~*e !clrstack 查看所有线程的调用栈。我们发现了一个非常诡异的现象:

大量线程的调用栈中,都出现了 Monitor.Enter 或 lock 等待状态。


第三斧:定位锁争用

用 !syncblk 查看同步块表,真相开始浮出水面:

关键发现:

  1. 有一个 SyncBlock 的 MonitorHeld 值极高,说明争用极其激烈。
  2. 锁的持有者是线程 253。
  3. 锁关联的对象直指 log4net。

切换到线程 253 查看调用栈:

调用栈清晰地显示:线程 253 正持有锁,执行 log4net 的日志写入操作,而其他 182 个线程全在门外等着。


三、 疑点重重:log4net 为什么要背锅?

看到这里,初步结论是:log4net 写日志引发了锁争用雪崩。

但作为老司机,我知道这还没完。log4net 虽然性能不算顶尖,但也不至于让 CPU 直接归零吧?

我们查看了当时的代码。为了链路追踪,写了一个中间件,给每个请求设置 TraceId:

cs 复制代码
// 中间件代码
app.Use(async (context, next) =>
{
    TraceIdManager.SetLog4TraceId();
    await next.Invoke();
});


// TraceId 设置类
public static class TraceIdManager
{
    public static string SetLog4TraceId()
    {
         string traceId = string.Empty;
            try
            {
                if (HttpContext.Current != null && HttpContext.Current.Request != null)
                {
                    traceId = HttpContext.Current.Request.Headers["X-Eus-Requesttraceid"];
                }
                log4net.LogicalThreadContext.Properties["trace_id"] = string.IsNullOrEmpty(traceId) ? Guid.NewGuid().ToString("N") : traceId;
                return traceId;
            }
            catch { }
            return traceId;
    }
}

/// <summary>
/// 获取当前的请求入参
/// </summary>
public static Microsoft.AspNetCore.Http.HttpContext Current
{
    get
    {
        // 每次访问都在解析 DI 容器!
        object factory = ServiceProvider.GetService(typeof(IHttpContextAccessor));
        return ((HttpContextAccessor)factory).HttpContext;
     }
}

看起来人畜无害,对吧?但问题就出在这个 SetLog4TraceId() 方法内部。


四、 真相大白:隐藏在 HttpContext.Current 里的"毒药"

为了排查,我们做了一次对照实验。

我们修改了代码,不再通过静态属性获取 HttpContext,而是由中间件直接传入参数:

bash 复制代码
// 修复后的代码
public static string SetLog4TraceId(HttpContext context = null)
{
    string traceId = string.Empty;
    try
    {
        if (context != null && context.Request != null)
        {
            traceId = context.Request.Headers["X-Eus-Requesttraceid"];
        }
        // 同样是设置 log4net 上下文
        log4net.LogicalThreadContext.Properties["trace_id"] = traceId;
    }
    catch { }
    return traceId;
}

结果令人震惊:同样的配置,CPU 飙高的问题再也没有发生过。


根因剖析:为什么改了一行代码,救了整个系统?

关键在于旧代码中获取 HttpContext 的方式。旧代码里有一个静态属性:

bash 复制代码
public static HttpContext Current
{
    get
    {
        // 每次访问都在解析 DI 容器!
        object factory = ServiceProvider.GetService(typeof(IHttpContextAccessor));
        return ((HttpContextAccessor)factory).HttpContext;
    }
}

这就是那颗"定时炸弹"。

  1. 高频 DI 解析 :每一个请求进来,都要通过 ServiceProvider.GetService() 去解析服务。在 Castle Windsor 或内置 DI 中,解析过程涉及容器内部锁。
  2. 跨线程访问陷阱 :HttpContextAccessor.HttpContext 底层是 AsyncLocal。当 log4net 的异步线程(非请求线程)尝试写日志时,访问这个属性会触发复杂的上下文传播逻辑。
  3. 死锁链形成 :
    • 请求线程:拿 DI 锁 → 拿 log4net 锁 → 写日志
    • log4net 线程:拿 log4net 锁 → 尝试获取 HttpContext → 拿 DI 锁
    • 两条链路交叉,形成了跨层级的锁依赖链。

在高并发下,这 182 个线程就在 DI 容器锁和 log4net 锁之间反复横跳,CPU 被上下文切换彻底烧干。


五、 解决方案与总结

临时止血

当时我们采取了临时措施:关闭 Trace 级别日志,屏蔽 trace_id 设置。日志量下降后,锁争用瞬间缓解,CPU 回归正常。

最终方案

  1. 干掉 HttpContext.Current :在 ASP.NET Core 中,严禁在高频路径(如中间件、日志上下文)使用静态方式反向解析 HttpContext。
  2. 参数传递优于静态获取 :像上面修复的代码一样,通过参数直接传入 HttpContext,彻底绕开 DI 解析和 AsyncLocal 的跨线程访问。

六、 给.NET开发者的避坑清单

  1. 不要在高频路径上用 ServiceProvider.GetService(),尤其是中间件和日志组件。
  2. 不要在非请求线程(如后台线程、Task.Run)访问 HttpContext。
  3. HttpContext.Current 是 ASP.NET 时代的遗留产物,在 Core 时代请直接扔掉。
  4. 对照实验是排查问题的利器:当你怀疑某个组件有问题时,写个 Demo 或改一行代码验证,往往比看 100 行源码更有效。

写在最后

这次事故让我深刻体会到:**有时候,最危险的代码往往看起来最无辜。**​ 一行 HttpContext.Current,差点让整个产线停摆。

相关推荐
夜之眷属16 小时前
JVM实战:服务器堆外内存去哪了(NMT实测)
java·服务器·jvm·后端·性能优化
小羊没烦恼!18 小时前
Windows Azure Platform体验(1):Windows Azure
java·大数据·后端·python·flask·word·.net
打工仔折腾 AI1 天前
Docker镜像分层与卷挂载到底怎么工作:一次文件系统层面的实测分析
运维·人工智能·后端·python·docker·容器·性能优化
Cx330❀2 天前
Qt 多线程深度解析:从底层原理到 UI 线程与同步实战
开发语言·qt·ui·搜索引擎·性能优化·图形渲染
cyf312 天前
RDMA 写操作带宽性能建模与参数敏感性分析
性能优化·rdma·rocev2
夜之眷属2 天前
记一次战斗服务器 CPU 打满 100% 且“无法恢复“的排查
java·linux·运维·服务器·后端·性能优化
CSharp精选营2 天前
IIS 后面挂 Kestrel,这 3 个配置错了就 502
kestrel·.net·iis配置
鑫 源2 天前
C#源码开源, 获取macOS 电池数据,包括iOS (xamarin开发)
c#·.net·xamarin
余槐i2 天前
WAL 下写并发仍是 1:SQLite 单写者模型实测与绕开方案
数据库·python·性能优化·sqlite·wal
晚安日记wanna2 天前
MySQL 回表为什么这么慢?5 种优化手段逐个拆解
数据库·mysql·性能优化