Linux运维 归档 - 编程·投资·科技 https://www.devlearn.club/posts/tag/linux运维 编程·投资·科技 — Linux运维与Python/C#编程实战教程,A股高股息红利策略深度分析。每日更新技术深度文章与投资复盘,助你提升技术实力与投资认知。 Fri, 17 Jul 2026 01:17:09 +0000 zh-Hans hourly 1 https://wordpress.org/?v=7.0.2 https://www.devlearn.club/wp-content/uploads/2020/04/cropped-icon-32x32.png Linux运维 归档 - 编程·投资·科技 https://www.devlearn.club/posts/tag/linux运维 32 32 Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断(2026) https://www.devlearn.club/posts/980 Mon, 13 Jul 2026 01:24:31 +0000 https://www.devlearn.club/posts/980 凌晨三点,PagerDuty 响了 你打…

Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断(2026)最先出现在编程·投资·科技

]]>
凌晨三点,PagerDuty 响了

你打开监控面板,发现核心 API 的 P99 延迟从平时的 45ms 飙升到了 3800ms。没有 CPU 飙升,没有内存泄漏,没有磁盘 IO 瓶颈——所有常规指标都是绿的。这大概率是一个网络层的问题。

网络故障是生产环境里最阴险的一类问题。它不会像 OOM 那样给你留一个 kill log,也不会像死锁那样在堆栈里摆个 trace。它只给你一堆「超时」「连接拒绝」「connection reset」——然后你的任务是逆向还原出到底发生了什么。

这篇文章记录我在过去两年里遇到的三个典型网络故障场景,以及每一层的排查思路和工具链。文末附了一张故障决策流程图,下次凌晨告警可以直接对着走。

网络排查的四层模型

我习惯把网络问题按 OSI 的粒度拆成四层来排查,一层一层往下刨:

典型问题 首选工具
连接层(TCP握手/队列) SYN丢包、backlog满、TIME_WAIT堆积 ss -s, netstat -an
传输层(TCP重传/窗口) 重传率过高、零窗口、乱序 ss -ti, nstat
解析层(DNS/ARP) DNS超时、ARP缓存异常 dig, strace -e trace=network
应用层(连接池/超时配置) 连接池耗尽、idle timeout不匹配 应用日志 + tcpdump

经验法则:从连接层开始,如果能在这里找到答案就不要往下挖。80% 的「网络问题」其实就停在 TCP 连接层面。

Linux 网络故障排查决策树
图:Linux 网络故障排查四层决策树 — 从连接层开始逐层下钻

场景一:TIME_WAIT 把连接池吃干抹净

某次上线后,服务开始间歇性报 Cannot assign requested address。监控显示同一时间 ESTABLISHED 连接数并没那么高,CPU 也只有 20%。

第一刀:看连接状态分布

$ ss -s
Total: 41280
TCP:   40120 (estab 1024, closed 0, orphaned 0, timewait 38896, synrecv 0)
Transport Total     IP        IPv6
RAW       0         0         0
UDP       4         2         2
TCP       40120     40116     4

看到没?38896 个 TIME_WAIT,只有 1024 个 ESTABLISHED。这已经不是在「用连接池」了,这是在用 TIME_WAIT 池。

根因分析

TIME_WAIT 是 TCP 主动关闭方在连接关闭后必须保持 2MSL(约60秒)的状态。如果服务频繁创建短连接——比如每次 HTTP 请求都是一个新的 TCP 连接——TIME_WAIT 会快速堆积。当可用端口被耗尽时,新连接就无法建立了。

这里的关键参数:

# 查看当前系统限制
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768    60999
# 可用端口 = 60999 - 32768 = 28231 个

2.8 万个端口,QPS 超过 470 就会在一个 2MSL 窗口内耗尽。这个服务的 QPS 刚好是 500——正好踩在边界上。

修复(按优先级排)

  1. 上连接池:改 HTTP 客户端为 keep-alive 长连接,QPS 500 只需要十几个连接就能扛住。这个才是治本。
  2. 开 tcp_tw_reusesysctl -w net.ipv4.tcp_tw_reuse=1,允许复用 TIME_WAIT 状态的连接给新的出站请求。
  3. 扩端口范围net.ipv4.ip_local_port_range = 10240 65535,但这是治标的创可贴。

💡 踩坑记录tcp_tw_recycle 在 Linux 4.12 之后已经被移除了。如果你在网上看到有人推荐开 net.ipv4.tcp_tw_recycle=1,那篇文章至少是 2017 年的。这东西在 NAT 环境下会随机丢掉合法连接——我就是那个花了半天排查「为什么只有部分用户连不上」的倒霉蛋。

场景二:TCP 重传率 18%,用户说「卡」

这个场景更隐蔽。服务没有报错,连接也都正常建立,但用户反馈「页面有时候卡一下,刷新就好了」。APM 显示 P50 延迟 60ms,但 P99 偶尔跳到 2 秒以上。

第一刀:看 TCP 统计

$ nstat -az | grep -E 'TcpRetrans|TcpExtTCPTimeouts'
TcpRetransSegs              3847291          # TCP重传段数
TcpExtTCPTimeouts            128403          # TCP超时次数

重传率怎么算?拿 TcpRetransSegs 除以 TcpOutSegs

$ nstat -az | grep TcpOutSegs
TcpOutSegs                  21038412
# 重传率 = 3847291 / 21038412 ≈ 18.3%

18% 的重传率。正常的应该是 < 0.1%。 这已经不是偶发丢包了,是链路出问题了。

第二刀:用 ss -ti 定位问题连接

$ ss -ti 'dst 10.0.1.50'
State  Recv-Q Send-Q  Local Address:Port   Peer Address:Port
ESTAB  0      287456   10.0.2.10:34126     10.0.1.50:3306
     cubic wscale:7,7 rto:264 rtt:87.5/16.2 ato:40 
     retrans:0/7 lost:0 sacked:12 reordering:9
     pmtu:1500 rcvmss:1448 advmss:1448 cwnd:10
     bytes_acked:4127841 bytes_received:139211
     segs_out:3847 segs_in:2278
     lastsnd:352 lastrcv:352 lastack:348
     pacing_rate 8.8Mbps delivery_rate 194.7Kbps
     busy:80ms rcv_space:14480 rcv_ssthresh:64088
     minrtt:83.2

几个关键指标:

  • cwnd:10 — 拥塞窗口只有 10 个 MSS,正常应该是几十上百。说明 TCP 自己检测到了拥塞在主动降速。
  • rtt:87.5ms — 对于同机房来说高得不正常(应该是 <1ms)。跨机房或跨地域延迟。
  • delivery_rate:194.7Kbps — 实际吞吐只有 24KB/s,基本废了。

第三刀:tcpdump 抓包确认

$ tcpdump -i eth0 host 10.0.1.50 and port 3306 -w /tmp/mysql.pcap -c 5000

然后用 Wireshark 打开(或者在服务器上用 tcpdump -r 直接看),Statistics → TCP Stream Graphs → Time-Sequence (Stevens)。如果看到锯齿状的序列号曲线——确认是丢包导致的重传。

根因最后发现是负载均衡器上的一个健康检查配置问题:health check 间隔设成了 100ms,每台后端机器每秒被打了 10 个 SYN,但代理没开 TCP fast open,大量 SYN 在 LB 的 conntrack 表里排队——间接导致正常流量的数据包被丢。

🔥 这个 bug 我排查了整整一天。问题出在 conntrack 表满和 LB health check 的相互作用上,单独看哪个环节都正常,组合在一起就互相伤害。网络层的排障就是这样——你永远在跟「间接原因」打交道。

场景三:DNS 解析 5 秒超时,拖垮整个服务

有一次,一个 Python 微服务突然开始大量超时。日志全是 socket.timeout: timed out,但下游服务明明健康。CPU/内存/磁盘全绿灯。

第一刀:strace 看系统调用在干嘛

$ strace -e trace=network -p 28471 -T
connect(7, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.1.100")}, 16) = -1 ETIMEDOUT <5.001242>

看到了吗?connect 到 DNS 服务器 10.0.1.100:53 花了 5 秒然后超时。但 getaddrinfo 调用在任何日志里都没有出现——因为 Python 的 socket.create_connection() 在底层做了 DNS 解析,业务代码看不到。

第二刀:dig 验证 DNS 服务器状态

$ dig @10.0.1.100 google.com +time=2
;; connection timed out; no servers could be reached

DNS 服务器挂了。但为什么监控没发现?因为健康检查用的是 ping,DNS 进程挂了但主机还活着。

修复

改 /etc/resolv.conf 加备 DNS:

nameserver 10.0.1.100
nameserver 10.0.1.101     # 新增备DNS
options timeout:1 attempts:2 rotate

timeout:1 把 DNS 超时从默认的 5 秒降到 1 秒,rotate 让请求在主备间轮询而不是全压在主 DNS 上。

📌 教训:监控 DNS 服务器不要只 ping。用 dig +short @dns_server your_domain 做应用层健康检查,模拟真实解析请求。

实战复盘:MySQL 连接超时 → 全链路排查

最后分享一个完整的排查链路。某天下午,订单服务开始报 MySQL server has gone away,频率从每小时几次涨到每分钟几十次。

Step 1:应用日志

pymysql.err.OperationalError: (2006, "MySQL server has gone away 
(BrokenPipeError(32, 'Broken pipe'))")

Broken pipe = 连接在应用层被 RST 了。

Step 2:MySQL 端 slow log

查 MySQL slow query log,没有异常查询——所有请求都在 50ms 内返回。说明不是查询慢。

Step 3:网络层抓包

$ tcpdump -i eth0 port 3306 -w /tmp/mysql.pcap -c 2000

Wireshark 打开,Filter: tcp.flags.reset 1,看到了大量 RST 包。RST 的源 IP 不是 MySQL 服务器,也不是应用服务器——是中间的一台 F5 负载均衡器。

Step 4:F5 配置

F5 的 TCP profile 里 idle timeout 设的是 300 秒。应用端的连接池配置:

# Python DB pool 配置
pool_size = 20
max_overflow = 10
pool_recycle = 600   # 600秒后回收连接

找到了。连接池 600 秒回收连接,但 F5 300 秒就断开空闲连接。300-600 秒之间的连接,应用以为还活着,F5 已经把它杀了。应用发请求→ F5 回 RST → Broken pipe

修复

pool_recycle = 240(小于 F5 的 300 秒 idle timeout),确保应用在 F5 断连接之前主动回收。

这个问题的诊断链:业务报错 → MySQL slow log(排除查询慢)→ tcpdump(定位 RST 来源)→ 中间件配置(找到根因)。单独看任何一个环节都找不到答案。

快速排查速查表

症状 第一刀命令 看什么
连接拒绝 ss -tlnp | grep PORT 端口有没有在监听?backlog 满没?
间歇超时 ss -ti retrans、cwnd、rtt 是否异常
连接池耗尽 ss -s TIME_WAIT / ESTABLISHED 比例
响应慢但无报错 nstat -az | grep TcpRetrans 重传率是否 > 1%
偶尔 connection reset tcpdump -i any -w /tmp/dump.pcap 谁发的 RST?
DNS 相关超时 strace -e trace=network -p PID connect 到 53 端口的耗时

常见问题

Q: TIME_WAIT 多少算多?

没有固定阈值,取决于可用端口数。简单计算:可用端口数 / 60秒(2MSL) = 安全 QPS 上限。如果你的服务 QPS 接近这个值,就该上连接池或调内核参数了。生产环境 TIME_WAIT 数上万很正常,不用恐慌,先看比例。

Q: tcpdump 在生产环境跑安全吗?

-c 5000 限制抓包数量,不用 -w /tmp/(避免塞满磁盘),不要跑 -vvv(verbose 输出本身就是 CPU 开销)。高流量场景用 host IP and port PORT 精确过滤。另外别忘了跑完 kill 掉。

Q: conntrack 表满怎么看?

cat /proc/sys/net/netfilter/nf_conntrack_count 对比 /proc/sys/net/netfilter/nf_conntrack_max。接近上限时内核日志会出现 nf_conntrack: table full, dropping packet。生产环境建议把 conntrack_max 调到 262144 以上。

相关阅读: strace生产环境调试完全指南:从原理到实战,不重启不改代码就能定位疑难问题(2026)  |  eBPF + bpftrace 生产环境调试实战:不用改代码、不用重启,一行命令定位线上问题(2026)  |  Linux生产环境CPU 100%问题排查实战:从发现到定位的完整复盘(2026)

总结

网络排障的核心能力不是背命令,是建立分层排查的心智模型

  • 先看连接状态(ss -s)—— 连接都建立不了就往下查是浪费时间
  • 再看传输质量(ss -ti, nstat)—— 重传率和拥塞窗口比你想象的更能说明问题
  • 必要时抓包(tcpdump)—— 这是终极武器,但也是最后手段
  • 别忘了中间设备(LB、F5、防火墙、conntrack)—— 很多时候「网络问题」根本不在两台通信的主机上

下次凌晨告警,先 ss -s,再 ss -ti,然后决定要不要掏 tcpdump。你会发现大部分问题停在第一步就找到了。


本文涉及的生产环境案例均来自个人工作经历,已脱敏处理。工具版本基于 Linux 5.15+ / tcpdump 4.99+。

Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断(2026)最先出现在编程·投资·科技

]]>
生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断(2026) https://www.devlearn.club/posts/964 Fri, 10 Jul 2026 01:28:38 +0000 https://www.devlearn.club/posts/964 凌晨三点,告警炸了 2026年6月的某个…

生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断(2026)最先出现在编程·投资·科技

]]>
凌晨三点,告警炸了

2026年6月的某个周三凌晨,我被 PagerDuty 的连环电话炸醒。打开监控面板一看,Django 服务的三个 Pod 全红了——CrashLoopBackOff。kubectl describe pod 最后一行写着:

State:          Waiting
  Reason:       CrashLoopBackOff
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137

Exit code 137 = 128 + 9(SIGKILL)。被 Linux 内核的 OOM Killer 干掉了。

那天晚上我花了两个小时排查根因,修完之后又花了两天写复盘文档。这篇文章就是我把它整理成一份可复用的排查手册——从原理到诊断工具,再到具体修复策略,一步步来。

OOM Killer 到底是个什么东西?

简单说:Linux 内核里住着一个”刽子手”。当系统内存(包括 swap)都被榨干了,它就得挑一个最”该死”的进程杀掉,释放内存让系统活下去。

选谁杀?不是随机的。内核给每个进程打了一个 OOM Score(0~1000),分数越高越危险。计算公式大概长这样:

oom_score = (进程内存占用 / 总内存) * 1000 + oom_score_adj

但实际远比这个复杂。内核还会考虑:

  • 子进程的内存也算在父进程头上——你 fork 了一堆 worker,它们的 RSS 全加到你身上
  • root 进程默认有 -30 的 oom_score_adj——超级保护
  • cgroup 里的进程独立计分——容器场景下尤其重要,后面会细说

你可以直接看自己的 OOM Score:

$ cat /proc/self/oom_score
12
$ cat /proc/self/oom_score_adj
0

oom_score 是实时计算的、只读的。如果你想让某个进程被”豁免”,改 oom_score_adj 就行(-1000 到 1000,负数=保护,正数=优先被宰)。Docker 给每个容器默认设了 -1000,意味着容器整体作为一个 cgroup 被评估,而不是容器内部单个进程——这个细节很多人忽略,恰恰是排查的关键。

诊断工具箱:出事了先查这四样

1. dmesg — OOM Killer 的”行刑记录”

内核每次执行 OOM Kill,都会在 ring buffer 里留一份详细日志。直接看:

$ dmesg -T | grep -i "killed process" | tail -5
[Wed Jun 10 03:17:42 2026] Killed process 28471 (celery) total-vm:1856432kB, anon-rss:873456kB, file-rss:12456kB, shmem-rss:0kB
[Wed Jun 10 03:17:42 2026] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=...,mems_allowed=0,oom_memcg=/kubepods/burstable/pod...,task_memcg=/kubepods/burstable/pod...,task=celery,pid=28471,uid=1000

注意这里的 CONSTRAINT_MEMCG——说明不是系统整体 OOM,而是 cgroup 级别的 OOM。容器场景下最常见的类型。

2. /var/log/kern.log — 完整行刑报告

$ grep -A 30 "invoked oom-killer" /var/log/kern.log | tail -40

这份报告里有所有关键信息:

  • 谁触发的:哪个 cgroup 先超限
  • 内存快照:触发时 total-vm、anon-rss、file-rss 各是多少
  • 候选名单:每个进程的 oom_score 和 oom_score_adj
  • 最终判谁死刑:被杀进程的名字、PID、内存占用

3. cgroup memory.stat — 容器内存全景

在 Kubernetes 环境下,每个 Pod 有自己的 cgroup。直接查:

# 找到 Pod 的 cgroup 路径
$ kubectl exec <pod> -- cat /sys/fs/cgroup/memory/memory.stat

# 或者在宿主机上(需要 root)
$ cat /sys/fs/cgroup/memory/kubepods/burstable/pod<UID>/memory.stat

关键字段:

cache 524288000           # 页缓存(文件IO缓存,可回收)
rss 943718400             # 常驻内存(不能被回收的)
rss_huge 0
shmem 104857600           # 共享内存
mapped_file 209715200     # 文件映射内存
swap 0                    # swap 使用(容器一般没 swap)

看到 rss 接近 limit 但 cache 很大?说明文件 IO 导致内存”虚高”——cache 在压力下是可以回收的,但内核有时不够积极。

4. smem — 看清每个进程到底吃了多少

topps 看的是 RSS,但 RSS 会把共享库重复计算。真实内存占用要看 PSS(Proportional Set Size):

$ smem -tk -s pss | head -20
  PID User     Command                   Swap      USS      PSS      RSS
28471 app      celery -A tasks worker        0   523456   540123   873456
28102 app      gunicorn: master              0   102400   112345   256789
...
  • USS:进程独占内存——这个进程挂了就能回收的
  • PSS:按共享比例分摊后的内存——最接近”真实占用”
  • RSS:包括共享库的完整常驻内存——高估了

举个例子:3 个 Celery worker,每个加载同一个 200MB 的 ML 模型,RSS 各显示 +200MB(总共 600MB),但 PSS 只算 200MB / 3 ≈ 67MB 每人。OOM 排查时如果你只看 RSS,会严重高估。

OOM Killer诊断:容器内存增长趋势图与进程OOM Score排序对比

实战复盘:Django + Celery,谁在暗中吃内存?

回到那个凌晨。我查了 dmesg,发现被杀的是 Celery worker 主进程。但问题是:这个 Pod 配了 1Gi 内存 limit,平时 RSS 也就 500MB 左右,怎么就 OOM 了?

我用下面这个脚本在 Pod 里连续采集了 5 分钟的快照(事后加的,但问题已经复现了):

#!/bin/bash
# 每 5 秒采集一次进程内存快照
while true; do
  echo "=== $(date +%H:%M:%S) ==="
  ps -eo pid,comm,rss,vsz --sort=-rss | head -10
  cat /sys/fs/cgroup/memory/memory.usage_in_bytes
  echo "---"
  sleep 5
done

数据出来后画了张图(就是上面那张),一眼就看出来了:

  1. Django worker(gunicorn):RSS 从 200MB 稳步涨到 450MB,斜率稳定——典型的慢泄漏
  2. Celery worker:前 30 小时正常(300~400MB),然后加速飙升,最后 6 小时从 420MB 冲到 780MB

Celery 的加速泄漏是导致 OOM 的直接凶手。那问题来了——什么任务会让 Celery 这样吃内存?

定位根因:三个误区的纠正

误区 1:”内存泄漏一定是指针没 free”

Python 有 GC,纯 Python 代码很少发生经典的内存泄漏。我们这次的问题是 Celery 任务里调用了 Pandas 处理一个 80MB 的 CSV,中间产生了多个临时 DataFrame 没显式 del——Python 的引用计数没清掉它们是因为这些 DataFrame 被缓存在某个模块级字典里了。

memory_profiler 一跑就现原形:

$ python -m memory_profiler tasks.py
Line #    Mem usage    Increment   Line Contents
================================================
    42    312.5 MiB    312.5 MiB   df = pd.read_csv('/data/huge_report.csv')
    43    423.8 MiB    111.3 MiB   df_clean = df.dropna().pipe(transform)
    44    502.1 MiB     78.3 MiB   df_merged = pd.merge(df_clean, lookup, on='id')
    ...
    52    502.1 MiB      0.0 MiB   return result  # ← df_clean 和 df_merged 没释放

Celery 的 worker 进程是长生命周期的——它不会在每次任务结束后重启。任务函数的局部变量理论上应该被 GC 回收,但如果有个全局 cache dict 或者类属性引用了这些 DataFrame,GC 就收不走。

误区 2:”加内存 limit 就行了”

我们确实加了 limit: 1Gi,但这不是万能药。memory.limit_in_bytes 只管硬上限,到了就杀。对于慢泄漏,你需要的是提前感知而不是等到被杀。

正确的做法:监控 memory.usage_in_bytes / memory.limit_in_bytes 的比例,设告警阈值(比如 80%)。Kubernetes 里用 Prometheus + node-exporter 或直接在应用里暴露 metrics。

误区 3:”OOM 之后重启就好了”

这句话跟”蓝屏之后重启就好了”一样——治标不治本。我们的 Celery worker 被杀后,Kubernetes 确实会自动重启,但重启后又会接同样的任务、触发同样的泄漏,6 小时后再次 OOM。CrashLoopBackOff 就是这种循环达到上限后的状态。

修复策略:三条路选哪条?

方案 做法 适用场景
治本:修泄漏 任务结束时显式 del + gc.collect(),避免全局引用 你已经定位到了泄漏源
治标:worker 回收 Celery 的 --max-tasks-per-child 参数,worker 处理 N 个任务后自动重启 泄漏源不明或短期无法修复
防御:预判式 Kill 应用内监控 RSS,接近阈值时主动 Graceful Shutdown 必须零宕机的核心服务

我们最终选的是组合拳

# 1. 修泄漏:Celery task 最后加清理
@app.task
def process_report(filepath):
    try:
        df = pd.read_csv(filepath)
        # ... 处理逻辑 ...
        return result
    finally:
        # 关键三行
        del df
        import gc; gc.collect()

# 2. Celery worker 配置加固
# celery_app.py
app.conf.worker_max_tasks_per_child = 50      # 50个任务后重启
app.conf.worker_max_memory_per_child = 400000  # 400MB 后重启(单位KB)

worker_max_memory_per_child 是 Celery 4.0+ 才有的参数,会在 worker 内存超过阈值后完成当前任务再重启,比 OOM Killer 优雅得多。

FAQ

Q: 为什么容器里 dmesg 看不到 OOM 日志?

dmesg 读的是宿主机的内核 ring buffer,容器内部默认没有权限。解决:在宿主机上执行,或者开 privileged 模式(不推荐),或者用 Loki/Promtail 把宿主机 /var/log/kern.log 采集进日志系统。

Q: OOM Score 能和 RSS 不成比例吗?

会。如果一个进程 fork 了 20 个子进程,每个子进程 RSS 100MB,父进程的 OOM Score 会计入全部 2GB——即使父进程自己只用了 50MB。这就是为什么被 kill 的常常是父进程(比如 Celery 主进程),而不是某个子 worker。

Q: Kubernetes Pod OOM 和节点 OOM 有什么区别?

Pod OOM(cgroup级别):Pod 的内存使用超过了 resources.limits.memory,只杀该 Pod 内的进程。节点 OOM:节点上所有 Pod 的总内存超出物理内存,内核按全局 OOM Score 挑一个杀——被杀的可能是别人的 Pod。dmesg 里 CONSTRAINT_MEMCG = Pod OOM,CONSTRAINT_NONE = 节点 OOM。

Q: 为什么我的应用内存没涨但 OOM 了?

检查文件缓存(page cache)。大量文件读写会让 cache 占满容器内存,虽然 cache 理论上可回收,但如果应用分配新内存的速度超过了内核回收 cache 的速度,OOM Killer 就会被触发。解决:调整 vm.vfs_cache_pressure 或加内存 limit 的 buffer。

总结

OOM Killer 的排查说难不难,说易不易——它有固定的套路,但每个案例的根因都不一样。我自己的 checklist:

  1. dmesg 看行刑记录 → 确定是谁被杀了、是 cgroup 级别还是节点级别
  2. cgroup memory.stat → 看 RSS vs cache 比例,判断是泄漏还是缓存膨胀
  3. smem -s pss → 找到真实内存大户(不看 RSS,RSS 骗人)
  4. 连续采集快照 → 绘制内存增长曲线,区分慢泄漏 vs 突增
  5. 修复 + 监控 → 修根因 + 设 80% 告警 + Celery worker_max_memory_per_child 兜底

那次之后,我们的所有 Python 服务都加了内存告警,Celery worker 统一配置了 worker_max_memory_per_child。我甚至写了个小脚本,每分钟检查 RSS,超过 85% limit 就主动发 SIGTERM——自己杀自己,比被内核杀体面多了

顺带一提,如果你在排查类似问题,Python 性能剖析三件套(py-spy、Scalene、memray)Python 生产环境内存泄漏排查实战 这两篇也有不少可复用的诊断方法。

生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断(2026)最先出现在编程·投资·科技

]]>
strace生产环境调试完全指南:从原理到实战,不重启不改代码就能定位疑难问题(2026) https://www.devlearn.club/posts/942 Mon, 06 Jul 2026 01:25:36 +0000 https://www.devlearn.club/posts/942 凌晨两点的电话 上个月的一个晚上——准确…

strace生产环境调试完全指南:从原理到实战,不重启不改代码就能定位疑难问题(2026)最先出现在编程·投资·科技

]]>
凌晨两点的电话

上个月的一个晚上——准确说是凌晨两点——我被运维的电话叫醒。

“线上那个支付服务卡死了,没有任何报错日志,CPU和内存都正常,但就是不响应请求。”

我迷迷糊糊地连上服务器,top一看,进程还在,但就是一条请求都处理不了。journalctl翻到最近的日志,最后一条是正常的业务日志,之后就沉默了——就好像这个进程突然决定躺平了一样。

没有报错日志,这就很头疼了。好在,我们还有 strace。

一条命令下去,问题秒定位:进程卡在 futex 系统调用上——某个线程持锁后挂了,所有其他线程都在等这把锁。

这就是 strace 的威力:当所有日志都沉默的时候,系统调用会告诉你真相

今天这篇文章,我不会给你抄 man page。我会用四个真实生产场景,带你掌握 strace 的正确用法——以及那些文档不会告诉你的坑。

strace 到底是什么

简单说:strace 是 Linux 下的系统调用追踪器。它可以在一个进程执行每次系统调用(syscall)时,打印出调用的名称、参数、返回值。

什么是系统调用?就是你的程序跟操作系统内核打交道的接口。读文件、写socket、申请内存、创建线程——这些操作最终都要通过系统调用完成。也就是说,strace 能看到你的程序在”物理层面”到底在干什么

举个例子,你的 Python 代码里写了一行 open("config.json"),strace 会告诉你它实际调用的是:

openat(AT_FDCWD, "config.json", O_RDONLY) = 3
read(3, "{...}", 4096)                 = 1024
close(3)                               = 0

Python解释器、标准库、底层运行时——所有层都被穿透了,你看到的是最原始的真相。

四个生产环境实战场景

场景一:定位进程”假死”——futex 死锁

回到开头的故事。进程在 top 里状态显示 S(sleeping),不消耗CPU,但就是没反应。

直接 attach 上去看看它在等什么:

$ strace -p 28491 -f -e trace=futex
strace: Process 28491 attached with 8 threads
[pid 28493] futex(0x7f8b4c0010a0, FUTEX_WAIT_PRIVATE, 2, NULL) = -1 EAGAIN
[pid 28494] futex(0x7f8b4c0010a0, FUTEX_WAIT_PRIVATE, 2, NULL

输出显示所有工作线程都卡在 futex(FUTEX_WAIT_PRIVATE) 上——这是 pthread mutex 的底层实现。所有线程都在等同一把锁,而持锁的那个线程大概率已经挂了

-f 追踪所有线程,用 -e trace=futex 只过滤锁相关调用,问题一目了然。

如果你想知道是哪个倒霉线程在持锁,可以加 -k 打印内核调用栈:

$ strace -p 28491 -f -e trace=futex -k -o /tmp/strace.log

-k 会在每次系统调用后打印内核空间的函数调用链,帮你定位到具体是哪个内核路径导致的阻塞。这个选项在 CentOS 7 之后的内核上都支持。

场景二:文件打开失败但日志里没有

有一次上线后,服务运行正常但某个功能就是404。查了应用日志,没报错。

怀疑是没读到配置文件——但应用的错误处理写得跟屎一样,异常被吞了。直接上 strace 追文件操作:

$ strace -p 12934 -e trace=open,openat,stat -f 2>&1 | grep -E "ENOENT|EACCES"
[pid 12935] openat(AT_FDCWD, "/opt/app/config/production.yaml", O_RDONLY) = -1 ENOENT (No such file or directory)
[pid 12935] openat(AT_FDCWD, "/opt/app/config/production.yml", O_RDONLY) = 3

真相大白:配置文件叫 production.yml,但程序先尝试了 production.yaml。第一次打开返回 -1 ENOENT(文件不存在),然后才 fallback 到正确的文件名。这本该是一条 WARNING 日志,但被某个 try-except 吞了。

这个场景极其常见。越是”没报错但行为诡异”的 bug,越适合用 strace 来扒。

场景三:追查”幽灵”网络调用

服务偶尔出现 5 秒级别的响应延迟,但 APM 上看不到任何慢查询或慢 RPC。

$ strace -p 18823 -f -e trace=network -T 2>&1 | grep -A2 "<5\\."

解释一下:-e trace=network 只追踪网络相关调用(connect/sendto/recvfrom 等),-T 显示每个调用的耗时(在右括号后面)。

输出显示:

connect(8, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.0.1.5")}, 16) = 0 <5.012345>
sendto(8, "...", 42, 0, NULL, 0)        = 42 <0.000014>
recvfrom(8, "...", 512, 0, NULL, NULL)  = 128 <0.000241>

那个 <5.012345> 就是耗时。真相是:DNS 解析超时了 5 秒!应用在做 DNS 查询(端口53),DNS 服务器 10.0.1.5 不可达,等了 5 秒才超时。

这玩意儿 APM 根本追不到——因为它在 libc 的 getaddrinfo() 层就卡住了,还没进入应用的 trace span。

这就是 strace 的不可替代性:它能看到应用框架/APM 盲区里的东西。

场景四:权限问题——EACCES 的 N 种死法

还有一个经典场景:服务在开发机上跑得好好的,一到容器里就挂。查 strace:

$ strace -f -e trace=file /usr/local/bin/myapp 2>&1 | grep EACCES
openat(AT_FDCWD, "/var/run/myapp.pid", O_WRONLY|O_CREAT, 0644) = -1 EACCES (Permission denied)
openat(AT_FDCWD, "/etc/ssl/private/app.key", O_RDONLY) = -1 EACCES (Permission denied)

两个权限问题:写 pid 文件的目录没有写权限,读 SSL 私钥的用户不对。应用日志就一行 FATAL: startup failed,全靠 strace 才找到具体原因。

-e trace=file 过滤文件相关调用,加上 grep EACCES,权限问题无所遁形。

常用的参数组合速查

strace 的参数很多,但生产环境我 90% 的时间只用这几套组合拳:

场景 命令 说明
快速看进程在干嘛 strace -p PID 最简用法,直接 attach
追所有线程 strace -p PID -f 多线程程序必备
只看文件操作 strace -e trace=file 排查配置、权限、路径问题
只看网络调用 strace -e trace=network 排查 DNS、连接建立、超时
显示耗时 strace -T 每个调用的墙钟时间,定位慢调用
显示时间戳 strace -t-tt -tt 精确到微秒
输出到文件 strace -o /tmp/strace.log 输出量大时必须写文件,否则 stdout 会炸
统计模式 strace -c -p PID 不打印每次调用,结束时输出汇总统计
strace性能开销对比与syscall分布
▲ strace 不同追踪模式下的性能开销对比(柱状图)与典型后端服务的 syscall 占比分布(饼图)

strace -c:先概览,再深入

很多时候你不需要一开始就看每一条系统调用。先跑一个汇总,看看时间花在哪里了:

$ strace -c -p 19283
# 运行 30 秒后 Ctrl+C
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.42    3.245187       32451       100           futex
  0.31    0.010045           3      2999           read
  0.15    0.004899           2      2000           write
  0.08    0.002734           2      1003           epoll_wait
  0.02    0.000624           1       500           clock_gettime
------ ----------- ----------- --------- --------- ----------------
100.00    3.263489                  6602           total

99.42% 的时间花在 futex 上,每次平均 32 毫秒。这直接印证了锁竞争问题。用 -c 做 top-down 分析,找到热点后再用 -e trace=futex 深入追踪。

性能开销:strace 到底有多慢?

这是被问最多的问题,也是最容易被误解的部分。先说结论:

strace 本身几乎不拖慢系统调用,但它会让每次系统调用都触发上下文切换,产生额外的 ptrace 开销。对于高频系统调用的程序(比如每秒几十万次 read/write),性能影响可能高达 30%-80%。对于大部分后端服务(每秒几千次调用),影响在 5% 以内。

我用一个 Python 脚本做了实测——分别在不挂 strace、挂 strace 但不过滤、挂 strace 且过滤特定调用三种情况下跑了 10 万次文件读取:

# 基准(无 strace)
10万次文件读取耗时: 1.82s

# strace 全量追踪 (-f -o /dev/null)
10万次文件读取耗时: 18.43s  (10x 慢)

# strace 过滤追踪 (-e trace=!read,write -o /dev/null)
10万次文件读取耗时: 2.01s  (1.1x 慢)

关键在 -e trace=! 这个语法。用 排除掉高频调用(read/write/futex),性能开销直接从 10 倍降到可忽略。

生产环境最佳实践:

  • 先用 -c 统计 30 秒了解调用分布
  • 然后用 -e trace=具体调用 精确追踪
  • 一定要加 -o /tmp/file,不要输出到 stdout(否则 strace 自己的 write 也会被追踪,递归爆炸)
  • 追完立刻 Ctrl+C,别挂着就跑

strace vs 其他工具:什么时候别用 strace

strace 是瑞士军刀,但不是万能工具。有几个场景它不如专用工具:

场景 用 strace? 更好的工具
CPU 热点分析 ❌ 不行,只能看 syscall 级别 perf — 看函数级热点
内核态追踪 ⚠ -k 可以但有限 bpftrace / eBPF — 无侵入内核追踪
动态库函数调用 ❌ 不行,只能看 syscall ltrace — 追踪 libc 等动态库调用
网络抓包 ⚠ 能看到 syscall 但不能看包内容 tcpdump / ss
程序crash定位 ⚠ 可用但不优雅 gdb / coredump

一句话总结选型:想知道”程序在跟操作系统交互什么”→ strace;想知道”程序哪段代码慢”→ perf;想知道”内核里发生了什么”→ bpftrace

我之前写过 Linux 性能剖析实战:perf 工具从 CPU 采样到火焰图生成全流程eBPF + bpftrace 生产环境调试实战,这三者搭配使用基本能覆盖所有生产环境排查场景。

FAQ

Q: strace 会让程序变慢,生产环境能用吗?

可以,但要讲究方法。核心原则:用 -e trace= 精确过滤,用 -o 输出到文件,追踪时间控制在 30 秒以内。高频 I/O 程序先评估影响,低频程序直接上问题不大。

Q: strace 输出里 EAGAIN 是什么意思?需要担心吗?

不一定是问题。EAGAIN(Resource temporarily unavailable)在非阻塞 I/O 中极其常见——它表示”现在没数据,你待会再来”。但如果某个调用连续大量返回 EAGAIN,可能是真的有问题(比如 socket buffer 满了)。

Q: Docker 容器里能用 strace 吗?

能,但默认需要 --cap-add=SYS_PTRACE--privileged。Kubernetes 里需要在 Pod Security Policy 中允许 SYS_PTRACE capability。或者可以在宿主机上 strace 容器进程(strace -p $(docker inspect -f '{{.State.Pid}}' container_name))。

Q: strace 和 gdb 有什么区别?什么时候用哪个?

strace 看”程序在跟 OS 交互什么”(系统调用级别),gdb 看”程序内部状态是什么”(变量值、调用栈、断点)。程序卡死不响应 → strace;程序 core dump → gdb;行为诡异但没报错 → 先 strace 再看要不要 gdb。

写在最后

strace 是我工具箱里出场率最高的排查工具之一。它不需要安装额外包(几乎所有发行版都有),不需要重启进程,不需要改代码——只要是 Linux 上跑的程序,它就能给你信息。

但 strace 真正的价值不在于那几个命令行参数,而在于你对操作系统的理解。你得知道 futex 是锁的底层实现,epoll_wait 是事件循环在等 I/O,connect 超时可能是 DNS 的问题——这些背景知识决定了你看到输出时能不能反应出来”这里有问题”。

所以,学 strace 最好的方式不是背参数,而是在排查实际问题时刻意用它。下次线上出问题时,别急着翻代码,先 strace -c -p PID 跑 30 秒看看——你可能会对自己写的程序有一个全新的认识。


相关阅读:

📡 延伸阅读:Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断

strace生产环境调试完全指南:从原理到实战,不重启不改代码就能定位疑难问题(2026)最先出现在编程·投资·科技

]]>
eBPF + bpftrace 生产环境调试实战:不用改代码、不用重启,一行命令定位线上问题(2026) https://www.devlearn.club/posts/923 Fri, 03 Jul 2026 01:05:28 +0000 https://www.devlearn.club/posts/923 一个凌晨三点的事故 那是一个周三的凌晨,…

eBPF + bpftrace 生产环境调试实战:不用改代码、不用重启,一行命令定位线上问题(2026)最先出现在编程·投资·科技

]]>
一个凌晨三点的事故

那是一个周三的凌晨,钉钉告警群炸了。生产环境一台 Nginx 机器 CPU 飙到 100%,但 top 看到的是软中断(%si)占大头,而不是用户态进程。常规手段——perf、strace、tcpdump——要么太重(影响线上),要么信息不够细。

运维小哥在群里发了一句「要不要重启?」。我回:「别急,给我两分钟。」

然后我敲了一行 bpftrace 命令——没有重启,没有加日志,没有重新部署。两分钟后定位到是一个新上线的微服务在疯狂建短连接。改了连接池参数,CPU 从 100% 掉到 15%。

这就是 eBPF 的快感。今天这篇文章,带你从零上手 bpftrace,学会几个「救过命的命令」,下次遇到类似场景你就是那个说「别急」的人。

eBPF 是什么?为什么它这么狠?

eBPF(extended Berkeley Packet Filter)是 Linux 内核里一个沙箱化的虚拟机。你可以往内核里注入一段字节码程序,在内核事件(系统调用、函数进入/退出、网络包到达等)发生时执行,不需要改内核代码、不需要重启、不影响线上服务

传统排查工具的问题:

  • strace:每个系统调用都要走 ptrace,开销巨大。在 QPS 上万的机器上跑 strace 等于自残。
  • perf:采样型,只能看 CPU 热点,看不到具体参数和返回值。
  • tcpdump:能看到包,但不知道是哪个进程、哪个系统调用发的。
  • 加日志:需要改代码 + 重新部署。等你改完部署完,问题可能已经自己恢复了。

eBPF 的核心优势:你在内核的「插桩点」上挂载一段小程序,无论线上跑了多久、QPS 多高,它都能安全高效地采集数据。并且 eBPF 程序会经过内核验证器检查,不会把内核搞崩。

而 bpftrace 就是 eBPF 的高级封装——你不用写 C 代码编译成 BPF 字节码,一行 awk 风格的命令就能用。

安装 bpftrace

在 Ubuntu 24.04 / 22.04 上安装很简单:

$ sudo apt install bpftrace
# 验证
$ sudo bpftrace -e 'BEGIN { printf("eBPF ready!\n"); exit(); }'
Attaching 1 probe...
eBPF ready!

CentOS / RHEL 需要先启用 EPEL:

$ sudo dnf install epel-release
$ sudo dnf install bpftrace

内核版本要求 ≥ 4.9(推荐 ≥ 5.4)。现在的服务器基本都是 5.x 或 6.x,没什么好担心的。

五个救过命的 bpftrace 一行命令

1. 谁在疯狂调用某个系统调用?

场景:CPU 软中断高,怀疑是有程序在大量建连/断连。

$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_accept* { @[comm] = count(); }'
# Ctrl-C 退出
@[nginx]: 1523
@[my-service]: 48723   # ← 这个有问题!
@[sshd]: 2

@[comm] = count() 是按进程名做聚合计数。跑 10 秒钟 Ctrl-C,一眼看出 my-service 在疯狂 accept。

继续深挖——看它到底在 accept 什么:

$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_accept4 { 
    printf("pid=%d comm=%s\n", pid, comm); 
}'

如果你想看每次 accept 的 fd、源 IP 等细节,可以 hook sys_exit_accept4(返回时),因为 fd 在返回值里:

$ sudo bpftrace -e 'tracepoint:syscalls:sys_exit_accept4 { 
    printf("pid=%d fd=%ld\n", pid, args->ret);
}'

2. 哪个文件在被高频读写?

场景:磁盘 IO 打满,iotop 看到一堆进程但不知道谁在写哪个文件。

$ sudo bpftrace -e 'kprobe:vfs_read,kprobe:vfs_write { 
    @bytes[comm, str(args->buf ? ((struct file *)args->filp)->f_path.dentry->d_name.name : "?")] = sum(arg2); 
}'
# 10秒后 Ctrl-C

这个稍微复杂点——kprobe:vfs_read 是内核函数的动态探针,args->filp 是文件指针,我们顺着它拿到文件名。输出类似:

@bytes[my-service, access.log]: 2147483648
@bytes[nginx, error.log]: 52428800
@bytes[python3, data.db]: 67108864

一眼看出 my-service 在往 access.log 里写了 2GB——这日志量显然不正常。

3. 慢文件操作:什么东西在卡 IO?

场景:业务接口 P99 延迟突然从 50ms 涨到 2s,怀疑是磁盘 IO 卡住。

$ sudo bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs; @file[tid] = str(args->filp->f_path.dentry->d_name.name); }
kretprobe:vfs_read /@start[tid]/ { 
    $dur_ms = (nsecs - @start[tid]) / 1000000;
    if ($dur_ms > 100) {
        printf("SLOW READ: %s pid=%d tid=%d duration=%dms\n", @file[tid], pid, tid, $dur_ms);
    }
    delete(@start[tid]);
    delete(@file[tid]);
}'

这个一行命令做了几件事:
kprobe:vfs_read 在进入时记录时间戳和文件名;kretprobe:vfs_read 在返回时算耗时,>100ms 的打印出来。

输出示例:

SLOW READ: data.db pid=12345 tid=12346 duration=3200ms
SLOW READ: cache.bin pid=12345 tid=12347 duration=1800ms

磁盘 IO 延迟 3 秒——大概率是磁盘故障或文件系统锁竞争。

4. 哪个进程在 fork 大量子进程?

场景:服务器 load average 突然暴增但 CPU 不高,怀疑有进程在疯狂 fork 后立即退出。

$ sudo bpftrace -e 'tracepoint:sched:sched_process_fork { 
    @[comm] = count();
}'
# 10秒后 Ctrl-C

类似地,看谁在大量创建线程:

$ sudo bpftrace -e 'kprobe:copy_process { @[comm] = count(); }'

5. 一次性看所有系统调用分布

场景:不知道是什么问题,先摸个底。

$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { 
    @syscalls[comm] = count(); 
}'

# 或者直接看被调用最多的系统调用
$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { 
    @[probe] = count(); 
}'

10 秒后 Ctrl-C,你说不定能看到 futex: 3000000——恭喜,有人在锁上打架。

进阶:用 bpftrace 写个迷你火焰图

perf 能生成火焰图,但 bpftrace 也能做堆栈采样,而且更轻量:

$ sudo bpftrace -e 'profile:hz:99 { @[kstack, ustack, comm] = count(); }' > /tmp/stacks.txt
# 跑 30 秒后 Ctrl-C

profile:hz:99 是 timer-based 采样,每秒 99 次(和 perf 默认频率一样)。kstack 是内核堆栈,ustack 是用户态堆栈。

拿到堆栈数据后用 Brendan Gregg 的 FlameGraph 工具转成 SVG:

$ git clone https://github.com/brendangregg/FlameGraph
$ # 先把 bpftrace 输出转成折叠格式(需要自己写个简单脚本)
$ ./FlameGraph/flamegraph.pl folded.txt > flame.svg

在你没有 perf 权限或者 perf 采集太重的场景下,bpftrace 采样是一个很好的替代。

什么时候不该用 bpftrace?

bpftrace 虽然狠,但不是银弹:

  • 长时间持续监控:bpftrace 更适合跑几分钟然后 Ctrl-C 看聚合结果。长时间 24×7 监控应该用 BCC 工具(如 tcptop、biolatency)或者切换到 eBPF 的 Go/Rust SDK。
  • 非常高频的事件:比如每个网络包都 hook,在高流量机器上会导致丢事件。bpftrace 会自动丢弃来不及处理的事件并打印 lost events 计数。
  • 复杂状态机:bpftrace 的脚本语言不是图灵完备的(有意为之,保证安全性),如果你需要复杂的状态跟踪(如跟踪一个 TCP 连接从建立到关闭的全生命周期),用 BCC Python 或者 libbpf。
  • 需要看用户态变量:bpftrace 可以通过 uprobe 看用户态函数,但解析复杂数据结构(如 C++ 的 std::string)比较痛苦。

实战复盘:短连接风暴

回到开头的故事。那晚的排查路径:

  1. top → CPU 100%,但 %si(软中断)占了 60%,不是用户态进程。
  2. sar -n DEV 1 → 网卡流量正常,不是 DDoS。
  3. ss -s → TCP 连接数 80000+,远超正常水平的 5000。
  4. bpftrace 一行命令定位 sys_enter_connect → 是 Java 微服务在循环建连。
  5. 看代码 → 每次 HTTP 请求都 new 一个 HttpClient,用完不关。
  6. Fix → 改连接池配置 + 加连接复用。重启后连接数从 80000 降到 2000。

整个排查从收到告警到定位根因,8 分钟。如果没有 bpftrace,我们大概率会走:看监控大盘 → 看不懂 → 怀疑网络 → 拉网工 → 网工说没问题 → 怀疑应用 → 加日志 → 灰度发布 → 等复现 → 再分析。这个循环至少半天。

一句话总结 bpftrace 的价值:它让你「不用改一行代码、不用重启服务」就能知道内核里正在发生什么。

FAQ

Q: bpftrace 和 BCC 工具(如 execsnoop、biolatency)是什么关系?

BCC(BPF Compiler Collection)是一套用 Python 写的 eBPF 工具集,适合持续运行和复杂场景。bpftrace 是快速临时排查工具,一行命令即用即走。BCC 工具更稳定(有完善的错误处理和输出格式),bpftrace 更灵活(你可以现场拼一个查询)。

Q: 跑 bpftrace 会影响线上性能吗?

取决于你在 hook 什么。hook 一个冷门系统调用(如 sys_enter_mkdir)几乎零开销。hook sys_enter_read 且在高 IO 场景下会有 1-3% 的 CPU 开销。bpftrace 内置了开销控制——每个探针有默认的开销上限,超过会自动报 lost events 而不是把机器拖垮。另外,eBPF 程序运行在内核的 JIT 编译器中,执行效率远高于传统的 kprobe + printk。

Q: bpftrace 命令报 “ERROR: Error attaching probe” 怎么办?

常见原因:1) 没加 sudo;2) 内核版本太低(需要 ≥ 4.9);3) 目标内核符号不存在(kprobe 挂的函数名不对)。排查方法:sudo bpftrace -l 'kprobe:vfs_*' 列出所有可用探针,确认函数名正确。另外 BTF(BPF Type Format)需要内核 ≥ 5.2 才完全支持,旧内核访问结构体字段可能受限。

Q: 生产环境的容器里能用 bpftrace 吗?

eBPF 是内核级能力,需要在宿主机上跑(或者 privileged 容器)。普通容器受限于 namespace 和 capabilities。推荐:在宿主机上安装 bpftrace,用 --pid 参数限定追踪目标容器进程:bpftrace --pid $(docker inspect -f '{{.State.Pid}}' container_name) -e '...'

相关阅读:

小结

eBPF 和 bpftrace 是现代 Linux 运维和性能分析的「核武器」。它把以前需要改内核、改应用、加日志、重启服务的排查路径压缩成一行命令。如果你还没用过,今天就把这五个一行命令记下来——哪天半夜告警响了,你会感谢现在的自己。

记住这三句话:

  • 看谁在调用什么系统调用tracepoint:raw_syscalls:sys_enter
  • 看谁在读写什么文件kprobe:vfs_read / kprobe:vfs_write
  • 看什么东西慢kretprobe 记录进入和退出时间差

然后你就拥有了「别急,给我两分钟」的底气。

🔗 延伸阅读:bpftrace 适合内核级深挖,但如果你只需要快速定位”进程在等什么”,用 strace 生产环境调试完全指南 会更轻量。两把刀各有各的顺手场景。

📡 延伸阅读:Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断

eBPF + bpftrace 生产环境调试实战:不用改代码、不用重启,一行命令定位线上问题(2026)最先出现在编程·投资·科技

]]>
Python性能剖析三件套:py-spy、Scalene、memray实战对比——一次接口优化从80ms到8ms的全记录 https://www.devlearn.club/posts/913 Wed, 01 Jul 2026 01:14:16 +0000 https://www.devlearn.club/posts/913 前言——那个让我怀疑人生的接口 上周四凌…

Python性能剖析三件套:py-spy、Scalene、memray实战对比——一次接口优化从80ms到8ms的全记录最先出现在编程·投资·科技

]]>
前言——那个让我怀疑人生的接口

上周四凌晨两点,我被 PagerDuty 叫醒。线上一个报价接口的 P99 延迟飙到了 800ms,而 SLA 规定的是 200ms。重启没用,加机器也没用——典型的代码层面的性能问题。

这个接口做的事情其实不复杂:从 Redis 取缓存、查 PostgreSQL 做价格计算、调一个第三方汇率 API、再把结果序列化返回。代码大概 200 行,cProfile 跑完输出几千行——根本看不出谁是真正的凶手。

长话短说,最终我把这个接口从 P99 80ms 优化到 8ms,靠的不是灵光一闪,而是三把性能剖析的”手术刀”:py-spy、Scalene、memray。这篇文章就是那次排查的完整复盘——包括翻车的地方。

为什么不用 cProfile

我先说结论:cProfile 不是不能用,但它的使用场景非常窄

cProfile 的最大问题是侵入性。你必须在代码里加 import cProfile、或者用 python -m cProfile 启动——这在生产环境基本不可行。更致命的是,cProfile 本身的 overhead 在 10%-30%,对于已经很快的函数,它的采样会把时间花在”测量”上而不是”执行”上。

还有一点:cProfile 的输出是扁平的函数调用列表,你要自己脑补调用链路。对比火焰图那种一眼看到热点的可视化,差了十万八千里。

所以真正好用的生产级 Python 性能工具,必须满足三个条件:

  1. 无需改代码——直接 attach 到运行中的进程
  2. 低开销——采样模式,不是全量插桩
  3. 可视化——火焰图、时序图、调用链,不是几千行文本

下面这三个工具都满足,但各自擅长的不一样。

三件套速览

工具 擅长 原理 开销 适合场景
py-spy CPU 热点定位 采样(读进程内存) ≈1% 线上突发 CPU 飙高
Scalene CPU + 内存 + GPU 综合 采样 + 插桩 5%-15% 开发/预发环境深度分析
memray 内存分配追踪 插桩 10%-20% 内存泄漏、大对象分配

记住这个表,后面你会反复回来对照。

实战第一步:py-spy 定位 CPU 热点

报价接口在线上 P99 800ms,我第一反应是 CPU 瓶颈。py-spy 最擅长这个——它直接 attach 到运行中的进程,读 Python 调用栈采样,不需要重启、不需要改代码

# 安装
pip install py-spy

# attach 到运行中的进程,采样 30 秒
sudo py-spy top --pid 28473 --duration 30

# 或者直接生成火焰图
sudo py-spy record -o /tmp/profile.svg --pid 28473 --duration 30

火焰图出来之后,问题一目了然:一个 pandas 的 merge() 操作占了 67% 的 CPU 时间。这个 merge 是为了把汇率表跟价格表做 join,但汇率表其实只有 200 行——完全可以用 Python 原生 dict 做映射,比 pandas 快 10 倍以上。

除此之外还发现一个 json.dumps() 调了三次(序列化 → 加时间戳 → 再序列化),每次都在重新创建 JSONEncoder。这个用 py-spy 的火焰图一眼就能看到调用栈上那个显眼的矩形块。

py-spy 的杀手锏:当线上 CPU 飙高,你不知道哪个线程在吃 CPU 时,py-spy 就是你的救星。三秒定位,不用重启服务。

实战第二步:Scalene 发现隐藏的内存浪费

CPU 问题修完后,P99 从 800ms 降到了 120ms——不错,但离目标 200ms 的 SLA 还有距离,而且我想搞清楚有没有内存层面的浪费。

Scalene 是学术界出身(UMass 团队开发的),它的独特之处是同时分析 CPU、内存和 GPU,而且会告诉你”这行代码在干嘛”而不是只给数字。

# 安装
pip install scalene

# 直接跑你的脚本(不用改代码)
scalene --html --outfile /tmp/scalene_report.html your_script.py

Scalene 的报告出来之后,我看到一个让我无语的事情:每次请求都在创建一个新的 psycopg2 连接。代码里用的是一个”连接池”类,但那个类的 __init__ 方法里写的是 self.conn = psycopg2.connect(...)——每次调用 get_connection() 都建一个新连接。

Scalene 标红了这一行的 Memory Growth 指标,显示每个请求分配了约 8MB 的内存且不释放。PG 连接创建的开销是 TCP 握手 + SSL 协商 + 认证,单次就需要 15-25ms。

修复很简单——把连接池实现改成真正的连接复用。修完之后,P99 从 120ms 降到 45ms。

Scalene 的独特价值:它不是告诉你”这里花了几秒”,而是告诉你”这行代码在第 N 次循环时分配了大量内存”——这种带有时间维度的内存分析,是其他工具做不到的。

实战第三步:memray 追踪内存分配细节

P99 45ms 已经很接近目标了。但我注意到内存占用在持续增长——不是内存泄漏,而是每次请求后内存不降回基线。

这时候用 memray——由 Bloomberg 开源,专做 Python 内存分配追踪。

# 安装
pip install memray

# 运行并记录内存分配
memray run -o /tmp/output.bin your_script.py

# 生成火焰图(内存分配视角)
memray flamegraph /tmp/output.bin -o /tmp/mem_flamegraph.html

# 生成表格报告
memray table /tmp/output.bin

memray 的报表非常细。我发现请求结束后,一个 OrderedDict 里存了 54 个 Decimal 对象——每个 Decimal 对象约 400 字节,54 个就是 21KB。这本身不大,但这个 dict 作为类属性被 lru_cache 缓存了,每次缓存命中都不会释放。

解决方法:把 lru_cachemaxsize 从默认的 128 改成 8,并且在请求结束后显式调用 cache.clear()

修完之后内存基线稳定了,更重要的是——因为少了不必要的对象分配,P99 从 45ms 降到了 8ms

memray 的核心能力:它可以告诉你每个对象分配的大小、位置、以及是否被释放。当你怀疑”某个 dict 里的值越堆越多”但又不确定时,memray 就是最好的答案。

优化全记录:从 80ms 到 8ms

轮次 工具 发现的问题 修复 P99 变化
0 基线 800ms
1 py-spy pandas merge 占 67% CPU 替换为 dict 映射 120ms
2 Scalene 每次请求新建 PG 连接 修复连接池复用 45ms
3 memray lru_cache 缓存大量 Decimal 对象 减少缓存大小 + 手动清理 8ms

三个工具,三个维度,三层优化。没有哪一个工具能单独发现所有问题——CPU 热点靠 py-spy,内存浪费靠 Scalene,分配细节靠 memray。组合使用才是正确姿势。

三工具选型指南

总结一下什么时候用哪个:

  • 线上 CPU 飙高 → py-spy,attach 即用,1% 开销,马上出火焰图
  • 接口响应慢,不知道瓶颈在哪 → Scalene,CPU + 内存双维度,开发环境跑
  • 内存持续增长、怀疑有泄漏 → memray,看分配链和释放情况
  • GPU 相关(AI 推理) → Scalene,目前唯一支持 GPU profiling 的 Python 工具
  • 想优化但不想改代码 → py-spy(采样模式)+ memray(命令行启动),都不需要改源码

还有一个很少人知道的技巧:py-spy 可以 dump 当前调用栈而不中断进程。如果你怀疑某个线程死锁了,用这个:

sudo py-spy dump --pid 28473

它会打印所有线程的当前调用栈——等于一个即时的线程快照。我在排查一个 threading.Lock 死锁的时候靠这个在三分钟内找到了一对互相等待的线程。

常见问题 FAQ

Q: 这三个工具能同时用吗?

不建议。py-spy 和 Scalene 都依赖采样机制,同时运行会互相干扰。正确的做法是分轮次:先用 py-spy 快速定位 CPU 热点,修完后再用 Scalene 做深度分析,最后用 memray 查内存。

Q: Docker 容器里能用 py-spy 吗?

可以,但需要加 --cap-add SYS_PTRACE 或者用 --pid=host。py-spy 依赖 Linux 的 process_vm_readv 系统调用来读取目标进程内存,Docker 默认的 seccomp profile 会阻止这个调用。如果不想改容器权限,可以在宿主机上直接 attach 到容器内的 PID。

Q: Scalene 的 GPU profiling 准确吗?

Scalene 的 GPU 分析是通过 NVIDIA Management Library (NVML) 读取 GPU 利用率和显存使用。它不能精确到 Python 代码行的 GPU 时间(因为 CUDA 是异步的),但可以告诉你”这段代码运行期间 GPU 利用率从 10% 飙到了 90%”——对于定位 GPU 瓶颈已经够用了。

Q: 有没有可能不需要三个工具,一个就够了?

如果你只需要做 CPU profiling,py-spy 一个就够了。但 Python 的性能问题往往不只 CPU——内存分配、GC、I/O 等待都可能是瓶颈。Scalene 覆盖的面最广(CPU + 内存 + GPU),但它的内存分析粒度不如 memray。我的建议是:日常用 Scalene,遇到内存问题加 memray,线上应急用 py-spy。

📖 相关推荐:Python 并发编程深度实战:GIL 原理与最优并发策略选择(2026) — 线程池 vs 进程池 vs asyncio 决策框架

总结

性能优化这件事,最难的不是改代码,而是知道改哪里。90% 的优化时间应该花在 profiling 上,真正改代码可能就几行。py-spy、Scalene、memray 这三个工具,一个负责”快准狠”地定位 CPU 热点,一个负责 CPU + 内存 + GPU 的综合分析,一个负责内存分配的精确追踪——三者配合,你就能在一个小时内找到别人花一天都找不到的瓶颈。

回到那个报价接口:从 800ms 到 8ms,改了不到 30 行代码。但为了找到这 30 行该改的地方,三个工具各跑了两轮。这才是性能优化的真实面貌——不是靠直觉,而是靠数据。

相关阅读

提示:本文提到的所有工具都支持 Python 3.8+。如果你使用的是 Python 3.12+,memray 的兼容性最好,py-spy 可能需要从 GitHub 源码安装最新版。生产环境用 py-spy 前,先在 staging 环境验证一下——虽然它的开销极低,但每个环境的情况不同。

如果你觉得这篇文章有帮助,欢迎在评论区分享你的 profiling 经验——你用哪种工具发现了什么坑?

📖 相关阅读:Python 类型注解进阶实战:Protocol、Generic、TypedDict 让生产代码更安全 — 从 Protocol 的结构子类型到 Generic 的容器安全,再到 TypedDict 的 API 数据边界保护,三招让你的 Python 代码告别 TypeError。

相关推荐:生产环境 OOM Killer 排查实战:从内存飙升到容器被杀的全链路诊断 — 跨进程视角的完整排查手册,涵盖 dmesg、cgroup、smem 工具链。

Python性能剖析三件套:py-spy、Scalene、memray实战对比——一次接口优化从80ms到8ms的全记录最先出现在编程·投资·科技

]]>
Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治的全过程 https://www.devlearn.club/posts/903 Mon, 29 Jun 2026 01:21:27 +0000 https://www.devlearn.club/posts/903 凌晨3点的告警电话 去年冬天的一个凌晨,…

Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治的全过程最先出现在编程·投资·科技

]]>
凌晨3点的告警电话

去年冬天的一个凌晨,我被 PagerDuty 的尖叫声吵醒。迷迷糊糊抓起手机一看——订单服务的 P99 延迟从 80ms 飙到了 2300ms,数据库 CPU 直接打满 100%。

我一边骂骂咧咧穿衣服,一边开电脑登 VPN。看了一圈监控面板,发现问题出在 Redis 上——缓存大面积失效,所有请求直接穿透到 MySQL,数据库当场被打穿。

那天晚上我踩了 三个经典的 Redis 坑:缓存穿透、缓存雪崩、热点 Key。每一个单独拿出来都不算复杂,但三个叠在一起,就是一场完美的风暴。这篇文章就是那次事故的完整复盘——从排查过程到根治方案,都是能直接用的实战代码。

第一坑:缓存穿透——恶意请求打穿了防线

现场还原

登上服务器后,先看了 Redis 的命中率:

$ redis-cli INFO stats | grep keyspace
keyspace_hits:1423781
keyspace_misses:9384721
# 命中率不到 13%……

再查数据库慢查询日志:

SELECT * FROM products WHERE id = -1;
SELECT * FROM products WHERE id = -2;
SELECT * FROM products WHERE id = -99999;

看到这个就明白了——有人在用不存在的 ID 大量请求接口。每个请求先查 Redis(miss),再查 MySQL(也 miss),但 Redis 里没有缓存「不存在」这个结果,每次都会穿透到数据库。

为什么「缓存空值」兜不住

缓存穿透最常见的建议是「缓存空值」。但这里有两个坑:

  1. 空值缓存撑爆内存:如果攻击者遍历 -1 到 -10000000,Redis 里就得存 1000 万个 null。你的内存是给正常业务用的,不是给垃圾数据用的。
  2. TTL 窗口漏洞:就算设了 5 分钟过期,攻击者可以每 5 分钟换一批 ID,持续打穿。

根治方案:布隆过滤器

核心思路:在查缓存之前,先用一个概率数据结构判断这个 key 是否可能存在

下面是 Python 实现(pybloom-live):

from pybloom_live import ScalableBloomFilter
import redis, json

# 初始容量 100 万,误判率 0.1%,支持自动扩容
bloom = ScalableBloomFilter(
    initial_capacity=1000000,
    error_rate=0.001,
    mode=ScalableBloomFilter.SMALL_SET_GROWTH
)

# 启动时从 DB 加载所有有效 ID
def load_bloom_from_db():
    valid_ids = db.query("SELECT id FROM products")
    for pid in valid_ids:
        bloom.add(str(pid))

# 查询入口
def get_product(product_id: str):
    r = redis.Redis(host='localhost', port=6379, decode_responses=True)
    
    # 第一关:布隆过滤器——说不存在就 100% 不存在
    if product_id not in bloom:
        return None
    
    # 第二关:Redis
    cached = r.get(f"product:{product_id}")
    if cached:
        return json.loads(cached)
    
    # 第三关:数据库
    data = db_get(product_id)
    if data:
        r.setex(f"product:{product_id}", 3600, json.dumps(data))
    return data

关键参数:误判率 0.1%,10 万个 key 只用约 120KB 内存。布隆过滤器 永远不会假阴性——它说不存在,就一定不存在。最坏情况只是多查一次 Redis,不会丢数据。

上线后:命中率从 13% 回到 96%,数据库 CPU 从 100% 降到 35%。

第二坑:缓存雪崩——同一秒过期的定时炸弹

现场还原

穿透问题解决后的第三天,凌晨 4 点,告警又响了。

这次的症状很奇怪——不是持续高负载,而是每隔一小时就有一个 2 分钟的尖峰。查 Redis key 过期时间:

$ redis-cli --scan --pattern "product:*" | while read k; do
    echo "$k → TTL: $(redis-cli TTL $k)"
done | head -5

product:1001 → TTL: 12
product:1002 → TTL: 9
product:1003 → TTL: 5
product:1004 → TTL: 7
product:1005 → TTL: 3
# 几十万个 key 的 TTL 全集中在 0-60 秒…

根源:所有缓存用统一的 setex(key, 3600, value) 创建,在业务高峰期同一批写入,TTL 就像同步的定时炸弹,到点一起引爆。

三重保险修复

单纯随机 TTL 不够,上了三件套:

import random, redis, time, threading

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class CacheService:
    def __init__(self):
        self.local = {}       # L0: 本地内存
        self.local_ttl = {}   # L0 TTL
        self._circuit = False # 熔断状态
        self._half_open = 0   # 半开时间戳
        self._lock = threading.Lock()
    
    def get(self, key: str):
        now = time.time()
        
        # L0: 本地缓存(30 秒 TTL)
        with self._lock:
            if key in self.local and self.local_ttl.get(key, 0) > now:
                return self.local[key]
        
        # L1: Redis + 熔断
        if not self._circuit or now > self._half_open:
            try:
                val = r.get(key)
                if val:
                    with self._lock:
                        self.local[key] = val
                        self.local_ttl[key] = now + 30
                    return val
            except redis.ConnectionError:
                # Redis 挂了 → 开熔断,10 秒后半开试探
                self._circuit = True
                self._half_open = now + 10
                return self.local.get(key)
        
        # L2: 数据库(最后的防线)
        data = db_get(key)
        if data:
            # 🔑 随机 TTL:1800-3600 秒(30-60 分钟)
            ttl = random.randint(1800, 3600)
            try:
                r.setex(key, ttl, data)
            except redis.ConnectionError:
                pass
            with self._lock:
                self.local[key] = data
                self.local_ttl[key] = now + 30
        return data

为什么随机窗口是 30-60 分钟? 经验法则:随机窗口 ≥ 基础 TTL 的 30%-50%。比如基础 TTL 1 小时,随机窗口就用 30 分钟。目的是让 10 万个 key 的过期时间分散到 30 分钟的时间跨度内,不在同一秒集中过期。

上线后那个「每小时一次尖峰」的监控图终于平了。

第三坑:热点 Key——一个爆款商品拖垮了整个集群

现场还原

前两个坑填平之后,系统稳定跑了两个月。然后双十一来了。

运营在首页 banner 推了一个秒杀商品,QPS 从平时的 200 飙到 8000+。问题是:Redis Cluster 里,这个 key 的 hash slot 落在单台节点上,那台节点 CPU 瞬间 100%,连带影响同节点的其他业务 key。

$ redis-cli --cluster info 127.0.0.1:7000
# 节点 3:CPU 100%,QPS 9000
# 节点 1、2:CPU 15%,QPS 400
# 热点 Key 完全集中在一个分片上

集群分片对热点 Key 没用——同一个 key 永远只落在一个 slot 上。

修复:两级缓存 + Key 拆分

对读多写少的热点数据,最有效的方案是在应用进程内做本地缓存:

import threading, time

class HotKeyCache:
    """进程内本地缓存 —— 热点 Key 专用"""
    
    def __init__(self, redis_client):
        self.redis = redis_client
        self.cache = {}
        self.ttl = {}
        self.lock = threading.Lock()
    
    def get(self, key: str):
        now = time.time()
        with self.lock:
            if key in self.cache and self.ttl.get(key, 0) > now:
                return self.cache[key]
        
        # 本地 miss → 穿透一次到 Redis
        val = self.redis.get(key)
        if val:
            with self.lock:
                self.cache[key] = val
                self.ttl[key] = now + 3  # 只缓存 3 秒!
        return val
    
    def invalidate(self, key: str):
        with self.lock:
            self.cache.pop(key, None)
            self.ttl.pop(key, None)

关键设计:本地 TTL 只有 3 秒。热点数据可能随时变化(库存、价格),3 秒既能拦截 8000 QPS 中的绝大部分(只穿透 1 次到 Redis),数据延迟也完全可接受。实测:8000 QPS → Redis 只收到约 1 QPS。

极端场景(单 key > 10000 QPS),还可以用 Key 拆分——同一数据存多副本到不同 slot:

REPLICAS = 8

def get_hot_product(product_id):
    idx = random.randint(0, REPLICAS - 1)
    key = f"product:{product_id}:hot:{idx}"
    return r.get(key)

8 个副本均匀分布在集群不同节点上,把单点压力分散到整个集群。

监控:没有监控就别用缓存

踩完这三个坑,我在 Prometheus + Grafana 上搭了一套 Redis 监控面板。核心指标就四个:

  • 缓存命中率:低于 85% 报警。正常 90-98%。
  • Key 过期集中度:每分钟过期 key 数的标准差。突升 = 雪崩风险。
  • 热点 Key 检测redis-cli --hotkeysMONITOR 采样,单 key QPS > 1000 标记。
  • 各节点 CPU 偏差:集群节点间 CPU 差异 > 30% 说明有热点。

把面板挂上,配好告警。以后再也不会凌晨 3 点被叫醒了——至少能在面板上提前看到趋势,而不是被 PagerDuty 炸醒。

说实话,这三个问题都不是什么高深技术。但它们的共同点是——你只有被炸过一次,才会真正理解为什么要做这些防护

Redis缓存三大问题优化前后P99延迟对比
▲ 三大问题的 P99 延迟优化前后对比。缓存穿透 850ms→12ms(↓99%),雪崩 2300ms→45ms(↓98%),热点 Key 420ms→8ms(↓98%)

FAQ

Q: 布隆过滤器误判了会不会把存在的 key 也拦截了?

不会。布隆过滤器只会「假阳性」——说存在但实际不存在。它永远不会「假阴性」。如果它说不存在,那就是 100% 不存在。最坏情况:多查一次 Redis,不丢数据。误判率 0.1% 意味着 1000 次「不存在」查询里只有 1 次穿透到 Redis,完全可以接受。

Q: 随机 TTL 窗口设多大合适?

窗口 ≥ 基础 TTL 的 30%-50%。比如基础 1 小时,随机窗口就用 30 分钟。核心原则:不要让超过 10% 的 key 在同一分钟内过期。如果你的流量非常集中(如整点推送),就把窗口加宽到 50%。

Q: 本地缓存和 Redis 数据不一致怎么办?

这就是本地 TTL 要设短的原因——3 秒意味着最多 3 秒不一致。对商品详情场景完全可以接受。如果对一致性要求极高(如库存扣减),就不要用本地缓存,走 Redis + Lua 原子操作。另外在数据更新时调用 invalidate() 主动失效。

总结

回顾这次事故,三个问题的本质都一样:你假设流量是均匀的、温和的,但生产环境从来不是。三样东西加起来不到 200 行代码:

  • 缓存穿透 → 布隆过滤器挡不存在的 key(成本:几 KB 内存)
  • 缓存雪崩 → 随机 TTL + 多级缓存 + 熔断(成本:几十行代码)
  • 热点 Key → 本地缓存 + Key 拆分(成本:几十行代码 + 一点内存)

建议今天就把它们加进代码库——别等到被炸了再补。那次事故之后我对自己说:一个凌晨 3 点的 PagerDuty,值一万行防御代码。

相关阅读:

📖 扩展阅读:eBPF + bpftrace 生产环境调试实战 — 不用改代码不用重启,一行命令定位线上问题。

Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治的全过程最先出现在编程·投资·科技

]]>
Linux性能剖析实战:perf工具从CPU采样到火焰图生成全流程(2026) https://www.devlearn.club/posts/869 Wed, 24 Jun 2026 01:11:45 +0000 https://www.devlearn.club/posts/869 上周处理了一个线上问题:Nginx 反向…

Linux性能剖析实战:perf工具从CPU采样到火焰图生成全流程(2026)最先出现在编程·投资·科技

]]>
上周处理了一个线上问题:Nginx 反向代理的 PHP 服务 CPU 持续 90%+,top 一看是 php-fpm 在吃 CPU,但具体是哪个函数、哪段代码在吃——完全不知道。重启只能管 10 分钟。

这种时候,strace 太慢、gdb 太重,真正好用的工具是 perf——Linux 内核自带的性能分析器,零依赖、非侵入式,能直接告诉你 CPU 到底在忙什么。

这篇文章就是我在那之后总结的一站式 perf 实战指南。从安装到生成火焰图,每一步都有命令和解释。

perf火焰图分析:优化前后CPU热点对比
perf 分析前后 CPU 热点分布对比:json_decode 从 28% 降到 5%

📎 推荐阅读:Python性能剖析三件套:py-spy、Scalene、memray实战对比 —— 三个工具的横向对比,从CPU热点到内存分配全覆盖。

📖 扩展阅读:eBPF + bpftrace 生产环境调试实战 — 不用改代码不用重启,一行命令定位线上问题。

🔗 延伸阅读:perf 能告诉你 CPU 热点在哪里,但有些问题 perf 看不到——比如进程卡在 futex 等锁、DNS 解析超时。这种场景用 strace 生产环境调试完全指南 更直接。

perf 是什么?为什么不用 strace?

perf 是 Linux 内核的性能分析子系统(perf_events),它通过硬件性能计数器(PMU)和软件事件来采样 CPU 正在执行什么。本质上它是采样不是追踪——每隔一定时间(比如 1ms)看一眼 CPU 在跑什么,然后统计。

strace 可以追踪系统调用,但它有两个致命伤:一是——每个 syscall 都要上下文切换,生产环境跑 strace 等于给服务踩刹车;二是 strace 看不到用户态 CPU 热点——如果你的代码在 while 循环里做 json_decode,strace 啥也看不出来。

perf 的采样开销通常在 1-3%,生产环境直接跑没问题。

第一步:安装 perf

不同发行版的包名不一样,这里踩过的坑直接告诉你:

# Ubuntu/Debian
sudo apt install linux-tools-common linux-tools-$(uname -r) linux-cloud-tools-$(uname -r)

# CentOS/RHEL
sudo yum install perf

# Alpine(容器里常见)
apk add perf

# 验证安装
perf --version

如果 Ubuntu 报 Unable to locate package linux-tools-xxx,多半是内核版本太新包还没跟上。装个通用版本就行:sudo apt install linux-tools-generic

第二步:perf top — 实时看 CPU 热点

最快的上手方式是 perf top,它像 top 一样实时刷新,但显示的不是进程而是函数

sudo perf top

输出大概长这样:

Samples: 12K of event 'cpu-clock', 4000 Hz, Event count: 2876543210
Overhead  Shared Object       Symbol
  28.32%  php8.3              [.] zend_hash_find
  18.71%  php8.3              [.] php_json_decode_ex
  14.25%  libc.so.6           [.] __strstr_sse2
  10.48%  php8.3              [.] _zend_array_merge
   8.11%  [kernel]            [k] copy_user_enhanced_fast_string

一眼就能看到:zend_hash_find 占 28%,json_decode 占 18%。你的直觉可能会说”json_decode 慢”,但 perf 告诉你——哈希查找才是真凶。

实用技巧:e 键展开调用链,能看到是调用了这个热点函数。按 d 可以过滤只看某个 DSO(动态共享对象),比如 php8.3

perf top 只能看当前,不能回溯。排查间歇性 CPU 飙高的时候,你需要采样数据保存下来——这就是 perf record 的主场。

第三步:perf record — 采样保存 + 离线分析

采样 30 秒,保存到 perf.data:

# 全系统采样(推荐,能看到所有进程)
sudo perf record -a -g -F 99 -- sleep 30

# 只采样 php-fpm 进程
sudo perf record -p $(pgrep -d',' php-fpm) -g -F 99 -- sleep 30

# 参数说明:
# -a: 全系统
# -g: 记录调用链(call graph),火焰图必需
# -F 99: 采样频率 99Hz(推荐值,避免与系统时钟同频导致偏差)
# sleep 30: 采样 30 秒

采样完后看报告:

sudo perf report --stdio

输出是文本版的调用树,能看到每个函数的 CPU 占比和父子调用关系。但说实话,文本版的可读性很差——当调用链深到 10 层以上时,你很难直观看出瓶颈在哪。

这时候就该上火焰图了。

第四步:生成火焰图(FlameGraph)

火焰图是 Brendan Gregg 发明的可视化方法,把 perf 的调用栈数据变成一张 SVG 图——宽度 = CPU 时间,颜色随机(或按函数类型着色),从下到上是调用栈

生成火焰图需要 Brendan 的脚本:

# 克隆 FlameGraph 仓库(你需要 git)
git clone https://github.com/brendangregg/FlameGraph.git
cd FlameGraph

# 1. perf script 把采样数据转成可读文本
sudo perf script -i /path/to/perf.data > out.perf

# 2. 折叠调用栈
./stackcollapse-perf.pl out.perf > out.folded

# 3. 生成 SVG 火焰图
./flamegraph.pl out.folded > flamegraph.svg

三条命令出图。打开 flamegraph.svg,你会看到一张像山峰一样层叠的图——最高的”山峰”就是 CPU 消耗最大的调用路径。点击任意函数块可以放大,鼠标悬停看百分比。

这里有个容易踩的坑:如果你的 perf.data 是在 Docker 容器里采样的,宿主机的符号表可能不匹配。解决方案是用 perf archive 打包符号文件:

perf archive /path/to/perf.data
# 生成 perf.data.tar.bz2,带到开发机解压后再 perf script

第五步:读懂火焰图 — 一个真实案例

回到开头那个 CPU 90% 的问题。火焰图出来后,我一眼看到两个大平顶:

  1. json_decode — 占了 28% 的 CPU 时间,看调用链是一个 API 接口在循环里反复 decode 同一个 JSON 字符串
  2. zend_hash_find — 18%,追踪下去发现是每次请求都去读一个 200KB 的配置文件做 Hash 查找

两个问题都是典型的重复计算。修复非常简单:

// 修复 1:缓存 json_decode 结果
// Before: 每个请求 decode 3 次同一个 JSON
$data = json_decode($raw_json, true);  // 第1次
// ... 中间代码 ...
$data2 = json_decode($raw_json, true); // 第2次(重复!)
// ... 
$data3 = json_decode($raw_json, true); // 第3次(重复!)

// After: 一次 decode,到处使用
$data = json_decode($raw_json, true);
// 后续全部引用 $data

修复 2 是把配置文件用 APCu 缓存起来,避免每次请求做 Hash 查找。

改完重启 php-fpm,CPU 从 90% 掉到 12%。perf 再采样一次生成火焰图,原来的”高原”变成了”丘陵”。

进阶:perf 采样事件不止 CPU

perf record 默认采样 cpu-clock 事件,但它能采样的远不止这个:

# 查看可用事件
perf list

# 采样缓存未命中(找内存瓶颈)
sudo perf record -e cache-misses -a -g -- sleep 10

# 采样上下文切换(找调度问题)
sudo perf record -e context-switches -a -g -- sleep 10

# 采样缺页错误(找内存分配热点)
sudo perf record -e page-faults -a -g -- sleep 10

# 采样分支预测失败(找 CPU 流水线停顿)
sudo perf record -e branch-misses -a -g -- sleep 10

记住:CPU 高不一定全是计算密集。如果你采样 cache-misses 发现火焰图最高的函数是 memcpy——那是内存带宽瓶颈,不是代码算法问题。

perf stat — 一行命令看整体

如果你只是想快速了解”这个程序到底哪里有问题”,perf statperf record 更快:

sudo perf stat -d php your_script.php

输出:

Performance counter stats for 'php your_script.php':
       1234.56 msec task-clock
          4567      context-switches     #  3.699 K/sec
           123      cpu-migrations       # 99.630 /sec
         98765      page-faults          # 80.012 K/sec
    3456789012      cycles               #  2.799 GHz
    1234567890      instructions         #  0.36  insn per cycle
     234567890      branches             # 190.012 M/sec
      12345678      branch-misses        #  5.26% of all branches
     345678901      L1-dcache-loads      # 280.012 M/sec
      12345678      L1-dcache-load-misses #  3.57% of all L1-dcache accesses

关键指标解读:

  • instructions per cycle (IPC) < 1.0:CPU 在等内存/等分支预测,代码有优化空间
  • branch-misses > 5%:分支预测失败率高,检查是否存在大量不可预测的条件分支
  • L1-dcache-load-misses > 5%:数据局部性差,考虑调整数据结构(AoS → SoA)

FAQ

Q: perf 采样需要 root 权限吗?

看内核参数 /proc/sys/kernel/perf_event_paranoid。值 ≤ 0 时普通用户也能采样(Docker 容器通常设为 -1),值 ≥ 2 时只有 root 能用。生产环境 Docker 里一般直接 sudo 就行。

Q: perf top 看不到函数名,只显示地址怎么办?

通常是缺少调试符号。安装对应的 dbgsym 包:sudo apt install php8.3-dbgsym。如果找不到 dbgsym 包,检查 /etc/apt/sources.list.d/ddebs.list 是否配置了 debug 源。

Q: perf record 生成的 perf.data 文件很大怎么办?

降低采样频率:-F 49 或更低。减少采样时间:sleep 10 而不是 60。或者在 Docker 容器里采样(容器内进程少,数据量小很多)。采样 30 秒 99Hz 的 perf.data 通常在 5-20MB,不算大。

Q: 火焰图中看到了 __memcpy_avx_unaligned 占大量 CPU,怎么优化?

这通常说明你的代码在做大量内存拷贝。排查方向:是否在函数间传递了大对象而不是引用?是否在循环中拼接大字符串?PHP 里 $a = $b 是写时复制但 $a .= $b 会触发拷贝。如果是字符串操作热路径,考虑用引用传递或分块处理。

Q: perf 和 eBPF(bpftrace)有什么区别?什么时候用哪个?

perf 是”看”——采样统计 CPU 热点、缓存未命中、分支预测等。bpftrace 是”插”——在函数入口/出口插桩,记录参数、返回值、延迟。简单说:先用 perf 找到热点函数,再用 bpftrace 深入分析那个函数的输入输出。两者配合使用效果最好。

总结

perf 是我用过性价比最高的性能分析工具——内核对它的支持是原生的,零依赖,采样开销低到可以忽略。配合火焰图,原本”CPU 高”这种模糊的问题能在几分钟内精确到函数级别。

记住这个排查流程:

  1. perf top — 30 秒看全局,找到可疑的 DSO/函数
  2. perf record -g -F 99 — 采样 30-60 秒,保存现场
  3. 生成火焰图 — 一眼看到最大的”平顶”
  4. perf stat — 补充 IPC、cache-miss 等宏观指标
  5. bpftrace(可选)— 在热点函数插桩看参数

别再用 echo 大法和 var_dump 排查性能问题了。CPU 从来不骗人,你只需要学会听懂它说的话。

💡 相关文章: 如果你还没看过,推荐搭配阅读本站的 Linux生产环境CPU 100%问题排查实战MySQL慢查询优化实战,这两篇和本文组成了完整的性能排查三部曲。

Linux性能剖析实战:perf工具从CPU采样到火焰图生成全流程(2026)最先出现在编程·投资·科技

]]>
2026年终端神器盘点:8个让开发者效率翻倍的CLI工具 https://www.devlearn.club/posts/850 Sun, 21 Jun 2026 01:12:14 +0000 https://www.devlearn.club/posts/850 除了CLI工具,AI编程工具也在飞速进化…

2026年终端神器盘点:8个让开发者效率翻倍的CLI工具最先出现在编程·投资·科技

]]>
除了CLI工具,AI编程工具也在飞速进化——我做了个 2026年AI编程工具深度横向评测,把Copilot、Cursor、Windsurf对比了一遍,看看哪些真正值得上。

前言:从一次”等 grep 跑完”的中午说起

上周五中午,我对着终端等一个 grep -r "ERROR" /var/log/app/ 跑完,顺手刷了会儿手机。3分钟后它出结果了——19条匹配,信息有用,但等了整整三分钟。

隔壁同事瞟了一眼:”你还在用 grep?换 ripgrep 啊,你那27GB日志它十几秒就扫完。”

我当时没当回事。直到下午排查线上问题又等了一次 grep 跑完,心态彻底崩了——决定把整个终端工具链翻新一遍。结果你猜怎么着?换了 rg 之后,同样的搜索 2.1 秒出结果。

13.6 倍的速度差距。我确认了好几遍不是幻觉。

这周把几个最值得换的工具整理出来,每个我都用了至少一周。它们不是那种”收藏即吃灰”的玩具——安装上去当天就能感受到明显变化。

1. ripgrep (rg) — 让你不再”等 grep 跑完”

一句话: grep 的现代替代品,默认递归、默认忽略 .gitignore、默认输出带颜色、默认快 10-100 倍。

为什么这么快?

ripgrep 用 Rust 写的,底层用了 SIMD 指令加速文本搜索。它还会自动跳过 .gitignore 里的文件和二进制文件——这很重要,因为多数项目里真正需要搜的”文本文件”只占一小部分。

日常用法对比

# 以前你用 grep:
grep -r "function" --include="*.py" src/

# 现在用 rg,一行搞定:
rg "function" src/ --type py

# 搜日志文件名匹配:
rg "5xx" /var/log/ --glob "*.log"

# 输出匹配行数(取代 grep -c):
rg -c "TODO" src/

# 在输出中显示上下文(取代 grep -C):
rg -C 3 "panic" main.go

我的真实体验

换了 rg 后的第一个周一,我习惯性地敲了 grep,然后默默按了上箭头改成 rg。一周后已经完全想不起 grep 的语法了——rg 的默认行为(递归、自动忽略无关文件)太符合直觉,根本不需要记参数。

唯一小坑:如果你习惯用 grep -r "pattern" . 不加 --include,rg 默认会搜更多文件(因为它不限制文件类型),但速度反而更快。第一次用的时候先跑 rg "test" 试试——10GB 项目可能只需要 2-3 秒,别被吓到以为没跑完。

2. fd — 比 find 快 50 倍的查找命令

一句话: 如果你还在用 find . -name "*.py" 找文件,你每个月大概在无谓等待上浪费了 10 分钟。

对比

# 找 Python 文件:
find . -name "*.py" -type f       # 传统,4.8s 搜索 5万文件
fd -e py                           # fd,0.3s 搜索 5万文件

# 正则搜索(fd 默认支持正则):
find . -regex ".*test.*\.py$"    
fd ".*test.*\.py$"                  # 更简洁

# 搜索时忽略某目录:
find . -name "*.log" -not -path "./node_modules/*"
fd -e log -E node_modules           # 简洁多了

# 找到文件后执行命令(xargs 替代):
find . -name "*.py" -exec wc -l {} \;
fd -e py -x wc -l

实用技巧

我用 fd 最频繁的场景不是搜索,而是配合 fzf 做模糊文件打开:

# 在 vim 中::e $(fd -tf | fzf)
# 配合 neovim 的 Telescope 插件
# 或者直接用终端:vim $(fd "config" | fzf)

3. bat — cat 的”着色版”,看完就回不去了

一句话: cat 的增强替代,自动语法高亮 + 显示行号 + Git 修改标记。

# 查看脚本(自动高亮 + 行号):
bat deploy.sh

# 查看 JSON(自动识别格式,再也不用 cat 后靠眼睛找括号):
bat config.json

# 显示不可打印字符(比 cat -A 好看 100 倍):
bat -A binary_file

# 作为 less 的分页器:
bat --paging=always large_file.py

我最喜欢 bat 的一个细节:它会在左边栏显示 Git 修改状态(新增/修改/删除行)。代码 review 的时候 bat app.py 一眼就能看到哪些行是新改的——比你先 git diff 再看文件快得多。

安装后记得设别名:alias cat='bat' 或者 alias c='bat'。我用了两周后已经肌肉记忆全是 bat 了。

4. lazygit — 命令行里的 Git GUI

一句话: 如果你还在用 git add -p + git commit + git push 三步走,试试懒人模式吧。

用 lazygit 之前:

# 每次提交要敲 3-5 个命令
git status
git add -p src/main.go  # 还要手动选 hunk
git commit -m "fix: ..."
git push

用 lazygit 之后:

# 打开一个 TUI 界面,键盘操作
lazygit
# 然后按:
# 空格 → 暂存选中文件
# c → 提交
# P → 推送
# 全程不用敲一个 git 命令

lazygit 最牛的功能

  • 交互式 rebase: 在commit列表里用 j/k 移动,回车选 commit —— 这个功能我在终端里从来没记住过 git rebase -i 的语法
  • 冲突解决: 可视化显示冲突文件的两边版本,按左右键选择保留哪边
  • stash 管理:git stash list 直观得多
  • 分支切换: 列表显示所有分支,/ 键搜索

别觉得”学个新工具很麻烦”。lazygit 的核心操作就 5 个键:空格/stage、c/提交、P/推送、p/拉取、q/退出。上手 5 分钟,终身收益。

5. fzf — 通用模糊搜索神器

一句话: 它不是一个”工具”,而是一个”接口”——把任何列表都变成可模糊搜索的交互界面。

实用场景

# Ctrl+R 历史命令搜索增强版(按 Ctrl+R 后直接模糊匹配):
# 原来 bash 的 Ctrl+R 要按多次循环找,fzf 直接过滤

# 切换 Git 分支:
git branch | fzf | xargs git checkout

# 搜索并打开文件(配合 fd 极速):
vim $(fd -tf | fzf)

# 杀掉进程:
kill -9 $(ps aux | fzf | awk '{print $2}')

# 搜索 Docker 容器日志:
docker logs $(docker ps | fzf | awk '{print $1}')

我的翻车经历

刚装上 fzf 那周,我几乎把所有命令都加了 fzf 管道。最离谱的一次:用 ls | fzf 来选文件——同事路过看到了,沉默了五秒说”你是不是有点过度依赖了?”

但讲真,Ctrl+R 增强版和 git branch | fzf 这两个场景,每一个都值回安装成本。

6. zoxide — 告别 cd 的”反复 cd ..”噩梦

一句话: cd 的智能版,学一次路径后下次直接 z 关键词 跳过去。

对比

# 以前去项目目录:
cd ~/projects/work/backend/services/user-auth

# 用 zoxide 后(只要去过一次):
z user-auth

# 或者模糊匹配
z back user

# 列出最常访问的目录:
zi
# 显示一个列表,选哪个跳哪个

# 交互模式(配合 fzf):
zi

zoxide 的原理是记录你 cd 到每个目录的频率和时效性,然后用 frecency(频率+时效性)算法排序。它比 autojump 和 fasd 都准确——至少我用下来几乎没有跳到”我明明想去另一个同名目录”的情况。

唯一的坑:刚开始用的时候你不能依赖它——它需要积累大概 20-30 次 cd 记录才能准确。装好后正常用 cd 一周,之后就回不去了。

7. tmux — 终端多路复用器

一句话: 在一个终端窗口里开多个”虚拟终端”,每个 session 独立,SSH 断开不丢失。

为什么要用 tmux

以前我本地开 5 个终端窗口:一个跑 dev server、一个看日志、一个写代码、一个 git 操作、一个随手跑命令。窗口多了 Alt+Tab 按到手疼。

tmux 让我把 5 个窗口合成 1 个:

# 创建新 session
tmux new -s dev

# 在 tmux 内:
# Ctrl+B % → 左右分屏
# Ctrl+B " → 上下分屏
# Ctrl+B c → 新建标签页
# Ctrl+B d → 断开(程序继续跑)

# 重新连接:
tmux attach -t dev

SSH 必备

在服务器上开 tmux,SSH 断开了程序还在跑,重连后 tmux attach 就回到原来的界面。这招救过我至少三次——有次部署到一半 WiFi 断了,重连后 tmux 里的 build 还在跑,心都放下了。

强烈建议把 tmux 的 prefix 键从 Ctrl+B 改成 Ctrl+A(很多人觉得这样更顺手):

# ~/.tmux.conf
set -g prefix C-a
unbind C-b
bind C-a send-prefix

8. httpie — 人类可读的 curl 替代

一句话: 如果你经常调试 API,httpie 的彩色输出 + 简洁语法会让你爱上发 HTTP 请求。

# curl 版:
curl -X POST https://api.example.com/users \
  -H "Authorization: Bearer xxx" \
  -H "Content-Type: application/json" \
  -d '{"name":"John","email":"john@example.com"}'

# httpie 版:
http POST https://api.example.com/users \
  Authorization:"Bearer xxx" \
  name=John email=john@example.com

httpie 默认格式化 JSON 响应并着色,支持 session 管理(同一个 session 自动带 cookie)——调试需要登录的 API 时,不用每次手动带 cookie。

如果不想装新的客户端,也可以用 curlie(也是 Rust 写的),语法兼容 curl 但输出像 httpie。

总结:一张表看全

传统命令行工具与现代替代品的性能对比和功能特性对比图表

工具 替代对象 一句话推荐理由 安装方式
ripgrep (rg) grep 快 10-100 倍,默认递归,自动忽略无关文件 apt install ripgrep
fd find 快 10-50 倍,语法更简洁,配合 fzf 绝配 apt install fd-find
bat cat 自动语法高亮 + Git 标记,看文件像看 IDE apt install bat
lazygit git CLI TUI 界面操作 git,交互式 rebase 终于能用了 官方脚本安装
fzf Ctrl+R 通用模糊搜索框架,万物皆可 fzf apt install fzf
zoxide cd 智能跳转目录,打字少 80% apt install zoxide
tmux 多窗口 终端编辑器 + 分屏 + 会话持久化 apt install tmux
httpie curl 人类友好的 HTTP 客户端,API 调试利器 apt install httpie

换工具这件事,最难的不是安装,而是改掉肌肉记忆。我给自己定了个规矩:每次不小心敲了旧命令,就暂停一秒想想新工具怎么写。一周后,换 rg、fd、bat 这三个基本就没痛感了。

如果你已经用了其中几个,评论区说说你最喜欢的组合?我最近在试 fd | fzf | xargs nvim 的搜索-打开工作流,效率确实高——如果你有更好的玩法也欢迎分享。

FAQ

Q: 这些工具在 macOS 上能用吗?

大部分都可以用 Homebrew 安装:brew install ripgrep fd bat lazygit fzf zoxide tmux httpie。只有 fd 在 macOS 上叫 fd(Ubuntu 上需装 fd-find 并别名 fdfindfd)。

Q: 这些工具会影响现有脚本吗?

只有定义别名(如 alias grep='rg')才会影响其他脚本。建议不要设覆盖别名——保持旧命令在脚本中可用,平时交互用新命令。shell 配置文件里只加新命令的别名就好。

Q: ripgrep 和 grep 的兼容性怎么样?

不兼容。rg 不支持 -P(Perl 正则)、-o(只输出匹配部分,虽然 rg 有 -o 但行为略有差异)、--include(用 --type--glob)。复杂的 shell 脚本不要直接改 rg。交互使用直接上手 rg,需要兼容的场景保留 grep。

Q: lazygit 学习成本高吗?

不高。核心操作只有 6 个按键:空格(暂存)、c(提交)、P(推送)、p(拉取)、q(退出)、?(帮助)。底部状态栏一直显示当前可用的快捷键。运行 lazygit 后 5 分钟就能上手。

Q: 这些工具哪个最值得先换?

如果只能推荐一个,我会选 ripgrep。它带来的速度提升是最明显的——搜索日志文件从”等 30 秒”变成”眨个眼就出来了”。其次是 fzf,因为它可以配合其他所有工具使用,价值叠加效应最强。这两个装好只要 30 秒,当天就能感受到区别。

内链推荐

2026年终端神器盘点:8个让开发者效率翻倍的CLI工具最先出现在编程·投资·科技

]]>
Linux生产环境CPU 100%问题排查实战:从发现到定位的完整复盘(2026) https://www.devlearn.club/posts/839 Fri, 19 Jun 2026 01:05:41 +0000 https://www.devlearn.club/posts/839 凌晨两点的告警电话 手机震了三下我才醒。…

Linux生产环境CPU 100%问题排查实战:从发现到定位的完整复盘(2026)最先出现在编程·投资·科技

]]>
凌晨两点的告警电话

手机震了三下我才醒。一看,运维群炸了——“线上接口全部超时!”“CPU 100%了!”“用户投诉爆了!”。

这种事情干过运维的都懂:你半梦半醒地打开电脑,一边祈祷是监控误报,一边连 VPN。然后 top 一敲,心就凉了半截——load average 三位数,CPU 全部飙红。

这篇文章就是我从几十次线上 CPU 飙高事故中总结出来的排查方法论。不是教科书,是真刀真枪干出来的。每一步都有具体命令、具体输出、具体判断标准。你下次遇到 CPU 告警,按这个流程走,十分钟定位、半小时修复。

第一步:快速确认——是不是真的 CPU 满了

别上来就瞎操作。先确认三件事:负载高不高、CPU 利用率多少、I/O 有没有问题。因为有些“CPU 高”其实是 I/O wait,方向完全不一样。

$ uptime
 02:14:23 up 47 days,  3:12,  2 users,  load average: 28.51, 24.33, 18.67

这台机器是 16 核,load average 28 意味着有 12 个进程在排队等 CPU。不是 I/O wait 的问题,是真的 CPU 吃满了。

$ top -bn1 | head -5
top - 02:14:35 up 47 days,  3:13,  2 users,  load average: 28.51, 24.33, 18.67
Tasks: 342 total,   4 running, 338 sleeping,   0 stopped,   0 zombie
%Cpu(s): 92.3 us,  5.1 sy,  0.0 ni,  2.0 id,  0.0 wa,  0.0 hi,  0.6 si,  0.0 st

关键看这几个指标:us(用户态 CPU)92.3% → 不是内核问题,是应用代码在烧 CPUwa(I/O wait)0.0% → 不是磁盘问题。id(idle)只剩 2% → CPU 确实没余量了。

判断口诀:us 高 → 查应用代码;sy 高 → 查系统调用/kernel;wa 高 → 查磁盘/网络 I/O;si 高 → 查中断。

第二步:定位元凶进程

接下来找出哪个进程在吃 CPU:

$ top -bc -n1 | head -18

  PID USER      %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
28342 app       89.2  2.3 4523600 376512 ?     Sl   01:23  42:15 /usr/local/bin/java -jar app.jar
 1289 app        4.0  1.1 2104568 184224 ?     Sl   Jun13  32:10 /usr/local/bin/python3 /app/worker.py
 3012 root       2.3  0.1  25688  1708 ?        Ss   Jun13  18:22 /usr/sbin/nginx

PID 28342 的 Java 进程占了 89.2% CPU——它就是元凶。记录下来,下一步对它做手术。

hot tip:top -bc 而不是 top -b,区别是 -c 会显示完整命令行,你能一眼看出是哪个服务、哪个 jar 包、甚至启动参数。

第三步:线程级定位——搞清楚是谁在烧 CPU

一个 Java 进程里可能有几百个线程,不能“整个进程杀掉”就完事。得精确到线程。

$ top -Hbp 28342 -n1 | head -10

  PID USER      %CPU COMMAND
28342 app        0.3 java
28355 app       85.1 java          ← 这个线程!
28356 app        0.1 java
28357 app        2.3 java

-H 开启线程视图,-p 指定进程。结果很清楚:TID 28355 的线程占了 85.1% CPU。把它转成十六进制(jstack 用):

$ printf '%x
' 28355
6ec3

然后导出线程 dump:

$ jstack 28342 > /tmp/jstack_dump.txt
$ grep -A 20 '0x6ec3' /tmp/jstack_dump.txt
"http-nio-8080-exec-37" #137 daemon prio=5 os_prio=0 tid=0x00007f8b4c001000 nid=0x6ec3 runnable
   java.lang.Thread.State: RUNNABLE
        at com.example.service.ReportService.generateReport(ReportService.java:156)
        at com.example.controller.ReportController.getReport(ReportController.java:42)
        ...

找到了。一个 HTTP 请求线程,卡在 ReportService.generateReport() 方法里出不来了。

如果进程不是 Java 的怎么办?Python 用 py-spy dump --pid PID;Go 用 curl localhost:6060/debug/pprof/goroutine?debug=2;C/C++ 直接用 perf(下一步)。

第四步:perf 揪出热点函数

jstack 只告诉你哪个方法,但方法内部到底哪行代码在烧 CPU?perf 上场。

$ perf top -p 28342

Samples: 189K of event 'cpu-clock', Event count (approx.): 45897250000
Overhead  Shared Object       Symbol
  48.21%  libc-2.31.so        [.] __strlen_avx2
  18.33%  perf-28342.map      [.] 0x00007f8b2c1a3f80
  12.05%  [kernel]            [k] copy_user_generic_string
   8.72%  libpthread-2.31.so  [.] __pthread_mutex_lock
   5.11%  libjvm.so           [.] SpinPause

看到了没有——strlen 占了 48% CPU。strlen 是字符串长度计算,正常情况下它不该出现在热点里。这意味着代码里可能在做一个超长字符串的反复操作:正则、拼接、序列化 JSON、或者大数据量的字符串替换。

用 perf record 采样 30 秒然后生成报告:

$ perf record -p 28342 -g -- sleep 30
$ perf report --stdio | head -30

# 调用栈一目了然:
48.21%  strlen
  └─ 38.15%  regexec          ← 正则引擎!
      └─ 35.22%  java.util.regex.Matcher.find
          └─ 33.08%  ReportService.generateReport
              └─ 30.12%  ReportService.formatCSVData

串联起来了。 generateReportformatCSVData → 用了正则去解析 CSV 大文件。几十万行 CSV 用正则一行行扫,CPU 不爆才怪。

第五步:生成火焰图——一张图看清一切

perf report 的输出是文本,不够直观。火焰图能把整个调用栈压缩成一张 SVG:

$ perf script > /tmp/out.perf
$ git clone https://github.com/brendangregg/FlameGraph.git
$ FlameGraph/stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded
$ FlameGraph/flamegraph.pl /tmp/out.folded > /tmp/flamegraph.svg

打开 SVG,你会看到一个宽得离谱的“平顶山”——strlen → regexec → Matcher.find → formatCSVData 这条调用链占了一半宽度。这就是你要优化的目标。

火焰图怎么看?宽 = 占 CPU 多,高 = 调用深。平顶(宽度大但高度小)= 函数本身耗时多。尖峰(宽度小但高度大)= 调用链长。优化先砍平顶。

Linux生产环境CPU 100%排查七步SOP流程图

第六步:strace 验证——系统调用视角

如果怀疑是系统调用层面出了问题(比如频繁 open/read 小文件),用 strace 统计:

$ strace -cp 28342 -o /tmp/strace_summary.txt
# 运行 15 秒后 Ctrl+C

$ cat /tmp/strace_summary.txt
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 45.21    5.231452         123     42531           read
 22.17    2.564021          89     28765           write
 18.33    2.121543         245      8654           futex
  8.09    0.936221         312      3001           poll
  3.15    0.364102          98      3712           brk
------ ----------- ----------- --------- --------- ----------------
100.00   11.571234                  90345       142 total

这里 read 占 45%,调用 42531 次——合理,正则扫 CSV 确实会产生大量读操作。但如果有异常信号(比如 brk 调用异常多 = 频繁内存分配),就要针对性优化。

第七步:根因修复与验证

回过头来修那个正则。原始代码大概是这样的:

// ❌ 用正则解析 CSV —— 灾难级实现
String pattern = "((?:\"|[^\"])*),((?:\"|[^\"])*)";
Pattern r = Pattern.compile(pattern);
for (String line : lines) {
    Matcher m = r.matcher(line);
    // 几十万行 × 正则回溯 = CPU 爆炸
}

修复方案:

// ✅ 用 Apache Commons CSV(流式解析,零正则)
Reader in = new FileReader("data.csv");
Iterable<CSVRecord> records = CSVFormat.DEFAULT.parse(in);
for (CSVRecord record : records) {
    String col1 = record.get(0);
    // 不触发回溯,O(n) 直读
}

或者更极简的——如果 CSV 格式简单(无内嵌逗号/引号),直接用 String.split(",")

// ✅ split 是 O(n),正则回溯是 O(2^n)
String[] cols = line.split(",", -1);

通用排查清单(遇到 CPU 高,按顺序问自己):

  1. ✅ 是 us(用户态)还是 sy(内核态)高?——确定查应用还是查内核
  2. ✅ 是单进程还是多进程一起飙?——确定是应用的锅还是系统的锅
  3. ✅ 线程级:哪个线程?——精确到线程 ID
  4. ✅ 热点函数:perf top 找到最耗 CPU 的函数
  5. ✅ 常见元凶:正则回溯、死循环、频繁 GC、N+1 循环、大对象反复序列化
  6. ✅ 验证:修复后用 ab -n 10000 -c 100 压测,确认 CPU 不再飙高

排查工具箱速查表

工具 用途 典型命令 适用场景
top 进程级 CPU/内存总览 top -bc -n1 第一步:快速定位进程
pidstat 线程级 CPU/内存/IO 统计 pidstat -t -p PID 1 持续监控单个线程
jstack Java 线程 dump jstack PID > dump.txt Java 进程专属
perf CPU 热点采样 + 火焰图 perf top -p PID 任何语言:找最耗CPU的函数
strace 系统调用追踪 strace -cp PID 怀疑系统调用层面问题
py-spy Python 进程采样 py-spy dump --pid PID Python 进程专属
htop 交互式进程监控 htop -p PID 需要频繁切换排序维度
vmstat 系统整体资源快照 vmstat 1 10 CPU+内存+IO 三维总览

四条血泪教训

1. 不要在生产环境反复试错。先用 perf record -p PID -g -- sleep 30 采 30 秒样,然后在本地分析 perf.data。生产环境跑 perf top 本身也有 2-3% CPU 开销。

2. 先止血,再排查。如果接口已经全部超时,别在那里慢慢看 jstack——先重启、先切流量、先限流。可用性 > 排查完整性

3. 正则回溯是 Java CPU 飙高的头号杀手。我遇到过四次线上 CPU 100%,三次是正则。养成习惯:永远不用正则解析结构化数据(CSV/JSON/XML),永远用专用解析器。

4. 保留现场。出问题后别急着重启——先 jstack、先 perf record、先 top -b -n1 > /tmp/top_snapshot.txt没有快照的故障复盘等于猜谜。

常见问题 FAQ

Q: CPU 100% 但 top 里 wa(I/O wait)也很高,先查哪个?

先查 I/O。wa 高意味着 CPU 在等磁盘/网络,此时 us 高可能是“虚假的高”——进程在忙等 I/O 完成而非真的在计算。用 iostat -x 1iotop 定位是哪个磁盘/哪个进程在产生 I/O。如果确认 I/O 没问题后再按本文流程查 CPU。

Q: jstack 导出 thread dump 时 Java 进程会卡住吗?

会短暂暂停所有线程(SafePoint),通常几十毫秒到几百毫秒。对于大部分应用可以忽略不计。但如果你的应用对延迟极度敏感(如高频交易),建议用 jcmd PID Thread.print 代替 jstack,或者用异步采样工具如 async-profiler。

Q: perf 提示 “Permission denied” / “Cannot access memory” 怎么办?

三个可能的原因:(1) /proc/sys/kernel/perf_event_paranoid 值太高——设为 -1;(2) 当前用户不是 root 且没有 CAP_SYS_ADMIN——用 root 执行;(3) Java 进程没开 -XX:+PreserveFramePointer——加上这个 JVM 参数重启后 perf 才能正确展开调用栈。

Q: 排查完了,怎么防止下次再出现?

三个防线:(1) 监控告警——CPU > 80% 持续 2 分钟就发告警,不等 100%;(2) 限流熔断——接口加 Rate Limiter,单个请求超 30 秒自动熔断;(3) 上线前压测——任何涉及正则/大循环的代码,上线前用 abwrk 跑一轮基准压测,对比 CPU 曲线。

了解更多

如果你对性能排查感兴趣,本站还有这些相关文章:

免责声明:本文内容基于个人生产环境实战经验编写,所有命令和工具用法均已在 Ubuntu 20.04/22.04/24.04 及 CentOS 7/Stream 上验证。不同环境可能有差异,生产环境操作前建议先在测试环境验证。文中的排查方法不构成任何商业服务承诺。

📎 相关阅读:Python并发编程选型指南:ThreadPoolExecutor vs asyncio vs ProcessPoolExecutor实测对比(2026) — 同一台机器实测四种并发方案,附选型决策树。

🔗 推荐阅读:Linux性能剖析实战:perf工具从CPU采样到火焰图生成全流程 — 深入CPU热点定位的下一步。

📌 排查系列新文:Python 生产环境内存泄漏排查实战:tracemalloc + objgraph + gc 三件套从发现到修复

📖 相关推荐:Redis生产环境踩坑实录:缓存穿透、雪崩、热点Key——从凌晨告警到根治

📖 扩展阅读:eBPF + bpftrace 生产环境调试实战 — 不用改代码不用重启,一行命令定位线上问题。

🔗 延伸阅读:当 CPU 问题排查完后还遇到进程假死无日志的情况,试试 strace 生产环境调试完全指南,不重启不改代码就能看到程序在系统调用层面到底卡在哪里。

📡 延伸阅读:Linux 网络故障排查实战:从 TCP 超时到连接池耗尽的全链路诊断

Linux生产环境CPU 100%问题排查实战:从发现到定位的完整复盘(2026)最先出现在编程·投资·科技

]]>
Linux systemd服务管理完全指南:从编写Unit文件到定时任务替代crontab(2026) https://www.devlearn.club/posts/825 Tue, 16 Jun 2026 07:57:43 +0000 https://www.devlearn.club/posts/825 前言:为什么你需要学systemd? 如…

Linux systemd服务管理完全指南:从编写Unit文件到定时任务替代crontab(2026)最先出现在编程·投资·科技

]]>
前言:为什么你需要学systemd?

如果你在Linux上跑过任何服务——Nginx、MySQL、你写的Python脚本、一个定时任务——那你一定遇到过这些场景:

  • 服务器重启了,你的程序没自动起来,用户开始骂人
  • 想用crontab做定时任务,但日志乱七八糟,不知道跑成功没
  • 程序挂了,你不知道,直到有人告诉你「网站打不开了」
  • 想限制一个服务的内存/CPU,完全不知道怎么搞

这些问题,systemd都能解决。它是现代Linux系统的「大管家」——管启动、管监控、管日志、管资源。本教程从零开始,带你从写第一个Unit文件到用它替代crontab。

适用环境:Ubuntu 24.04 / 22.04,CentOS Stream 9,Debian 12。systemd版本 >= 249。

第一步:理解systemd的三个核心概念

别被systemd的复杂性吓到。日常使用中,你只需要搞懂三个东西:

概念 是什么 类比
Unit 一个「工作单元」的配置文件 一个任务说明书
Service 最常见的Unit类型,定义如何启动/停止一个后台程序 员工(干活的人)
Timer 定时触发Service的Unit 闹钟

Unit文件放在哪?两个地方:

  • /etc/systemd/system/ — 你自己写的,优先级最高
  • /lib/systemd/system/ — 系统自带的,别改

第二步:写你的第一个Service Unit

假设你有一个Python脚本 /opt/myapp/main.py,需要开机自启、挂了自动重启。以前你可能用nohup、screen、tmux……现在用systemd:

创建Unit文件

sudo vim /etc/systemd/system/myapp.service

写入以下内容:

[Unit]
Description=My Python Application
After=network.target
Documentation=https://docs.myapp.com

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/python3 /opt/myapp/main.py
Restart=always
RestartSec=10
StandardOutput=append:/var/log/myapp/output.log
StandardError=append:/var/log/myapp/error.log

[Install]
WantedBy=multi-user.target

逐行解释(每一行都有用)

[Unit] 段 — 描述和依赖:

  • Description=:人类可读的名字,用 systemctl status myapp 时会显示
  • After=network.target:确保在网络启动后才启动你的服务——不然你的程序连不上数据库
  • Documentation=:可选,给你的同事(和三个月后的自己)看的

[Service] 段 — 核心:怎么启动:

  • Type=simple:最常见的类型,systemd认为ExecStart启动后服务就运行了。大多数Python/Node/Go程序用这个
  • User=www-data永远不要用root跑你的应用!
  • WorkingDirectory=:程序的工作目录,影响相对路径
  • ExecStart=:启动命令,必须写绝对路径
  • Restart=always:挂了自动重启(包括正常退出和崩溃)
  • RestartSec=10:挂了等10秒再重启,防止「启动即崩溃」的无限循环
  • StandardOutput/Error=append::日志输出到文件(不设的话输出到journald)

[Install] 段 — 什么时候启动:

  • WantedBy=multi-user.target:系统进入多用户模式(正常启动完成)时启动。绝大多数服务都用这个

启动你的服务

# 1. 重载systemd配置(每次改了Unit文件都要执行)
sudo systemctl daemon-reload

# 2. 启动服务
sudo systemctl start myapp

# 3. 查看状态
sudo systemctl status myapp

# 4. 设置开机自启
sudo systemctl enable myapp

# 5. 验证开机自启
sudo systemctl is-enabled myapp
# 输出:enabled ✅

如果一切正常,systemctl status 会显示绿色的 active (running),以及最后10行日志。

第三步:日常管理命令速查

# 启动/停止/重启
sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp

# 重载配置(不重启进程)
sudo systemctl reload myapp   # 需要服务支持(如Nginx)

# 查看状态
sudo systemctl status myapp
sudo systemctl is-active myapp   # 只返回active/inactive
sudo systemctl is-enabled myapp  # 查是否开机自启

# 查看日志
sudo journalctl -u myapp         # 全部日志
sudo journalctl -u myapp -f      # 实时跟踪(类似tail -f)
sudo journalctl -u myapp --since "1 hour ago"
sudo journalctl -u myapp -n 50   # 最后50行

# 禁用/启用
sudo systemctl disable myapp     # 取消开机自启
sudo systemctl enable myapp      # 恢复开机自启
sudo systemctl mask myapp        # 彻底禁止启动(连手动也不行)
sudo systemctl unmask myapp      # 解除mask

第四步:用systemd Timer替代crontab

crontab有几个痛点:日志难查、环境变量一团乱、不知道上次任务跑完没就启动了第二次。systemd Timer完美解决。

Timer需要两个文件:一个Service(定义跑什么),一个Timer(定义什么时候跑)。

场景:每天凌晨3点备份数据库

先写Service:/etc/systemd/system/db-backup.service

[Unit]
Description=Database Backup Job
After=mysql.service

[Service]
Type=oneshot
User=backup
ExecStart=/opt/scripts/backup-db.sh
StandardOutput=journal
StandardError=journal

Type=oneshot 表示这是一次性任务(跑完就退出),不是常驻服务。

再写Timer:/etc/systemd/system/db-backup.timer

[Unit]
Description=Daily Database Backup Timer

[Timer]
OnCalendar=daily
# 等价于 OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

Timer关键选项解释

  • OnCalendar=daily:每天一次。等价于 *-*-* 00:00:00。其他快捷方式:hourlyweeklymonthly
  • OnCalendar=*-*-* 03:00:00:精确到秒。格式:年-月-日 时:分:秒。* 表示任意
  • Persistent=true必加!如果机器在预定时间处于关机状态,开机后立即补跑。不加的话就跳过了
  • RandomizedDelaySec=300:随机延迟0-300秒再执行。防止多台机器同时做同一件事把数据库打爆

更灵活的定时表达式

# 每周一凌晨2点
OnCalendar=Mon *-*-* 02:00:00

# 每月1号和15号上午9点
OnCalendar=*-*-01,15 09:00:00

# 每30分钟
OnCalendar=*:0/30

# 工作日(周一到周五)每小时
OnCalendar=Mon..Fri *-*-* *:00:00

启动和管理Timer

sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer   # 启用+立即启动

# 查看所有Timer
sudo systemctl list-timers --all

# 查看某个Timer的下次触发时间
sudo systemctl status db-backup.timer

# 手动触发(不等定时)
sudo systemctl start db-backup.service

第五步:进阶技巧——资源限制与安全加固

限制内存和CPU

在你的Service文件 [Service] 段加上:

[Service]
# ... 其他配置 ...

# 内存限制
MemoryMax=512M        # 硬限制,超过就杀进程
MemoryHigh=400M       # 软限制,超过就开始throttle

# CPU限制
CPUQuota=50%          # 最多用半个CPU核心
# CPUQuota=200%       # 最多用2个CPU核心

# 进程数限制
TasksMax=100          # 最多开100个线程/进程

安全加固——不让你的服务为所欲为

[Service]
# 禁止服务修改自身的可执行文件
ProtectSystem=strict

# 只读访问/etc,不允许修改系统配置
ProtectSystem=full
ReadWritePaths=/var/log/myapp /var/lib/myapp

# 隔离/home、/root、/run/user
ProtectHome=yes

# 禁止访问网络(适用于纯计算/本地任务)
PrivateNetwork=yes

# 独立的/tmp,服务看不到其他进程的临时文件
PrivateTmp=yes

# 禁止获取新的特权(就算程序有漏洞也无法提权)
NoNewPrivileges=yes

常见问题

Q: systemctl status显示active但程序没跑起来?

最常见的原因:你的程序启动了但马上就退出了(比如Python脚本有语法错误)。systemd看到进程退出,按Restart=always重启……然后你的程序又立刻退出。这会形成一个快速循环。查看日志:sudo journalctl -u your-service -n 50,找报错信息。另外检查Type设置——如果你的程序会fork到后台(daemonize),Type应该用forking而不是simple

Q: systemd和crontab到底选哪个?

新项目一律推荐systemd Timer。crontab在以下场景依然可以:超级简单的命令(如每天echo一下)、个人临时任务。但只要是生产环境的定时任务——systemd Timer。日志统一(journalctl)、环境变量明确、依赖管理(After=网络/数据库)、失败重试——这些crontab都要额外写脚本处理,而systemd原生支持。

Q: 怎么让systemd在服务启动前先等数据库就绪?

在[Unit]段加:After=mysql.serviceRequires=mysql.service。After=确保顺序,Requires=确保mysql先启动成功(否则本服务不启动)。如果数据库和你的应用不在同一台机器,可以考虑用ExecStartPre=/usr/bin/sleep 5(简单粗暴),或更优雅地写一个健康检查脚本放在ExecStartPre。

Q: 修改了Unit文件之后必须daemon-reload吗?

是的。任何对Unit文件的修改(包括新建、删除、编辑),都要执行sudo systemctl daemon-reload,否则systemd会用缓存的旧配置。唯一例外是systemctl edit service-name --full这样用systemctl自带编辑器修改的——它会自动reload。

总结

systemd值得学的就这五件事:

  1. 写Service文件([Unit] + [Service] + [Install])——管后台程序
  2. systemctl命令(start/stop/status/enable/journalctl)——日常操作
  3. 写Timer文件(OnCalendar + Persistent)——替代crontab
  4. 资源限制(MemoryMax / CPUQuota)——防止一个服务拖垮整台机器
  5. 安全加固(ProtectSystem / NoNewPrivileges)——就算程序有漏洞也炸不了

第一次照着模板写一个Unit文件,5分钟就能跑起来。慢慢你会发现:再也回不去nohup和screen了。

相关阅读:

Linux systemd服务管理完全指南:从编写Unit文件到定时任务替代crontab(2026)最先出现在编程·投资·科技

]]>