用 eBPF off-CPU 分析揪出内核锁竞争:LinkedIn 的排障实录

2026-05-28 34 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

数据库每隔一阵就短暂冻结,然后自动恢复,日志里什么线索都没留下——这种"幽灵故障"大概是运维最头疼的一类。LinkedIn 的工程师就撞上了这么一件事:驱动用户 Feed 的数据库反复出现短时间不可用,恢复后干净得像什么都没发生过。常规监控全副武装,却抓不到任何有价值的现场。

最终他们靠 eBPF off-CPU profiling 锁定了根因:一个内核级的锁竞争问题。这条排查路径值得每一位做基础设施的工程师了解。

故障的"隐身术"

LinkedIn 遭到的故障有几个恼人的特征:

  • 持续时间极短——每次冻结只维持几秒到十几秒,传统采样监控(比如每 10 秒采一次的 CPU 指标)大概率刚好跳过这个窗口。
  • 自动恢复——不需要人工干预,服务自己就好了,连告警都来不及触发。
  • 无日志痕迹——应用层日志只看到"请求超时",没有异常堆栈、没有 OOM、没有磁盘 I/O 峰值。

换句话说,从应用视角看,进程像是被"暂停"了几秒然后继续运行。这种表现指向一个方向:进程没在跑,但也不是在等 I/O——它可能被内核锁卡住了。

为什么传统工具看不见

常规的排查手段在这里几乎全部失效:

工具 为什么没用
top / htop 采样间隔太长,短冻结窗口被平滑掉
应用 APM 只看到请求延迟升高,不知道进程为什么停
perf record(on-CPU) 进程冻结期间根本不在 CPU 上跑,on-CPU profiling 采样不到
strace 系统调用层面看不到内核锁等待,开销也不低
内核日志 (dmesg) 没有 panic、没有 oops,内核自己不觉得有问题

关键洞察:进程冻结时不在 CPU 上执行,on-CPU profiling 天然盲区。你需要的是 off-CPU profiling——记录进程"离开 CPU"的原因和时长。

off-CPU profiling:记录等待而非执行

off-CPU profiling 的思路和 on-CPU profiling 互补:

  • on-CPU:记录"谁在跑",抓热点函数。
  • off-CPU:记录"谁在等",抓阻塞点。

进程离开 CPU 的常见原因包括:自愿睡眠(nanosleep)、等 I/O 完成、等内核锁(mutexspinlockrwsem)、被调度器抢占。对于 LinkedIn 这个场景,锁等待是最可疑的。

eBPF 的优势在于:可以在内核里挂探针,几乎零开销地记录每一次 off-CPU 事件的时间戳、调用栈和等待原因,不需要改内核、不需要重启服务。

实战:用 BCC 工具做 off-CPU 分析

BCC(BPF Compiler Collection)提供了一套现成的 off-CPU 工具。以下命令可以直接在你的 Linux 机器上运行(需要内核 4.9+,安装 bcc-tools 包)。

1. 快速定位谁在等、等了多久

# 安装 bcc-tools(Ubuntu/Debian)
sudo apt-get install bpfcc-tools linux-headers-$(uname -r)

# 查看目标进程的 off-CPU 时间分布
# -p 指定 PID,-f 显示完整调用栈
sudo offcputime-bpfcc -p <PID> -f

输出类似:

    comm             tid     total_onoffcpu    count
    mysqld           1234    8320              45
        futex_wait_queue_me
        mutex_lock
        __do_page_fault
        handle_mm_fault
        ...

total_onoffcpu 是累计等待微秒数,count 是发生次数。如果某个调用栈的等待时间异常集中,那就是瓶颈所在。

2. 自定义 eBPF 程序:抓内核 rwsem 等待

BCC 的现成工具覆盖了常见场景,但 LinkedIn 需要更精确地追踪特定内核锁。下面是一个最小可运行的 BCC Python 脚本,追踪 rwsem(读写信号量)的等待时间:

#!/usr/bin/env python3
"""
trace_rwsem_wait.py — 追踪内核 rwsem 写锁等待时长
用法: sudo python3 trace_rwsem_wait.py <进程名>
需要: bcc-tools, 内核 4.9+
"""

from bcc import BPF
import sys
import time

if len(sys.argv) < 2:
    print("用法: sudo python3 trace_rwsem_wait.py <进程名>")
    sys.exit(1)

TARGET_COMM = sys.argv[1]

# eBPF C 代码:挂到 rwsem_write_lock 的入口和返回
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

struct key_t {
    u64 stack_id;
    u32 pid;
    char comm[16];
};

BPF_HASH(start, struct key_t, u64);
BPF_STACK_TRACE(stack_traces, 2048);
BPF_HASH(wait_time, struct key_t, u64);

int trace_entry(struct pt_regs *ctx) {
    struct key_t key = {};
    u64 ts = bpf_ktime_get_ns();

    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (pid == 0)
        return 0;

    key.pid = pid;
    bpf_get_current_comm(&key.comm, sizeof(key.comm));

    // 只追踪目标进程
    char target[TARGET_COMM_LEN];
    target[0] = 0;
    bpf_probe_read_kernel_str(target, TARGET_COMM_LEN, TARGET_COMM);
    if (key.comm[0] != target[0])
        return 0;

    key.stack_id = stack_traces.get_stackid(ctx, BPF_F_REUSE_STACKID);
    start.update(&key, &ts);
    return 0;
}

int trace_return(struct pt_regs *ctx) {
    struct key_t key = {};
    u64 *tsp, delta;

    u32 pid = bpf_get_current_pid_tgid() >> 32;
    key.pid = pid;
    bpf_get_current_comm(&key.comm, sizeof(key.comm));

    char target[TARGET_COMM_LEN];
    target[0] = 0;
    bpf_probe_read_kernel_str(target, TARGET_COMM_LEN, TARGET_COMM);
    if (key.comm[0] != target[0])
        return 0;

    // 用 0 作为 stack_id 查找 entry 时存的 timestamp
    key.stack_id = 0;
    tsp = start.lookup(&key);
    if (tsp == 0)
        return 0;

    delta = bpf_ktime_get_ns() - *tsp;
    start.delete(&key);

    // 重新获取真实 stack_id 用于聚合
    key.stack_id = stack_traces.get_stackid(ctx, BPF_F_REUSE_STACKID);
    wait_time.increment(&key, delta);
    return 0;
}
"""

# 替换目标进程名占位符
bpf_text = bpf_text.replace("TARGET_COMM_LEN", str(len(TARGET_COMM) + 1))
bpf_text = bpf_text.replace("TARGET_COMM", '"' + TARGET_COMM + '"')

b = BPF(text=bpf_text)

# 挂载 kprobe 到内核 rwsem 写锁函数
# 函数名因内核版本不同可能变化,常见为 down_write 或 rwsem_write_lock
try:
    b.attach_kprobe(event="down_write", fn_name="trace_entry")
    b.attach_kretprobe(event="down_write", fn_name="trace_return")
except Exception:
    # 较新内核可能用 rwsem_down_write_slowpath
    b.attach_kprobe(event="rwsem_down_write_slowpath", fn_name="trace_entry")
    b.attach_kretprobe(event="rwsem_down_write_slowpath", fn_name="trace_return")

print(f"追踪 {TARGET_COMM} 的 rwsem 写锁等待... Ctrl+C 停止\n")

# 定时输出结果
while True:
    try:
        time.sleep(5)
        print("\n=== rwsem 写锁等待时间 (微秒) ===")
        for k, v in b["wait_time"].items():
            stack = b["stack_traces"].get(k.stack_id)
            comm = k.comm.decode('utf-8', 'replace')
            print(f"  {comm}/{k.pid}  等待: {v/1000:.0f} us")
            if stack:
                for addr in stack:
                    sym = b.ksym(addr, show_module=True, show_offset=True)
                    print(f"    {sym}")
            print()
        b["wait_time"].clear()
    except KeyboardInterrupt:
        print("\n停止追踪。")
        break

运行方式:

# 追踪 mysqld 进程的 rwsem 写锁等待
sudo python3 trace_rwsem_wait.py mysqld

注意down_write / rwsem_down_write_slowpath 的符号名取决于内核版本,可先用 sudo cat /proc/kallsyms | grep rwsem 确认你机器上的实际函数名再替换。部分云厂商内核会做符号混淆(kallsyms 显示十六进制地址),需要关闭混淆或使用未混淆的调试内核。

3. 用火焰图可视化 off-CPU 数据

off-CPU 时间数据用火焰图展示最直观。Brendan Gregg 的工具链可以直接生成:

# 采集 30 秒 off-CPU 数据,输出折叠格式
sudo offcputime-bpfcc -p <PID> -f 30 > offcpu_folded.txt

# 生成火焰图(需要 flamegraph.pl)
git clone https://github.com/brendangregg/FlameGraph
perl FlameGraph/flamegraph.pl --title="off-CPU" --countname=us offcpu_folded.txt > offcpu.svg

打开 offcpu.svg,最宽的块就是 off-CPU 时间最集中的调用路径。如果看到 mutex_lockrwsem_down_write_slowpath 占据大片面积,内核锁竞争就是根因。

LinkedIn 的根因:内核锁竞争下的系统级冻结

LinkedIn 通过上述方法最终确认:数据库进程在特定负载模式下,大量线程同时争抢同一个内核 rwsem(读写信号量)。写锁持有时间虽短,但并发请求量大时,排队等待的线程数急剧上升,每个线程的等待时间叠加,宏观表现为整个数据库"冻结"几秒。

这类问题的特征:

  • 不是应用代码 bug——锁在内核里,应用层无感知。
  • 不是资源不足——CPU、内存、磁盘都够用,瓶颈在锁的排队机制。
  • 触发条件苛刻——只在特定并发模式和负载组合下出现,常规压测可能复现不了。

排查类似问题的清单

如果你也遇到了"短时冻结、无迹可寻"的故障,可以按这个顺序排查:

  1. 确认症状——进程短暂无响应但自动恢复,日志无异常。先排除网络抖动和上游问题。
  2. 排除常规原因——检查 CPU 饱和、内存压力、磁盘 I/O 延迟、网络丢包。如果这些指标正常,转向 off-CPU 分析。
  3. 运行 off-CPU profiling——用 offcputime-bpfcc 或自定义 eBPF 程序采集等待时间分布。
  4. 生成火焰图——可视化定位最宽的等待路径。
  5. 识别锁类型——调用栈中出现 mutexrwsemspinlock 等内核同步原语,确认是锁竞争。
  6. 分析触发条件——什么负载模式下争抢加剧?是否与特定操作(如大批量 page fault、mmap 区域争用)相关。
  7. 制定缓解方案——可能的方向包括:减少并发争抢点(拆分锁、调整数据结构)、降低锁持有时间(优化内核路径)、限流削峰、升级内核版本(内核社区可能已修复该锁的公平性问题)。

eBPF off-CPU profiling 的开销极低(纳秒级探针),可以在生产环境持续运行而不影响服务。这正是 LinkedIn 能在幽灵故障复现的短暂窗口里抓住证据的关键——你不需要提前部署重型监控,只需要在怀疑时刻启动一个 eBPF 脚本。

风险提示:eBPF kprobe 挂载需要 root 权限和内核符号可见。在容器化环境中,通常需要在宿主机节点上运行,而非容器内部。部分生产环境的安全策略可能限制 eBPF 使用,需要和平台团队协调。


相关推荐