做 Linux 平台 Qt 开发的同学多多少少都被同一个问题恶心过程序在开发机上跑一整周都稳如老狗打包丢到客户现场或者产线上跑着跑着进程就没了日志里只剩最后一行打印用户那边只丢过来一句“软件自己关了”。你远程登上去dmesg里可能只有一句segfault at 0 ip 00007f... sp ... error 4连个函数名都没有。这种时候如果没有一份可分析的程序崩溃文件排查基本等同于盲人摸象——只能靠加日志、猜逻辑、反复重启复现运气好三天定位运气不好一个月还在打转。这篇内容就围绕 Linux 下 Qt 程序崩溃文件的生成、采集与分析展开把 core dump 的内核机制、Qt 侧的信号捕获、符号还原和线上环境避坑一次性讲透。不管你是刚接触 Qt 的新手还是带了几年团队的老兵只要你的程序是跑在 Linux 上、用 C 或 QML 写的这里面的配置和代码都能直接抄过去用。1. Linux下Qt程序崩溃排查的整体思路1.1 先搞清楚“崩溃文件”到底指什么很多人一说到崩溃文件就默认是 core dump其实在 Qt 项目里这个概念要分成三类看混着理解很容易走偏。第一类是内核级 core dump。进程收到致命信号SIGSEGV、SIGABRT、SIGBUS 等后Linux 内核把整个进程的虚拟内存映像、寄存器状态、栈信息落盘生成一个core或core.pid文件。这个文件信息最全能还原崩溃瞬间几乎所有线程的调用栈、局部变量、堆状态是定位疑难崩溃的首选。第二类是应用层自定义的崩溃日志。通过sigaction注册信号处理函数在进程死之前抓住最后一口气把调用栈、线程 ID、Qt 版本、加载的插件列表写进一个文本文件或者上报服务。它的信息量不如 core dump但胜在体积小、可读性好、能直接通过日志系统回传特别适合客户端软件和无人值守的嵌入式设备。第三类是Qt 自身的诊断输出。Qt 在某些错误路径下会通过qFatal、qWarning输出信息QML 引擎崩溃时也会打印 JS 栈。这部分信息通常混在 stderr 里容易被忽略但往往能直接告诉你“是哪个 QML 组件炸了”。注意三类文件不是互相替代的关系。工程上通常的做法是“自定义日志常开 core dump 在生产环境按需开启”自定义日志负责快速定位大致范围core dump 负责最终精确复盘。1.2 为什么 Linux 上这事比 Windows 麻烦从 Windows 转过来的开发者最容易不适应的一点是Windows 上程序崩溃会弹个框Visual Studio 能直接附加、能生成 minidump一套流程很顺手。换到 Linux很多默认配置是“不让生成 core 文件”的。原因在于内核层面有两个开关卡着你。一个是进程的资源限制RLIMIT_CORE默认值通常是 0意思是“禁止生成 core 文件”这是为了避免服务器上程序反复崩溃把磁盘写满。另一个是内核参数kernel.core_pattern它决定了 core 文件往哪写、叫什么名字很多发行版尤其是近几年默认把它交给了systemd-coredump接管文件不再出现在你熟悉的当前目录而是躺在/var/lib/systemd/coredump里需要用coredumpctl去捞。再加上 Qt 程序常见的三个特点——多线程、插件动态加载、编译时开了优化导致崩溃现场更复杂崩溃的可能不是主线程符号可能被 strip 掉了栈回溯可能因为-O2的尾调用优化而断层。所以 Linux 下搞崩溃分析本质上是“把内核开关打开、把符号留好、把堆栈抓全”三件事。1.3 一套可落地的组合方案我实际项目里用的组合是这样的你可以按自己的场景裁剪层次手段适用环境信息量开销内核层core dump coredumpctl内网测试机、可复现环境最高大可能几百 MB应用层信号处理 backtrace生产环境、客户端中极小应用层Google Breakpad / Crashpad大型客户端中高小框架层qInstallMessageHandler 日志全环境低极小小团队、嵌入式设备建议就守住“信号处理 backtrace 写文件”这一条线成本最低、见效最快桌面客户端或者需要长期收集线上崩溃的再叠加 Breakpad 做 minidump 上传只有在测试阶段需要深挖某个必现崩溃时才去开 core dump 用 gdb 啃。2. 核心原理从信号到core dump再到backtrace2.1 Linux 信号机制与 Qt 程序崩溃的关系Linux 下程序“异常退出”绝大多数不是自己exit的而是被信号干掉的。和崩溃相关的信号主要有这么几个SIGSEGV11段错误访问了非法内存地址空指针解引用、野指针、数组越界都会触发它。SIGABRT6程序主动 abortC 未捕获异常、assert失败、glibc 检测到堆破坏比如 double free都会走这里。SIGBUS7总线错误常见于内存对齐问题、mmap 文件被截断后继续访问。SIGFPE8算术异常整数除零、溢出。SIGILL4非法指令通常意味着代码段被破坏或者编译器/CPU 指令集不匹配。信号的处理有个关键特性大多数信号可以被捕获但捕获之后能不能安全地做事是另一回事。内核在投递信号时进程的堆状态可能已经被破坏了你再去调用malloc、printf、QString这些依赖堆和锁的函数很可能二次崩溃把唯一的现场也给毁了。这就是为什么崩溃处理函数里只能用**异步信号安全async-signal-safe**的函数比如write、open、close、_exit、sigaction本身。printf、malloc、backtrace_symbols严格来说都不在安全清单里这一点后面实践部分我会给出绕开的写法。2.2 core dump 的生成条件与内核参数要让内核老老实实吐出 core 文件得同时满足四个条件缺一不可进程的RLIMIT_CORE大于 0。用ulimit -c查看ulimit -c unlimited临时打开。kernel.core_pattern指向一个可写的路径或者管道程序。用cat /proc/sys/kernel/core_pattern查看默认可能是core也可能是|/usr/lib/systemd/systemd-coredump %P %u ...。文件系统有足够空间且挂载时没有禁止。某些挂载选项和只读文件系统会直接让 core 落盘失败。进程没有设置PR_SET_DUMPABLE为 0也不是 setuid/setgid 程序出于安全考虑这类程序默认不产生 core。core_pattern支持一堆占位符写规则的时候很有用占位符含义%p崩溃进程的 PID%e可执行文件名%t崩溃时间戳Unix 秒%h主机名%u实际用户 ID%s导致崩溃的信号编号%ccore 文件大小软限制我一般把规则设成/var/crash/core-%e-%p-%t这种带程序名和时间戳的形式好处是多个进程崩溃不会互相覆盖事后一眼能看出是谁、什么时候炸的。改这个参数需要 root而且重启会失效要持久化得写进/etc/sysctl.d/下的配置文件。2.3 backtrace 与符号表的关系应用层抓栈最常用的就是 glibc 提供的三个函数int backtrace(void **buffer, int size); char **backtrace_symbols(void *const *buffer, int size); void backtrace_symbols_fd(void *const *buffer, int size, int fd);backtrace的原理是沿着栈帧链frame pointer chain或者.eh_frame异常处理表往回走。这里有个坑如果编译时用-O2并且省略了帧指针纯靠 frame pointer 的方式就会断链得到的栈会缺帧甚至完全错乱。稳妥的做法是编译时加-fno-omit-frame-pointer同时保证链接器不把.eh_frame裁掉。另一个坑是符号解析。backtrace_symbols输出的内容长这样./myapp(_Z9crashTestv0x1a) [0x55f3a2c0119a]括号里是被 mangle 过的 C 符号名直接看根本认不出。想去掉 mangle需要在链接时加-rdynamic等价于-Wl,--export-dynamic把符号导出到动态符号表里否则你拿到的可能就是一串纯地址。就算加了-rdynamicC 名字还得用abi::__cxa_demangle还原成人类可读的形式。实操心得-rdynamic会让可执行文件体积略微膨胀因为它把符号表塞进了动态段。发布版本如果介意体积可以只在崩溃日志里记录地址离线用addr2line配合保留的带符号二进制去还原这样线上程序可以保持小巧。3. 环境准备与系统参数配置3.1 检查并调整 core 文件大小限制第一步永远是先看现状ulimit -c cat /proc/sys/kernel/core_pattern cat /proc/sys/fs/suid_dumpable如果ulimit -c输出 0那 core 是不会生成的。临时打开ulimit -c unlimited注意这个命令只对当前 shell 及其子进程生效你在这个 shell 里启动 Qt 程序才有效。如果是通过systemd托管的服务光在 shell 里设没用得改 service 文件[Service] LimitCOREinfinity改完之后systemctl daemon-reload systemctl restart myservice。如果是 Docker 容器默认core_pattern是宿主机的容器内往往写不出去需要在docker run时加--ulimit core-1并且把宿主机的 core 路径映射进去或者干脆在容器里把core_pattern改成容器内可写目录需要--privileged或至少--security-opt放开。3.2 配置 core_pattern 与 systemd-coredump现在很多发行版默认把core_pattern设成了管道|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h这种情况下你在程序目录下死活找不到core文件别慌文件被 systemd 收走了。查看方式coredumpctl list coredumpctl info PID coredumpctl gdb PID最后一条命令会直接用 gdb 把对应的 core 加载起来非常省事。如果你更喜欢传统的“在当前目录生成 core 文件”就把管道改掉sudo sysctl -w kernel.core_pattern/var/crash/core-%e-%p-%t sudo mkdir -p /var/crash sudo chmod 777 /var/crash持久化写进/etc/sysctl.d/99-coredump.conf。这里还要顺手看一下kernel.core_uses_pid设为 1 时即使core_pattern里没写%p文件名也会带上 PID。注意把core_pattern设成固定文件名比如纯粹的core在多进程场景下非常危险多个程序同时崩溃会互相覆盖只剩最后一个。强烈建议至少带上%e和%p。3.3 编译选项符号留得住栈才抓得全崩溃能不能分析得动八成的功夫在编译阶段。我推荐的项目编译配置是set(CMAKE_CXX_FLAGS_RELEASE -O2 -g -fno-omit-frame-pointer) set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} -rdynamic)关键点逐个解释-g生成调试信息DWARF。很多人以为 Release 就不能带-g其实-g只影响调试段不影响代码优化体积增大可以后续用objcopy剥离。-O2保留优化性能不受影响。但要注意-O2下的内联和尾调用会让栈帧“合并”某些函数在栈里看不到这是正常的不是工具坏了。-fno-omit-frame-pointer强制保留帧指针寄存器让基于 frame pointer 的栈回溯更可靠。-rdynamic导出符号让backtrace_symbols能打印函数名而不是裸地址。发布时如果要把带符号的版本和对外版本分开标准做法是objcopy --only-keep-debug myapp myapp.debug objcopy --strip-debug myapp objcopy --add-gnu-debuglinkmyapp.debug myapp这样二进制瘦身但调试信息完整保留在myapp.debug里gdb 和addr2line会自动通过 debuglink 找到它。3.4 Qt 版本与调试符号包如果你用的是发行版仓库里的 Qt比如 Ubuntu 的qtbase5-dev记得顺手装调试符号包sudo apt install libqt5core5a-dbgsym qtbase5-dbg用apt的dbgsym源需要先配置ddebs仓库这一步在 Ubuntu 上比较容易漏。装好之后崩溃栈里那些libQt5Core.so.5里的帧才能显示出具体函数名否则全是??。如果 Qt 是自己源码编译的编译时加上-debug或者-force-debug-info配置项别用默认的 release 编译不然以后分析栈的时候你会想砸键盘。Qt 5.14、5.15 这类离线安装包版本默认装出来是不带调试符号的用configure -debug重新编一遍才算稳妥。4. 在Qt代码中实现崩溃捕获4.1 注册信号处理函数核心代码不长但每一行都有讲究。先看头文件#include csignal #include execinfo.h #include unistd.h #include fcntl.h #include cstring #include cstdio #include cstdlib #include ctime #include sys/syscall.h注册部分用sigaction而不是老的signal因为sigaction能控制更多行为也能保证语义一致static void installCrashHandler() { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction crashHandler; sa.sa_flags SA_SIGINFO | SA_RESETHAND; sigemptyset(sa.sa_mask); sigaction(SIGSEGV, sa, nullptr); sigaction(SIGABRT, sa, nullptr); sigaction(SIGBUS, sa, nullptr); sigaction(SIGFPE, sa, nullptr); sigaction(SIGILL, sa, nullptr); }SA_SIGINFO让我们能在处理函数里拿到siginfo_t里面包含si_addr出错的地址和si_code错误细分类型这对判断“是空指针还是野指针”很有用。SA_RESETHAND表示处理完一次后把信号动作恢复成默认防止在处理函数里再次崩溃导致无限递归。4.2 处理函数里到底能做什么这是整个方案最容易被写错的地方。前面说过信号处理函数里只能用异步信号安全函数。所以下面这种写法是错误的// 错误示范printf/malloc/QString 在信号处理里都不安全 void crashHandler(int sig, siginfo_t* info, void* ctx) { printf(crash! signal%d\n, sig); // 不安全 QString msg QString(signal %1).arg(sig); // 极不安全 qDebug() msg; // 极不安全 }正确思路是在进程启动时就把日志文件打开拿到一个 fd 存好崩溃时只做backtracewrite。write是异步信号安全的backtrace虽然不是 POSIX 明确列出的安全函数但它在实践中不依赖 malloc风险可控glibc 的实现会去读.eh_frame可能涉及 dl 相关操作所以更保险的做法是加SA_NODEFER并接受一定概率失败。一个实用版本长这样static int g_crashFd -1; static void crashHandler(int sig, siginfo_t* info, void* /*ctx*/) { // 1. 先把错误信息写到文件用 write 而非 printf char head[256]; int len snprintf(head, sizeof(head), \n CRASH \nsignal%d (%s)\nsi_code%d\nsi_addr%p\n, sig, strsignal(sig), info ? info-si_code : -1, info ? info-si_addr : nullptr); if (len 0 g_crashFd 0) { ssize_t unused write(g_crashFd, head, len); (void)unused; } // 2. 抓调用栈最多 64 层 void* buffer[64]; int frames backtrace(buffer, 64); if (g_crashFd 0) backtrace_symbols_fd(buffer, frames, g_crashFd); // 3. 记录线程 ID便于区分是多线程里的哪一个 char tid[64]; long t syscall(SYS_gettid); int tlen snprintf(tid, sizeof(tid), \nthread_id%ld\n, t); if (tlen 0 g_crashFd 0) { ssize_t unused write(g_crashFd, tid, tlen); (void)unused; } // 4. 确保落盘后退出用 _exit 而不是 exit if (g_crashFd 0) fsync(g_crashFd); _exit(128 sig); }几个细节值得展开说说。strsignal在 man page 里标注为 async-signal-safe可以放心用snprintf严格来说不在安全清单但它不碰堆实践中稳定如果你要追求极致安全可以改成手写整数转字符串。syscall(SYS_gettid)是安全的比pthread_self()更直观因为pthread_self返回的是不透明的指针gdb 里没法直接对上。最后一定要用_exit而不是exit后者会跑atexit钩子、刷新 stdio 缓冲、析构全局对象在堆已经坏掉的情况下大概率二次崩溃。4.3 与 Qt 日志系统整合光有崩溃栈还不够很多时候你需要知道崩溃前一刻程序在干什么。把qInstallMessageHandler挂上把 Qt 自己的日志重定向到同一个文件能让崩溃日志的价值翻倍void myMessageHandler(QtMsgType type, const QMessageLogContext ctx, const QString msg) { static QMutex mutex; QMutexLocker locker(mutex); QByteArray local msg.toLocal8Bit(); const char* level INFO; switch (type) { case QtDebugMsg: level DEBUG; break; case QtInfoMsg: level INFO; break; case QtWarningMsg: level WARN; break; case QtCriticalMsg: level CRIT; break; case QtFatalMsg: level FATAL; break; } // 写入同一个日志文件附带时间戳、文件名、行号 fprintf(stderr, [%s] %s (%s:%d)\n, level, local.constData(), ctx.file ? ctx.file : ?, ctx.line); fflush(stderr); if (type QtFatalMsg) abort(); // Qt 约定FATAL 后终止 }然后在main里尽早安装int main(int argc, char* argv[]) { g_crashFd open(/var/log/myapp/crash.log, O_WRONLY | O_CREAT | O_APPEND, 0644); installCrashHandler(); qInstallMessageHandler(myMessageHandler); QApplication app(argc, argv); // ... return app.exec(); }实操心得把qInstallMessageHandler放在QApplication构造之前能捕获到 Qt 初始化阶段的告警如果把初始化放在后面早期的插件加载失败信息就漏掉了。另外日志文件建议按天切分并限制总大小否则客户端跑几个月日志能撑爆硬盘。4.4 QML 场景下的补充手段如果项目里用了 QML纯 C 的信号捕获只能抓到 C 侧的栈QML 引擎内部崩溃往往是SIGSEGV直接打到libQt5Qml.so里看不出是哪段 JS 干的。这时候需要额外做两件事一是给QQmlEngine挂warnings信号把 QML 的运行时告警记下来二是给qmlRegisterType注册的 C 类型做好空指针防御因为 QML 传null过来是家常便饭。另外 QML 里用WorkerScript、Timer频繁创建销毁对象容易触发引擎内部的内存管理 bug这类崩溃的特征是栈里出现QV4::、QJSEngine相关的帧。看到这种栈第一反应应该是升级 Qt 小版本而不是硬啃代码。5. 崩溃文件的分析与定位5.1 用 gdb 打开 core 文件的标准流程拿到 core 文件之后第一步是确认它和哪个可执行文件、哪个库版本对应file core.myapp.12345输出里会写明 “core file ... from myapp”。然后加载gdb ./myapp core.myapp.12345进去之后几条命令基本能覆盖八成场景(gdb) bt # 当前线程的调用栈 (gdb) bt full # 带局部变量的完整栈 (gdb) info threads # 所有线程 (gdb) thread apply all bt # 所有线程的栈排查多线程崩溃必用 (gdb) info registers # 寄存器状态 (gdb) frame 3 # 切到第 3 帧 (gdb) info locals # 看该帧的局部变量 (gdb) p *this # 看 this 指针指向的对象如果 gdb 提示no debugging symbols found说明你打开的二进制不带符号或者 Qt 库的 debug 包没装。这时候别急着放弃先确认myapp.debug是否放在同一目录或者用set debug-file-directory指到符号目录。5.2 从栈里读出“故事”一个典型的 Qt 崩溃栈大概是这样的简化过#0 0x00007f8a1c3d4e10 in QListData::size() const () from libQt5Core.so.5 #1 0x000055f3a2c01234 in MyWidget::onDataReady (this0x0) at mywidget.cpp:88 #2 0x000055f3a2c01abc in QtPrivate::FunctorCall...::call (...) at qobjectdefs_impl.h:146 #3 0x00007f8a1c2b3c40 in QMetaObject::activate (...) from libQt5Core.so.5这个栈告诉你的信息量很大#1帧里this0x0说明是对一个空指针调用成员函数问题出在MyWidget::onDataReady。再往上#3是信号槽的激活路径说明这个槽是被某个信号触发的。接下来要查的就是MyWidget什么时候被 delete 了为什么信号槽连接没断开。这里的关键技巧是从下往上读、从最早属于你自己代码的那一帧开始看。Qt 框架帧只是“过路”你的代码帧才是“案发地”。5.3 addr2line 与离线符号还原有些场景你只有一串地址没有 core 也没有 gdb比如应用层日志里记的是裸地址。这时候用addr2lineaddr2line -e ./myapp -f -C -i 0x1234 0x5678-f显示函数名-C做 C demangle-i展开内联函数输出类似MyWidget::onDataReady() /home/dev/myapp/mywidget.cpp:88如果地址是运行时地址而二进制开了 PIE地址随机化需要先减掉加载基址。加载基址可以在崩溃日志里一并记下来方法是在程序启动时读/proc/self/maps里myapp那一行的起始地址。这一步不做addr2line给出的结果会完全是错的这是新手最容易踩的坑之一。5.4 常见崩溃类型的判读经验现象常见根因排查方向SIGSEGVsi_addr0x0空指针解引用查this是否为 0查 QObject 生命周期SIGSEGVsi_addr是野地址悬垂指针、对象已 delete查跨线程对象访问、查deleteLater时序SIGABRT且栈里有free/malloc堆破坏、double free用 AddressSanitizer 复现SIGABRT且栈顶是__cxa_throw未捕获的 C 异常检查try/catch覆盖范围SIGBUSsi_addr在 mmap 区域文件被截断后继续读检查内存映射文件的生命周期栈里全是QV4::帧QML 引擎内部问题升级 Qt 小版本、精简 QML 对象创建销毁这张表是我这几年踩坑总结出来的比翻文档快得多。看到信号和地址基本能猜出个大概方向。6. 常见问题排查与避坑经验6.1 配了 ulimit 还是不生成 core这是被问得最多的一个问题。排查顺序建议这样走先cat /proc/pid/limits | grep core看目标进程实际的软硬限制。注意ulimit -c看的是当前 shell跟你程序的实际限制可能不一样尤其是通过桌面图标、systemd、守护进程启动的情况。再看cat /proc/sys/kernel/core_pattern如果是管道形式文件被 systemd 收走了用coredumpctl list找。如果确实想落到目录改成路径形式并保证目录可写。然后检查文件系统df -h看空间够不够mount | grep 挂载点看有没有noexec、nosuid之外的奇怪选项有些网络文件系统本身就拒绝写大文件。最后考虑PR_SET_DUMPABLE被置 0 的情况Qt 程序里很少主动这么干但如果用了某些安全库或者容器运行时可能会被改掉。注意core 文件大小可能等于进程的虚拟内存Qt GUI 程序轻松上几个 G。生产环境一定要设ulimit -c为有限值比如 2G或者在core_pattern里做大小限制别让一次崩溃把磁盘写满导致服务全挂。6.2 栈信息不完整或者全是问号“问号栈”全是??几乎都是符号问题而不是栈本身坏了。三个排查点一是有没有-rdynamic。没有它可执行文件里的函数名不会进动态符号表backtrace_symbols只能给地址。二是有没有 strip。发布流程里如果执行了strip myapp-g生成的调试信息就没了同时-rdynamic导出的动态符号一般还能留一部分但函数名会缺。正确做法是用objcopy --only-keep-debug分离而不是直接 strip。三是Qt 库的 debug 包装了没有。栈里libQt5Core.so.5那些帧如果显示??八成是没装dbgsym。还有一种情况是栈只有两三层上面全是??这通常是栈被破坏了。栈溢出比如递归失控、缓冲区溢出把返回地址覆盖都会导致回溯在中途断掉。这种时候 core dump 里的寄存器信息比backtrace更靠谱得用 gdb 手动看$rsp、$rbp和栈内存。6.3 多线程崩溃怎么定位Qt 程序多线程是常态QThread、QtConcurrent、线程池到处跑。崩溃日志里如果只记了一个线程的栈很可能刚好记的不是出事那个线程。改进办法是在信号处理函数里遍历/proc/self/task目录把每个线程的 tid 都列出来再配合backtrace只能抓当前线程栈的限制去做二次处理。更彻底的做法是让子线程自己装一份信号掩码把崩溃处理集中到主线程或者干脆上 Breakpad——它内部会用 ptrace 挂到所有线程上抓完整栈这块自己写成本很高。实际经验是多线程崩溃里真正有问题的往往不是崩的那个线程而是它访问了别人正在释放的对象。Qt 的信号槽默认是Qt::AutoConnection跨线程时变成队列连接但如果对象没有正确的父子关系或者用了裸指针传递生命周期管理就会失控。我的习惯是跨线程通信全部走信号槽 QSharedPointer坚决不裸传指针能避开一大类崩溃。6.4 发布版本的权衡最后说说发布版本上做取舍。带-g的二进制体积可能大出两三倍客户端下载体验会受影响。我的做法是项目内测版正式版编译优化-O0 -g3-O2 -g帧指针保留保留符号全量分离到.debugbacktrace 日志开开core dump开视场景符号文件上传是是关键原则是正式版可以 strip 二进制但符号文件必须归档上传并且要和版本号、构建时间一一对应。我见过太多团队发版后没存符号线上崩溃一堆地址却无从还原只能干瞪眼。符号文件不大的话按版本丢进一个内部服务器配上构建流水线自动归档这个投入产出比极高。再补一个实操小技巧崩溃日志文件建议用O_APPEND打开并且每个日志段落用明确的 CRASH 分隔。这样多个进程、多次崩溃写进同一个文件也不会串行错乱事后用grep -n CRASH一跳一个准。另外如果程序里有fork子进程记得在子进程里重新打开日志 fd否则父子共用同一个 fd 偏移量会互相覆盖写乱。这些细节平时没人提真到排查线上问题的时候全都是能救命的东西。