干这行久了会发现一个规律:真正折磨人的往往不是架构设计、核心业务代码这类大工程,反而是那些看起来"哪儿都没坏"却又确实用不了的IT疑难杂症。服务跑着、端口通着、报错信息暧昧不明,可功能就是异常。今天我把这些年实际处理过、身边同事也经常踩进去的几个典型的"疑难杂症"整理成一篇排查攻略,每个案例都会完整复盘从表象到根因的推理链路,以及最终的修复动作,希望能帮你缩短从"一脸懵"到"破案"之间的时间。
1. 磁盘明明没满,应用却疯狂报"No space left on device"
1.1 症状与第一反应:被df -h骗了
前两年我们有一台CentOS 7的服务器,跑着Nginx和一套Python写的报表服务。有天下午监控告警,说某个核心目录写入失败,应用日志里全是OSError: [Errno 28] No space left on device。我第一反应当然是看磁盘占用,结果df -h一敲,根分区用了不到60%,/data分区也只用了70%,剩余空间充足得很。
这时候人很容易懵。因为报错信息实在太有指向性了,它明确告诉你"空间不够",可检查下来空间明明够。当时我还顺手重启了一下应用服务,结果治标不治本,十几分钟后同样的报错又出现。后来冷静下来,把排查顺序拉开,才意识到一直被"空间"这两个字带偏了。
关键点在于,Linux下的"No space left on device"实际上有两种完全不同的触发条件。一种是物理数据块耗尽,就是df -h能看出来的;另一种是inode耗尽——文件系统里用于记录文件元数据(权限、所有者、大小、数据块指针等)的索引节点用完了。你可以把inode理解成快递柜里的格子,每个文件无论大小至少占一个格子,哪怕文件是0字节也一样。格子满了,哪怕整个柜子还有很多空余容积,新快递也塞不进去。
1.2 排查链路:df -i一眼锁定根因
在排除了物理空间之后,我立刻执行了df -i命令:
bash复制df -i
输出显示根分区/dev/mapper/centos-root的IUse%已经到了100%,inode数量为0。到这里,"No space left on device"的真正原因就清楚了——不是空间真的耗尽,而是小文件数量太多,把文件的"登记名额"全部吃掉了。
继续深挖是哪个目录贡献了海量小文件。用for循环按目录统计inode分配数量,或者直接用find在根目录下逐层排查:
bash复制for d in /*; do echo "$d: $(find $d -xdev 2>/dev/null | wc -l)"; done
因为根分区和/data是不同分区(不同文件系统),用-xdev参数限定不许跨文件系统查找,速度会快很多也准确很多。结果出来后,/tmp和/var/tmp下面的文件数量高得离谱,总量达到了几十万个级别。再一层层往下翻,发现是系统里一个定时任务生成的日志文件没有做任何旋转和清理,每分钟产生一批小日志,文件名带时间戳,一直在堆。
顺便说一句,/tmp目录表面上不起眼,却是这类异常的高发区。PHP会话文件、临时上传文件、各种脚本运行的中间产物,只要生成逻辑里忘了清理,积累一两年就是几十万个文件。
1.3 修复动作与长效预防
修复分两步走。立即清理存量文件,把确定没用的旧临时文件删掉。考虑到文件数量太大,直接rm -rf一次删几万个文件会有点慢,整个过程我放在了晚上业务低峰执行。清理完后再看df -i,可用inode恢复了将近一半。
然后针对那个只知道生成不知道清理的定时任务,在脚本里加了按天切割和保留天数的逻辑,只保留最近7天的日志,并且顺手给/tmp目录配了tmpreaper或者systemd-tmpfiles-clean这类清理机制。在Crond任务的管理上,也加了一条硬性规范:凡是输出日志的任务,必须同时规划日志轮转,不允许"裸奔"式追加。
这里也想提醒一句,现在很多云主机的默认镜像都在用xfs文件系统,inode数量在格式化时就已经固定了,除非额外加-m maxpct之类的调优参数,否则事后没法无损扩容。所以遇到"空间看似充足但写不进东西"的报错,第一件事就把df -h和df -i一起跑掉,这个习惯能帮你节省大量排查时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 端口明明在监听,可外部就是连不上
2.1 三个最隐蔽的"假监听"情况
这类问题在内网部署时特别常见,尤其在开发同学自己搭服务的时候。明明netstat -tlnp或者ss -tlnp显示端口处于LISTEN状态,进程也活着,但用另一台机器去访问,要么超时要么直接Connection refused。这种矛盾感很强,因为"端口有监听"是程序员判断服务是否正常的第一直觉,可一旦这个直觉和数据传输的实际情况脱节,就很容易陷入死循环。
按我的经验,端口在监听却连不上,九成是下面三种情况之一:
第一种是监听地址不对。服务配置里写的是127.0.0.1:8080,那它只会监听回环地址,外部网络根本过不来。这个用ss -tlnp看Local Address列就能识别:如果是127.0.0.1或者::1,就要考虑改成0.0.0.0或者::。
第二种是安全组或防火墙规则没放行。云服务器尤其如此,机器内部的firewalld或者iptables规则即使全放开了,云平台控制台里的安全组如果没加对应端口,流量照样被挡在外面。这类问题的排查特点是:在服务器本机上用curl http://127.0.0.1:8080完全正常,换另一台同网段机器就超时。
第三种是SELinux在背后拦截。CentOS 7及以后版本默认开启SELinux,如果Nginx或者自定义程序监听了非标准端口,而SELinux的布尔值或端口上下文没有同步放行,系统会在底层直接丢弃连接。这个最坑,因为ss能看到监听,进程本身也没报错,从哪儿看都"正常"。
2.2 按层剥开:从本机到跨机的定位顺序
遇到"端口在听却连不上",我的排查顺序有严格的讲究,顺序错了容易白折腾。
第一步永远是本机自测:
bash复制curl -v http://127.0.0.1:8080
这个动作能排除"服务进程是否真的可响应"这个基本前提。如果本机curl都失败,那就完全没有必要往下查网络,问题一定在进程自身的行为上,比如端口被其他进程占用、服务启动后监听失败但进程没退出这类情况。
第二步,在服务器上检查监听地址:
bash复制ss -tlnp | grep 8080
重点看监听在0.0.0.0还是127.0.0.1。这个细节经常被忽略。很多框架的默认配置是只监本地的,改完配置记得要重启进程而不是reload——有些进程reload后监听地址并不会重新绑定,这是又一个隐藏坑。
第三步,用同网段跳板机做跨机测试。注意这里不要直接拿笔记本电脑连公司WiFi测,最好选一台和服务器真正处于同一内网网段、同一安全组策略环境下的机器。如果跨机不通,再沿着链路分叉排查:先看防火墙,iptables -L -n和firewall-cmd --list-all都看一遍;再看云控制台安全组。
第四步,上述都正常时,怀疑SELinux:
bash复制getenforce
# 如果是Enforcing,尝试放行端口或者临时Enforcing状态下的策略调整
实际处理过的一个案例是Nginx监听8090端口,SELinux没有放行,导致外网访问不通但本机一切正常。用ausearch -m avc -ts recent可以在审计日志中看到清晰的SELinux拦截记录,然后通过semanage port -a -t http_port_t -p tcp 8090来放行。
2.3 一条非常有用的基线经验
现在的云环境里,我最推荐的排查基线是:先安全组,再监听地址,后SELinux。因为安全组在机器外部,你在服务器内网看到的"一切正常"完全不受它影响,这种盲区最容易迷惑人。监听地址问题虽然肉眼可见,但需要你有意识地去读ss那几列输出。SELinux问题则需要看审计日志,路径更长。
有一回同事排查一个Redis端口外部连不上的问题,前后折腾了大半天,最后发现监听地址是127.0.0.1,配置文件里明明写了bind 0.0.0.0但进程没重启。这种"配置改了没重启"的事故相当普遍,我在好几次排障中都被它坑过。建议改完任何涉及监听地址、端口、协议类型的配置后,先确认进程PID是否真的更换过,再继续往下查。
3. Java服务内存持续上涨,GC日志却一片正常
3.1 现象与经典误判:以为堆内存泄漏
说起Java服务的内存问题,算是IT疑难杂症里的高频常客。症状大家都很熟:服务刚启动的时候内存占用正常,运行几天后监控曲线一路向上,一直到接近容器或物理机的内存上限时才触发OOM,或者表现为服务响应越来越慢,弃用重启后又能撑一段时间。
这种服务重启后好转、时间长了又恶化的模式,很容易让人直觉判断为"堆内存泄漏",也就是代码里某个集合或者在缓存对象没释放,导致堆内存被一点点吃满。于是打开GC日志,查看堆使用率,结果却发现老年代回收完全正常,堆占用很平稳,Full GC频率也不高。这时候就出现了经典矛盾:物理内存确实一直在涨,可Java堆没怎么涨,那涨的到底是什么?
答案多数指向堆外内存,也就是JVM直接内存(Direct Memory),以及元空间、线程栈、JIT编译产物等堆外区域。特别是使用了Netty、gRPC、Kafka客户端这类高性能IO框架时,框架在底层会大量使用DirectByteBuffer来避免堆内内存与内核之间的一次拷贝,这些缓冲区存在堆外,不受-Xmx控制。框架本身会通过池化和引用计数复用缓冲区,但一旦业务代码把ByteBuffer对象保存到了不该保存的位置(比如静态集合),或者框架的回收机制失效,堆外内存就会只涨不降。
3.2 用三把钥匙定位:NMT、pmap、GC日志
我的定位顺序是先确定方向,再逐步缩小范围。
第一步,打开JVM的Native Memory Tracking功能。在启动脚本的JAVA_OPTS里加上:
bash复制-XX:NativeMemoryTracking=summary
然后通过jcmd命令查看内存分类:
bash复制jcmd <pid> VM.native_memory summary
这个命令会把内存按Java Heap、Class、Thread、Code、GC、Compiler、Internal、Symbol、Native Memory Tracking等分类列出。如果发现Internal或者Other区域特别大,基本可以断定是堆外内存的问题。这里有个小经验:NMT本身会引入少量性能开销,生产环境建议配合压测验证后再长期开启,排查时可以开summary级别,不必用detail级别。
第二步,用pmap从操作系统层面做交叉验证:
bash复制pmap -x <pid> | tail -20
重点看堆外的匿名内存映射。如果pmap里存在好几个异常大的anon内存段,而NMT报告里堆占用并不高,那就说明有一些内存是由JVM内部机制或JNI代码分配但没被NMT完全统计进去的。曾经处理过一个案例,NMT显示Internal内存占了将近3GB,最后定位到是java.util.zip.Inflater在解压大文件时使用了不少堆外缓冲区且释放不及时。
第三步,对比GC日志确认堆确实没有异常:
bash复制jstat -gcutil <pid> 5000
连续观察几次,如果Old区占比一直稳定在百分之三四十,且没有持续增长趋势,堆内存这条线可以放心排除。
3.3 常见根因与修复方案
排除了堆泄漏之后,堆外内存持续上涨的根因通常落在这么几个方向上:
- 业务代码持有
DirectByteBuffer引用且未释放。最常见的写法是把ByteBuffer.allocateDirect()创建的缓冲区保存在ThreadLocal或者静态容器里,处理完一批数据后没有清除引用,新的请求又继续创建,旧对象一直被认为是可达的,回收器无法处理。 - Netty等框架的
PooledByteBufAllocator在高并发场景下,如果业务把ByteBuf对象带出了Netty处理线程,又没有显式release(),会持续产生内存泄漏。 - JVM开启了大页或启用了某些GC算法,导致堆外占用结构发生变化。
修复动作上,代码层面需要做两个改变。一是针对直接缓冲区,原则上不要在长生命周期对象中保存DirectByteBuffer,如果的确需要复用,应该用ThreadLocal管理并保证用完后清理。二是对Netty消息处理做明确的引用计数管理,出站消息在写完后必须release()。
另外一个常被忽视的操作层面问题,是容器内存限制与JVM内存参数不匹配。很多人启动容器时只设置了-Xmx=2g,但整个JVM实际占用的内存是"堆 + 元空间 + 线程栈 + 堆外直接内存 + JIT代码缓存",粗算下来会比-Xmx多出20%到40%。如果容器上限只留了2.5g,运行一段时间后必然被系统杀死。这也是为什么K8s环境里Java服务被OOM Kill,但看Java堆一直正常的原因之一。
经验值方面,在容器里给Java服务配置内存上限时,我一般会以-Xmx的1.5倍作为容器内存的Request/Limit参考,比如-Xmx=2g,容器Limit至少给3g,同时额外设置-XX:MaxDirectMemorySize=512m来限制堆外直接内存的上限,避免某些异常场景下堆外无限增长。
3.4 从GC日志反推问题分代
顺带补充一个反直觉但很实用的经验:堆外内存泄漏排查时不要只看Full GC频率,更不要看到Full GC不多就觉得GC没问题。堆外内存的回收依赖GC对Cleaner对象的处理,而Cleaner本身是一个虚引用,当堆内存还很充裕时,GC可能长时间不触发,随之而来的是堆外内存长期无人回收。所以这类问题的现场常常表现为:启动后10个小时内内存涨得特别快,之后进入平台期不再变化,直到某次Full GC后突然释放掉一部分再重新上涨。如果你监控里看到这种锯齿状曲线,就要联想到堆外DirectBuffer问题。
还有一种隐蔽场景:用了Unsafe或者JNI调用的第三方库(比如某些加密库、OCR库),它们直接通过malloc分配内存,连NMT都统计不到。遇到这种情况,可以把NMT的报告和pmap再结合一次,如果pmap里有很多既不属于Heap也不属于Internal的大块anon内存,多半是JNI分配。这时候就要用gdb或者jemalloc的profiling模式去抓分配栈,这已经属于比较深的排查手段了,一般场景用不上,但知道有这么个方向就不至于完全卡住。
4. 服务偶发超时,重试一下就又好了
4.1 症状解析与"重试正常"带来的假安全感
这类问题在分布式系统里可以说是天花板级别的讨厌。服务没有宕机、网络没有断、数据库负载也不高,但就是在某个时段出现一批请求超时,客户端报"connect timeout"或者"read timeout",监控图上出现一个短暂的小尖刺,随后又恢复正常。等你登录服务器准备排查时,现象往往已经消失了,看起来一切如常。
这种"偶发、短促、自愈"的特征,反而是一个重要线索。一般规律性长期故障问题定位起来很容易,因为它会反复出现。而偶发性问题多与某个阈值的瞬时耗尽有关,比如TCP连接队列满了、线程池瞬间没有空闲线程、或者数据库连接池用尽。这些资源的排队逻辑都是"突发积压,快速恢复",所以现象自然就是尖刺状。
经验来看,最容易被忽略的是TCP全连接队列溢出。当并发请求瞬间超过监听队列的接纳能力时,内核会直接丢弃握手完成的连接,客户端的表现就是connect超时或者连接被重置。服务端因为连接还没建立到应用层,所以应用日志里几乎看不到任何异常。这个问题的诡异之处,就在于服务端"什么都没记录",而客户端"确确实实失败了"。
4.2 用ss和netstat抓两个队列的现场
定位全连接队列溢出不需要复杂的APM工具,一条ss命令就够了:
bash复制ss -tlnp
注意看State列为LISTEN那一行的Send-Q和Recv-Q两列。在监听socket的语境下,Send-Q列显示的是当前listen backlog的最大容量(通过listen()系统调用传入的值,内核还会受net.core.somaxconn限制),Recv-Q列显示的则是当前正在等待应用层accept的连接数量。当Recv-Q稳定持续接近或者达到Send-Q的上限时,那些握手成功但未被应用层领取的连接,已经开始被丢弃。
单看一次快照还不够,因为偶发问题需要观察趋势。可以用个简单循环记录:
bash复制while true; do ss -tln | awk 'NR==1 || /8080/ {print strftime("%T"), $0}'; sleep 2; done
放到后台跑一段时间,等下一次超时尖刺出现后回看记录。如果发现尖刺时刻的Recv-Q正好顶到了Send-Q的数值,那问题就实锤了。
另外还要配合netstat -s看TCP层累计统计:
bash复制netstat -s | grep -i "listen"
输出里的times the listen queue of a socket overflowed是自系统启动以来的累计溢出次数。如果这个数字很大且持续增长,说明队列溢出一直在发生,只是严重程度有波动。
这里有一个容易混淆的概念:ss显示的Recv-Q/Send-Q在LISTEN状态表示连接队列,在ESTAB状态则分别表示"未读字节数"和"未确认字节数",语义不同,使用时要先确认State列再解读。
4.3 调整方案:内核参数与应用层backlog同时改
确认是连接队列溢出后,通常有两层调整:
第一层是内核参数:
bash复制sysctl -w net.core.somaxconn=1024
sysctl -w net.ipv4.tcp_max_syn_backlog=2048
其中net.core.somaxconn决定了应用层listen backlog的上限,tcp_max_syn_backlog控制半连接队列的上限。如果应用层传的backlog参数比somaxconn大,内核会静默截断,所以这个参数要一起调。
第二层是应用层配置。Nginx里对应的是listen 8080 backlog=1024;,Java服务对应的是ServerSocket构造函数的backlog参数,Spring Boot内置Tomcat则通过server.tomcat.accept-count来配置。很多框架默认只有50或者100,根本扛不住瞬时几百上千的并发涌入。Tomcat的accept-count默认值是100,这个值在流量稍微一抖的地方就特别容易触发队列溢出。
这里也解释一个常见误解,很多人以为把backlog调得越大越好,实际上backlog过大反而可能导致应用层accept不及时,网络延迟上升。它只是一个缓冲池,不是并发能力本身。真正的并发能力要看应用线程池和线程处理耗时,backlog只是给缓冲留时间。
4.4 顺手排查半连接队列
全连接队列溢出之外,半连接队列溢出同样会表现为偶发连接超时,但机制是完全另一条路径。半连接队列是TCP三次握手中处于SYN_RECV状态的连接存放的队列,主要受net.ipv4.tcp_max_syn_backlog限制。当SYN Flood甚至只是正常的短时高并发连接建立请求涌来时,半连接队列满了之后新到的SYN包会被直接丢弃。
判断半连接队列是否溢出,可以用netstat -s的这条统计:
bash复制netstat -s | grep -i "SYNs to LISTEN"
输出里的SYNs to LISTEN sockets dropped如果持续增长,说明内核在丢弃SYN包。引发这种丢弃的条件通常是tcp_max_syn_backlog和tcp_syncookies的配置组合不当。我见过有台机器为了防止SYN Flood把tcp_syncookies设为1(启用),同时又设了很小的tcp_max_syn_backlog,结果正常流量稍微热点也会偶发丢包。调整思路是同步增大tcp_max_syn_backlog,并把tcp_syncookies合理配置,不要在启用syncookies时还依赖极端的队列大小来扛量。
4.5 这类问题为什么光看监控很难发现
最后额外解析一下为什么这类偶发问题用常规的监控系统很难直接发现。大多数监控平台对TCP连接数的采集粒度是分钟级,甚至五分钟一个点。而连接队列的溢出往往发生在几十秒甚至几秒之内,处于两个监控点之间,指标聚合之后就完全消失了。就算你加了ss实时采集,如果采集周期和问题发生的周期对不上,也容易漏掉。
所以处理这类"偶发超时又自愈"问题时,我的习惯是不要太依赖事后看监控大屏,更多依赖"提前打点"。接线上游探测、客户端访问日志、负载均衡的upstream响应时间曲线,都比服务器端监控更早反映问题。调度一次压测把并发瞬时拉高,往往就能当场复现。压测工具不一定要上复杂的Locust、JMeter,简单的ab -n 20000 -c 500就够了,重点是制造瞬时并发峰值而不是平稳压力。
5. 两个通用的排障心法,比具体命令更重要
5.1 "证据链"思维:每一步排查都要有记录
花了很多年才意识到,处理疑难杂症和普通故障最大的区别,不是技术水平差距,而是排查信息的组织方式。普通故障一查就中,靠的是经验正好覆盖到那个点上。疑难杂症之所以难,是因为根因链条长,任何一步误判都可能把方向彻底带偏。
在多次踩坑之后,我养成一个习惯:每执行一条命令,都把输出保存下来,并在旁边注明这条命令想验证什么结论。比如跑df -i,就是为了验证"inode是否耗尽"这个假设;跑ss -tlnp | grep 8080,就是为了验证"监听地址是否合法"这个假设。这种习惯的副作用是,当排查陷入僵局时,回看每一条记录可以迅速发现哪一步的假设其实根本没被验证过,往往真正的问题就藏在那里。
很多资深的运维专家提供的教程里都有这种排查思路的影子,但如果不落地到自己的环境,看过就忘的概率很高。我建议新入行的朋友从第一天起就采用这种带注释的命令记录方式,哪怕只是在终端里多写一行注释,长期积累下来的收益非常可观。
5.2 "最简单假设优先":先查配置,再查环境,最后怀疑代码
排查复杂问题时,人很容易被现象本身带走。看到端口在监听,就认为网络没问题;看到GC正常,就认为内存没问题。这本质上是在跳步,把一个中间检查结果当成了最终结论。
我的一个排障优先级原则是:先怀疑配置和环境,再怀疑代码和内核。不是说代码问题不重要,而是配置和环境类问题复现成本低、验证速度快,代码问题则需要构建复现条件。先花五分钟验证配置,再花半小时怀疑代码,平均成功率更高。大多数疑难杂症最后查出来,都是配置文件、内核参数、安全策略这些"看起来不起眼"的环节出了问题。
另外解释一些通用工具的使用习惯。strace在处理很多"查不出原因"的应用层问题时是终极武器,比如服务启动失败但日志不报错,直接strace -f -o /tmp/xxx.log <启动命令>就能看到卡在哪个系统调用上。lsof在排查文件句柄占用和端口占用时效率很高,但要注意高版本Linux的lsof -i在容器环境里输出可能不完整,这种情况优先用ss。
5.3 一些可以预埋的排查钩子
与其等疑难杂症出现了再临时抱佛脚,不如在日常运维里预埋一些"排查钩子",让问题发生时证据自动保留下来。我现在常用的做法有这几个:
- 所有Java服务统一开启GC日志,并且配置为发生OOM时自动生成堆转储:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/。 - Nginx和负载均衡层开启upstream的响应时间日志,
$request_time和$upstream_response_time能帮你快速判断是本层慢还是后端慢。 - 在定时任务中每小时记录一次关键端口的
ss -tlnp快照到本地日志,保留7天。这个方法成本极低,但遇到偶发连接问题时,回看快照往往能直接看到异常时段的Recv-Q积压。 - sysctl配置修改之前,先
sysctl -a > /tmp/sysctl_before.txt留底,避免改乱了不知道改了什么。
6. 写在最后的几点体会
回过头看这几个案例,它们处理的场景各不相同,但底层的规律是相通的:每个疑难杂症都有至少一个"误导表面",而破局的关键从来不是更高级的工具,而是更准确的假设排序和你是否完整地验证过每一个中间结论。
具体到操作层面,我最后再分享两个小技巧。第一个是,遇到"哪里都正常但就是不对"的问题,先检查时间。服务器时钟漂移导致的证书验证失败、会话过期、日志时间错乱,是IT领域最容易被忽略的隐形杀手。一条date命令和timedatectl status不会浪费你十秒钟,但能排除掉一大类诡异问题。
第二个是,保留每台服务器的"配置变更时间线"简单记录。大部分疑难杂症都有一个共同特征:不是突然发生的,而是某次"看似无关的变更"之后开始出现的。配置变更、内核参数调整、安全组修改,这些都是高危变更。谁改过、什么时候改的、改了什么,这三条信息在疑难杂症排查中的价值,远超你安装的任何监控工具。哪怕只是在一个共享文档里用三五行字随手记一下,关键时刻就能救命。
做IT这一行,终究躲不开疑难杂症,但我个人已经不再把它当成麻烦来看。每次成功的排查,都是对自己思维盲区的一次修正,顺手把排查记录和结论沉淀成团队的知识库,以后的同类问题就会变得越来越不"疑难"。这套思路,才是比任何具体命令都值得沉淀下来的东西。
