数据采集与观测
# 采集与可观测性原理
📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 25 分钟 | 实操:1 小时 🔗 前置阅读:卷零·02 | ➡️ 后续延伸:卷一·01
性能工程的"地基"。没有可观测性,就没有性能工程。本文回答:"数据从哪里来?怎么采集?采得对不对?代价多大?"
# 目录介绍
# 01.可观测性三件套
# 1.1 Metrics / Logs / Traces
工业界共识的可观测性"三大支柱":
| 类型 | 含义 | 数据形态 | 典型用途 | 存储成本 |
|---|---|---|---|---|
| Metrics | 数值化指标,时间序列 | 时间戳 + 标签 + 数值 | 监控、告警、趋势 | 低 |
| Logs | 离散事件文本 | 时间戳 + 上下文 + 文本 | 故障复盘、调试 | 高 |
| Traces | 请求跨多个组件的执行路径 | Trace ID + Span 树 | 分布式归因 | 中 |
三者关系:
Metrics(What 出问题了)
│
▼
Traces(出问题在 哪个 链路环节)
│
▼
Logs(出问题时 发生了 什么)
2
3
4
5
6
7
# 1.2 Profile:第四种信号
近年提出的第四类信号,专属于性能领域:
| 类型 | 含义 | 数据形态 |
|---|---|---|
| Profiles | 程序运行时函数级耗时 / 资源分布 | 调用栈 + 计数(如火焰图) |
为什么需要 Profile:
- Metrics 告诉你"CPU 100%",但不告诉你"哪个函数在烧 CPU"。
- Logs 告诉你"渲染卡了",但不告诉你"哪行代码导致"。
- Profile 把执行时间归因到代码行 / 函数 / 调用栈。
# 1.3 四类信号的关系
信号 粒度 回答的问题 代价
──────────────────────────────────────────────────────
Metrics 指标级 "怎样 / 多少?" 低
Logs 事件级 "发生过什么?" 中
Traces 请求级 "在哪一步发生?" 中
Profiles 函数级 "什么代码导致?" 高
2
3
4
5
6
性能工程的关键认知:四类信号必须互相关联才能形成完整归因链。一个完整的卡顿排查:
- Metric 告警:FPS P99 > 50ms
- Trace 定位:在 ListView 滚动期间发生
- Profile 归因:65% 时间花在
Bitmap.decodeStream - Log 验证:当时图片 URL 是 4K 大图
单一信号无法解决问题。可观测性体系的设计目标,是让四类信号通过统一上下文(Trace ID / 用户 ID / 会话 ID)互相串联。
# 02.数据源的物理层级
性能数据存在于系统的不同层级,了解层级是设计采集方案的前提。
# 2.1 硬件层:PMU 与硬件计数器
CPU 内置 PMU(Performance Monitoring Unit),可统计:
- Cycles(CPU 周期数)
- Instructions(指令数)
- Cache Miss(缓存未命中)
- Branch Misprediction(分支预测失败)
- TLB Miss
由 PMU 计算的关键指标:
IPC = Instructions / Cycles (越高越好,Intel 一般 > 2 算优秀)
Cache Miss Rate = Misses / Accesses (越低越好)
2
采集方式:
| 平台 | 工具 |
|---|---|
| Linux(Android 嵌入式) | perf_event_open / perf / simpleperf |
| macOS / iOS | Instruments CPU Counters / sys_perf_event |
| Web | 不可访问(沙箱限制) |
意义:当 CPU 利用率高但任务推进慢时,PMU 数据能区分是 CPU bound 还是 memory bound(cache miss 严重)。
# 2.2 内核层:tracepoint / kprobe / eBPF
Linux 内核提供三种 tracing 机制:
| 机制 | 含义 | 用途 |
|---|---|---|
| tracepoint | 内核预埋的静态事件点 | 调度、IO、文件系统 |
| kprobe / uprobe | 动态插入的探针 | 任意函数入口 / 出口 |
| eBPF | 内核态可编程沙箱 | 灵活、低开销自定义采集 |
eBPF 的革命性:
- 在内核态执行用户提供的程序,无须改动内核代码。
- 开销极低(事件级),可生产环境长期运行。
- Android 12+ 已内置 eBPF 支持,是新一代 APM 基石。
典型用途:
- off-CPU 分析(线程在等什么)
- IO 延迟分布
- 系统调用归因
- 内存分配追踪
# 2.3 运行时层:VM / GC / 调度钩子
各运行时提供性能事件回调:
| 运行时 | 关键钩子 |
|---|---|
| Android ART | GC 回调、ClassLoad、JIT 编译事件、Looper.setMessageLogging |
| iOS Objective-C/Swift | Method Swizzling、os_signpost、Mach 消息 |
| V8 (Web/Node) | Performance Observer、v8.startProfiling、Heap Snapshot |
| .NET | EventListener、ETW |
| 嵌入式 RTOS | OS hook(FreeRTOS trace) |
采集示例:
- Android:
Choreographer.postFrameCallback采集帧时钟 - iOS:
os_signpost_interval_begin/end标记区间 - Web:
PerformanceObserver监听longtask/paint/largest-contentful-paint
# 2.4 框架层:UI 框架回调与生命周期
UI 框架本身暴露大量回调:
| 框架 | 关键回调 |
|---|---|
| Android | Activity / Fragment 生命周期、ViewTreeObserver、FrameMetrics |
| iOS | UIViewController 生命周期、viewDidAppear、displayLink |
| React / Vue | componentDidMount / mounted / Profiler API |
| Flutter | WidgetsBinding.addObserver / Timeline |
# 2.5 应用层:业务埋点
业务自身定义关键时刻(如"购买按钮点击 → 订单页可见")。
埋点设计四原则:
- 语义清晰:事件名表达业务含义,不依赖技术细节。
- 上下文完整:随事件携带页面、用户、版本、网络等元数据。
- 时钟一致:所有埋点使用同一时钟源(避免时间戳跨线程错乱)。
- 可演进:保留事件版本字段,便于口径变更追溯。
# 03.采集方法学
# 3.1 采样 vs 全量
| 模式 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|
| 全量 | 数据完整、可精确归因 | 开销大、存储成本高 | 崩溃、关键事件 |
| 采样 | 开销小、可长期运行 | 偶发问题易遗漏 | CPU profile、调用栈 |
采样的两种方式:
- 时间采样(Time-based):固定周期触发(如每 10ms 抓一次栈),适合 CPU 分析。
- 事件采样(Event-based):按特定事件(如每分配 100 次)抓栈,适合内存分析。
采样率与误差:
采样率越高 → 数据越准 → 性能开销越大
经验法则:
生产环境 CPU profile 采样率:99Hz(每 ~10ms 一次)
开销约:1-3%
2
3
4
5
# 3.2 拉模式 vs 推模式
| 模式 | 谁主动 | 适用 |
|---|---|---|
| Pull | 采集器主动读 | /proc/stat、PerformanceObserver.getEntries |
| Push | 被采集对象主动报 | 框架回调、埋点 |
端侧实践:
- 短期高频指标(CPU%、内存)→ Pull(轮询)
- 离散事件(崩溃、生命周期)→ Push(回调)
# 3.3 在线 vs 离线
| 模式 | 含义 | 用途 |
|---|---|---|
| 在线(Online) | 设备运行时实时采集 + 上报 | 线上监控、告警 |
| 离线(Offline) | 录制完整 trace 文件,事后分析 | 性能回归、深度分析 |
典型工具:
- Android Perfetto / Systrace:录制完整系统级 trace
- iOS Instruments:录制 .trace 文件
- Chrome DevTools Performance:录制时间轴
- 嵌入式 ftrace:录制内核 trace
实践组合:
- 线上:轻量 Metrics + 异常时触发 Profile 录制 + 上报关键 Trace
- 线下:在自动化测试场景下,强制录制完整 Trace 用于回归
# 3.4 侵入 vs 无侵入
| 模式 | 特点 |
|---|---|
| 侵入式 | 业务代码显式调用 SDK API,逻辑可见但维护成本高 |
| 无侵入式 | 通过钩子 / 插桩自动采集,业务无感 |
APM 的趋势是"无侵入式",但需要谨慎处理稳定性 / 兼容性问题(详见下一节)。
# 04.无侵入式钩子原理
无侵入采集是 APM 的核心技术。原理上分四类:
# 4.1 字节码 / 中间码插桩
在编译 / 加载阶段重写代码字节,插入采集逻辑。
| 平台 | 技术 |
|---|---|
| Android Java | ASM / Javassist / Transform API(Gradle Plugin) |
| Android Kotlin | KSP / 编译器插件 |
| iOS Swift | SwiftSyntax / Sourcery(编译期) |
| Web | Babel / SWC plugin、AST 改写 |
示例:监控所有 Activity.onCreate:
原方法:
public void onCreate(Bundle b) { /* 业务 */ }
ASM 后:
public void onCreate(Bundle b) {
long __t = System.nanoTime();
try { /* 业务 */ }
finally { APMTracer.trace("onCreate", System.nanoTime() - __t); }
}
2
3
4
5
6
7
8
9
优点:性能影响极小(编译期完成),覆盖完整。 缺点:增加构建时间,需维护版本兼容性。
# 4.2 PLT/GOT Hook(C/C++ 动态库)
Linux/Android 动态链接器使用 PLT/GOT 表寻址外部符号。修改 GOT 表即可拦截系统调用。
典型用途:
- 监控
malloc/free实现内存追踪 - 监控
pthread_create监控线程创建 - 监控
open/read/write实现 IO 追踪
代表实现:bytehook(字节)、xhook、bhook。
限制:
- 仅对外部调用生效,对静态链接函数无效。
- Android 7+ 后,部分动态库(libart 等)加固使 hook 难度增加。
# 4.3 Inline Hook 与 Method Swizzling
# Inline Hook(C/C++ 通用)
直接修改函数前几个字节为跳转指令,跳到代理函数。
特点:
- 强大,可 hook 任意函数。
- 危险,对 ABI / 架构高度敏感(ARM vs ARM64 vs x86 不同)。
- 可能与系统 ASan、PAC(Pointer Authentication)冲突。
# Method Swizzling(Objective-C)
利用 OC 的动态消息机制,交换两个方法的实现指针:
原 Method A 的 IMP <─→ 新 Method A' 的 IMP
典型用途:
- 全局监控
viewDidAppear计算页面加载时间 - 拦截
NSURLSession计算网络耗时
限制:仅对 OC runtime 有效,纯 Swift 类无 dynamic 标记则无效。
# 4.4 浏览器 API 代理
Web 环境无法 hook 内核 / 二进制,但可代理 JS API:
// 拦截 fetch 计算网络耗时
const _fetch = window.fetch;
window.fetch = function (...args) {
const start = performance.now();
return _fetch.apply(this, args).finally(() => {
APM.trace('fetch', performance.now() - start);
});
};
2
3
4
5
6
7
8
典型代理目标:fetch、XMLHttpRequest、addEventListener、setTimeout、history.pushState。
# 4.5 安全与稳定性边界
无侵入 hook 是双刃剑,常见风险:
| 风险 | 案例 | 防御 |
|---|---|---|
| 兼容性崩溃 | OS 版本变化导致符号偏移 | 多版本灰度 + 黑名单兜底 |
| 启动卡死 | hook 自身耗时导致主线程长时间阻塞 | hook 内严禁同步 IO / 锁 |
| 安全风险 | hook 被恶意利用 / 触发系统加固检测 | 仅对自身进程内符号 hook |
| 性能退化 | 全量 hook 高频函数(如 malloc) | 按需开启 + 降采样 |
铁律:
Hook 越底层,威力越大,副作用也越大。生产环境的无侵入 SDK 必须满足"hook 失败不影响业务"的兜底原则。
# 05.采集数据的可信度
# 5.1 测量原理:观察者效应
任何测量都会扰动被测对象。性能领域尤其严重:
- 打点本身耗时(几百 ns 到几 us)
- 触发额外的内存分配
- 引发额外上下文切换
- 改变 JIT 优化决策
经验数据:
| 采集方式 | 单次开销 | 适合场景 |
|---|---|---|
| 计数器累加 | < 100ns | 高频路径 |
nanoTime 时间戳 | ~50-200ns | 区间测量 |
| 调用栈采样(不展开) | ~1-10us | 偶发采样 |
| 调用栈展开 + 符号化 | ~100us-1ms | 离线分析 |
| 内存分配 trace | ~500ns / 次 | 限频开启 |
# 5.2 时钟与时间戳陷阱
# 单调时钟 vs 墙钟
| 时钟 | 特点 | 用途 |
|---|---|---|
| 墙钟(Wall Clock) | 可被用户 / NTP 调整 | 显示日期 |
| 单调时钟(Monotonic) | 只增不减 | 性能区间测量 ✅ |
铁律:性能区间测量必须用单调时钟:
- Android:
SystemClock.elapsedRealtimeNanos()/System.nanoTime() - iOS:
mach_absolute_time()/CACurrentMediaTime() - Web:
performance.now() - C/C++:
clock_gettime(CLOCK_MONOTONIC)
❌ 用
System.currentTimeMillis()测耗时是常见错误。NTP 调时会让你测到负数。
# 跨核 / 跨线程时间戳
不同 CPU 核心的 TSC 可能不同步。多线程时间戳直接对比可能错乱。
对策:
- 在同一线程上打点,跨线程用消息传递时间戳。
- 使用系统提供的同步时钟 API(多数现代 OS 已抽象)。
# 5.3 采样偏差
| 偏差类型 | 描述 | 例子 |
|---|---|---|
| 选择偏差 | 仅采集到部分场景 | 只采集前台启动,漏掉后台拉起 |
| 生存偏差 | 仅采集"未崩溃用户" | 排除 OOM 用户 → 内存数据偏低 |
| 时间偏差 | 采集时机不均匀 | 凌晨用户少、网络好 → 数据偏好 |
| 设备偏差 | 高端机用户多 → 整体数据偏好 | 必须按机型切片 |
对策:
- 采集前定义"采集口径",并文档化。
- 多维度切片(机型 / 网络 / 时段 / 版本)后再聚合。
- A/A 实验验证采集本身无偏。
# 5.4 误差量化
任何采集都应附带误差范围。例如:
冷启动 P95 = 1.82s ± 0.04s(95% CI, n=12000, 采样率 100%)
帧时长 P99 = 23.5ms ± 0.8ms(n=8000, 采样率 100Hz)
2
误差未声明的指标 = 不可信指标。
# 06.采集系统的成本控制
# 6.1 性能开销预算
APM SDK 的健康开销:
| 维度 | 经验阈值 |
|---|---|
| CPU 开销 | < 1%(高频)/ < 3%(profile 期间) |
| 内存常驻 | < 5MB |
| 内存峰值(dump 时) | < 30MB |
| 启动耗时 | < 30ms |
| 包体积 | < 500KB |
| 上报流量 | < 50KB / DAU / 天 |
超过预算的 APM 是问题,不是解药。
# 6.2 采样降级策略
设备压力大时,APM 应主动降级:
正常态:全量采集帧时长 + 99Hz CPU 采样
压力态:仅采集异常帧 + 关闭 CPU 采样
极端态:只保留崩溃 / ANR 采集
2
3
触发条件:
- 内存压力(
onTrimMemory/didReceiveMemoryWarning) - CPU 持续高占用
- 电量低于阈值
- 用户主动开启省电模式
# 6.3 上报与聚合
| 阶段 | 优化点 |
|---|---|
| 本地聚合 | 客户端先做 直方图 / 计数,避免上报原始事件 |
| 批量上报 | 累积到一定量或定时上报,避免高频网络 |
| 压缩 | gzip / protobuf,文本日志可减小 70% |
| 网络感知 | WiFi 优先、弱网降频 |
| 时序保序 | 上报失败重试时保持事件顺序,便于复盘 |
| 去重 | 同一事件多次上报需具备幂等键 |
# 07.跨平台采集对照
| 信号 / 场景 | Android | iOS | Web | 嵌入式 (Linux) |
|---|---|---|---|---|
| CPU 利用率 | /proc/[pid]/stat | host_processor_info | Long Tasks API(粗) | /proc/stat |
| 线程级 CPU | /proc/[pid]/task/[tid]/stat | thread_info | 不可见 | /proc/[pid]/task |
| 内存 RSS/PSS | /proc/[pid]/status, smaps | task_vm_info | 不可见(隔离) | /proc/[pid]/status |
| 帧时钟 | Choreographer / FrameMetrics | CADisplayLink / MetricKit | requestAnimationFrame / PerformanceObserver(frame) | 显示控制器 IRQ |
| 系统 trace | Perfetto / Systrace / atrace | os_signpost + Instruments | DevTools Performance | ftrace / lttng |
| 函数 profile | simpleperf / perf | Time Profiler / dtrace | DevTools Profiler | perf / pprof |
| 调用栈采样 | Thread.getStackTrace / unwind | backtrace | Profiler API | libunwind |
| 内存分配追踪 | Allocation Tracker / bytehook malloc | Allocations Instrument | Allocation Profiler | tcmalloc / jemalloc |
| 崩溃捕获 | setUncaughtExceptionHandler + 信号 | Mach exception + 信号 | window.onerror | signal / sigaction |
| ANR / 卡顿 | Watchdog 线程 + signalQUIT | RunLoop observer + Watchdog | Long Tasks > 50ms | 心跳超时 |
| 网络拦截 | OkHttp Interceptor / 代理 | URLProtocol swizzle | fetch / XHR 代理 | 自定义 |
| GC 事件 | ART trace | ARC(无 GC) | PerformanceObserver(gc) | — |
| eBPF | Android 12+ 部分支持 | 不开放 | 不可访问 | 完整支持 ⭐ |
# 一句话总结
可观测性的本质,是用最小的扰动获取最大的可解释性。
设计采集系统时,永远问三个问题:采的对吗?采的全吗?采的代价值吗?