Linux 性能工程实战:从容器、网络丢包、动态追踪到监控与系统化优化
Linux 性能工程实战:从容器、网络丢包、动态追踪到监控与系统化优化
Linux 性能问题真正困难的地方,通常不是“不知道某个命令怎么用”,而是面对一个模糊现象时,不知道应该观察什么、怎样把现象映射到系统原理、如何逐层缩小范围,以及优化之后如何证明问题真的解决了。
容器启动慢、服务间歇性丢包、ksoftirqd CPU 占用高、Nginx 吞吐量骤降,看起来是完全不同的故障。把这些问题放到一起,会发现它们背后的方法高度一致:
先从用户可感知的性能现象出发,建立指标;再按照应用和操作系统的工作原理逐层缩小范围;当常规指标无法解释现象时,用 perf、ftrace、eBPF 等动态追踪深入调用路径;最后只针对已经确认的瓶颈优化,并用同一套基准重新验证。
这套方法比记住几十个性能命令更重要。工具只是观测系统的“传感器”,真正决定排障效率的是你对 CPU、内存、I/O、网络、容器隔离和应用执行模型的理解。
本文案例的技术背景主要处于 Ubuntu 18.04、Linux 4.x、Docker、Java 8/10 前后的环境。文末单独整理了现代 Linux、JDK 和容器环境下需要重新确认的版本差异,避免直接照抄历史配置。
一、把性能问题看成一个闭环,而不是一堆命令
一个完整的性能工程过程,可以抽象为下面这条链路:
flowchart LR
A[用户现象<br/>慢、错误、吞吐下降、启动慢] --> B[建立可重复基线]
B --> C[应用指标 RED<br/>Rate / Errors / Duration]
B --> D[系统资源 USE<br/>Utilization / Saturation / Errors]
C --> E[定位瓶颈层]
D --> E
E --> F[进程、协议栈、内核资源分析]
F --> G[perf / ftrace / eBPF<br/>动态追踪与调用栈]
G --> H[确认根因]
H --> I[针对性优化]
I --> J[重新压测与对比]
J --> K[监控、日志、Trace 固化]
K --> A
这里有两个重要原则。
第一,现象不是根因。CPU 100% 只是现象,可能是业务算法、频繁系统调用、软中断、锁竞争或内核网络处理引起的。网络超时也不意味着一定是“网络不好”,监听队列、连接跟踪、临时端口、应用处理过慢都可能表现为超时。
第二,优化之后瓶颈会迁移。一个高并发服务可能先被 nf_conntrack_max 卡住,调大后暴露 PHP 工作进程不足,再继续暴露监听队列太短、临时端口不足、TIME_WAIT 过多。性能优化不是找到一个参数改掉就结束,而是不断把当前最窄的瓶颈移走,直到系统达到目标。
二、性能瓶颈的两层模型:系统资源与应用程序
从故障定位角度,可以先把问题拆成两个观察面。
2.1 系统资源瓶颈
典型系统资源包括:
- CPU;
- 内存;
- 文件系统与磁盘 I/O;
- 网络;
- 文件描述符;
- Socket 队列;
- 本地临时端口;
- 连接跟踪表;
- 内核队列与各种协议栈资源。
对这些资源最实用的抽象是 USE Method:
- Utilization(使用率):资源有多少比例正在被使用;
- Saturation(饱和度):资源排队、等待的程度;
- Errors(错误数):资源处理过程中出现了多少错误。
例如 CPU 使用率不高,不代表 CPU 没有问题。如果运行队列很长,线程长期等待调度,CPU 已经存在饱和;网络带宽没有跑满,也不代表网络没有问题,SYN 重传、丢包、监听队列溢出同样能让服务不可用。
2.2 应用程序瓶颈
应用问题又可以归纳成三类:
- 资源瓶颈:应用被 CPU、内存、I/O、网络或内核资源限制;
- 依赖服务瓶颈:数据库、Redis、RPC、消息队列、远程服务等变慢;
- 应用自身瓶颈:算法复杂度、锁、死锁、线程模型、工作进程数量、业务逻辑等出现问题。
因此,真正高效的排障不是“先看 CPU,再看内存,再看磁盘”这种固定流程,而是:
先确认用户现象发生在哪一层,再选择能够验证这个假设的指标。
三、容器化之后,性能边界发生了什么变化
容器没有改变 Linux 的基本性能分析原理,但它增加了新的边界:
namespace改变进程看到的 PID、网络、挂载点等视图;cgroups改变 CPU、内存等资源的可用上限;- Overlay/联合文件系统增加 I/O 路径;
- veth、bridge、NAT、Netfilter 增加网络路径;
- CNI、CSI、GPU Device Plugin 等组件继续叠加新的依赖。
所以分析容器性能时,必须同时问两个问题:
进程实际能使用多少资源?
以及:
进程自己认为它能使用多少资源?
二者不一致时,非常容易产生诡异问题。
3.1 Java 容器 OOM:容器有 512 MB,JVM 却按主机内存算堆
一个典型案例是 Tomcat 容器:
1 | |
应用逻辑会主动分配约 256 MB:
1 | |
表面看,256 MB 小于容器 512 MB,应该不会 OOM。但实际访问一次后,容器退出。
第一步不是猜 JVM,而是先确认容器状态:
1 | |
关键状态类似:
1 | |
OOMKilled=true 已经明确说明容器中的进程因为内存不足被杀死。137 通常对应进程收到 SIGKILL 后退出。
继续看内核日志:
1 | |
案例中可以看到类似信息:
1 | |
这里已经把根因范围缩得很小:
- OOM 来自 memory cgroup;
- cgroup 上限确实是 512 MB;
- Java 匿名内存接近 512 MB;
- 主要不是页缓存,而是进程主动申请的内存。
再检查 JVM 自己认为最大堆能有多大:
1 | |
案例输出:
1 | |
也就是说,最大堆接近 1.95 GB,远远超过容器的 512 MB。
再看容器内部:
1 | |
当时的运行环境中,容器里的 free 仍然看到宿主机约 8 GB 内存,而不是 512 MB 的 cgroup 限额。旧版本 JVM 根据自己看到的主机物理内存做 Ergonomics,最终得到的最大堆也远大于容器上限。
这就是典型的:
cgroup 限制是 512 MB,但应用的资源自感知仍然是宿主机级别。
3.1.1 历史案例中的处理方式
案例通过显式 JVM 参数约束堆:
1 | |
这样确实能避免 JVM 按宿主机内存把堆放大到接近 2 GB。
但工程上还要注意一个容易被忽略的问题:
容器内存限制不能简单等于
-Xmx。
JVM 除了 Java Heap,还有 Metaspace、线程栈、Code Cache、Direct Buffer、JIT、GC 数据结构以及其他 Native Memory。生产环境应给这些非堆内存保留余量,而不是把 512 MB 容器完整交给 -Xmx512m。
这个问题在案例留言中也被直接提出,说明仅仅“把 JVM 堆设成容器上限”并不是完整的容量规划方案。
3.2 容器启动慢:CPU 使用率只有 10%,为什么线程却等了 97%?
修复 OOM 后,Tomcat 能稳定运行,但启动仍然需要约 22 秒。
先看系统整体:
1 | |
宿主机两个 CPU 都大量空闲,内存也非常充足。表面上看,机器没有压力。
这时如果只看“系统 CPU 使用率”,很容易误判。
继续针对容器中的 Java 线程看:
1 | |
关键不是 %CPU,而是 %wait。案例中部分 Java 线程:
1 | |
这意味着线程大部分时间在等待 CPU 调度,而不是执行。
回看启动参数:
1 | |
容器只有 0.1 个 CPU,也就是大约 10% CPU 配额。Java 启动本身会进行类加载、验证、初始化、JIT 等大量工作,所以 CPU quota 很快成为启动阶段的饱和点。
把 CPU 上限从 0.1 调整到 1 后,案例启动时间从约 22 秒下降到约 2 秒。
这个案例特别值得记住,因为它说明:
低 CPU 使用率不等于 CPU 不饱和。
如果系统有 Cgroup CPU quota,线程可能在配额耗尽后被 throttle。观察等待时间、调度和 cgroup 限流,比只盯着宿主机总体 CPU 更有意义。
3.3 为什么不能为了省事取消容器资源限制
最简单的“优化”当然是删掉 CPU、内存限制,让容器随便用。
问题是,这会把单个应用的问题放大成整台宿主机的问题。一个内存泄漏或 CPU 死循环,就可能把其他容器一起拖慢。
因此正确思路不是“不要限制”,而是:
- 为容器设置合理的 CPU、内存限制;
- 确保应用能够感知这些限制;
- 让 JVM、线程池、连接池等应用内部资源与容器配额匹配;
- 通过监控持续验证是否出现 throttle、OOM、排队等饱和现象。
四、从容器启动扩展到 Serverless 冷启动
冷启动不是单纯的“应用启动慢”,它是一条跨系统链路。
一个 Serverless/FaaS 实例从请求到可服务,大致经历:
flowchart LR
A[事件触发] --> B[资源调度]
B --> C[镜像拉取]
C --> D[网络配置]
D --> E[容器/沙箱启动]
E --> F[应用初始化]
F --> G[Ready]
因此冷启动分析最重要的问题不是先跑 top,而是:
总耗时分别花在了哪一个阶段?
应先用链路跟踪或分阶段埋点把启动过程拆开,再对最慢的阶段继续下钻。
典型优化方向包括:
- 镜像拉取慢:热点镜像缓存、减小镜像体积;
- 网络配置慢:网络资源预分配;
- 调度慢:降低调度路径成本;
- 容器启动慢:预创建或复用暖实例;
- 应用初始化慢:继续用 CPU、I/O、动态追踪分析应用内部。
这也是性能分析中很重要的一个思想:
面对复杂系统,先拆端到端耗时,再优化最慢阶段;不要一上来就在单机里找热点。
五、网络丢包:沿着 Linux 网络收发路径逐层排查
网络丢包最容易出现“工具说没问题,但业务还是超时”的情况。原因是一个数据包从网卡到应用,要经过很多层,每一层都可能丢。
flowchart BT
A[物理网络 / 虚拟交换] --> B[网卡与 Ring Buffer]
B --> C[链路层 / qdisc / QoS]
C --> D[IP 层 / 路由 / MTU]
D --> E[Netfilter / iptables / conntrack]
E --> F[TCP / UDP]
F --> G[Socket 队列与缓冲区]
G --> H[应用程序]
B -. Ring 溢出 .-> X1[丢包]
C -. tc/netem/QoS .-> X2[丢包]
D -. 路由/MTU/分片 .-> X3[丢包]
E -. DROP/表满 .-> X4[丢包]
F -. 重传/端口/协议资源 .-> X5[异常]
G -. backlog/缓冲区溢出 .-> X6[丢包]
H -. 处理异常/超时 .-> X7[失败]
从链路底层到应用层,常见原因包括:
- 物理链路拥塞或错误;
- 网卡 Ring Buffer 溢出;
- 帧校验失败;
tc/QoS 主动丢包;- 路由失败;
- MTU 配置错误;
- conntrack 表满;
- iptables DROP/REJECT;
- SYN 队列、Accept 队列、Socket 缓冲区满;
- 应用处理不过来;
- 客户端超时主动断开。
5.1 先选对探针:ping、hping3 和 curl 验证的不是同一件事
案例中 Nginx 使用 TCP 80 端口,所以不用 ICMP ping 作为主要验证工具,而是使用:
1 | |
结果:
1 | |
这已经说明 TCP SYN 层面存在严重异常。
但要注意:
ping验证 ICMP;hping3 -S主要验证 TCP SYN/SYN-ACK;curl才真正发送 HTTP 请求和 Payload。
因此“80 端口能握手”不能推出“HTTP 服务正常”。
这个差异后来正是定位 MTU 问题的关键。
5.2 链路层:先看网卡统计,再看 qdisc
进入容器:
1 | |
查看接口统计:
1 | |
经典字段:
RX-OK:成功接收包数;RX-ERR:接收错误;RX-DRP:接收丢包;RX-OVR:Ring Buffer 溢出;TX-*:发送方向对应指标。
但案例中网卡统计没有明显错误。这不能说明链路层彻底没问题,因为 tc qdisc 造成的丢包不会一定体现在这些接口统计中。
继续看 qdisc:
1 | |
案例输出:
1 | |
说明 netem 人为配置了 30% 丢包。
删除规则:
1 | |
这里出现了第一个很典型的性能排障陷阱:
发现一个真实问题,不代表它就是唯一问题。
删掉 netem 后,业务仍然约 50% 丢包,必须继续向上层分析。
5.3 网络层与传输层:netstat -s 看重传、超时和握手失败
查看协议栈统计:
1 | |
案例重点指标包括:
1 | |
这些数据说明主要异常集中在 TCP 建连阶段:
- 连接尝试失败;
- SYN 重传;
- TCP 超时;
- 半连接状态被重置。
netstat -s 的价值在于告诉你“哪一层在异常”,但它通常不能直接告诉你“哪条规则、哪个函数、哪个包造成了异常”。
因此还需要继续检查 Netfilter、conntrack 和抓包。
5.4 conntrack:先比较当前值和上限
检查:
1 | |
案例中:
1 | |
当前远未达到上限,因此可以排除“连接跟踪表满”这个假设。
注意这里的方法比“看见 Docker 就怀疑 conntrack”更重要:
任何优化前都应该先验证资源是否真的耗尽。
5.5 iptables:不要只看规则,要看规则计数器
查看 filter 表:
1 | |
案例发现 INPUT 和 OUTPUT 链都有 DROP 规则,且计数器持续增加。这两条规则使用 statistic 模块随机丢弃约 30% 的包。
对应删除形式可以写成:
1 | |
删除之后:
1 | |
TCP SYN 测试恢复为:
1 | |
如果此时停止排查,会得到一个错误结论,因为真正的 HTTP 请求仍然超时。
5.6 hping3 正常、curl 超时:抓包把问题缩到 Payload
继续测试:
1 | |
结果:
1 | |
抓包:
1 | |
观察到:
- SYN;
- SYN+ACK;
- ACK;
三次握手正常。
接下来直到 3 秒超时,都没有看到 HTTP GET;最后只看到客户端发 FIN 关闭连接。
这时应该问:
为什么只带 TCP Header 的握手包能到,而携带 HTTP Payload 的包到不了?
再次查看接口:
1 | |
案例中出现:
1 | |
默认以太网 MTU 通常是 1500,而容器接口被故意设成了 100。SYN 包很小,可以通过;HTTP GET 携带数据后,整个包超过 MTU,因此产生异常。
恢复 MTU:
1 | |
随后 curl 恢复正常。
现代环境更推荐用:
1 | |
这个案例真正值得保留的不是“把 MTU 改成 1500”,而是下面这个推理链:
1 | |
这就是“用协议原理解释工具差异”。
六、内核线程 CPU 高:从 ksoftirqd 进入内核调用栈
普通进程 CPU 高,可以用 strace、pstack、lsof、perf 等工具。但内核线程不是普通用户态进程,很多进程级工具无法直接观察其内部行为。
6.1 先认识 Linux 的几个特殊进程
Linux 启动后,最重要的几个低 PID 进程是:
- PID 0:idle/swapper,CPU 没有其他任务时执行;
- PID 1:init/systemd,管理用户态进程;
- PID 2:
kthreadd,管理内核线程。
查看内核线程:
1 | |
或者:
1 | |
常见内核线程包括:
| 内核线程 | 作用 |
|---|---|
ksoftirqd/N |
处理软中断,每个 CPU 通常都有 |
kswapd |
内存回收 |
kworker |
执行内核工作队列 |
migration/N |
调度负载均衡和 CPU 迁移 |
jbd2/* |
文件系统 Journaling |
pdflush |
历史内核中的脏页回写线程,后续已并入其他机制 |
看到内核线程名字,本身就能给排障方向。
例如 ksoftirqd 高,第一反应就应该是:
软中断为什么这么多?是不是网络收发?
6.2 用 hping3 制造大量 SYN 后,软中断超过 30%
案例使用:
1 | |
也就是每 10 微秒发一个 SYN。
服务器 top 中:
1 | |
两个 CPU 的 si 软中断都超过 30%,ksoftirqd 也成为最显眼的内核线程。
这时知道“软中断高”还不够,还需要知道它到底执行了什么。
6.3 为什么 pstack 不适合,perf 可以
对 ksoftirqd/0:
1 | |
会出现无法 attach 的问题。
即使:
1 | |
也只能看到非常粗的内核线程框架:
1 | |
要看热点调用路径,可以用:
1 | |
案例中能看到类似调用链:
1 | |
这些函数已经非常有信息量:
net_rx_action:网络接收软中断;netif_receive_skb:接收 skb;br_handle_frame:经过 Linux bridge;br_nf_pre_routing:bridge 上进入 Netfilter PREROUTING;- 后续还能看到 IP 转发、Netfilter、发送等路径。
这与 Docker 的网络拓扑完全对应:
1 | |
也就是说,ksoftirqd CPU 高不是一个“神秘内核问题”,而是大量网络包经过 bridge、Netfilter、容器网络路径后产生的软中断计算成本。
七、火焰图:把“热点函数列表”变成可阅读的调用关系
perf report 能看调用树,但层级一深就很难读。火焰图的价值在于把大量采样栈压成一个可交互的 SVG。
生成流程:
1 | |
假设 perf.data 已生成:
1 | |
7.1 火焰图怎么看
两个轴一定不要理解反:
- 横轴宽度:采样次数或采样占比,越宽越“热”;
- 纵轴高度:调用栈深度;
- 上层函数由下层函数调用;
- 同一层左右位置通常不是时间顺序。
颜色通常只是为了区分函数,并没有天然的性能含义。
最重要的阅读动作是:
从底部向上,找最宽的调用链。
7.2 火焰图的常见类型
| 类型 | 关注点 |
|---|---|
| on-CPU | CPU 真正在执行什么 |
| off-CPU | 在等待 I/O、锁或其他资源时阻塞在哪 |
| 内存火焰图 | 内存分配、释放、换页等调用栈 |
| 热/冷火焰图 | 综合 on-CPU 与 off-CPU |
| 差分火焰图 | 对比优化前后或两个时期的热点差异 |
CPU 火焰图和内存火焰图的绘制格式本身没有本质区别,区别主要在采样事件和函数栈来源。
例如普通 perf record 常用于 CPU 采样;如果换成内存相关事件:
1 | |
得到的调用栈就主要围绕缺页行为。
也可以直接跟踪内存相关系统调用事件,例如:
1 | |
7.3 火焰图最容易被误解的两个点
第一,从某个顶部函数向下看到的是这个函数的调用栈,不是整个内核网络流程的完整流程图。TCP、conntrack 等路径可能分布在其他较窄的“火焰”里。
第二,普通火焰图不表达横向时间顺序。不要根据两个函数左右位置推断“先调用左边,再调用右边”。
八、动态追踪:当普通指标已经无法解释问题时
性能问题最麻烦的一类是:
- 线上偶发;
- 无法稳定复现;
- 重启后消失;
- 只有高并发或特定输入才出现;
- 不能为了调试停服务;
- 不能临时修改代码重新发版。
传统方法有两个明显限制:
- GDB 断点会暂停程序;
- 加日志需要修改代码、重新编译和部署。
动态追踪的核心价值是:
在内核或应用保持运行的同时,把探针挂到特定事件或函数上,观察正在发生什么。
相比基于 ptrace 的进程跟踪,动态追踪通常对目标程序行为干扰更小。资料给出的经验值是额外开销通常可以控制在约 5% 或更低,但实际开销仍然取决于探针数量、触发频率和采集内容。
8.1 动态追踪的三类事件源
硬件事件:PMC
Performance Monitoring Counter 可以记录:
- CPU cycles;
- instructions;
- branch prediction;
- Cache miss;
- LLC 等硬件性能事件。
这类事件是 perf stat、perf record 很重要的数据源。
静态探针
静态探针在源码中预先定义,典型包括:
- Tracepoint;
- USDT(Userland Statically Defined Tracing)。
Tracepoint 在内核中非常常见;USDT 则用于用户态程序,MySQL、PostgreSQL 等软件都曾提供这类探针。
动态探针
运行时再附加,不需要源码预埋:
kprobe/kretprobe:内核函数进入与返回;uprobe/uretprobe:用户态函数进入与返回。
8.2 从 DTrace 到 SystemTap,再到 eBPF
这套动态追踪体系的思想脉络可以理解为:
1 | |
SystemTap 会把脚本编译为内核模块再加载执行,功能很强,但启动和调试信息依赖较重。在旧版本 RHEL 环境中,它长期是非常重要的选择。
eBPF 则把可验证的 BPF 程序加载到内核中运行,可以与 kprobe、uprobe、tracepoint、Maps 等机制组合,后来成为 Linux 可观测性和网络技术的重要基础。
九、ftrace:已知内核函数后,看它内部到底调用了什么
ftrace 非常适合这样的场景:
perf 已经告诉你某个内核函数可疑,现在想继续看它具体调用了哪些函数。
ftrace 通常通过 debugfs/tracefs 提供接口:
1 | |
如果 debugfs 没挂载:
1 | |
查看支持的 tracer:
1 | |
常用的包括:
function:函数跟踪;function_graph:函数调用图。
查看可追踪函数和事件:
1 | |
以历史内核中的 do_sys_open 为例:
1 | |
输出会显示:
- CPU;
- 任务名与 PID;
- 函数执行耗时;
- 缩进后的函数调用关系。
原生接口很灵活,但操作步骤多。trace-cmd 可以把流程简化:
1 | |
一个非常实用的工具选择原则是:
perf / 火焰图适合“不知道热点在哪”;ftrace 适合“已经知道函数名,想继续深挖调用过程”。
十、perf 不只是采样:perf probe 与 perf trace
perf 最常见的用途是 CPU 采样,但它也能动态添加 probe。
查看事件:
1 | |
添加内核函数探针:
1 | |
记录:
1 | |
查看:
1 | |
如果只知道函数被调用还不够,还可以查询参数:
1 | |
在案例内核中可以看到:
1 | |
于是可以把 filename 加进 probe:
1 | |
然后:
1 | |
这样能直接看到进程正在打开哪些文件。
10.1 为什么性能敏感服务里不要随便 strace
strace 基于 ptrace。高频系统调用场景下,它可能产生两个明显影响:
- 频繁用户态/内核态切换;
- 通过信号和进程控制影响目标进程执行。
因此数据库、低延迟服务等对时序敏感的程序,不应该把长时间 strace 当成无成本操作。
perf trace 提供了类似的系统调用跟踪能力:
1 | |
并且可以做系统级跟踪,而不只局限于一个目标进程。
10.2 跟踪用户态函数
perf probe 也可以通过 uprobes 跟踪用户态二进制文件。
例如跟踪 Bash 的 readline 返回:
1 | |
查看二进制中的函数和参数:
1 | |
如果函数名、参数无法解析,通常需要安装调试符号或使用带 -g 的版本重新编译。
十一、eBPF 与 BCC:把动态追踪变成可编程数据处理
ftrace 和 perf 很强,但灵活性仍然受限。eBPF 的不同之处在于,它允许把受验证的程序加载到内核中,让事件过滤、聚合、统计直接在内核侧完成。
可以把 eBPF tracing 简化成三步:
1 | |
BCC(BPF Compiler Collection)进一步把很多重复工作封装成 Python/Lua API。
下面是资料中用于追踪 do_sys_open 的经典 BCC 思路。代码属于早期 BCC API 风格,理解结构比直接复制到当前内核更重要:
1 | |
输出可以类似:
1 | |
BCC 的门槛比 perf 高,因为要理解:
- C;
- Python;
- 内核数据结构;
- 被跟踪函数的参数;
- BPF Maps;
- 内核版本特性。
但一旦需要“按参数分布聚合”“统计 I/O 大小直方图”“跟踪高频内核事件”这类任务,eBPF 的能力就远高于简单采样。
十二、动态追踪工具怎么选
可以把工具选择压缩成一张表:
| 场景 | 更适合的工具 |
|---|---|
| 不知道热点在哪,先找 CPU 热点 | perf + 火焰图 |
| 已知内核函数,想看调用图 | ftrace / trace-cmd |
| 需要动态添加内核/用户态 probe | perf probe |
| 需要系统调用追踪,又不想长时间依赖 ptrace | perf trace |
| 新一些的 Linux,需要灵活统计与聚合 | eBPF / BCC |
| 旧版本 RHEL 内核 | SystemTap |
| 历史容器运行时综合分析 | sysdig |
资料中有一个非常形象的描述:
1 | |
这更适合理解其定位:它试图从容器场景提供一个统一的系统事件观察面。
动态追踪通常还依赖 Debug Symbol。遇到:
1 | |
可以:
- 安装对应 debuginfo/dbgsym;
- 或重新编译应用并增加
-g。
当 perf、eBPF 或 SystemTap 显示一个完全陌生的内核函数时,最可靠的解释来源仍然是对应内核版本源码。使用 Bootlin Elixir 这类内核源码浏览器,可以从函数定义继续追变量、宏和调用关系。
十三、吞吐量下降:一个性能瓶颈不断迁移的完整案例
吞吐量案例非常适合建立“系统性思维”,因为它不是一个单点问题,而是一连串资源限制。
架构是:
1 | |
13.1 初始基线:1000 并发,只有 189 QPS
压测:
1 | |
初始关键结果:
1 | |
所有响应都异常,并且吞吐只有约 189 QPS。
传输数据总量只有约 573 KB,因此带宽不是第一嫌疑。应从 TCP 连接和应用处理能力继续看。
13.2 第一层:连接跟踪表只有 200
为了观察现场,把压测时间拉长:
1 | |
查看连接:
1 | |
案例中:
1 | |
高并发 1000,但真正建立的连接只有个位数,显然不正常。
看内核日志:
1 | |
发现:
1 | |
检查:
1 | |
结果:
1 | |
确认表已满。
案例调大:
1 | |
重新压测:
1 | |
吞吐从 189 提升到 5382,接近 30 倍。
但还有:
1 | |
也就是说,QPS 好看了,业务成功率仍然很差。
这是性能优化中非常重要的提醒:
不要只盯吞吐量。错误率和响应时间必须一起看。
13.3 第二层:Nginx 499,PHP-FPM max_children=5
Nginx 日志:
1 | |
出现大量:
1 | |
Nginx 的 499 表示服务端还没来得及响应,客户端已经主动关闭连接。压测工具有超时,所以真正的问题仍然是服务器处理过慢。
再看 PHP-FPM:
1 | |
发现:
1 | |
PHP-FPM 最多只有 5 个子进程,同时处理能力明显不足。
案例把 max_children 从 5 提高到 20。资料给出的经验是每个 PHP-FPM 子进程可能占用约 20 MB,但这个值必须以实际应用 RSS/PSS 为准,不能直接套用。
重新测试后,成功请求数量明显提升,但仍有大量异常。
13.4 第三层:Socket Listen Queue 溢出
检查协议统计:
1 | |
两次采样之间:
1 | |
都在快速增长。
看监听队列:
1 | |
案例里 Nginx 和 PHP-FPM 的队列都只有约 10,Nginx 当前队列已经顶到上限。
进一步检查:
1 | |
案例中:
1 | |
因此监听队列本身就是瓶颈。
资料的案例把应用 backlog 增大到 8192,把 somaxconn 增大到 65536。
这里还有一个非常重要的后续更正:原案例对半连接和全连接队列的区分不够严谨。高并发建连问题还应该检查:
1 | |
因此生产排查时要同时区分:
- SYN 半连接队列;
- 已完成握手、等待
accept()的全连接队列; - 应用
listen(backlog); somaxconn上限。
只改一个 somaxconn 并不能覆盖所有连接队列问题。
13.5 第四层:Nginx 作为客户端,临时端口范围只有 50 个
监听队列问题消失后,Nginx 又出现:
1 | |
也就是 EADDRNOTAVAIL。
Nginx 连接 PHP-FPM 时自己是客户端,需要本地临时端口。
查看:
1 | |
案例故意设成:
1 | |
总共只有约 50 个临时端口。
扩展:
1 | |
错误减少,但整体吞吐反而下降,并出现较多 Socket read errors。
这不是“优化失败”,而是说明瓶颈继续迁移。
13.6 第五层:系统 CPU 高,火焰图指向临时端口分配
此时 top:
1 | |
于是用 perf + 火焰图:
1 | |
再生成 Flame Graph。
热点调用链中出现:
1 | |
inet_hash_connect() 负责临时端口分配,__inet_check_established() 需要检查端口是否已经被占用。
查看:
1 | |
发现:
1 | |
大量 TIME_WAIT 占据端口,使寻找可用临时端口本身变成 CPU 热点。
再看:
1 | |
案例为:
1 | |
开启端口重用后,最终测试:
1 | |
虽然仍有少量 read error,但业务错误已经基本消失,吞吐稳定在 5000 QPS 以上。
13.7 这个案例真正要记住的不是 sysctl 数值
整个过程可以压缩为:
flowchart TD
A[189 QPS + 全部错误] --> B[ss -s]
B --> C[conntrack 表满]
C --> D[调大 conntrack]
D --> E[QPS 5382 但大量 499]
E --> F[PHP-FPM max_children 太小]
F --> G[成功数增加]
G --> H[Listen Queue 溢出]
H --> I[调整 backlog / somaxconn / SYN backlog]
I --> J[EADDRNOTAVAIL]
J --> K[临时端口范围过小]
K --> L[扩大 local port range]
L --> M[系统 CPU 高]
M --> N[perf 火焰图]
N --> O[TIME_WAIT 导致端口分配热点]
O --> P[针对性处理]
这就是多瓶颈系统的典型特征:
每解决一层限制,下一层瓶颈才有机会暴露。
因此生产环境里最危险的做法,就是看到一个 sysctl 参数就直接“调大到很大”。
十四、系统监控:用 USE 把“资源是否有瓶颈”变成长期数据
现场排障工具有一个天然缺陷:
你登录服务器时,问题可能已经消失。
因此性能工程最终一定会走向监控。
14.1 USE 方法
一个实用的资源监控表可以这样建立:
| 资源 | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | CPU 使用率 | 运行队列、Load | Hardware Error |
| 内存 | 已用内存、Swap 使用率 | 换页、直接回收、内存压力 | 分配失败、OOM |
| 存储设备 | I/O busy 时间 | I/O 队列、延迟 | I/O Error |
| 文件系统 | 容量、inode 使用率 | 空间/inode 接近上限 | 读写错误 |
| 网络 | BPS/PPS、带宽利用率 | 重传、队列拥塞 | 网卡错误、丢包 |
| 文件描述符 | 已用 FD 比例 | 接近系统/进程上限 | 打开失败 |
| conntrack | count/max |
接近表上限 | table full/drop |
| TCP 连接 | 当前连接数 | TIME_WAIT、SYN backlog | reset、timeout |
USE 不是说“只监控三个指标”,而是给每一种资源建立三个最重要的问题:
1 | |
这样告警出现后,能很快知道是哪个资源进入了瓶颈状态。
14.2 大量性能工具最终都在读 /proc 和 /sys
很多常见工具其实是对内核数据的不同展示:
CPU:
1 | |
内存:
1 | |
I/O:
1 | |
网络:
1 | |
这说明监控系统和现场命令不是两套体系。监控系统只是把这些数据长期保存下来,让你在故障发生后仍然能回看现场。
十五、Prometheus:把采集、存储、查询、告警和可视化连起来
一个完整监控系统至少需要:
- 数据采集;
- 服务发现;
- 时序存储;
- 查询处理;
- 告警;
- 可视化。
Prometheus 的基本结构可以抽象为:
flowchart LR
A[Targets / Exporters] -->|Pull| P[Prometheus Server]
B[Short-lived Jobs] --> PG[Pushgateway]
PG -->|Pull| P
SD[Service Discovery<br/>Kubernetes / File SD] --> P
P --> T[TSDB]
P --> Q[PromQL]
Q --> G[Grafana / Web UI]
Q --> AM[Alertmanager]
AM --> N[Email / IM / Pager]
Retrieval / Targets
Prometheus 最常见的是 Pull:目标暴露 HTTP Metrics Endpoint,Prometheus 定期采集。
对生命周期很短的任务,可以通过 Pushgateway 保存需要被拉取的指标。
Service Discovery
在 Kubernetes 等动态环境中,Pod、Service 会不断变化,静态写死监控地址不可维护,因此服务发现非常关键。
TSDB
监控数据天然是:
1 | |
所以适合时间序列数据库。
PromQL
PromQL 负责:
- 查询;
- 过滤;
- 聚合;
- 速率计算;
- 告警表达式;
- Grafana 数据源。
Alertmanager
告警不是越多越好。Alertmanager 通过:
- 分组;
- 抑制;
- 静默;
减少告警风暴,把真正需要人处理的问题送出去。
十六、应用监控:RED 比 USE 更接近用户体验
系统资源监控关心“机器是否健康”,应用监控更关心:
用户请求是否健康。
对于服务和微服务,RED 是很实用的抽象:
- Rate:单位时间请求量;
- Errors:错误请求数或错误率;
- Duration:请求处理耗时。
这三个指标也可以理解为:
1 | |
一个接口 QPS 下降、P99 上升、错误率升高,即使 CPU 只有 20%,应用也已经有性能问题。
16.1 只有 RED 还不够
发生性能问题后,还需要继续定位,所以应用至少还应监控三类内部指标。
进程资源
例如:
- CPU;
- RSS/PSS;
- GC;
- 文件描述符;
- I/O;
- Socket;
- 线程池;
- 连接池。
依赖调用
每个 RPC/数据库/缓存依赖至少有:
- 调用次数;
- 错误数;
- Duration。
关键业务阶段
例如一个订单接口内部可以拆成:
1 | |
如果每一步都有耗时和错误指标,性能故障就不必从整个进程开始盲猜。
十七、Metrics、Trace、Logs 是互补关系
可观测性不应该只做一个 Grafana Dashboard。
17.1 Metrics:告诉你哪里异常
Metrics 是某个时间窗口上的数值:
1 | |
适合长期保存、聚合、告警。
17.2 Trace:告诉你请求在哪一跳慢
分布式调用链可以使用 Zipkin、Jaeger、Pinpoint 等系统。
入口生成 Trace ID,后续 RPC、数据库调用等都携带同一个 Trace Context,就可以重建完整请求路径:
1 | |
这样能迅速判断问题到底在:
- 当前服务;
- 某个下游;
- 数据库;
- Redis;
- RPC 网络;
- 还是某个具体 Span。
17.3 Logs:告诉你当时发生了什么
日志擅长保存上下文:
- 请求参数;
- 错误堆栈;
- 状态码;
- 业务对象;
- SQL;
- 异常分支。
经典日志架构是 ELK:
1 | |
Logstash 负责采集和处理,Elasticsearch 负责索引与搜索,Kibana 负责检索和展示。
由于 Logstash 资源开销较大,资源紧张场景也常用 Fluentd 等更轻量采集器,形成 EFK 类架构。
三者的关系可以概括为:
1 | |
十八、收到性能告警后,应该怎么分析
现在可以把分析流程收敛成一套工程化套路。
18.1 第一步:先明确用户现象
不要从 top 开始,而是先确定:
- QPS 下降了吗;
- 错误率上升了吗;
- 平均延迟还是 P99 上升;
- 是所有请求还是特定接口;
- 是持续还是间歇;
- 从什么时间开始;
- 是否与发布、扩容、流量变化相关。
这一步决定后面要验证的假设。
18.2 第二步:从监控恢复故障现场
优先看问题窗口,而不是“现在”:
- RED;
- USE;
- Pod/容器 throttle;
- OOM;
- GC;
- 网络错误;
- TCP 重传;
- I/O latency;
- dependency duration。
如果监控已经能把范围缩到某个进程,就不必从整台机器重新扫一遍。
18.3 第三步:确认系统资源瓶颈
CPU
第一层:
1 | |
关注:
- user/system/iowait/softirq;
- load;
- runnable;
- context switch;
- per-process CPU;
%wait。
继续:
1 | |
如果是软中断:
1 | |
内存
第一层:
1 | |
进程:
1 | |
缓存/泄漏:
1 | |
磁盘与文件系统
系统:
1 | |
进程:
1 | |
深入:
1 | |
网络
接口与吞吐:
1 | |
连接:
1 | |
协议统计:
1 | |
路由:
1 | |
DNS:
1 | |
防火墙/NAT:
1 | |
连接跟踪:
1 | |
抓包:
1 | |
18.4 第四步:如果系统资源正常,回到应用和依赖
如果 CPU、内存、I/O、网络都没有明显瓶颈,应用仍然慢,重点就转向:
- 锁;
- 线程池;
- 连接池;
- RPC;
- 数据库;
- 缓存;
- 算法;
- 热点函数;
- 系统调用。
优先使用:
1 | |
而不是继续随机调整 sysctl。
十九、性能优化的一般方法
找到瓶颈以后,优化通常比定位更依赖具体业务。但可以保留几个稳定的方向。
19.1 CPU 优化
核心目标:
减少无效工作、提高 Cache Locality、降低调度开销。
典型方法:
- 优化算法;
- 简化热点路径;
- 减少不必要系统调用;
- 批处理;
- CPU Affinity;
- 中断负载均衡;
- 避免频繁线程切换;
- 对非关键进程使用 cgroup 约束;
- 给关键任务更合理的优先级。
19.2 内存优化
常见方向:
- 修复泄漏;
- 减少频繁动态分配;
- 内存池;
- Huge Page;
- 控制缓存;
- 为容器/进程设置内存上限;
- 避免不受控 Swap;
- 为关键服务做合理 OOM 策略。
注意,“禁用 Swap”不是任何场景的机械规则。它的核心目的,是避免延迟敏感服务在内存压力下突然进入高延迟换页路径。是否完全禁用,需要结合工作负载和现代容器编排策略判断。
19.3 磁盘和文件系统优化
典型方向:
- SSD 替代慢磁盘;
- 合理 RAID;
- 调整 I/O Pattern;
- 批量读写;
- 增大单次 I/O;
- 降低随机 I/O;
- 利用 Page Cache;
- 调整 Dirty Page 回写;
- 按业务隔离磁盘;
- 使用合适的 I/O Scheduler。
历史资料中常见 noop、deadline 等调度器名称;现代 blk-mq 环境下具体可用调度器和推荐方式要以当前内核、设备类型为准。
19.4 网络优化
内核资源
可能涉及:
- Socket Buffer;
somaxconn;- SYN backlog;
- conntrack;
- 文件描述符;
- 临时端口范围;
- Keepalive;
- 重传;
- TIME_WAIT;
- MTU。
原则不是“全调大”,而是:
1 | |
网卡能力
高吞吐场景可以利用:
- RSS;
- 多队列;
- GRO;
- GSO;
- 硬件 offload;
- IRQ Affinity。
极限网络性能
当 Linux 通用协议栈本身成为瓶颈,才考虑:
- DPDK;
- XDP。
这类技术换来的是更高复杂度,不应该用于普通 Web 服务的常规优化。
19.5 应用程序优化通常收益最大
系统参数经常只是把“上限”调高,真正能从源头减少工作量的地方仍然是应用。
典型方向:
- 改算法;
- 减少数据库访问;
- SQL 优化;
- 缓存;
- 写时复制;
- 合并 I/O;
- 长连接;
- DNS Cache;
- I/O 多路复用;
- 异步处理;
- 合理线程/进程模型;
- 消息队列;
- CDN;
- 负载均衡;
- 水平扩展。
如果单机优化已经复杂到难以维护,通常应该考虑:
是否应该水平扩展,而不是继续追求单机极限。
二十、Linux 性能工具速查:从指标出发选工具
性能工具很多,但不要按“工具名”背,应该按“我要看什么指标”来选。
20.1 CPU
| 指标 | 常用工具 | 重点 |
|---|---|---|
| 平均负载 | uptime、top、/proc/loadavg |
是否有长期排队 |
| 系统 CPU | top、vmstat、mpstat、sar |
user/sys/iowait/softirq |
| 进程 CPU | top、ps、pidstat、htop |
谁在消耗 CPU |
| 上下文切换 | vmstat、pidstat -w |
调度开销 |
| 软中断 | top、mpstat、/proc/softirqs |
网络高并发常见 |
| 硬中断 | vmstat、/proc/interrupts |
IRQ 分布 |
| CPU Cache | perf stat |
cache miss 等硬件事件 |
| 热点函数 | perf + 火焰图 |
调用栈 |
| 已知函数深挖 | ftrace |
精确函数调用 |
| 高级动态追踪 | BCC/eBPF、SystemTap | 自定义事件和聚合 |
20.2 内存
| 指标 | 常用工具 |
|---|---|
| 系统已用/可用内存 | free、vmstat、sar、/proc/meminfo |
| 进程 RSS/VSS | ps、top、pidstat -r、/proc/<pid>/status |
| 地址空间分布 | pmap、/proc/<pid>/maps |
| Swap | free、vmstat、sar |
| 缺页 | pidstat -r、sar -B |
| Page Cache | cachestat、cachetop |
| Slab | slabtop、/proc/slabinfo |
| 泄漏 | memleak、valgrind |
20.3 文件系统与磁盘 I/O
| 指标 | 常用工具 |
|---|---|
| 文件系统容量 | df |
| inode | df -i |
| 目录项/Slab | slabtop、/proc/slabinfo |
| IOPS/吞吐/延迟/队列 | iostat、sar、dstat、/proc/diskstats |
| 进程 I/O | pidstat -d、iotop |
| Block I/O 跟踪 | blktrace |
| Block 延迟 | biosnoop、biotop |
| 系统调用 | strace、perf trace |
| 基准测试 | fio |
20.4 网络
| 指标 | 常用工具 |
|---|---|
| BPS | sar、nethogs、iftop、/proc/net/dev |
| PPS | sar -n DEV、/proc/net/dev |
| 连接数 | ss、netstat |
| 错误/重传 | netstat -s、sar -n EDEV |
| 延迟 | ping、hping3 |
| conntrack | nf_conntrack_count/max、conntrack |
| 路由 | ip route、mtr、traceroute |
| DNS | dig、nslookup |
| 防火墙/NAT | iptables |
| 网卡能力 | ethtool |
| 抓包 | tcpdump、Wireshark |
| 动态追踪 | ftrace、BCC/eBPF、SystemTap |
| 带宽基准 | iperf |
| 发包基准 | pktgen |
| HTTP 基准 | ab、wrk |
选择工具时还要考虑现实约束:
- 能不能安装软件;
- 是否有 root;
- 内核版本;
- 是否允许产生额外开销;
- 是否在线上;
- 是否有调试符号;
- 容器里还是宿主机里;
- 是否需要进入 namespace。
二十一、把现场命令变成长期监控
真正成熟的性能体系,不应该等出问题后才临时执行:
1 | |
更好的做法是把这些命令背后的指标长期采集。
例如:
1 | |
这样一次性能故障可以形成完整闭环:
1 | |
每解决一次事故,都应该让下一次事故更容易定位。
二十二、现代环境下需要重新确认的历史差异
这些案例非常适合学习方法,但其中很多命令和结论来自 2019 年前后的运行环境。迁移到现代 Linux、Kubernetes 和 JDK 时,需要区分“方法”与“具体实现”。
22.1 Java 已增强容器资源感知
历史案例中的 Java 8 环境看不到 cgroup 限制,导致 JVM 按宿主机内存计算 Heap。
Java 10 开始明显增强容器资源感知,后续 JDK 继续完善 cgroup 支持。现代 JDK 通常能够根据容器限制计算可用内存和 CPU。
但仍然应该实际验证:
1 | |
以及容器中的:
- Max Heap;
- GC;
- Direct Memory;
- Native Memory;
- CPU Processor Count。
不要因为“JDK 支持容器”就放弃容量验证。
22.2 cgroups v1 与 v2 文件不同
历史 Docker 环境大量使用 cgroups v1。现代发行版越来越多采用 cgroups v2。
概念仍然一样:
1 | |
但控制文件、指标和观测方式会变化。
22.3 netstat / ifconfig 已偏历史
文章中大量使用:
1 | |
它们来自 net-tools。
现代 Linux 更常见:
1 | |
例如:
1 | |
22.4 iptables 可能只是 nftables 的兼容前端
现代发行版常使用 nftables,iptables 命令可能由 iptables-nft 后端实现。
因此遇到防火墙/NAT 问题时,要先确认当前系统到底使用哪套规则体系,而不是假设所有机器都仍然是旧式 xtables。
22.5 内核函数名不是稳定 API
示例中的:
1 | |
在不同内核版本中可能已经发生变化或被其他路径替代。
因此动态追踪示例最重要的是方法:
1 | |
而不是记死某个函数名。
22.6 早期 BCC 示例不代表现代 eBPF 的唯一写法
现代 eBPF 生态还有:
- bpftrace;
- libbpf;
- CO-RE;
- BTF;
- 更丰富的现成 tracing 工具。
但是 BCC 示例仍然很适合理解:
1 | |
22.7 不要复制案例里的 sysctl 数值
下面这些值都是为了演示瓶颈而设计的案例参数:
1 | |
即使优化后的:
1 | |
也不能直接作为生产推荐值。
正确做法是:
1 | |
tcp_tw_reuse 等参数在不同内核版本上的实现和适用条件也可能变化,更不应该只因为“TIME_WAIT 多”就机械开启。
二十三、一套可以真正落地的性能排障 Checklist
遇到性能问题时,可以按下面顺序执行。
1. 明确用户现象
1 | |
2. 建立或复现基线
记录:
1 | |
3. 看应用 RED
1 | |
4. 看系统 USE
1 | |
5. 找到最可疑进程/组件
1 | |
6. 按系统原理继续缩小
例如网络:
1 | |
7. 常规指标解释不了时再动态追踪
1 | |
8. 修改最小变量
一次只修改能够验证当前根因的关键配置或代码,避免多个优化同时上线导致无法归因。
9. 使用相同压测重新验证
必须比较:
1 | |
不能只证明“某一个数字变好了”。
10. 把新发现补进监控
如果这次事故是:
1 | |
那么以后就应该长期监控:
1 | |
而不是等下一次再跑 dmesg。
二十四、总结:性能分析的核心是“用原理解释指标”
把容器 OOM、慢启动、网络丢包、ksoftirqd、动态追踪、Nginx 吞吐优化和监控体系放到一起,会得到一套非常稳定的方法论。
容器性能问题,要把 cgroups、namespace、容器网络和应用自身资源模型一起看;网络故障,要按 网卡、qdisc、IP、Netfilter、TCP、Socket、应用逐层排查;内核线程异常,要借助 perf 和火焰图从热点函数理解内核正在做什么;常规指标不足时,再通过 ftrace、perf probe、eBPF/BCC动态追踪真实执行路径。
系统资源用 USE 建立长期指标,应用服务用 RED 关注吞吐、错误和延迟,再通过 Metrics、Trace、Logs 把“发现问题、定位组件、恢复上下文”串成一个闭环。
真正值得形成肌肉记忆的不是:
1 | |
而是:
1 | |
性能工具只是手段,系统原理才是索引。
当你能够把一个异常指标放回 Linux 的执行路径中解释清楚,性能排障就不再是“碰运气调参数”,而会变成一个可以重复、可以验证、可以监控、可以持续改进的工程过程。