平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“C#构建Windows服务与IIS实时监测系统”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。
从实现思路看,一套面向IT运维及.NET开发者的Windows服务与IIS自动监测重启工具,基于C#与.NET Framework 4在Visual Studio 2022中开发,以Winform程序形式交付。当业务网站、应用程序池或系统服务因异常停止时,可自动拉起进程,为排查根因争取缓冲时间,适用来临时救场、日常巡检及自动化运维场景。压缩包共70个文件,以cs源码为核心,辅以config设置、exe程序、dll库以及resources资源文件,同时包含Visual Studio解决方案文件与项目工程,便于直接编译、修改或二次开发;整体大小仅373KB,结构轻量。项目内不仅有窗体程序演示,还封装了IIS站点/应用池操作、Windows服务管理、文件与日志处理等帮助类,可更快嵌入自有运维工具,也可作为Winform入门学习的参考范例。目前已有113人学习采用,对需低成本实现服务自愈或初探C#桌面开发的读者具有一定参考价值。
1. 为什么要把Windows服务、IIS网站和应用程序池放进同一个监测项目
结合项目来看,线上Windows服务器出故障,大多数时候不是一条告警,而是一串连锁反应:某个Windows服务悄悄停了,依赖它的IIS网站开始超时,请求堆积在应用程序池里,CPU和内存飙高,再过几分钟站点直接无响应。分开看,每个环节都有人盯,但真正要定位“先坏的是谁”,必须把Windows服务、IIS网站和应用程序池放进同一个实时监测源码项目里一起采样。这个项目要解决的核心问题,就是用一套代码同时拿到三类状态——服务进程活没活、站点能不能响应、应用池指标是否正常——同时按时间线关联起来。适合写这类系统的,是Windows服务器运维、IIS站点管理员,以及所有被“半夜网站打不开”折磨过的人。
2. 数据从哪来:SCM、ServerManager与性能计数器三条读取路径
2.1 Windows服务状态:ServiceController与SCM查询的取舍
从实现思路看,做Windows服务实时监测,第一反应是用 sc.exe query 或者 PowerShell 的 Get-Service ,这两个方式在排查时好用,但放进源码项目里有两个问题:每轮查询都要拉起一个外部进程,几百个服务轮下来开销不小;而且输出是文本,解析容易踩本地化差异。更稳的做法是在C#里直接用 System.ServiceProcess.ServiceController ,它封装了Windows服务控制管理器(SCM)的API,拿到的是结构化数据,状态、启动类型、依赖关系一次读全。
using System.ServiceProcess;
// 只盯关键服务,不要全量扫描所有服务
var watchList = new[] { "W3SVC", "MSSQLSERVER", "Tomcat8" };
foreach (var serviceName in watchList)
{
try
{
using var sc = new ServiceController(serviceName);
sc.Refresh(); // 强制刷新缓存,避免读到上一次查询结果
Console.WriteLine($"{serviceName}\t{sc.Status}\t{sc.StartType}");
if (sc.Status != ServiceControllerStatus.Running)
{
// 状态异常,交给告警模块处理
}
}
catch (Exception ex)
{
Console.WriteLine($"{serviceName}\t读取失败: {ex.Message}");
}
}
这段代码的关键点是 Refresh() 。 ServiceController 内部有状态缓存,不刷新的话,连续查询可能拿到过期状态。 StartType 能区分自动、手动和禁用,监测时重点关注“应该自动启动但当前不是Running”的服务。另外注意 W3SVC 就是“World Wide Web Publishing Service”的服务名,IIS的核心服务,必须挂在监测列表最前面。
2.2 IIS网站和应用程序池:读applicationHost.config比跑appcmd更稳
IIS的设置中心是 C:\Windows\System32\inetsrv\config\applicationHost.config ,网站、应用程序池、绑定信息全在这里。日常排查习惯用 appcmd list site 查看,但写进实时监测项目,每次都调appcmd进程太重。微软官方提供的 Microsoft.Web.Administration 命名空间,借助 ServerManager 类能够直接读取同时坚控这些设置和运行状态,不需shell命令,性能也好不少。
using Microsoft.Web.Administration;
using var serverManager = new ServerManager();
foreach (var site in serverManager.Sites)
{
var bindings = string.Join(", ",
site.Bindings.Select(b => $"{b.Protocol}://{b.BindingInformation}"));
Console.WriteLine($"站点: {site.Name}\t状态: {site.State}\t绑定: {bindings}");
}
foreach (var pool in serverManager.ApplicationPools)
{
Console.WriteLine($"应用池: {pool.Name}\t状态: {pool.State}\t管道: {pool.ManagedPipelineMode}");
}
ServerManager 读的状态包括站点和应用程序池的 Started/Stopped ,这些信息每分钟扫一遍没问题。注意 Site.State 是IIS自己的管理状态,不是“能不能访问”的状态——一个站点State是Started,但绑定的端口可能已经失败,所以第5章会补一层探测。 BindingInformation 格式是 IP:端口:主机头 ,比如 *:80:www.example.com ,同一台IIS上多个网站,靠这个字段区分谁是谁。
2.3 性能计数器:把“实时”从秒级拉到毫秒级
落到代码里,服务状态和站点状态是离散的“生死”信息,要看到实时的负载变化,必须用性能计数器。IIS相关计数器分布在几个类别里:“Web Service”按网站实例统计连接数和请求速率;“W3SVC_W3WP”按应用程序池统计工作进程的CPU和内存;“APP_POOL_WAS”记录回收次数等生命周期指标。抓取这些数据的API是 System.Diagnostics.PerformanceCounter 。
2.3.1 计数器实例怎么取
从实现思路看,性能计数器的一个坑是“实例名”,中文Windows系统可能把计数器类别翻译成本地语言,实例名也可能和站点名不完全一致(比如特殊字符会被转义)。最稳妥的写法是先枚举实例名,再逐个取值:
using System.Diagnostics;
var category = "Web Service";
var categoryObj = PerformanceCounterCategory.GetCategories()
.First(c => c.CategoryName == category);
foreach (var instanceName in categoryObj.GetInstanceNames())
{
if (instanceName == "_Total") continue;
using var cc = new PerformanceCounter(category, "Current Connections",
instanceName, readOnly: true);
cc.NextValue(); // 第一次NextValue返回0,必须预热
Thread.Sleep(1000);
Console.WriteLine($"{instanceName}\t当前连接数: {cc.NextValue()}");
}
readOnly: true 表示只读,监测程序不需写计数器,避免提权。预热那一下很关键,性能计数器第一次取值会初始化,直接拿到的数字没有参考意义,一般要间隔一秒以上再取第二次。
2.3.2 采样间隔与数据保存
落到代码里,实时不是越频繁越好。性能计数器本身有系统级的采样开销,1秒一次和5秒一次看到的趋势差不多,但CPU开销差几倍。常用做法是:性能计数每5~15秒采样一次,服务状态和站点状态每30~60秒扫一遍。采样结果写成时序数据,轻松方案是直接追加CSV,按天轮转文件,保留30天;数据量大再考虑接时序数据库。监测项目刚起步,先用文件轮转,把格式定好才是重点:
timestamp,category,instance,metric,value
2025-01-08 14:23:05,IIS_SITE,www.example.com,CurrentConnections,128
2025-01-08 14:23:05,APP_POOL,DefaultAppPool,CPU_Percent,23.5
2025-01-08 14:23:05,WINDOWS_SERVICE,W3SVC,Running,True
这个CSV格式里, category 区分数据来自IIS站点、应用池还是Windows服务; instance 写具体对象名; metric 是指标名。字段顺序固定,后面写报警逻辑时,按 category + instance + metric 做过滤就够了。
3. 用C#写最小可运行的实时监测源码项目
3.1 初始化监测框架:轮询用Timer而不是死循环
实际处理时,实时监测的骨架是一个常驻进程,每轮做三件事:读Windows服务状态、读IIS站点和应用池快照、采样性能计数器。轮询调度不能写死 while (true) { ... Thread.Sleep(...) } ,因为单轮里某一步卡住(比如ServerManager打开设置慢),后续所有步骤都会延迟。建议用 System.Threading.Timer ,让每一轮独立触发,互不阻塞:
var timer = new Timer(Tick, null, TimeSpan.Zero, TimeSpan.FromSeconds(15));
Console.ReadKey();
timer.Dispose();
static void Tick(object state)
{
Task.Run(() =>
{
try
{
ScanWindowsServices();
ScanIisSites();
ScanPerformanceCounters();
}
catch (Exception ex)
{
Console.WriteLine($"轮询异常: {ex.Message}");
}
});
}
Task.Run 保证异步执行,即使一次扫描超过15秒,下一个周期也能按时触发,不会越挤越慢。异常捕获放在 Tick 内部,否则定时器回调里的未处理异常会导致整个进程崩溃。这种做法比在 while 循环里捕获更安全——即使某轮读IIS设置抛错,下一轮自动恢复。
3.2 服务监测模块:输出状态快照和自启动校验
实际处理时,服务监测的目标不只是“现在Running没有”,还要判断“该自动启动的有没有被改成禁用”。实际操作中常用的问题是:Windows服务无法自动启动,查下来启动类型被改了。所以监测时要把 StartType 一起记录,同时和预设期望值比对。
public class ServiceSnapshot
{
public string ServiceName { get; set; }
public string Status { get; set; }
public string StartType { get; set; }
public bool IsHealthy { get; set; }
}
public ListScanWindowsServices(string[] watchList)
{
var result = new List();
foreach (var name in watchList)
{
try
{
using var sc = new ServiceController(name);
bool healthy = sc.Status == ServiceControllerStatus.Running;
result.Add(new ServiceSnapshot
{
ServiceName = name,
Status = sc.Status.ToString(),
StartType = sc.StartType.ToString(),
IsHealthy = healthy
});
}
catch
{
result.Add(new ServiceSnapshot
{
ServiceName = name,
Status = "Missing",
IsHealthy = false
});
}
}
return result;
}
IsHealthy 是这个模块给上层判断用的唯一字段,规则统一: Running 就是健康,其他都算不健康,包括 Stopped 、 Paused 、 StartPending 。特别注意 StartPending ——服务正在启动中,说明可能启动了但卡住,也要标记出来。异常情况单独走 Missing ,表示系统里压根没有这个服务,多半是装漏了或者服务名写错。
3.3 IIS监测模块:站点与应用池分开处理
落到代码里,IIS监测拆两块,一块盯站点,一块盯应用池。站点状态异常(Stopped)能够直接告警,应用池状态异常(Stopped对应池里的站点会全部503)更要紧急告警。下面这个函数把站点和应用池都扫一遍,输出统一结构:
public class IisSnapshot
{
public string ObjectType { get; set; } // Site 或 AppPool
public string ObjectName { get; set; }
public string State { get; set; }
public string Detail { get; set; }
public bool IsHealthy { get; set; }
}
public ListScanIis()
{
var list = new List();
using var sm = new ServerManager();
foreach (var site in sm.Sites)
{
list.Add(new IisSnapshot
{
ObjectType = "Site",
ObjectName = site.Name,
State = site.State.ToString(),
Detail = string.Join(";", site.Bindings.Select(b => b.BindingInformation)),
IsHealthy = site.State == ObjectState.Started
});
}
foreach (var pool in sm.ApplicationPools)
{
list.Add(new IisSnapshot
{
ObjectType = "AppPool",
ObjectName = pool.Name,
State = pool.State.ToString(),
IsHealthy = pool.State == ObjectState.Started
});
}
return list;
}
ObjectType 字段用来区分两类对象,便于告警时知道往哪个群发消息。应用池的名字和站点名不一定是一对一,多个站点能够共用同一个应用池,所以监测应用池时如果发现某个池有问题,要能反查出哪些站点挂在上面,这个联动查询能够后续加,第一版先记录状态。
3.4 性能数据采样模块:一段轮换的计数器集合
落到代码里,性能计数器需留意“新建后用完及时释放”,特别是高频采样的场景,否则句柄泄漏会让监测进程自己变慢。下面的写法用 using 保证每次采样结束后释放,适合几分钟级的小批量采样:
public DictionarySampleCounters(string category, string[] counters)
{
var dict = new Dictionary();
foreach (var counterName in counters)
{
try
{
using var counter = new PerformanceCounter(
category, counterName, "_Total", readOnly: true);
counter.NextValue();
Thread.Sleep(500); // 两次采样间隔必须>0,否则第二次也是0
dict[counterName] = counter.NextValue();
}
catch (Exception ex)
{
dict[counterName] = -1; // 采样失败用-1标记,不要把异常抛出去
}
}
return dict;
}
采样失败用 -1 标记比抛异常更好。单个计数器偶发读不到值,可能是计数器临时不存在,直接抛异常会把整轮数据都丢掉。拿到采样值后还要记得做一次“合理性校验”,比如CPU百分比超过100(多核机器上 % Processor Time 是按单核算的,最大值可能是 核数 * 100 ),这类数据进库前要修正,否则后续看趋势图时会出现莫名的尖峰。
3.5 把三类数据汇总并落盘
理解这一步时,最后一步是把三个模块的数据合同时成统一格式写入文件。状态数据和性能数据建议分开写:状态数据只在“发生变化”时追加一行,性能数据固定间隔追加。这样文件体积可控,查问题时也能直接看“状态变更时间线”,不用在一堆每秒采样的数据里翻找。
public void PersistSnapshot(Listservices,
Listiis, Dictionary perf, DateTime time)
{
var line = string.Join("|",
time.ToString("yyyy-MM-dd HH:mm:ss"),
string.Join(";", services.Select(s => $"{s.ServiceName}:{s.IsHealthy}")),
string.Join(";", iis.Select(s => $"{s.ObjectType}:{s.ObjectName}:{s.IsHealthy}")),
string.Join(";", perf.Select(kv => $"{kv.Key}:{kv.Value}")));
File.AppendAllText("monitor.log", line + Environment.NewLine);
}
理解这一步时,一行里同时包含三类数据,这是为了关联时间线:网站打不开时,看同一行的Windows服务状态,就能判断是IIS停了导致网站挂,还是网站正常但服务没起来。 monitor.log 按天轮转,文件名带日期,定时任务里做一次归档清理,保留最近30天即可。想接数据库,把 line 拆成结构化字段丢进表里就行,接口留好。
4. 部署监测项目时要调的参数:轮询间隔、运行账户和告警阈值
4.1 轮询间隔设置:三个对象用三套频率
理解这一步时,监测项目跑起来后,第一个要调的就是轮询频率。Windows服务状态变化不频繁,30秒扫一次已经足够,扫太快反而会占用SCM通道,影响正常的服务启停操作。IIS站点状态同样30秒一次,但性能计数器不同,连接数和请求速率是连续变化的值,5秒采样一次能抓到毛刺,15秒采样只能看趋势。常用的部署设置如下所示:
| 监测对象 | 轮询间隔 | 数据保留 | 说明 |
|---|---|---|---|
| Windows服务状态 | 30秒 | 仅记录状态变化 | 变化少,全量日志太占空间 |
| IIS站点/应用池状态 | 30秒 | 仅记录状态变化 | 同上 |
| 性能计数器 | 5秒 | 全量保留7天 | CPU、连接数需连续曲线 |
| 汇总快照 | 5秒 | 保留30天 | 用来历史回放和故障分析 |
在这个场景下,轮询间隔最好写在设置文件中,不要硬编码。不同服务器负载差异很大,有的机器跑几千个请求每秒,性能计数器采样开销明显,间隔就要拉长;内部测试机几秒一次无所谓。源码项目里用 appsettings.json 存 PollingIntervalSeconds 一类设置,部署时按机器情况调。
4.2 告警规则:不是所有状态异常都要通知
在这个场景下,实时监测系统最容易烦到人的就是告警轰炸。服务刚重启时,SCM状态会经过 StopPending 、 StartPending ,如果每个中间状态都告警,一晚上能收到几十条。我的做法是:状态告警延迟确认,比如连续三次扫描(约90秒)都异常,才真正触发;瞬时异常只记录,不通知。这样能够过滤掉服务正常重启时的误报。
数值类指标也要按风险分级设置阈值。下面是实际运维中比较常用的参考值:
| 指标 | 警告阈值 | 严重阈值 | 建议持续时长 |
|---|---|---|---|
| w3wp进程CPU | 50% | 80% | 5分钟 |
| 站点当前连接数 | 基线+30% | 基线+100% | 3分钟 |
| 请求队列长度 | 2000 | 5000 | 10秒 |
| 应用池回收次数 | 1次/小时 | 3次/小时 | 1小时 |
理解这一步时,基线的意思是“这周同时间的平均值”,不是固定值。IIS的流量有明显的峰谷,固定阈值在低峰期形同虚设,高峰期又频繁误报。源码项目里如果做自动基线,用最近7天同时段均值加标准差作为浮动阈值,效果比硬编码的好很多。
4.3 运行账户:别用普通账户跑监测服务
在这个场景下,监测程序的运行身份直接决定能读到哪些数据。读取 ServerManager 需管理员权限,读取性能计数器里“Web Service”类别也需一定权限,如果监测程序注册成Windows服务同时跑在 NETWORK SERVICE 账户下,很可能出现“站点状态读不出来”“计数器全部无权限”的情况。常用的解决方法是把监测服务设置为 LocalSystem 账户运行,或者单独建一个加入 Performance Monitor Users 组和 Administrators 组的专用账户。
还有一个遇到过的真实坑:监测程序自己管理的几个Windows服务里,如果包含“Windows Time”这类和域相关的服务,用普通权限会读到 Access Denied 。处理方式是在读取时捕获异常,不把整个服务扫描搞挂——某个服务没权限,跳过就好,不要让一个错误终止整轮监测。
4.4 告警通道:先写日志,再接企业微信和邮件
在这个场景下,源码项目第一版,告警先不要直接接一堆外部渠道。先把告警事件写入独立日志文件,比如 alerts.log ,测试阶段靠看日志验证规则是否合理。规则稳定后再加输出端:常用的有邮件SMTP、企业微信/钉钉的WebHook机器人、以及轻松的HTTP回调。下面是接WebHook的代码片段,逻辑通用,改个URL就能用在大部分IM工具上:
using System.Net.Http;
using System.Text;
public async Task SendAlert(string title, string body)
{
var payload = new { msgtype = "text", text = new { content = $"{title}\n{body}" } };
var json = System.Text.Json.JsonSerializer.Serialize(payload);
using var client = new HttpClient();
var response = await client.PostAsync(
"https://your-webhook-url",
new StringContent(json, Encoding.UTF8, "application/json"));
// 这里判断 response.IsSuccessStatusCode,失败时记录到本地
}
需留意, SendAlert 必须做成异步且带超时,消息网关不可用时不能阻塞监测主流程。报警消息里最好带上“变更前状态和当前状态”,比如“应用池 DefaultAppPool 从 Started 变为 Stopped”,这样收到消息的人不用登录服务器就能判断严重性。
5. 进阶:状态“Started”不等于活着的假活检测与回收定位
5.1 给站点状态加一层TCP探测
结合项目来看,很多IIS故障是“假活”:应用池是Started,站点也是Started,但页面请求超时或得到500。原因可能是应用池的工作进程崩溃后没有及时重启、站点后台代码死锁,也可能仅仅是绑定的端口被别的进程占用了。要识别假活,就必须在状态监测之外加一层端口探测。做法是在 ScanIis 后对每个站点的绑定端口 做TCP连接测试:
using System.Net.Sockets;
public static bool PortIsOpen(string host, int port, int timeoutMs = 3000)
{
using var client = new TcpClient();
var task = client.ConnectAsync(host, port);
var finished = Task.WaitAny(task, Task.Delay(timeoutMs)) == 0;
return finished && client.Connected;
}
把每个站点的绑定解析成 应用池 + IP + 端口 一一探测,超时按3秒算。这样监测结果从“站点状态Started”升级为“站点端口可访问”,站点的生死判断就更贴近真实用户体验。需HTTP层的完整校验,再进一步用 HttpClient 请求健康检查路径,比如固定请求 /healthz 看得到状态码和数据内容,这一步能抓出“端口通但应用内部已经挂掉”的更隐蔽问题。
5.2 用回收次数与事件ID定位“频繁回收”的根因
落到代码里,应用池频繁回收是IIS站点间歇性卡顿的最常用原因,状态监测看不出来。要坚控这个指标,直接用性能计数器 APP_POOL_WAS(池名)\Total Application Pool Recycles 的增量。如果一小时内回收超过2次,基本能够认定有问题。回收原因能够查Windows事件日志中来源为 Microsoft-Windows-WAS 的事件,常用的事件ID是 5074 (回收完成)和 5059 (工作进程异常终止),排查命令如下所示:
Get-WinEvent -LogName System -MaxEvents 50 |
Where-Object { $_.ProviderName -eq "Microsoft-Windows-WAS" `
-and ($_.Id -eq 5074 -or $_.Id -eq 5059) } |
Select-Object TimeCreated, Id, Message | Format-List
在这个场景下,这条命令查出最近一次应用池回收时间和回收原因说明。如果看到回收原因是“设置更改”,多半是有人在IIS管理界面改过设置;原因是“时间间隔”,说明设了固定回收周期;如果是“内存超出限制”,则要考虑增大应用池的虚拟内存上限或排查代码里的内存泄漏。把回收事件和 APP_POOL_WAS 计数器的回收时间点对齐,就能判断是规律性回收还是异常崩溃。这一个联动排查步骤,能让实时监测项目从“发现问题”进化到“辅助定位根因”。检测到回收事件后,要在监测日志里记录当时站点的请求量和CPU值,给后续分析留下线索。
5.3 自动恢复动作:把“监测”变成“处置”
在这个场景下,监测项目做到后面,会自然想加自愈动作。最常用的是在检测到Windows服务或应用程序池停止时,自动尝试启动——但禁止直接对站点做无脑重启操作。自愈动作要加“次数限制”和“冷却时间”,比如同一应用池5分钟内最多自动重启2次,超过次数就只告警不动作,避免服务一直在崩溃-启动-崩溃的循环里打转。制定自动恢复策略时应该先从只读监测开始,确认告警准确率和误报率,再逐步接入自动处置,这才是把监测源码项目用好的递进路径。
理解这一步时,总的来说,C# Windows服务 IIS实时监测适合结合实际项目边做边理解。先抓住核心思路,再逐步补上细节和边界处理,最后效果会更稳定,也更容易复用。

