数据库每隔一阵就短暂冻结,然后自动恢复,日志里什么线索都没留下——这种"幽灵故障"大概是运维最头疼的一类。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 完成、等内核锁(mutex、spinlock、rwsem)、被调度器抢占。对于 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_lock 或 rwsem_down_write_slowpath 占据大片面积,内核锁竞争就是根因。
LinkedIn 的根因:内核锁竞争下的系统级冻结
LinkedIn 通过上述方法最终确认:数据库进程在特定负载模式下,大量线程同时争抢同一个内核 rwsem(读写信号量)。写锁持有时间虽短,但并发请求量大时,排队等待的线程数急剧上升,每个线程的等待时间叠加,宏观表现为整个数据库"冻结"几秒。
这类问题的特征:
- 不是应用代码 bug——锁在内核里,应用层无感知。
- 不是资源不足——CPU、内存、磁盘都够用,瓶颈在锁的排队机制。
- 触发条件苛刻——只在特定并发模式和负载组合下出现,常规压测可能复现不了。
排查类似问题的清单
如果你也遇到了"短时冻结、无迹可寻"的故障,可以按这个顺序排查:
- 确认症状——进程短暂无响应但自动恢复,日志无异常。先排除网络抖动和上游问题。
- 排除常规原因——检查 CPU 饱和、内存压力、磁盘 I/O 延迟、网络丢包。如果这些指标正常,转向 off-CPU 分析。
- 运行 off-CPU profiling——用
offcputime-bpfcc或自定义 eBPF 程序采集等待时间分布。 - 生成火焰图——可视化定位最宽的等待路径。
- 识别锁类型——调用栈中出现
mutex、rwsem、spinlock等内核同步原语,确认是锁竞争。 - 分析触发条件——什么负载模式下争抢加剧?是否与特定操作(如大批量 page fault、mmap 区域争用)相关。
- 制定缓解方案——可能的方向包括:减少并发争抢点(拆分锁、调整数据结构)、降低锁持有时间(优化内核路径)、限流削峰、升级内核版本(内核社区可能已修复该锁的公平性问题)。
eBPF off-CPU profiling 的开销极低(纳秒级探针),可以在生产环境持续运行而不影响服务。这正是 LinkedIn 能在幽灵故障复现的短暂窗口里抓住证据的关键——你不需要提前部署重型监控,只需要在怀疑时刻启动一个 eBPF 脚本。
风险提示:eBPF kprobe 挂载需要 root 权限和内核符号可见。在容器化环境中,通常需要在宿主机节点上运行,而非容器内部。部分生产环境的安全策略可能限制 eBPF 使用,需要和平台团队协调。