Linux 性能工程实战:从监控、定位、动态追踪到容器与网络调优
Linux 性能工程实战:从监控、定位、动态追踪到容器与网络调优
Linux 性能问题真正困难的地方,通常不是“不知道某个命令怎么用”,而是不知道应该从哪里开始、下一步看什么、看到异常后如何把指标与系统机制对应起来。
CPU、内存、磁盘 I/O、网络只是性能问题的表象分类。进入真实系统后,一个看起来像“网络慢”的问题,可能来自连接跟踪表耗尽;一个“容器启动慢”的问题,可能并不是 Docker 本身慢,而是 CPU cgroup 把 JVM 启动阶段压到了极低的调度配额;一个“HTTP 超时”的问题,甚至可能在 TCP 三次握手完全正常的情况下,由 MTU、监听队列、临时端口或应用工作进程配置触发。
因此,一套可复用的性能工程方法应该把以下几件事串起来:
- 用监控保存问题现场,而不是等故障发生后再“登录服务器碰运气”。
- 从系统原理出发,把问题映射到 CPU、内存、I/O、网络或应用调用链中的具体层次。
- 先用低成本工具确定方向,再用
perf、火焰图、ftrace、eBPF/BCC 等动态追踪手段深入热点。 - 优化必须围绕已经证实的瓶颈进行,并通过同一套基准测试验证收益和副作用。
- 系统与应用不是两套割裂的方法论。系统瓶颈会拖慢应用,应用的不合理行为同样会制造系统瓶颈。
本文围绕这条主线,建立一套从监控、诊断、动态追踪、案例定位到优化验证的 Linux 性能工程体系。
版本背景:文中的主要实验环境来自 Ubuntu 18.04、Docker、Java 8、Nginx、PHP-FPM 等环境。很多原理至今仍然成立,但部分具体命令、内核接口和默认行为已经演进。例如现代 JDK 默认具备更完善的容器资源感知能力,现代发行版常使用 cgroup v2、nftables 后端以及更新的块 I/O 调度器。遇到版本差异时,应以当前内核、JDK 和发行版文档为准,不要机械复制旧环境参数。
1. 性能工程的核心:指标只是现象,机制才是因果
性能排障最容易陷入两个误区。
第一个误区是“看到什么高就优化什么”。CPU 高就扩 CPU,内存高就加内存,TIME_WAIT 多就调内核参数。这种方式偶尔有效,但很容易把真正的问题藏得更深。
第二个误区是“工具驱动”。手里有 top 就看 top,有 perf 就上 perf,会 eBPF 就什么都用 eBPF。性能工具应该由待验证的假设来选择,而不是反过来让工具决定分析路径。
更稳定的方式是:
flowchart LR
A[业务症状/监控告警] --> B[建立可量化指标]
B --> C[按系统/应用原理定位层次]
C --> D[用低成本工具验证假设]
D --> E{证据是否足够}
E -- 否 --> F[perf/ftrace/eBPF/抓包等深入追踪]
F --> D
E -- 是 --> G[确认瓶颈机制]
G --> H[最小范围优化]
H --> I[同基准回归验证]
I --> J[纳入持续监控]
整个流程有两个关键动作:
- 把当前状态与系统原理进行比较:哪里不符合正常机制,哪里就是优先排查对象。
- 每一步尽量缩小搜索空间:先确认“哪类资源”,再确认“哪个进程”,再确认“哪个函数/协议层/队列”,不要一上来就抓全量数据。
2. 监控先行:没有历史数据,偶发问题几乎无法复盘
很多性能故障具有瞬时性:高负载持续几十秒、连接跟踪表偶尔打满、某个依赖服务在特定时间抖动。等工程师登录服务器后,现场可能已经恢复。
因此,监控系统的价值并不只是“画图”,而是持续保留证据,使性能分析从“现场侦查”升级为“有时间线的取证”。
2.1 系统资源:USE 方法
USE(Utilization, Saturation, Errors)方法把一类资源的性能状态压缩为三个维度:
- Utilization(使用率):资源被实际使用的时间或容量比例。
- Saturation(饱和度):资源是否出现排队、等待或无法继续接收更多工作。
- Errors(错误数):资源处理失败、丢弃、异常等事件。
只看使用率很容易误判。例如 CPU 只使用了 30%,但任务可能长期受 cgroup 配额限制并处在调度等待状态;磁盘利用率未必 100%,但高尾延迟和长队列同样可以让业务卡住。
| 资源 | 使用率 | 饱和度 | 错误/异常 |
|---|---|---|---|
| CPU | user/system/总使用率 | run queue、load average、调度等待 | 硬件错误、异常中断 |
| 内存 | 已用/可用内存、Swap 占比 | 换页、回收压力、缺页异常 | 分配失败、OOM |
| 磁盘 I/O | 设备利用率、吞吐、IOPS | I/O 队列长度、延迟 | I/O 错误 |
| 文件系统 | 空间、inode 使用率 | 缓存回收压力 | 读写错误、只读挂载等 |
| 网络 | BPS/PPS、接口利用率 | 重传、队列、连接状态压力 | 丢包、网卡错误、协议错误 |
| 文件描述符 | 已使用数量 | 接近上限 | EMFILE/ENFILE 等 |
| 连接跟踪 | nf_conntrack_count / max |
接近表容量 | table full, dropping packet |
| TCP 连接 | 建连/活跃连接 | SYN 队列、accept 队列、TIME_WAIT | reset、retransmit、timeout |
USE 的作用不是代替所有指标,而是帮你快速确定“哪个资源最值得继续深挖”。系统日志、进程明细、缓存统计、协议扩展统计等依然应该保留,只是它们通常属于第二层证据。
2.2 应用与微服务:RED 方法
应用程序的核心问题不是“CPU 有没有吃满”,而是用户看到的服务质量。因此应用层更适合 RED:
- Rate:请求速率,例如 QPS/RPS。
- Errors:失败请求数或错误率。
- Duration:请求处理时延,尤其要关注 P95/P99 等尾延迟。
CPU 很低并不能证明应用正常。锁竞争、RPC 卡顿、数据库慢查询、线程池排队、下游超时,都可能让 CPU 很空闲但接口很慢。
在 RED 之外,应用至少还应暴露三类补充指标:
- 进程本身的 CPU、内存、磁盘 I/O、网络使用;
- 对依赖服务的调用次数、失败率、时延;
- 应用内部关键阶段的耗时、队列长度、异常数和业务状态。
2.3 指标、日志、链路追踪是互补关系
只靠 Metrics 很容易知道“哪里红了”,但不一定知道“为什么红”。日志提供上下文,分布式追踪提供跨组件的因果路径。
flowchart TD
M[Metrics 指标] --> A[发现异常与趋势]
T[Tracing 链路追踪] --> B[定位跨服务瓶颈节点]
L[Logs 日志] --> C[还原参数/异常/业务上下文]
A --> D[性能根因]
B --> D
C --> D
一个微服务请求延迟异常,可以先从 RED 指标确认哪条接口和哪个时间段出现问题,再用 Jaeger、Zipkin、Pinpoint 等追踪系统定位到某个下游调用,最后利用对应组件日志解释具体错误。
日志侧的经典架构是 ELK:
- Logstash:采集与预处理;
- Elasticsearch:索引和检索;
- Kibana:查询与可视化。
当日志采集端资源敏感时,可以使用更轻量的采集器形成 EFK 等组合。核心思想不变:指标负责度量,日志负责上下文,追踪负责跨组件因果关系。
2.4 Prometheus 监控架构
一个完整监控系统至少包含采集、存储、查询处理、告警和可视化。
flowchart LR
T[Targets / Exporters] -->|Pull| P[Prometheus Retrieval]
SD[Service Discovery] --> P
J[Short-lived Jobs] --> PG[Pushgateway]
PG --> P
P --> TSDB[TSDB]
TSDB --> Q[PromQL]
Q --> AM[Alertmanager]
Q --> G[Grafana / Web UI]
Prometheus 的关键点包括:
- 默认以 Pull 模式从 HTTP 指标端点抓取数据;
- 服务发现可以自动适配动态实例,尤其适合容器和 Kubernetes;
- TSDB 保存时间序列;
- PromQL 负责查询、过滤、聚合;
- Alertmanager 负责分组、抑制、静默和通知;
- Grafana 负责构建面向排障的仪表板。
Pushgateway不应被理解为通用 Push 替代方案。工程上它更适合无法被稳定 scrape 的短生命周期批任务,长期服务仍应优先暴露可拉取的指标端点。
3. 一套通用的性能问题分析步骤
当监控告警已经告诉你“服务变慢”或“某资源异常”,可以把问题分成两个视角。
3.1 系统资源视角
最常见的是 CPU、内存、磁盘/文件系统 I/O、网络,以及连接跟踪、文件描述符、套接字队列等内核软件资源。
分析顺序不是固定命令表,而是从指标逐层缩小:
flowchart LR
A[系统告警] --> B{资源类别}
B --> C[CPU]
B --> D[内存]
B --> E[磁盘/文件系统]
B --> F[网络/内核资源]
C --> C1[系统还是进程?]
D --> D1[不足/泄漏/Swap/缓存?]
E --> E1[设备还是进程/文件系统?]
F --> F1[接口/协议/队列/连接跟踪?]
C1 --> G[定位进程/函数]
D1 --> G
E1 --> G
F1 --> G
3.2 应用视角
应用性能问题通常最终落在三类原因:
- 资源瓶颈:应用把 CPU、内存、I/O、网络或内核资源打满。
- 依赖服务瓶颈:数据库、缓存、中间件、RPC 下游慢或失败。
- 应用自身瓶颈:算法复杂度、锁竞争、线程模型、队列、错误重试、系统调用模式等。
这三类并非互斥。例如应用创建海量短连接,本质上是应用行为,但最终可能表现为 TIME_WAIT、临时端口耗尽、conntrack 表满和系统 CPU 升高。
4. CPU:从 top 到热点函数
CPU 性能分析要先理解几个不同概念:
- user CPU:用户态执行时间;
- system CPU:内核态执行时间;
- iowait:CPU 等待 I/O 的时间表现;
- softirq/hardirq:软中断和硬中断;
- steal:虚拟化环境中被宿主机抢占的时间;
- load average:可运行和不可中断任务的综合压力,不等于 CPU 使用率。
4.1 常见分析链
1 | |
如果 system CPU 或 softirq 异常高,不要只盯用户进程。大量网络收包、Netfilter、文件系统、块设备、驱动等工作都可能消耗内核 CPU。
5. 内核线程 CPU 高:为什么普通进程工具不够用
Linux 启动后有几个特殊进程:
- PID 0:idle/空闲任务;
- PID 1:init/systemd,管理用户态进程;
- PID 2:
kthreadd,管理内核线程。
常见内核线程包括:
| 内核线程 | 作用 |
|---|---|
ksoftirqd/N |
处理软中断,每个 CPU 通常都有 |
kswapd |
内存回收 |
kworker/* |
内核工作队列 |
migration/N |
调度迁移和负载均衡 |
jbd2/* |
ext4 等文件系统日志相关处理 |
pdflush |
老内核中的脏页回写线程,后续已整合进工作队列机制 |
可以从 PID 2 的子孙快速找到它们:
1 | |
5.1 ksoftirqd 高 CPU 案例
用 hping3 高频发送 TCP SYN:
1 | |
服务端 top 可能看到:
- 两个 CPU 的
si超过 30%; ksoftirqd/0、ksoftirqd/1出现在高 CPU 进程顶部。
普通的 pstack 对内核线程并不好用,/proc/<pid>/stack 也可能只展示到 kthread 框架层。此时 perf 更合适:
1 | |
典型调用链可以出现:
1 | |
这些符号提供了强烈的机制线索:
net_rx_action/netif_receive_skb:网络收包路径;br_*:Linux bridge;nf_*:Netfilter;PRE_ROUTING:数据包在路由前经过的 Netfilter 钩子,容器端口映射/NAT 可能参与其中。
当 Docker 创建 veth、接入 docker0、配置 NAT 后,网络包比裸机进程多经过桥接和 Netfilter 路径,因此在高 PPS 场景下会增加软中断和内核协议处理开销。
6. 火焰图:把调用栈变成可读的热点地图
perf report 可以展开调用栈,但调用层级较深时不够直观。FlameGraph 把采样调用栈转换为 SVG。
生成流程通常是:
1 | |
理解火焰图要记住四点:
- 横轴宽度代表采样比例,越宽说明在采样数据中占比越大;
- 纵轴表示调用栈深度,下层调用上层;
- 同一层左右位置通常不是执行时间顺序,不要把横轴当时间轴;
- 普通火焰图中的颜色通常只是区分函数,不代表“越红越慢”。
常见火焰图类型:
| 类型 | 数据关注点 | 适用问题 |
|---|---|---|
| On-CPU | 正在消耗 CPU 的调用栈 | CPU 高、热点函数 |
| Off-CPU | 阻塞/等待时的调用栈 | 锁、I/O、调度等待 |
| 内存火焰图 | 分配、释放、换页等事件 | 内存分配热点、缺页 |
| Hot/Cold | On-CPU + Off-CPU | 同时观察运行与等待 |
| 差分火焰图 | 两组 profile 的差异 | 优化前后、版本对比 |
CPU 火焰图与内存火焰图的格式没有本质区别,真正不同的是采样事件和输入调用栈。例如:
1 | |
7. 动态追踪:当采样还不能解释“为什么”
性能采样擅长回答“时间主要花在哪”,动态追踪进一步回答“某个事件发生时到底带了什么参数、经过什么调用、发生了多少次”。
动态追踪的关键思想是:在不修改业务代码、不停止服务的情况下,把处理逻辑挂到探针上。
7.1 动态追踪的事件源
事件源大体可以分成三类。
静态探针
编译时已经存在:
- tracepoints:Linux 内核预定义跟踪点;
- USDT(Userland Statically Defined Tracing):应用程序预埋用户态探针。
动态探针
运行时按需挂载:
- kprobe / kretprobe:内核函数进入和返回;
- uprobe / uretprobe:用户态函数进入和返回。
硬件事件
由 PMC(Performance Monitoring Counter)提供,例如:
- CPU cycles;
- instructions;
- cache miss;
- branch miss。
7.2 常见动态追踪机制
flowchart TD
E[事件源] --> TP[Tracepoints / USDT]
E --> KP[kprobes / uprobes]
E --> PMC[PMCs]
TP --> F[ftrace]
TP --> P[perf]
KP --> F
KP --> P
KP --> B[eBPF/BCC]
PMC --> P
TP --> B
KP --> S[SystemTap]
历史上 DTrace 建立了非常完整的动态追踪模型;Linux 生态后来形成了 ftrace、perf、SystemTap、eBPF/BCC 等方案。
8. ftrace:已知内核函数时,查看精确调用过程
ftrace 很适合这样的场景:你已经通过 perf 知道某个内核函数可疑,希望进一步查看它具体调用了谁、每层耗时如何。
ftrace 通常通过 tracefs/debugfs 暴露接口:
1 | |
以跟踪某个 open 路径相关内核函数为例,传统方式大致包括:
1 | |
function_graph 输出会包含 CPU、任务/PID、函数耗时和嵌套调用关系。
直接操作 tracefs 步骤较多,可以用 trace-cmd 封装:
1 | |
内核函数名称会随版本变化。文中示例的
do_sys_open在新内核中可能已经被拆分或替换。实际操作前应先在available_filter_functions、BTF、符号表或当前内核源码中确认。
9. perf:从采样器升级为事件追踪器
perf 不只会 record CPU。它还可以处理硬件事件、tracepoint,以及通过 perf probe 动态添加 kprobe/uprobe。
9.1 动态添加内核函数探针
1 | |
如果需要函数参数,可以先查询调试符号:
1 | |
假设要读取 filename:
1 | |
9.2 strace 与 perf trace
strace 基于 ptrace,非常方便,但对性能敏感进程可能产生明显扰动。大量系统调用会带来频繁跟踪开销,跟踪机制还可能影响目标进程的调度行为。
当目标是低侵入地观察系统调用时,可以考虑:
1 | |
这并不意味着 strace 不能用,而是要意识到观察者本身会改变系统。越是高 QPS、低延迟场景,越需要关注工具开销。
9.3 用户态 uprobe
对带符号信息的用户态二进制,也可以追踪函数进入/返回。例如对 Bash 的 readline:
1 | |
如果 perf probe 提示没有 debug information,通常有两种解决方式:
- 安装应用对应的 debuginfo/dbgsym 包;
- 使用源码重新编译,并启用
-g等调试信息。
10. eBPF/BCC:把“观察”变成可编程的数据处理
ftrace 和 perf 很强,但在“按条件过滤、聚合直方图、关联事件、维护状态”方面不够自由。eBPF 允许受验证的 BPF 程序在内核中执行,再通过 maps、ring/perf buffer 等机制与用户态交换数据。
从概念上可以简化为三步:
flowchart LR
A[用户态跟踪程序] --> B[编译为 BPF 字节码]
B --> C[Verifier 校验并加载到内核]
C --> D[kprobe/uprobe/tracepoint 等事件]
D --> E[Maps / Buffer]
E --> F[用户态聚合与展示]
BCC 把编译、加载、事件绑定、数据读取等通用部分封装起来,使脚本开发更容易。一个典型结构是:
1 | |
这里最重要的不是背代码,而是理解 eBPF 的能力边界:它适合做事件级、可编程、低侵入的观测,但开发者需要理解内核函数参数、数据结构、事件模型以及 eBPF 本身的限制。
BCC 自带大量现成工具,通常应该先复用,再考虑自己写。常见方向包括:
execsnoop:进程创建;opensnoop:文件打开;biolatency、biosnoop:块 I/O;tcpconnect、tcpaccept、tcpretrans:TCP;runqlat:调度延迟;offcputime:Off-CPU 分析;oomkill:OOM 事件。
11. 动态追踪工具怎么选
不要追求“最先进”,要选择能以最低风险验证假设的工具。
| 场景 | 优先工具 | 原因 |
|---|---|---|
| CPU 热点、调用栈概览 | perf + FlameGraph |
通用、成本低、适合先找方向 |
| 已知内核函数,想看调用过程 | ftrace / trace-cmd | 函数图直观 |
| 系统调用快速观察 | perf trace / strace |
使用简单;性能敏感场景优先低侵入方案 |
| 需要参数过滤、统计分布、事件关联 | eBPF/BCC | 可编程性最强 |
| 旧 RHEL/旧内核环境 | SystemTap | 历史上对旧内核支持更成熟 |
| 容器行为综合观察 | sysdig 等容器观测工具 | 将系统调用、网络、进程、容器元数据关联 |
一个非常实用的组合是:
1 | |
12. 容器化为什么会让应用“突然变慢”
容器不是虚拟机,它共享宿主机内核,但通过 namespaces 隔离视图,通过 cgroups 限制资源。性能问题因此多了一层:应用看到的世界与调度器真正允许它使用的资源,可能并不完全一致。
12.1 案例:Java 容器 OOM
案例容器限制为 512 MiB,并给 Java 应用分配一块约 256 MiB 的对象:
1 | |
现象是:Tomcat 最终启动并短暂响应一次请求,随后容器退出。
先确认容器状态:
1 | |
关键状态:
1 | |
再看内核日志:
1 | |
可以发现 memory cgroup 已达到 512 MiB 限制,Java 进程因 cgroup OOM 被杀。
12.2 为什么限制 512 MiB,JVM 却可能规划出更大的堆
在旧 JDK/旧容器环境中,JVM 可能依据宿主机物理内存计算默认堆大小,而不是依据容器的 cgroup 限制。
1 | |
案例中 JVM 看到的最大堆接近 2 GiB,而容器真实内存限制只有 512 MiB。
资料中的演示通过显式 -Xms/-Xmx 解决:
1 | |
但生产环境不能简单理解成“容器 512 MiB,Xmx 就设置 512 MiB”。JVM 还需要 Metaspace、线程栈、JIT Code Cache、Direct Memory、JNI/native 内存等空间,进程和 cgroup 本身也存在其他开销。堆上限必须给非堆和 native memory 留出余量。
现代 JDK 已经具备更成熟的容器资源感知能力,但仍建议通过监控确认 heap、native memory 和 cgroup memory 的实际关系,而不是只相信默认值。
12.3 OOM 修复后为什么启动仍然要 22 秒
问题容器还设置了:
1 | |
即只允许大约 0.1 CPU 的配额。
全局 top 看起来 CPU 很空闲,但针对容器 Java 线程运行:
1 | |
可以看到线程 %CPU 不高,却出现非常高的 %wait。这说明不是“系统没 CPU”,而是线程受 cgroup CPU 配额节流,长期等待被调度。
把配额从 0.1 CPU 提升到 1 CPU 后,案例启动时间从约 22 秒下降到约 2 秒。
这说明性能分析必须区分:
- 宿主机是否有空闲 CPU;
- 容器是否被允许使用这些 CPU;
- 应用是否因 cgroup throttling 被迫等待。
12.4 为什么不能干脆取消所有资源限制
取消限制会让单个异常容器消耗整机资源,最终把其他服务一起拖垮。正确做法不是“不要限制”,而是:
- 用压测和生产监控估算资源需求;
- 给 CPU/内存设置合理 request/limit 或 cgroup 配额;
- 让运行时(JVM、线程池、缓存、worker 数)理解这份资源预算;
- 同时监控 throttling、OOM、工作队列和尾延迟。
13. 容器冷启动:不要只测一个总耗时
Serverless/FaaS 的冷启动可以拆成多个阶段:
flowchart LR
A[事件触发] --> B[资源调度]
B --> C[镜像拉取]
C --> D[网络配置]
D --> E[容器/沙箱启动]
E --> F[应用初始化]
F --> G[Ready]
总耗时只是结果。真正有用的是每个阶段的 duration。
可以通过链路追踪或阶段埋点记录:
- 调度器排队时间;
- 镜像是否命中本地缓存、拉取耗时;
- CNI/网络 namespace 配置耗时;
- runtime 创建容器耗时;
- JVM/语言运行时初始化耗时;
- 应用加载配置、建立连接池、JIT/类加载耗时。
不同阶段对应完全不同的优化方式:热点镜像缓存、网络资源预分配、预热容器池、应用延迟初始化等。冷启动性能分析的核心仍然是:先拆阶段,再找最大耗时,再深入其子流程。
14. 网络丢包:必须沿协议栈逐层排查
“丢包”不是一个单独位置的故障。包可能在物理链路、NIC ring buffer、qdisc、链路层、IP 层、TCP 层、Netfilter、socket buffer,甚至应用层被丢弃或超时。
flowchart TB
A[应用] --> B[Socket]
B --> C[TCP/UDP]
C --> D[IP]
D --> E[Netfilter / 路由]
E --> F[qdisc / Traffic Control]
F --> G[NIC / Ring Buffer]
G --> H[物理网络]
14.1 先确认是不是与真实协议一致的“丢包”
Nginx 使用 TCP,因此测试 TCP 80 端口时,hping3 比 ICMP ping 更贴近目标路径:
1 | |
案例出现 50% packet loss,RTT 从数毫秒波动到秒级,已经具有典型重传/超时特征。
14.2 链路层与 qdisc
进入容器后先看接口:
1 | |
关注:
- RX-ERR / TX-ERR;
- RX-DRP / TX-DRP;
- RX-OVR / TX-OVR;
- MTU。
随后检查 Traffic Control:
1 | |
案例发现 netem loss 30%,并已有 dropped 计数。这说明响应包被 qdisc 主动丢弃。
删除规则:
1 | |
但再次测试仍有丢包,这恰好说明一个重要原则:找到一个问题,不代表已经找到全部问题。
14.3 网络层与传输层累计统计
1 | |
重点寻找:
- failed connection attempts;
- segments retransmitted;
- TCPTimeouts;
- TCPSynRetrans;
- resets received for embryonic SYN_RECV sockets。
案例中大量失败集中在三次握手和 SYN 重传,说明还需要继续检查 Netfilter、连接跟踪等路径。
netstat -s是自系统启动以来的累计值。线上排障应比较故障窗口前后的增量,或直接在监控系统中记录 counter rate,而不是只看一个绝对累计值。
14.4 连接跟踪表
Docker/NAT 环境尤其要检查 conntrack:
1 | |
如果 count 接近 max,并且 dmesg 出现:
1 | |
就已经有非常强的根因证据。
14.5 iptables/Netfilter 规则
对丢包问题,DROP、REJECT 规则的包计数比规则文本本身更重要:
1 | |
案例中 INPUT 和 OUTPUT 都存在随机 30% DROP 规则,而且命中计数持续增长。删除这些规则后,TCP SYN 测试变为 0% 丢包。
现代发行版可能通过 nftables 兼容层实现 iptables 命令;排障时要确认实际规则后端。
14.6 SYN 正常,不等于 HTTP 正常
删除 DROP 后,hping3 访问 80 端口完全正常,但:
1 | |
仍然超时。
这时抓包:
1 | |
可以看到 TCP 三次握手完成,但没有看到预期的 HTTP GET 数据,3 秒后客户端发送 FIN。
再次检查接口统计,发现 RX-DRP 持续增加,同时接口 MTU 被设置成了 100。SYN 包很小可以通过,而携带 HTTP 数据的包更大,因此行为出现差异。
恢复 MTU:
1 | |
此后 HTTP 正常。
这个案例说明:
- TCP 端口可达 != 应用请求可用;
- 小包正常 != 大包正常;
- 同一故障可以叠加多个独立根因;
- 抓包的价值不是“看有没有包”,而是把协议应有的交互与实际捕获到的交互逐帧比较。
15. 吞吐量断崖式下降:一个完整的多瓶颈案例
另一个案例使用 Nginx + PHP-FPM,通过 wrk 并发 1000 连接压测。起始吞吐量只有约 189 req/s,且几乎全部是非 2xx/3xx 响应。
这个案例很有价值,因为它不是一个根因,而是连续暴露了多个瓶颈。
15.1 第一步:先看连接状态,不要先猜 CPU
1 | |
1000 并发下,Established 只有个位数,而 TIME_WAIT/closed 却超过千级。
再看内核日志:
1 | |
出现:
1 | |
查询发现:
1 | |
测试环境故意把最大连接跟踪数设成了 200,并已全部用完。提升上限后,吞吐量从约 189 提升到 5382 req/s。
这里的
1048576是案例调试值,不是“生产通用最佳值”。conntrack 上限增加会消耗更多内存,应该根据连接规模、内存预算和哈希表行为评估。
15.2 第二步:吞吐量高了,但 499 仍然很多
查看 Nginx 日志:
1 | |
大量状态码 499。Nginx 的 499 表示客户端在服务器响应完成前主动断开,通常意味着客户端超时而服务端处理太慢。
继续看 PHP-FPM 日志:
1 | |
出现:
1 | |
说明 PHP-FPM 最大 worker 数只有 5,在高并发下形成应用级排队。增加 worker 后成功请求数量明显提升。
这里体现了系统性能与应用性能的联动:conntrack 是内核资源,max_children 是应用工作模型,两者都会把同一个 RPS 指标压低。
15.3 第三步:监听队列溢出
再次压测并比较协议统计增量:
1 | |
可以看到:
1 | |
查看监听队列:
1 | |
案例中 Nginx 和 PHP-FPM 的 backlog 只有 10,系统 somaxconn 也只有 10。
1 | |
监听队列的实际能力受应用传给 listen() 的 backlog 与内核上限共同影响。只调应用、不调内核,或者只调内核、不调应用,都可能没有效果。
15.4 第四步:临时端口范围只有 50 个
Nginx 作为 FastCGI 客户端连接本机 PHP-FPM,需要临时端口。错误日志出现:
1 | |
查询:
1 | |
案例故意配置为:
1 | |
只有 50 个临时端口,显然无法支撑大量短连接。扩大范围后,地址不可用问题明显缓解。
15.5 第五步:参数调大后,CPU 反而成了新瓶颈
前面消除了 conntrack、worker、backlog 和端口范围限制后,系统终于能处理更多请求,结果 system CPU 接近 50%,CPU idle 只剩约 2%。
这很正常:优化一个瓶颈,常常会把系统推向下一个瓶颈。
此时用 perf + 火焰图看到热点:
1 | |
inet_hash_connect() 与临时端口分配相关,__init_check_established() 需要检查候选端口是否可用。
再看:
1 | |
发现约 32768 个 TIME_WAIT。大量 TIME_WAIT 占用端口,内核为新连接寻找可用端口的成本上升。
案例继续检查:
1 | |
并在测试环境中开启可复用后,吞吐量恢复到约 5180 req/s,且不再有大规模 Non-2xx/3xx。
tcp_tw_reuse之类 TCP 参数具有明确的协议语义和版本差异,不能因为“案例有效”就无脑在生产打开。优化前应确认连接方向、内核版本、NAT 拓扑和实际 TIME_WAIT 来源。很多时候更根本的优化是减少不必要的短连接、启用连接池或长连接。
15.6 这个案例真正值得记住的不是参数,而是分析顺序
整个根因链可以画成:
flowchart TD
A[吞吐量 189 req/s + 大量失败] --> B[ss: estab 少 TIME_WAIT 多]
B --> C[dmesg: conntrack table full]
C --> D[扩大 conntrack 后 RPS 大增]
D --> E[Nginx 499]
E --> F[PHP-FPM max_children=5]
F --> G[成功请求上升]
G --> H[netstat: listen queue overflow]
H --> I[backlog/somaxconn 过小]
I --> J[connect EADDRNOTAVAIL]
J --> K[临时端口范围过小]
K --> L[CPU sys 高]
L --> M[perf/火焰图: inet_hash_connect]
M --> N[TIME_WAIT 大量占用]
N --> O[连接复用/连接模型优化]
性能调优不是“一次命中真凶”,而是不断消除当前最主要的限制,让下一个限制浮现出来。
16. 网络性能分析的分层方法
从 Linux 网络收发路径来看,可以建立如下检查清单。
16.1 链路层
关注:
- BPS/PPS;
- 网卡 RX/TX error/drop;
- Ring Buffer;
- qdisc;
- MTU;
- NIC offload;
- 硬/软中断分布。
常用工具:
1 | |
16.2 网络层
关注:
- 路由是否正确;
- MTU/分片;
- overlay 封装;
- Netfilter/NAT;
- conntrack。
1 | |
16.3 传输层
关注:
- Established/SYN_RECV/TIME_WAIT;
- 重传;
- listen/accept 队列;
- socket buffer;
- 临时端口;
- reset/timeout。
1 | |
16.4 应用层
关注:
- QPS/RPS;
- 错误率;
- 响应时间;
- DNS;
- HTTP/TLS;
- 连接池与超时;
- 下游依赖。
这也是为什么“网络问题”最后经常需要日志、链路追踪和应用指标一起分析。
17. 常用性能工具速查:从指标选工具
工具的正确使用方式是“我需要观察某个指标,所以选择工具”,不是“我会某个工具,所以到处用它”。
17.1 CPU
| 指标/目标 | 常用工具 | 备注 |
|---|---|---|
| 平均负载 | uptime、top、/proc/loadavg |
load 不等于 CPU 使用率 |
| 系统 CPU | top、vmstat、mpstat、sar、/proc/stat |
sar 适合历史数据 |
| 进程 CPU | top、ps、pidstat、htop、atop |
pidstat 适合按时间采样 |
| 系统上下文切换 | vmstat |
同时观察 runnable/uninterruptible |
| 进程上下文切换 | pidstat -w |
区分 voluntary/nonvoluntary |
| 软中断 | top、mpstat、/proc/softirqs |
网络高 PPS 常见 |
| 硬中断 | vmstat、/proc/interrupts |
可结合 IRQ affinity |
| CPU Cache/硬件事件 | perf stat |
cycles、instructions、cache-misses 等 |
| 热点/调用栈 | perf、FlameGraph |
先找大方向 |
| 动态追踪 | ftrace、BCC/eBPF、SystemTap | 已知热点后深入 |
17.2 内存
| 指标/目标 | 常用工具 |
|---|---|
| 系统已用/可用/剩余 | free、vmstat、sar、/proc/meminfo |
| 进程 RSS/VSS 等 | ps、top、pidstat -r、/proc/<pid>/status |
| 进程内存映射 | pmap、/proc/<pid>/maps、smaps |
| Swap 使用与换入换出 | free、vmstat、sar |
| 缺页异常 | pidstat -r、perf |
| Page Cache | free、vmstat、sar、cachestat |
| Cache 命中 | cachetop |
| slab | slabtop、/proc/slabinfo |
| 内存泄漏 | memleak、Valgrind 等 |
17.3 文件系统与磁盘 I/O
| 指标/目标 | 常用工具 |
|---|---|
| 文件系统容量 | df |
| inode | df -i |
| page/slab cache | /proc/meminfo、vmstat、sar、slabtop |
| 设备利用率/IOPS/吞吐/延迟/队列 | iostat -x、sar -d、dstat、/proc/diskstats |
| 进程 I/O | pidstat -d、iotop |
| 块 I/O 事件 | blktrace + blkparse |
| 系统调用 I/O | strace、perf trace |
| 块设备 I/O 动态追踪 | biosnoop、biotop |
| 内核 I/O 路径 | ftrace、BCC、SystemTap |
17.4 网络
| 指标/目标 | 常用工具 |
|---|---|
| BPS | sar、nethogs、iftop、/proc/net/dev |
| PPS | sar -n DEV、/proc/net/dev |
| 连接 | ss、netstat |
| 错误/重传/协议统计 | ss -s、netstat -s、sar -n EDEV/EIP |
| 延迟 | ping、hping3 |
| 连接跟踪 | conntrack、nf_conntrack_count/max |
| 路由路径 | mtr、traceroute、ip route |
| DNS | dig、nslookup |
| 防火墙/NAT | iptables/nft |
| 网卡特性 | ethtool |
| 抓包 | tcpdump、Wireshark |
| 动态追踪 | ftrace、BCC/eBPF、SystemTap |
17.5 基准测试工具
性能分析必须配合可重复基准,否则很难证明“优化真的有效”。
- 磁盘 I/O:
fio; - 网络带宽:
iperf; - 包处理:
pktgen等; - HTTP:
ab、wrk。
基准测试时必须固定:请求模型、并发度、数据集、预热时间、测试时长、CPU/内存限制、服务版本和网络拓扑。
18. 性能优化的一般方法
找到根因之后,优化才真正开始。这里的原则是:先消除无效工作,再提高资源利用效率,最后才考虑堆硬件或复杂架构。
18.1 CPU 优化
典型方向:
- 简化算法和代码路径,减少不必要计算;
- 合并系统调用、批处理工作;
- 通过 CPU affinity 改善缓存局部性;
- 平衡 IRQ/软中断到多核;
- 对不同任务设置优先级和 cgroup 配额;
- 避免过度线程化造成上下文切换和锁竞争。
“异步”并不天然降低 CPU 消耗。对于纯 CPU 密集型任务,错误的异步拆分反而可能增加调度与同步开销。异步更适合隐藏 I/O 等待、改善吞吐和解耦慢路径。
18.2 内存优化
典型方向:
- 修复泄漏与不受控缓存;
- 为服务设定合理 cgroup 内存预算;
- 控制 JVM/运行时堆、native memory 和线程数;
- 使用内存池、大页等减少高频分配和页表/缺页成本;
- 评估 Swap 策略,避免在低延迟服务中因频繁换页产生不可接受的尾延迟。
资料中的“无必要时关闭 Swap”来自低延迟性能场景。现代生产系统是否禁用 Swap,要结合内核、工作负载和容器编排策略判断,不能一刀切。
18.3 磁盘与文件系统 I/O 优化
典型方向:
- HDD 升级 SSD/NVMe,或通过 RAID 在可靠性/吞吐间权衡;
- 根据设备和工作负载选择合适 I/O scheduler;
- 调整预读、队列深度;
- 减少小而随机的 I/O,尽可能批量化;
- 合理利用 Page Cache;
- 调整 dirty page 回写策略;
- 将高干扰 I/O 工作负载隔离到独立设备。
旧环境常讨论 noop、deadline。现代多队列块层更常见的是 none、mq-deadline 等,具体以 /sys/block/<dev>/queue/scheduler 为准。
18.4 网络优化
内核资源层面可能涉及:
- socket buffer;
- SYN/accept backlog;
- conntrack 容量;
- 文件描述符;
- 本地临时端口范围;
- Keepalive、重传和超时参数;
- MTU;
- TIME_WAIT 与连接复用策略。
网卡层面可能涉及:
- GRO/GSO/TSO 等 offload;
- RSS/RPS/XPS 和多队列;
- IRQ affinity;
- Ring Buffer;
- 网卡队列长度。
极限网络场景下,还会考虑:
- DPDK:绕过传统内核网络栈,用户态轮询处理;
- XDP:在数据包进入完整协议栈前尽早处理。
但越靠近“极限优化”,维护成本越高。普通 Web 服务首先应该检查连接模型、队列、超时、反向代理和应用逻辑,别一上来就把 DPDK 当万能药。
18.5 应用程序通常才是最值得优化的位置
很多 system CPU 高,并不是“内核太慢”,而是应用做了过多系统调用;很多数据库 I/O 高,并不是“磁盘太慢”,而是 SQL、索引或数据模型不合理。
应用侧常见优化包括:
- 优化算法与数据结构;
- 缓存热点数据;
- 减少网络往返和磁盘访问;
- 使用连接池、长连接;
- I/O 多路复用;
- 合理的异步/多线程/多进程模型;
- 批量处理;
- 消息队列削峰;
- CDN、负载均衡和水平扩展。
当单机优化使系统越来越复杂时,应重新评估是否应该通过水平扩展解决问题,而不是执着于把一台机器压到极限。
19. 性能优化必须建立验证闭环
一次合格的优化至少要回答五个问题:
- 优化前的业务指标是多少?
- 当前证据能否明确证明瓶颈机制?
- 修改项是否只针对这个机制?
- 优化后相同基准下提升多少?
- 是否引入新的 CPU、内存、错误率或尾延迟副作用?
推荐保留这样的记录:
| 项目 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| RPS | 同一 wrk 参数 |
||
| P50/P95/P99 | 不只看平均值 | ||
| Error Rate | 业务错误 + Socket error | ||
| CPU user/sys/softirq | 区分 CPU 类型 | ||
| 内存/Swap | 包括 cgroup memory | ||
| TCP retrans/drop | 看增量 | ||
| conntrack 使用率 | count/max | ||
| listen queue overflow | counter rate |
没有这一步,所谓“优化”很容易只是把问题从 A 移到了 B。
20. 一份可落地的排障 Checklist
当线上出现“慢、抖、超时、吞吐下降”时,可以按下面顺序推进。
第一阶段:保留现场
1 | |
同时保存:应用 RED 指标、依赖调用、错误日志、Trace。
第二阶段:确定主导资源
- CPU:user/sys/softirq/iowait 哪个高?
- 内存:是真的不足,还是 cache、Swap、泄漏?
- I/O:设备忙还是某进程制造 I/O?
- 网络:带宽、PPS、丢包、重传、队列、conntrack 哪个异常?
- 应用:错误率、P99、依赖调用、工作队列是否异常?
第三阶段:定位主体
- 哪个进程/线程?
- 哪个容器/cgroup?
- 哪个 socket/端口?
- 哪个磁盘/文件?
- 哪个依赖服务?
第四阶段:进入机制层
- CPU 热点:
perf+ FlameGraph; - 精确内核函数:ftrace;
- 参数/事件关联:eBPF/BCC;
- 网络协议:
tcpdump+ Wireshark; - 系统调用:
perf trace/strace; - 内核符号不理解:查当前版本内核源码。
第五阶段:最小改动 + 回归
一次只改最重要的瓶颈因素,重新执行同样压测,并观察系统是否暴露新的限制。
21. 深入内核:看到陌生函数名怎么办
perf、ftrace、eBPF 最终经常会把你带到一个陌生内核函数,例如:
1 | |
不要凭函数名“脑补”。正确做法是查当前运行内核版本的源码:
- 先确认
uname -r; - 找到该版本源码;
- 搜索函数定义;
- 看调用者与被调用者;
- 结合数据结构、锁、分支条件和错误路径理解。
源码阅读能力是动态追踪的最终放大器。只会看到热点函数,但不知道函数在系统中的职责,就很难从“相关性”走到“因果关系”。
22. 最终方法论:把性能工具放回它应该在的位置
Linux 性能工程可以收敛成四层。
flowchart TB
A[业务层: Rate / Error / Duration] --> B[资源层: USE]
B --> C[系统机制层: 调度/内存/I/O/协议栈/cgroup]
C --> D[实现层: 系统调用/内核函数/应用函数]
D --> E[优化与验证]
E --> A
业务层告诉你用户是否真的受影响;资源层告诉你系统哪里出现压力;机制层解释为什么会出现这些指标;实现层让你落到具体函数、队列、规则和参数;最后通过基准与监控形成闭环。
真正值得长期积累的不是一组 sysctl 参数,而是下面几种能力:
- 能把业务症状翻译成可测量指标;
- 能从 Linux 原理判断指标之间的因果关系;
- 能根据指标选择成本最低的工具;
- 能从调用栈、抓包和内核统计中建立证据链;
- 能区分案例参数与生产最佳实践;
- 能在优化后继续寻找下一个瓶颈,而不是见到一次提升就结束;
- 能把排障过程中真正有价值的指标沉淀进监控系统。
性能优化从来不是“把所有参数调大”。它更像一个持续的工程循环:观察 → 假设 → 验证 → 优化 → 回归 → 再观察。只要这个循环建立起来,无论问题最终落在 JVM、容器、Linux 内核、网络协议还是应用代码,都能沿着同一套方法逐步逼近根因。