CPU问题定位与解决方案

CPU问题定位与解决方案

经验文章nimo972025-06-20 19:40:522A+A-

问题背景回顾

  • 部署方式:uWSGI + Python Web( Flask或者Django)
  • 现象:线上某些时刻,某个进程 CPU 飙升到 100%+,但 内存、IO、网络请求并发、句柄数都稳定
  • 初步判断:Python 进程自身行为异常,且非规律性触发,无法测试环境重现

排查思路概览

排查步骤

工具

目的

1. 确认高 CPU 的进程

top / htop / ps

定位占用 CPU 的进程 PID

2. 查看进程系统调用行为

strace

查看进程是否频繁访问某个资源

3. 查看调用栈情况

perf

初步尝试获取进程调用栈(适用于原生语言)

4. Python 专用分析

py-spy

获取 Python 层完整调用栈和函数占用 CPU 情况

5. 代码分析修复

——

结合火焰图分析源码逻辑问题


Python代码重现demo,模拟「国际化翻译文件频繁读取 + 全局列表不断膨胀导致CPU升高」

import threading
import time

# 模拟翻译文件内容
TRANSLATION_FILE = "translations.txt"

# 全局缓存,理论上应该只加载一次
translations_cache = {}
# 出问题的全局列表,错误地不断累加“未找到的key”
missing_keys_list = []

def load_translation_file():
    """模拟从文件读取翻译数据"""
    # 这里模拟文件读取开销
    time.sleep(0.01)
    # 模拟文件内容
    return {
        "hello": "你好",
        "bye": "再见",
    }

def get_translation(key):
    """获取翻译,找不到时错误地把key加到全局列表"""
    if not translations_cache:
        # 仅第一次加载翻译文件
        global translations_cache
        translations_cache = load_translation_file()

    if key in translations_cache:
        return translations_cache[key]
    else:
        # 出错点:每次找不到都append,导致列表不断增长
        missing_keys_list.append(key)
        # 误以为要重新加载文件,导致频繁IO(模拟)
        _ = load_translation_file()
        return f"[Missing: {key}]"

def worker_thread():
    """模拟请求线程,反复请求翻译"""
    keys = ["hello", "bye", "invalid_key_1", "invalid_key_2"]
    while True:
        for k in keys:
            val = get_translation(k)
            # 模拟响应时间
            time.sleep(0.001)

if __name__ == "__main__":
    print("启动模拟翻译服务,按 Ctrl+C 停止")
    threads = []
    for _ in range(5):  # 模拟5个请求线程
        t = threading.Thread(target=worker_thread)
        t.daemon = True
        t.start()
        threads.append(t)

    try:
        while True:
            # 每秒打印状态,观察missing_keys_list长度
            print(f"missing keys count: {len(missing_keys_list)}")
            time.sleep(1)
    except KeyboardInterrupt:
        print("退出模拟")

演示说明

  • translations_cache 理应只加载一次,但错误代码中每次get_translation找不到 key 时都会调用load_translation_file()模拟重新加载文件。
  • missing_keys_list 是个全局累积的列表,随着请求不断追加找不到的 key,内存和CPU负载持续增加。
  • 你可以运行这段程序,然后用 top 观察 Python 进程的 CPU 会逐渐升高。
  • load_translation_file() 通过 time.sleep(0.01) 模拟文件IO的延迟,放大CPU负载表现。

1、top命令查看cpu

top - 12:27:42 up 115 days, 11:35,  0 users,  load average: 1.61, 0.41, 0.31
Tasks: 104 total,   1 running, 103 sleeping,   0 stopped,   0 zombie
%Cpu(s): 47.3 us,  4.1 sy,  0.0 ni, 45.9 id,  0.0 wa,  2.2 hi,  0.5 si,  0.0 st
MiB Mem :   1871.0 total,    857.9 free,    386.5 used,    781.9 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1484.5 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                              
 148782 ecs-ass+  20   0  983604  31128   7828 S  100.0  1.6   0:24.19 python3                                              
 136866 root      20   0  139236  30232  19672 S   1.3   1.6 193:21.10 AliYunDunMonito                                      
   1357 root      20   0 1484692  16064   8776 S   0.7   0.8 753:28.56 argusagent                                           
 136845 root      20   0   97752  20372  13056 S   0.7   1.1 110:51.73 AliYunDun                                            
  63761 root      20   0  474944  23024   9412 S   0.3   1.2 156:07.86 tuned        

分析结果

1. 负载状况

1分钟负载上升到了 1.61(高于系统核心数为1的情况),但5分钟和15分钟均较低,说明是短时间内某个进程突发性地占用了CPU。

2.CPU使用情况

%Cpu(s): 47.3 us, 6.2 sy, 45.9 id
  • us(用户态)占比 47.3%,说明是应用层代码耗CPU,不是系统内核问题。
  • sy(系统态)也有一定占比,可能有频繁系统调用。
  • 还有 45.9% 空闲,系统还没崩,但这个 python3 进程已经吃掉了一个完整核心

3.python3 进程

PID 148782 python3 %CPU 100.0 %MEM 1.6
  • 这个进程吃了一个核心(单线程100%)。
  • 内存 1.0% 并不高,说明不是内存泄漏,而是CPU密集型任务陷入了死循环或高频率操作(如I/O、字符串、字典操作)
  • 持续时间只有 24 秒:这是刚发生的异常高CPU,属于典型“偶发性线上高CPU问题”

2、top 查看线程详细信息

top -p 148782 -H
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                              
top - 12:54:10 up 115 days, 12:01,  0 users,  load average: 1.83, 1.90, 1.74
Threads:  11 total,   1 running,  10 sleeping,   0 stopped,   0 zombie
%Cpu(s): 48.7 us,  3.0 sy,  0.0 ni, 46.5 id,  0.0 wa,  1.4 hi,  0.3 si,  0.0 st
MiB Mem :   1871.0 total,    837.0 free,    406.8 used,    782.5 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.   1464.2 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                              
 148843 ecs-ass+  20   0 1003176  51684   7784 R  11.0   2.7   1:58.37 python3                                              
 148845 ecs-ass+  20   0 1003176  51684   7784 S  11.0   2.7   1:55.57 python3                                              
 148844 ecs-ass+  20   0 1003176  51684   7784 S  10.7   2.7   1:55.95 python3                                              
 148847 ecs-ass+  20   0 1003176  51684   7784 S  10.3   2.7   1:52.59 python3                                              
 148838 ecs-ass+  20   0 1003176  51684   7784 S  10.0   2.7   1:56.71 python3                                              
 148839 ecs-ass+  20   0 1003176  51684   7784 S   9.7   2.7   1:55.11 python3                                              
 148840 ecs-ass+  20   0 1003176  51684   7784 S   9.7   2.7   1:55.12 python3                                              
 148842 ecs-ass+  20   0 1003176  51684   7784 S   9.7   2.7   1:54.67 python3                                              
 148841 ecs-ass+  20   0 1003176  51684   7784 S   8.7   2.7   1:52.63 python3                                              
 148846 ecs-ass+  20   0 1003176  51684   7784 S   8.7   2.7   1:55.23 python3                                              
 148837 ecs-ass+  20   0 1003176  51684   7784 S   0.0   2.7   0:00.10 python3 

表示过去这三段时间的平均可运行进程数。整体偏高,CPU 比较忙,特别是 1 分钟内负载明显高于其他时间。

CPU 使用率非常高(主要是用户进程),负载高的主要原因是 CPU 密集型任务。%wa低,没有 I/O 等待。

内存是够用的,没有出现内存吃紧的情况。

进程详情(Python)

几乎所有线程 CPU 占用都很高(9~11%),说明是多线程并发在执行高 CPU 操作

  • 主线程 148837 几乎不消耗 CPU。
  • 线程 148843 处于 R 状态,正在运行,其他处于 S(Sleeping)状态,但仍消耗 CPU,表示可能是 GIL(全局解释器锁)在多个线程之间轮转执行了密集计算任务。
  • 3、用 strace 挂载到主进程

    strace -f -p 148837 -tt -T -e trace=file
    或者
    strace -f -p 148837 -tt -T -o strace.log
  • -p 148782:指定进程 PID
  • -tt:显示精确时间戳
  • -T:显示每个 syscall 执行时间
  • -e trace=file:只跟踪文件操作(可用来观察频繁的 open/read/stat)
  • -f:跟踪子线程
  • 结果

    [pid 148839] 13:05:35.561729 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 9 <0.000285>
    [pid 148842] 13:05:35.568763 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 8 <0.001088>
    [pid 148841] 13:05:35.572165 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 10 <0.000055>
    [pid 148846] 13:05:35.577015 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 4 <0.000115>
    [pid 148840] 13:05:35.580729 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 5 <0.000037>
    [pid 148844] 13:05:35.583458 openat(AT_FDCWD, "translations.txt", O_RDONLY|O_CLOEXEC) = 11 <0.000045>

    或者聚焦某个高 CPU 线程:

    sudo strace -p 148843 -tt -T
  • 如果你某个 syscall(比如 read, stat, futex, recv) 重复出现,可能说明这就是热点函数的底层表现
  • strace 工具的 syscall(系统调用)类别


    1.File I/O 类(文件操作)

    • open, openat
    • read, pread64
    • write, pwrite64
    • stat, fstat, lstat
    • close
    • access

    作用:文件的打开、读取、写入、关闭、权限检查。

    异常表现:

    • 频繁打开读取同一个文件(如 i18n 配置),会导致高 CPU。
    • stat 每次请求都执行,说明文件缓存或路径判断未优化。
    open("/app/config/i18n.json", O_RDONLY) = 3 read(3, "...", 4096) = 4096 close(3) = 0

    这种情况通常出现在未正确缓存配置文件、日志文件等内容。


    2.网络 I/O 类

    • recv, recvfrom, recvmsg
    • send, sendto, sendmsg
    • accept, connect, bind, listen
    • poll, select, epoll_wait, epoll_ctl

    异常表现:

    • 大量 recv/send 表示可能有高频 socket 请求或长连接处理不当。
    • 高频 accept/connect 表示连接过多或频繁重建。

    如果 Python 用了 requests、urllib 等未连接池化,容易引发大量连接开销。


    3.进程/线程同步类

    • futex(Fast Userspace Mutex)
    • clone, pthread_create
    • wait4, nanosleep, usleep

    异常表现:

    • 高频 futex:可能是线程之间的锁竞争激烈(如全局解释器锁 GIL 争抢)。
    • nanosleep/usleep: 可能是某种低效的循环等待机制

    GIL 争抢通常表现在 Python 多线程(非多进程)中,反而降低并发效率。


    4.内存类

    • mmap, munmap
    • brk, mprotect
    • madvise

    异常表现:

    • 高频 mmap:大概率是某个模块反复加载文件(如 .so 动态库或 data 文件)。
    • brk 增加表示内存分配多,未必与 CPU 高直接相关,但可能是副作用。

    比如 Python 的 pickle.load()、Pandas 的大文件处理可能频繁触发 mmap。


    5.信号/定时器类

    • rt_sigaction, rt_sigprocmask
    • timer_create, timer_settime
    • alarm, getitimer

    异常表现:

    • 高频信号处理可能来自定时器或异步事件触发(比如轮询、超时重试机制)。

    4、py-spy 无侵入式地查看 Python 层函数火焰图

    pip install py-spy
    py-spy record -o profile.svg --pid 148837

    生成的 profile.svg 火焰图可以用浏览器打开

    5、分析火焰图发现问题根因

    load_translation_file
      -> open()
      -> read()
      -> append_to_global_translation_list()

    问题核心:

    • 业务使用了某第三方国际化(i18n)库
    • 本应全局缓存的翻译内容,却在找不到翻译时往全局变量里不断添加新的“空值”
    • 每次找不到翻译项都会重新尝试读取文件
    • 请求多了之后,这个列表变得超级大,查找翻译变慢,CPU 不断爆表

    为什么线下环境没有复现?

    • 因为触发条件是“翻译 key 缺失”
    • 而测试/灰度环境数据健康,没有非法 key
    • 灰度环境进程重启频繁,也避免了内存不断累积

    6、修复后的示例代码

    import threading
    import time
    
    TRANSLATION_FILE = "translations.txt"
    
    # 缓存翻译字典,初始化为空
    translations_cache = {}
    # 用集合避免重复记录
    missing_keys_set = set()
    # 加锁保护共享变量(线程安全)
    cache_lock = threading.Lock()
    
    def load_translation_file():
        """模拟从文件读取翻译数据"""
        time.sleep(0.01)
        return {
            "hello": "你好",
            "bye": "再见",
        }
    
    def get_translation(key):
        with cache_lock:
            # 如果缓存为空则加载一次
            if not translations_cache:
                translations_cache.update(load_translation_file())
    
            if key in translations_cache:
                return translations_cache[key]
    
            # 只添加没记录过的key,且不重复加载文件
            if key not in missing_keys_set:
                missing_keys_set.add(key)
                # 可以选择这里记录日志,提示缺失翻译
                print(f"[Warning] Missing translation key: {key}")
    
        return f"[Missing: {key}]"
    
    def worker_thread():
        keys = ["hello", "bye", "invalid_key_1", "invalid_key_2"]
        while True:
            for k in keys:
                val = get_translation(k)
                time.sleep(0.001)
    
    if __name__ == "__main__":
        print("启动修复后模拟翻译服务,按 Ctrl+C 停止")
        threads = []
        for _ in range(5):
            t = threading.Thread(target=worker_thread)
            t.daemon = True
            t.start()
            threads.append(t)
    
        try:
            while True:
                with cache_lock:
                    print(f"missing keys count: {len(missing_keys_set)}")
                time.sleep(1)
        except KeyboardInterrupt:
            print("退出模拟")
    

    关键改动说明

    • 使用了 cache_lock 锁保护全局共享变量,防止多线程竞争问题。
    • translations_cache 只加载一次(第一次调用时加载),后续复用。
    • missing_keys_set 用集合替代列表,避免重复增长。
    • 找不到翻译时,不再调用 load_translation_file(),消除无谓IO。
    • 可以加日志提醒缺失的 key,方便后续补全。

    程序不会频繁读文件,内存和CPU都会稳定,线上CPU异常高的问题就能解决。

    点击这里复制本文地址 以上内容由nimo97整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
    qrcode

    尼墨宝库 © All Rights Reserved.  蜀ICP备2024111239号-7