做.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 查看同步块表,真相开始浮出水面:

关键发现:
- 有一个
SyncBlock的MonitorHeld值极高,说明争用极其激烈。 - 锁的持有者是线程 253。
- 锁关联的对象直指 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;
}
}
这就是那颗"定时炸弹"。
- 高频 DI 解析 :每一个请求进来,都要通过
ServiceProvider.GetService()去解析服务。在 Castle Windsor 或内置 DI 中,解析过程涉及容器内部锁。 - 跨线程访问陷阱 :
HttpContextAccessor.HttpContext底层是AsyncLocal。当 log4net 的异步线程(非请求线程)尝试写日志时,访问这个属性会触发复杂的上下文传播逻辑。 - 死锁链形成 :
- 请求线程:拿 DI 锁 → 拿 log4net 锁 → 写日志
- log4net 线程:拿 log4net 锁 → 尝试获取 HttpContext → 拿 DI 锁
- 两条链路交叉,形成了跨层级的锁依赖链。
在高并发下,这 182 个线程就在 DI 容器锁和 log4net 锁之间反复横跳,CPU 被上下文切换彻底烧干。

五、 解决方案与总结
临时止血
当时我们采取了临时措施:关闭 Trace 级别日志,屏蔽 trace_id 设置。日志量下降后,锁争用瞬间缓解,CPU 回归正常。
最终方案
- 干掉
HttpContext.Current:在 ASP.NET Core 中,严禁在高频路径(如中间件、日志上下文)使用静态方式反向解析HttpContext。 - 参数传递优于静态获取 :像上面修复的代码一样,通过参数直接传入
HttpContext,彻底绕开 DI 解析和AsyncLocal的跨线程访问。
六、 给.NET开发者的避坑清单
- 不要在高频路径上用
ServiceProvider.GetService(),尤其是中间件和日志组件。 - 不要在非请求线程(如后台线程、Task.Run)访问
HttpContext。 HttpContext.Current是 ASP.NET 时代的遗留产物,在 Core 时代请直接扔掉。- 对照实验是排查问题的利器:当你怀疑某个组件有问题时,写个 Demo 或改一行代码验证,往往比看 100 行源码更有效。
写在最后
这次事故让我深刻体会到:**有时候,最危险的代码往往看起来最无辜。** 一行
HttpContext.Current,差点让整个产线停摆。