一场没有痕迹的故障
LinkedIn 的工程师遇到了一件怪事——用户 Feed 数据库每隔一段时间就会短暂失去响应,几十秒后又自己恢复,日志里什么也查不到。
这种间歇性故障在大型系统中其实很常见。数据库变慢了,应用层报了超时,但等你登录上去,一切都恢复正常了。没有任何 core dump、没有 OOM killer 的记录、CPU 使用率也看不出异常。
最要命的是——它频繁到严重影响用户体验,但又短到传统监控完全抓不住。
传统排查为什么会失效
大多数工程师面对这种情况,第一反应是查慢查询、看系统日志、检查 CPU 和内存水位。
但这些手段在这里全部失效了,原因是:
- 故障时间太短:每次只有几十秒,传统的监控采集周期(每分钟一次)根本抓不到
- 自动恢复:系统自己恢复了,没有留下现场
- 没有 OOM 或 CPU 飙升:不是资源不够的问题
- 应用层日志空白:数据库连接只是"卡住了"没有真正断开
这就相当于有人闯进你的系统搞破坏,然后擦掉所有痕迹走人了。传统的后门审计根本没用。
突破口:off-CPU 分析 + eBPF
LinkedIn 的工程师选择了当时看起来最不常规的路径——off-CPU profiling。
大多数性能分析工具关注的是「CPU 在忙什么」——这叫 on-CPU profiling。但当一个线程不消耗 CPU 却也不干活时(比如在等锁、等 I/O),on-CPU profiling 看到的是空白。
Off-CPU profiling 关注的是相反的问题:线程不跑 CPU 的时候在干什么。
实现这个需要 eBPF(Extended Berkeley Packet Filter)。eBPF 是 Linux 内核的一个沙箱机制,允许你在不修改内核代码、不加载内核模块的情况下,安全地运行用户定义的程序来观测内核行为。
简单理解:eBPF 相当于给内核装了一个零影响的监控探头,可以看到每个线程在什么系统调用上阻塞了、阻塞了多久、是谁调用的。
以下是一个简化版的 eBPF off-CPU 采样脚本,用来追踪线程的调度延迟:
from bcc import BPF
import time
bpf_text = """
#include <linux/sched.h>
struct key_t {
u32 pid;
char comm[16];
};
// 记录线程被切换出去的时间
int trace_switch_out(struct task_struct *prev) {
struct key_t key = {
.pid = prev->pid
};
bpf_probe_read_str(&key.comm, sizeof(key.comm), prev->comm);
u64 ts = bpf_ktime_get_ns();
// 存到 BPF map,唤醒时读回来算延迟
start_ts.update(&key, &ts);
return 0;
}
// 记录线程被唤醒的延迟
int trace_switch_in(struct task_struct *next) {
struct key_t key = {
.pid = next->pid
};
bpf_probe_read_str(&key.comm, sizeof(key.comm), next->comm);
u64 *start = start_ts.lookup(&key);
if (start) {
u64 delta = bpf_ktime_get_ns() - *start;
if (delta > 1000000) { // >1ms 的调度延迟才记录
latency.atomic_increment(delta / 1000000);
}
}
return 0;
}
"""
b = BPF(text=bpf_text)
# 挂载到内核调度器 tracepoint
b.attach_kprobe(event="finish_task_switch", fn_name="trace_switch_out")
这个脚本会记录每个线程被内核调度器切出和切入的时间差。如果某个线程的调度延迟异常高(比如几百毫秒),就可以定位是哪个进程、在哪个内核锁上阻塞了。
根因:一个被忽视的内核锁争用
通过 off-CPU profiling,LinkedIn 工程师发现了一个让他们有些意外的根因——内核级别的锁争用(kernel lock contention)。
具体来说,是 Linux 内核中某个不常被关注的锁在特定条件下出现了严重的竞争。当多个线程同时访问用户 Feed 数据库的连接池时,这个锁成了一个串行化的瓶颈。
这个问题在普通负载下不会暴露。只有 LinkedIn 这种量级的并发访问(数千万用户同时刷新 Feed)才会触发它。更隐蔽的是,这个锁的释放和获取有非常短暂的时间窗口——所以故障只能持续几十秒,然后自己缓解了。
要理解为什么锁争用会导致系统几乎"死锁",我们需要看一段伪代码来模拟这种行为:
// 简化版内核锁争用模型
package main
import (
"sync"
"time"
)
type ConnectionPool struct {
mu sync.Mutex
conns []*Connection
waiters int
}
func (p *ConnectionPool) Acquire() (*Connection, error) {
p.mu.Lock()
p.waiters++
// 内核锁的持有时间与等待队列深度相关
// 在高峰期,持有锁的时间呈指数级增长
if p.waiters > THRESHOLD {
// 模拟内核锁在高并发下的退化
// 每个新等待者都会延长持有时间
time.Sleep(time.Duration(p.waiters) * time.Microsecond)
}
conn := p.pop()
p.waiters--
p.mu.Unlock()
return conn, nil
}
这种锁争用模式被行业内称为 lock convoying(锁护送)——当一个锁的持有时间与等待队列深度相关时,系统会进入正反馈循环:越多人等 → 锁持有时长 → 更多人等 → 最终超时。
Root Cause 确认的关键一步
确定了可疑锁之后,eBPF 的作用还没有结束。LinkedIn 工程师用 eBPF 来验证假设——他们写了一个专用的 eBPF 程序来追踪这个特定锁的争用情况:
# 使用 bpftrace 追踪内核锁争用(简化版)
bpftrace -e '
kprobe:ticket_spin_lock {
@tid[tid] = nsecs;
}
kretprobe:ticket_spin_lock /@tid[tid]/ {
$delta = (nsecs - @tid[tid]) / 1000;
if ($delta > 100) {
@contended_locks[kstack(5)] = sum($delta);
}
delete(@tid[tid]);
}
interval:s:30 {
print(@contended_locks);
clear(@contended_locks);
}'
这个 bpftrace 脚本的关键在于:它只记录内核自旋锁的等待时间超过 100 微秒的情况。正常情况下内核锁的持有时间是微秒级的,当超过这个阈值,就说明存在异常的锁争用。
输出的调用栈直接指向了出问题的内核函数,LinkedIn 工程师据此通知了内核社区并提交了修复。
最终效果:p99 延迟直降 74%
修复后的效果非常显著:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| p99 延迟 | ~200ms | ~52ms |
| p95 延迟 | ~80ms | ~25ms |
| 平均延迟 | ~30ms | ~8ms |
| 故障频率 | 每天数次 | 0 |
p99 延迟降低 74%,间歇性死锁完全消除。这个问题的神奇之处在于——它不是某个软件版本引入的 bug,而是 Linux 内核中一个长期存在的设计缺陷,只有达到 LinkedIn 这种体量的并发压力才会触发。
给我们的启示
1. eBPF 是现代 Linux 排障的瑞士军刀
这个案例最精彩的地方在于:传统工具完全失效的场景下,eBPF 成了唯一能抓到问题的手段。如果你还在用 strace/gdb 逐行调试内核问题,是时候升级到 eBPF 了。
墨菲定律在性能工程中的变体是:你的系统一定会在你最没料到的地方、以最隐蔽的方式出问题——而当它发生时,你唯一的希望就是有合适的观测工具。
2. Lock convoying 是分布式系统的隐形杀手
锁护送(lock convoying)在分布式系统中比在单机中更危险,因为一个锁的拥堵会引发级联效应。LinkedIn 的这个案例是教科书级别的 lock convoying 表现,值得每位 SRE 工程师了解。
3. 不要忽视内核层面
大多数应用层开发者习惯把内核当黑盒,出了问题先在应用层找原因。但有些性能问题,根因就是内核里的一个小角落。LinkedIn 这次成功的关键就是敢于把目光投到操作系统层面。
如果你想在自己的系统上尝试 eBPF 排查,推荐从 bcc 工具集开始——runqlat 可以看调度延迟,offcputime 可以做 off-CPU profiling,lockstat 可以追踪内核锁。这些工具都能帮你看到传统监控看不到的那个世界。