第二章:性能计数器与监控

性能计数器这东西,说白了就是系统的「体检报告」。我刚做.NET性能调优那会儿,总觉得靠直觉就能找到瓶颈。结果呢?被现实狠狠教育了一顿。后来我养成了一个习惯——先看计数器,再动手改代码。

这一章,我们就来聊聊Windows性能计数器、.NET CLR专有的那些计数器,以及怎么自己动手造一个自定义计数器。

2.1 Windows性能计数器:系统级「听诊器」

Windows自带了一套非常完整的性能计数器体系。你想想看,CPU忙不忙、内存够不够、磁盘在不在狂转,这些都能通过计数器看到。

我个人最常用的几个系统计数器:

  • Processor\% Processor Time:CPU使用率。如果持续超过80%,你得小心了。
  • Memory\Available MBytes:可用内存。低于几百MB时,GC会频繁触发。
  • PhysicalDisk\Avg. Disk Queue Length:磁盘队列长度。超过2说明磁盘是瓶颈。
  • Network Interface\Bytes Total/sec:网络吞吐量。看你的应用是不是在疯狂收发数据。
我的小习惯: 我会把这些计数器做成一个「监控仪表盘」,用Performance Monitor(perfmon.msc)一次性全加上。这样一眼就能看出系统整体健康状况。

我记得有一次线上事故,接口响应突然变慢。我第一反应是代码有bug,结果一看磁盘队列长度飙到了15。原来是日志库在同步写文件,把磁盘IO打满了。换成异步写入后,问题立刻解决。

2.2 .NET CLR性能计数器:深入运行时内部

系统级计数器只能看到「表象」。要真正理解.NET应用的运行时行为,你得看CLR专属计数器。这些计数器藏在「.NET CLR Memory」、「.NET CLR Exceptions」等类别下面。

我重点讲几个最关键的:

计数器类别 计数器名称 说明
.NET CLR Memory # Gen 0 Collections 第0代GC次数。频繁触发说明小对象分配太多。
.NET CLR Memory % Time in GC GC占用的CPU时间百分比。超过10%就要优化内存了。
.NET CLR Memory Large Object Heap size 大对象堆大小。持续增长说明有大对象泄漏。
.NET CLR Exceptions # of Exceps Thrown 每秒抛出的异常数。异常很昂贵,别拿它做流程控制。
.NET CLR LocksAndThreads Contention Rate / sec 锁竞争频率。高并发下这个值高了,说明锁设计有问题。
避坑指南: 我曾经遇到一个项目,% Time in GC 长期在30%以上。排查后发现是代码里大量使用字符串拼接,每次拼接都产生新对象,导致GC频繁回收。改成 StringBuilder 后,这个值直接降到了3%以下。

为什么会这样?因为字符串是不可变的。你每次用「+」拼接,都在堆上创建一个新字符串。这些小对象很快填满第0代,触发GC。嗯,这里要注意——GC不是免费的,它要暂停所有线程。

2.3 自定义性能计数器:造自己的「专属仪表」

系统计数器和CLR计数器虽然强大,但有时候不够用。比如你想统计「每秒处理了多少订单」、「缓存命中率是多少」。这时候就得自己动手了。

.NET 提供了 System.Diagnostics.PerformanceCounter 类,可以创建和读写自定义计数器。我一般分三步走:

  1. 创建类别和计数器(通常在应用启动时做一次)
  2. 在业务代码中更新计数器值
  3. 用PerfMon或其他工具读取

来看一个实际例子。假设我们要统计「订单处理速率」:

// 第一步:创建计数器类别(需要管理员权限,通常安装时做)
if (!PerformanceCounterCategory.Exists("MyApp"))
{
    var counters = new CounterCreationDataCollection();
    
    // 速率计数器
    var rateCounter = new CounterCreationData
    {
        CounterName = "订单处理速率",
        CounterType = PerformanceCounterType.RateOfCountsPerSecond32
    };
    counters.Add(rateCounter);
    
    // 总数计数器
    var totalCounter = new CounterCreationData
    {
        CounterName = "订单总数",
        CounterType = PerformanceCounterType.NumberOfItems32
    };
    counters.Add(totalCounter);
    
    PerformanceCounterCategory.Create("MyApp", "我的应用性能计数器",
        PerformanceCounterCategoryType.SingleInstance, counters);
}

// 第二步:在业务代码中更新
var ratePerfCounter = new PerformanceCounter("MyApp", "订单处理速率", false);
var totalPerfCounter = new PerformanceCounter("MyApp", "订单总数", false);

// 每处理一个订单就调用
void ProcessOrder(Order order)
{
    // ... 处理订单逻辑 ...
    
    ratePerfCounter.Increment();   // 速率计数器自动计算每秒次数
    totalPerfCounter.Increment();  // 总数加1
}
重要提醒: 自定义计数器写入操作本身有性能开销。我建议不要在高频循环(比如每秒几万次)中直接更新计数器。可以先用本地变量累加,每隔几秒批量更新一次。

我曾经在一个高并发网关项目里,每个请求都更新计数器,结果发现计数器写入成了新的瓶颈。后来改成每5秒汇总一次,性能开销几乎可以忽略不计。

2.4 如何高效采集与分析

光有计数器还不够,你得知道怎么采集和分析。我常用的方式有三种:

  • Performance Monitor (PerfMon):Windows自带,适合临时排查。可以设置数据收集器集,记录到日志文件。
  • dotnet-counters:.NET Core/5+ 的跨平台工具。命令行下就能看,非常轻量。
  • 接入APM系统:比如SkyWalking、Prometheus + Grafana。适合生产环境长期监控。

我个人习惯是:开发环境用PerfMon快速定位,测试环境用dotnet-counters做压力测试分析,生产环境必须上APM系统。

一个小技巧: 用PerfMon时,可以把多个相关计数器叠加到一张图上。比如把「% Time in GC」和「# Gen 0 Collections」放在一起看,能直观发现GC频率和耗时之间的关系。

嗯,这一章的内容就到这里。记住一句话:没有数据支撑的性能优化,都是瞎猜。计数器就是你的眼睛,用好它们,你就能看到系统最真实的样子。