大规模性能埋点:在不牺牲性能的前提下看清系统

2026-09-03 36 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

可观测性常常有一个现实矛盾:业务系统越需要指标,埋点代码越可能出现在最热的执行路径上;而指标库为了并发安全引入的原子操作、锁和内存访问,又会反过来改变系统性能。Brian Martin 在演讲中讨论了如何构建低开销、可以放心启用的 instrumentation,让架构师和工程团队同时拥有系统可见性与可接受的运行成本。

指标库为什么会进入性能预算

一次看似简单的计数操作,可能包含共享内存更新、线程同步、缓存一致性通信、标签处理和周期性导出。请求量上升后,真正昂贵的往往不是加法本身,而是大量 CPU 核心同时争用同一个内存位置。

例如,多个 CPU 核心频繁更新同一个全局计数器时,即使使用原子指令避免了数据竞争,也可能产生严重的 cache line bouncing。指标越细、采样点越密,观测代码就越可能成为热点的一部分。

因此,“把指标关掉”不应该是性能问题的默认答案。更稳妥的方向是重新设计指标的更新路径:缩短热路径上的工作,把竞争分散到多个位置,并在读取或导出时再合并结果。

从原子操作到每 CPU 分片

原子操作适合简单、低频或竞争较小的计数器,但它不是所有指标的通用解决方案。对于高频事件,可以为每个 CPU 或线程准备独立的计数槽位,让写入尽可能只触碰本地缓存;查询时再遍历槽位求和。

下面是一个可直接编译运行的 C 示例。它使用线程本地槽位模拟分片计数器,适合用来理解这种设计;生产环境中可以根据运行时模型改为真正的 per-CPU 数据结构,并处理线程数量、CPU 亲和性和生命周期管理。

运行方式:保存为 sharded_counter.c,执行 cc -O2 -pthread sharded_counter.c -o sharded_counter && ./sharded_counter

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define WORKERS 4
#define EVENTS_PER_WORKER 1000000L

static _Thread_local long local_events;
static long totals[WORKERS];

static void *worker(void *arg) {
    int id = *(int *)arg;
    for (long i = 0; i < EVENTS_PER_WORKER; i++) {
        local_events++;
    }
    totals[id] = local_events;
    return NULL;
}

int main(void) {
    pthread_t threads[WORKERS];
    int ids[WORKERS];

    for (int i = 0; i < WORKERS; i++) {
        ids[i] = i;
        if (pthread_create(&threads[i], NULL, worker, &ids[i]) != 0) {
            perror("pthread_create");
            return EXIT_FAILURE;
        }
    }

    long total = 0;
    for (int i = 0; i < WORKERS; i++) {
        pthread_join(threads[i], NULL);
        total += totals[i];
    }

    printf("events=%ld expected=%ld\\n", total,
           (long)WORKERS * EVENTS_PER_WORKER);
    return 0;
}

这个示例的关键不是 _Thread_local 本身,而是写入与汇总的职责分离:工作线程只更新自己的槽位,读路径负责聚合。真实实现需要考虑一个重要边界:如果指标必须立即、精确地反映全局状态,分片会增加读取成本和短暂的不一致窗口;如果指标用于趋势分析,分片通常更容易换取低写入开销。

直方图不必依赖全局锁

延迟直方图比计数器复杂,因为它要把每次观测值映射到桶,并维护分布信息。一个全局锁保护的直方图在低负载时可能足够,但高并发服务中,所有请求都会在记录延迟时经过同一个临界区。

低开销实现可以采用以下思路:

  • 使用固定边界的桶,避免热路径上的动态内存分配。
  • 为线程或 CPU 建立独立桶,写入时不争用全局状态。
  • 使用原子计数或无锁交换结构,减少读写之间的阻塞。
  • 把百分位数计算、桶合并和格式化放到后台导出线程。
  • 对标签集合设置上限,避免高基数标签拖垮内存和导出链路。

“无锁”不等于“没有成本”。原子操作仍然会影响缓存和内存序,分片也会增加内存占用与合并复杂度。工程上应该用基准测试比较三项数据:请求路径延迟、CPU 使用率和导出时的合并开销,而不是只看某一个指标。

eBPF 适合补足系统边界

eBPF 可以在不改动应用源码的情况下,从内核和系统调用层观察网络、调度、文件 I/O 等行为。它适合回答“应用之外发生了什么”,例如请求是否在等待网络、进程是否频繁被调度、某类系统调用是否出现异常延迟。

可以这样实践:在已安装 bpftrace 的 Linux 主机上,运行下面的命令观察 read 系统调用耗时。命令只展示耗时超过 1 毫秒的调用,具体探针名称和权限会因内核、架构及 bpftrace 版本而不同。

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read
{
  @start[tid] = nsecs;
}

tracepoint:syscalls:sys_exit_read
/@start[tid]/
{
  $us = (nsecs - @start[tid]) / 1000;
  if ($us > 1000) {
    printf("pid=%d comm=%s read_us=%d\\n", pid, comm, $us);
  }
  delete(@start[tid]);
}
'

这类工具适合做低侵入诊断,但不应该被当作应用指标库的完全替代品。eBPF 看到的是系统边界上的事实,业务语义、订单状态或租户维度仍需要应用层明确记录。实际部署还要评估内核版本、权限、探针开销、数据量和敏感信息暴露风险。

一份可执行的落地清单

可以按下面的顺序推进“无畏埋点”:

  1. 先用基准测试测量未埋点、原子计数、分片计数和锁保护实现的差异。
  2. 识别最热的写入路径,把标签解析、字符串格式化和网络导出移出请求线程。
  3. 对高频计数器优先考虑 per-CPU 或线程分片,再设计聚合和重启语义。
  4. 对延迟分布使用固定桶,明确百分位数精度、内存上限和高基数策略。
  5. 使用 eBPF 补充内核层可见性,但为探针配置权限、采样和生命周期管理。
  6. 为每类指标设定性能预算,并把埋点开销纳入持续压测和版本回归。

低开销 instrumentation 的目标不是让每一次观测都免费,而是让观测成本可预测、可测量、可控制。只有当团队敢于在生产热路径中保留关键指标,系统可见性才真正成为工程能力,而不是故障发生后的临时补丁。


相关推荐