凌晨三点,线上服务突然消失。事件查看器里留下一行“进程已退出,退出码 0xc0000409”,应用日志最后一条还是两小时前的正常业务日志。没有崩溃现场,没有自动生成的 Dump,你能做的只有重新拉起进程,然后祈祷它复现。这种经历,凡是在 .NET 生产环境待过的人都懂。
这篇文章只聊一件事:在 .NET 程序的各种“死法”里,怎么稳定、有效地生成 Dump 文件。我先把结论放在前面——靠人肉去现场抓 Dump 永远抓不赢,正确姿势是提前把自动触发机制配置好,让进程在崩溃瞬间自己拍一张遗照。读这篇文章的人,无论是开发、运维还是 SRE,只要你想在排障时拿到第一手证据,而不是靠猜,都建议把下面这套思路过一遍。
1. 为什么说“生成 Dump”是排障的第一块基石
1.1 日志解决不了的“最后一公里”
日志的本质是开发者预先埋好的叙事线,它只能记录你预料到的分支。而线上崩溃往往发生在你根本没有预料到的路径里。应用日志最后一行是“订单处理成功,准备发送通知”,然后进程没了,你连个报错都看不到。
这还不是最坑的。最坑的是事件查看器里那个退出码:0xc0000409 通常对应 fail fast,0xc0000005 是访问违例,它们能告诉你“进程死于内存访问错误”,但完全不能告诉你“是哪一行代码触发的”。生产数据、并发时序、内存压力,这些条件在本地环境几乎不可能复现。你重新跑一百遍测试都跑不出线上那个崩溃,因为现场已经被销毁了。
所以我说日志只能解决排障的“倒数第二公里”,最后一公里必须靠 Dump。Dump 是进程在某个时刻的完整内存快照,它把崩溃瞬间的所有状态冻住了——每个线程的调用栈、变量、托管堆里的对象、加载的模块,全在里面。有了它,崩溃就不是一个抽象的描述,而是一堆可以反复查看的证据。
1.2 Dump 里到底藏着什么:一个“事故现场”的完整快照
一个 .NET 进程的 Dump 文件,严格来说应该叫“进程转储”,它至少包含以下几类关键信息:
- 所有线程的调用栈,包括原生栈和托管栈;
- 托管堆里的对象,以及对象之间的引用关系;
- 当前正在抛出的异常对象;
- 进程加载了哪些 DLL、在什么版本;
- 关键寄存器和线程上下文。
很多人把 Dump 想得很玄,其实完全可以用法医取证来类比。日志是目击者证词,证词可能有误导、有盲区;Dump 就像监控录像加现场物证加尸体解剖报告,它不跟你商量,里面有什么就是什么。常见的情况是:日志里什么都没报,但 Dump 一看,某个工作线程的托管栈停在MyService.DataProcessor.ProcessOrder(),参数对象是null,而旁边另一个线程已经在一个ManualResetEvent上挂了两个半小时。真相往往不在“报错的那一行”,而在于线程之间互相等待、资源被消耗殆尽。没有 Dump,这一切你永远看不到。
1.3 一个反直觉的结论:靠“人手抓”永远抓不赢
接下来说点反直觉的。很多团队排障的第一步是“赶紧去服务器上抓 Dump”,这实际上已经输了。真正的崩溃从发生到进程退出,时间窗口只有几百毫秒甚至更短。等你登录服务器、打开工具、输入 PID,进程早就没了,你抓的是空气。
所以我对“有效生成 Dump”的定义是:在进程还活着的时候就已经配置好自动触发条件,让它在崩溃前自己把现场留下。人肉操作只适用于一种场景——进程还没死,但已经处于挂起、死锁、内存泄漏等“活死状态”,这时候你还有机会手动抓快照。这个区别很重要,后面所有工具选型都围绕它展开。
另外要说明一下,本文是“上篇”,只讲怎么把 Dump 有效抓出来,不讲怎么分析。分析涉及dotnet-dump analyze、SOS 命令、托管堆遍历这些,内容量很大,留到下篇专门聊。先把“拍现场”这件事做扎实,照片都拍不到,后面洗照片的技术再好也没用。
2. “万种死法”的快速分类:先分清对手再出手
标题里写“万种死法”,多少有点夸张,但实际场景确实五花八门。不同的死法,对 Dump 生成策略的要求完全不一样。把对手分清楚再动手,比一上来就拿着工具乱抓重要得多。
2.1 托管异常:默认没有 Dump 的“人民内部矛盾”
最常见的死法,是托管未捕获异常:空引用、参数非法、OutOfMemoryException、StackOverflowException,这些异常一旦逃出所有try/catch,runtime 会把异常栈打印出来,然后终止进程。症状很“温柔”,日志里甚至能看到完整的异常栈。
但这里有个陷阱:异常栈只能告诉你在哪抛的,不能告诉你为什么在这里抛。比如OutOfMemoryException,崩溃点是某一次List<T>.Add或者字符串分配,但真正的原因是内存泄漏了三个小时,GC 堆里塞满了没有被释放的对象。这种问题只盯异常栈,永远找不到根因。
更关键的是,托管未捕获异常默认不生成 Dump。进程退出就退出了,现场不保留。你需要另外想办法,要么在AppDomain.CurrentDomain.UnhandledException事件里主动抓取,要么通过配置让 runtime 在 crash 时兜底。后面章节细说。
2.2 原生崩溃:Runtime 其实已经偷偷帮你留了后手
第二种死法是原生崩溃:P/Invoke 调用 C/C++ 代码时访问违例、原生堆被写坏、Linux 上收到 SIGSEGV 或 SIGABRT。这种死法最让人头疼,因为日志里通常什么都没有,进程“刷”地一下就没了。
但这里其实有一个多数人不知道的后手:.NET 的 runtime 内置了一套崩溃处理机制,当进程发生原生崩溃时,runtime 的 Crash Handling 会尝试调用一个独立的createdump进程来生成 Dump。为什么要用独立进程?因为崩溃的瞬间,原进程的内存和堆可能已经损坏,在内部抓取不可靠,必须另起一个干净的子进程来操作。
这套机制默认是关闭的,需要通过环境变量打开。打开之后,原生崩溃时 Dump 会自动落在指定目录,省去很多抢救动作。这个配置属于部署层面的“几项规定动作”,后面我会给出具体值。
2.3 挂起与死锁:进程还活着,比死了更麻烦
第三种死法最隐蔽:进程没有退出,CPU 可能也是 0%,但接口完全不响应。线程池被占满、数据库连接耗尽、多个线程互相等待锁,业务请求全部堆在队列里。这时候你没有崩溃可等,自动触发机制一概不生效。
挂起的处理思路和崩溃完全不同:你要主动去“拍摄现场”,而且最好连续拍两张照片。第一次抓一张 Dump,等三五秒再抓一张,对比两张 Dump 里线程栈是不是完全一致。如果一致,基本可以断定是死锁或者线程卡死,而不是某个偶发慢调用。这个“连拍对比”的方法,是排查 hang 类问题最常用的手法,没有 Dump 根本做不了。
2.4 进程外杀手:环境配置和预约之外的死法
还有一类死法,是进程本身没有任何错误,却被外部环境“处决”。典型的是容器环境下 cgroup 内存超限,被内核 OOM Killer 直接杀掉,退出码 137;或者是部署系统反复重启、被人为 kill、宿主机维护触发进程终止。这类死法最无奈,进程没有机会执行任何业务代码,runtime 的崩溃处理机制也来不及启动。
应对这类场景,Dump 本身的生成已经不是重点,重点是预防和旁路记录:内存监控的告警阈值必须设得比容器内存限制低,保证你在 OOM Kill 之前能收到信号并主动抓 Dump;部署系统在终止进程前应预留宽限期,让应用有机会优雅退出;日志和监控指标一定要外置,不能只存在容器本地文件系统。
2.5 一张表讲清楚“什么死法对应什么抓法”
| 死法类型 | 典型信号 | 默认有无 Dump | 推荐抓取方式 |
|---|---|---|---|
| 托管未捕获异常 | 日志有异常栈,进程退出 | 无 | 应用内注册 UnhandledException 钩子,或 runtime 环境变量兜底 |
| 原生崩溃(访问违例等) | 进程瞬时退出,日志空白 | 需开启才生 | 配置DOTNET_DbgEnableMiniDump等环境变量 |
| 挂起 / 死锁 | 进程活着但不响应 | 无 | 手动连续抓两个 Dump 对比线程栈 |
| 进程外 kill / OOM Kill | 退出码 137 等,无应用错误 | 无 | 监控告警 + 主动提前抓取,事后查 metric |
| 内存泄漏 | 内存持续上涨,最终 OOM | 无 | 定时抓 Dump 观察堆对象分布 |
这张表建议直接贴在团队 wiki 上。很多排障翻车,本质是对死法判断错误:明明是挂起,却按崩溃去等自动触发,等到天荒地老也没有 Dump;明明是原生崩溃,却只在应用日志里翻,翻到天亮也找不到原因。
3. 常用抓取工具全景对比:Windows 与 Linux 都别落下
3.1 Windows:Procdump 才是真正的瑞士军刀
Windows 上很多人用任务管理器右键“创建转储文件”,我只能说这适合“进程还没死、你刚好登录上服务器”的少数场景,而且完全无法自动化。线上服务崩溃是随机事件,不可能每次都有个人守在机器前面右键。
真正能扛生产环境的是 Procdump。它是 Sysinternals 套件里的命令行工具,体积小,直接拷到服务器就能用,不需要安装。第一步先跑procdump -accepteula接受许可,然后就可以按触发条件挂到进程上。
最常用的几个触发场景:
# 监听未处理异常,同时监听进程退出,抓全内存 Dump procdump -accepteula -ma -e -t MyService.exe D:\dumps # 监听第一次机会异常(first chance exception),适合排查纯托管异常 procdump -accepteula -ma -e 1 MyService.exe D:\dumps # 进程退出时抓取,适合某些退出了但不一定崩溃的场景 procdump -accepteula -ma -t 1234 D:\dumps参数含义拆开看就很好记:-ma表示抓完整内存转储,不带它默认只抓迷你转储,对 .NET 托管堆分析来说基本没用;-e表示在未处理异常时触发,加1变成在首次异常时触发;-t是进程退出时触发。可以把 Procdump 注册成 Windows 服务或者计划任务,进程一启动就挂上,这样线上环境等于有了一个常驻的“自动遗照拍摄器”。
3.2 Linux:DOTNET_ 环境变量与静默的 createdump
Linux 上 .NET 应用抓 Dump,第一优先级不是 gdb,而是 runtime 自带的崩溃转储机制。.NET 5 之后的环境变量统一为DOTNET_前缀,旧版是COMPlus_前缀。你需要配置三个变量:
export DOTNET_DbgEnableMiniDump=1 export DOTNET_DbgMiniDumpType=4 export DOTNET_DbgMiniDumpName=/var/dumps/crash_%p.dmpDOTNET_DbgEnableMiniDump=1打开崩溃时自动转储;DOTNET_DbgMiniDumpType=4表示生成全量转储;DOTNET_DbgMiniDumpName指定落盘路径,%p会被替换成进程 ID。路径所在的目录要提前建好,还要保证运行 .NET 进程的账号有写权限。
这套配置既可以用在 systemd 服务文件里,也可以用在容器的 entrypoint 脚本里,没有额外依赖,完全是 runtime 层面的能力,应用代码一行都不用改。配好之后强烈建议做一次故障注入测试:写一个测试程序,在入口处调用Environment.FailFast("test crash")强制 fail fast,然后确认 dump 目录里出现了.dmp文件。很多团队配置了环境变量但从来没验证过,真到崩溃那天才发现 dump 目录不可写,那就尴尬了。
3.3 dotnet-dump:进程还活着时的跨平台“后悔药”
dotnet-dump是 .NET 官方全局工具,主要解决另一类问题:进程还活着,但状态已经不对了,需要手动抓快照。安装命令:
dotnet tool install --global dotnet-dump抓取命令:
dotnet-dump collect -p 12345 -o /var/dumps/hang_$(date +%s).dmp-p指定进程 PID,-o指定输出文件。注意这个工具依赖 runtime 的诊断 IPC 通道,所以只适用于 .NET Core 3.0 及以上版本,.NET Framework 进程用它抓不了。它最擅长的场景就是挂起和死锁:接口不响应了,先dotnet-dump collect -p <PID>抓一张,等几秒再抓一张,后面分析时对比线程栈。
在容器里用dotnet-dump有一个高频坑:权限。如果容器运行时没有CAP_SYS_PTRACE权限,或者 Docker 的安全策略限制了 ptrace 系统调用,dotnet-dump会附加失败。遇到这种情况,先检查容器是否具备 ptrace 权限,必要时以--cap-add=SYS_PTRACE启动容器或使用 Kubernetes 的临时容器进入目标 Pod 执行。
3.4 选型对照:什么时候用哪个工具
| 工具 | 平台 | 适用场景 | 主要缺点 |
|---|---|---|---|
| Procdump | Windows | 自动化监听异常、崩溃、退出 | Linux 上不可用 |
| runtime 环境变量 | Linux / Windows | 原生崩溃自动抓取,完全免应用改造 | 需提前配置并验证 |
| dotnet-dump | Linux / Windows | 挂起、死锁、内存泄漏、活进程快照 | 要求进程仍活着,且需诊断 IPC 可用 |
| gdb / gcore | Linux | 极端情况手动救场 | 对托管栈不友好,需要额外脚本 |
| 任务管理器 | Windows | 随手抓一次活进程快照 | 无自动化,不适合反复触发 |
我个人的选型习惯是:Windows 生产机一律挂 Procdump,Linux 生产机一律配好 runtime 环境变量,然后两边都装好dotnet-dump作为手动救场工具。这套组合覆盖了 90% 以上的死法。
4. 抓取 Dump 的黄金法则:时机、姿势与现场保护
4.1 时机:崩前“自动”触发永远优于崩后“手忙脚乱”
我自己踩过一个大坑,说出来都是教训。有一次某个服务反复崩溃,我们一群人轮班盯着监控,只要谁先看到告警就去服务器抓 Dump。结果一周过去了,Dump 一个都没抓到,要么是赶不上,要么是抓下来发现进程已经退干净了。后来想明白一个道理:崩溃的现场只有几百毫秒,人是永远跑不过进程的。
正确的时机管理,是把抓取动作前置到进程的整个生命周期里,让它自己完成。Procdump 的异常触发、runtime 的环境变量,本质上都是把“抓抓手”从人换成机器。机器没有反应时间,也不需要登录服务器,它就在那里监听,信号一旦到达立刻执行。
另外,如果是内存泄漏排查,时机又是另一回事。你不可能等到 OOM 才抓 Dump,那时候 GC 堆已经一团糟,甚至可能抓不下来。正确做法是定时低峰期抓快照:比如每周三凌晨三点抓一张全量 Dump,连续几周对比托管堆里对象数量变化。泄漏类问题不需要“崩溃瞬间”,需要的是“持续观测”。
4.2 姿势:“全内存”与“小转储”,别让 Dump 本身成为事故
抓 Dump 不只是“抓下来就行”,抓什么样的 Dump 直接决定后面能不能分析。
- 全内存 Dump(Full Memory):包含进程所有已提交内存,托管堆、JIT 代码、模块全在里面,是分析 .NET 问题的标配。
- 迷你转储(Mini Dump):只包含线程栈和少量关键信息,文件小,但没有托管堆,分析托管异常和内存泄漏时基本等于废物。
- 堆转储:位于两者之间,包含托管堆但不包含所有内存,体积适中,适合只关心对象分布的场景。
工具参数上,Procdump 的-ma、dotnet-dump collect默认都是全量类型,runtime 环境变量里的DOTNET_DbgMiniDumpType=4也是全量。不要贪文件小去抓迷你转储,排障的时候缺一块内存区域,你永远不知道真相是不是恰恰藏在缺的那部分里。
但这里有个不能回避的现实问题:全内存 Dump 的体积非常大。一个提交了 16GB 内存的服务进程,完整 Dump 轻松超过 10GB。磁盘预留不足,抓取过程中途失败,比不抓更惨。我的经验是准备目标 Dump 预计体积 1.5 到 2 倍的磁盘空间,并且抓取期间监控磁盘余量。
还要提醒一点:抓全内存 Dump 时,进程通常会被挂起。内存越大,挂起时间越长,可能从几秒到几十秒。这在生产环境是有真实影响的服务中断,所以对“活进程手动抓 Dump”这个动作,我建议尽量放在业务低峰期执行,并且先和团队确认影响面。
4.3 现场保护:抓到 Dump 之后,先别急着重启
很多人抓到了 Dump,长舒一口气,立刻把进程重启了,然后开始分析。这个顺序是错的。重启之前有一堆“现场外围信息”必须留下来,否则 Dump 原始分析可能残缺。
至少要保存这些内容:
- 应用日志和事件日志,作为旁证;
- 进程启动参数和环境变量,特别是
DOTNET_、COMPlus_开头的配置;进程是 framework-dependent 还是 self-contained,runtime 版本是多少; - 加载的关键 DLL 版本信息,最好连同对应的二进制文件一起归档;
- Dump 抓取的时间点和当时进程的表现,比如 CPU 占用、内存占用。
为什么这些重要?因为 Dump 只能证明“当时发生了什么”,不能证明“为什么会发生”。比如 Dump 里显示线程池线程全部在等待数据库连接,这时候如果没有日志显示数据库当时处于抖动,你可能会误判是应用问题。现场外围信息越全,分析的时候越少走弯路。
另外,Dump 文件本身要尽快从服务器转移到安全存储,并且做哈希校验。我有一次遇到过抓完 Dump 后系统继续崩溃,Dump 文件在本地盘上被后续事件污染,等到分析时文件已经损坏。业内常说的“先备份再分析”,在 Dump 这种大文件身上同样成立。
4.4 为什么你拿到的 Dump 是“废的”:常见的翻车现场
最后分享一下我见过最多的几种“失败 Dump”,提前避坑比事后补救强一百倍。
第一种是权限不足。Linux 容器里没有 ptrace 权限时,dotnet-dump附加会失败或者抓出 0 字节文件。Windows 上也类似,抓取工具必须以至少同等权限运行,不能跨管理员权限级别。第二种是磁盘写满,Dump 写了一半,文件看起来很大,但解析时直接报损坏。第三种是抓错了进程,PID 被复用是最隐蔽的死法——你以为抓的是服务进程,实际抓的是一个新启动的无关进程。第四种是 Dump 类型不对,默认的迷你转储里根本没有托管堆,后面想查对象分布根本无从下手。第五种是二进制版本不匹配,Dump 里记录的模块哈希和现有符号对不上,栈解析错乱。
有一个简单的验证方法:抓到 Dump 之后,立刻在同一环境里用dotnet-dump analyze打开它,执行几条基础命令,能正常进入交互模式、能看到线程列表,基本就说明 Dump 是有效的。验证完再归档,别等要分析的时候才发现文件是坏的。
提示:抓 Dump 这件事,本质上和备份数据库一样,属于“不发生则以,发生就救命”的基建能力。如果自动化抓取机制没有经过一次真实崩溃或故障注入验证,就不能算配置完成。建议在你的服务健康检查清单里加一项:确认自动 Dump 配置存在且目录可写。
最后再讲一个我的实操习惯。我会把“自动 Dump 配置 + 磁盘空间检查 + Dump 归档路径”作为每台服务器的标准部署项,和监控告警同级对待。之前有一个服务频繁原生崩溃,日志里什么都找不着,后来就是靠服务器上一早就配置好的DOTNET_DbgEnableMiniDump=1,在崩溃瞬间留下了完整现场。分析之后发现是某个原生库在并发场景下的内存写越界,属于典型的日志看不出来的问题。如果没有那份 Dump,这个问题可能会在“重启修复”里反复横跳好几个版本。
生成 Dump 只是第一步,拿到 Dump 之后怎么把它拆开、怎么从线程栈和托管堆里定位根因,是另一套完全不同的技能树。这些我放到下篇,重点讲dotnet-dump analyze的常用排查套路,以及 SOS 命令在托管堆分析里的具体用法。如果你现在也被“进程崩了但找不到原因”折磨,先把自动抓 Dump 的机制落地,至少下一次,你不会再对着一个退出码干瞪眼。