3. 数据采集层设计

做实时特征链路,最怕什么?

不是算法不够准,也不是数据延迟高。最怕的是——线上跑得好好的,突然某个节点把CPU吃满,整个链路雪崩。而你,连是谁干的都不知道。

我早期带过一个实时推荐项目,上线第三天,特征计算节点CPU飙到95%。排查了整整四个小时,最后发现是一个特征工程里用了低效的正则匹配。那次之后,我下定决心:成本追踪必须从采集层做起,而且必须无侵入。

3.1 无侵入采集的核心原则

什么叫无侵入?说白了,就是业务代码一行都不用改。

你想想看,特征计算节点可能由不同团队维护,有的用Python,有的用Java,有的甚至用C++。如果每个节点都要手动埋点,那成本追踪还没上线,团队就先打起来了。

我个人习惯遵循三条原则:

  • 零代码改动:采集逻辑与业务进程完全解耦
  • 低性能损耗:采集本身对CPU/内存的占用控制在1%以内
  • 高兼容性:支持容器、虚拟机、裸金属多种部署方式

核心思路:利用操作系统和容器运行时提供的标准接口,从外部读取指标,而不是在进程内部埋点。

3.2 CPU开销采集:从/proc/stat到cgroup

CPU采集其实是个老话题。但实时特征链路有个特殊点——我们需要区分"计算节点自身"和"子进程"的消耗。

我记得有一次,一个特征节点fork了多个worker进程,但采集脚本只读了主进程的CPU,结果数据差了3倍。嗯,这里要注意。

3.2.1 基于/proc文件系统

Linux的/proc文件系统是宝藏。对于每个进程,/proc/[pid]/stat里包含了用户态、内核态、子进程的CPU时间片。

# 采集示例:读取进程CPU使用率
def get_process_cpu(pid):
    with open(f'/proc/{pid}/stat', 'r') as f:
        fields = f.read().split()
        utime = int(fields[13])   # 用户态时间
        stime = int(fields[14])   # 内核态时间
        cutime = int(fields[15])  # 子进程用户态时间
        cstime = int(fields[16])  # 子进程内核态时间
        return utime + stime + cutime + cstime

但这里有个坑:/proc/stat里的数值是累计值,你需要做两次采样取差值,再除以时间间隔,才能得到实时使用率。

我曾经踩过的坑:采样间隔太短(小于100ms),导致CPU使用率波动极大,看起来像心电图。后来我固定为1秒采样一次,并做了3秒滑动平均,数据才稳定下来。

3.2.2 容器场景:cgroup v2

现在大部分特征节点跑在Kubernetes里。容器内的/proc只能看到自己,看不到宿主机。这时候要用cgroup。

# 容器CPU限流采集
def get_container_cpu_quota():
    # cgroup v2 路径
    cpu_max = '/sys/fs/cgroup/cpu.max'
    with open(cpu_max, 'r') as f:
        quota, period = f.read().strip().split()
        if quota == 'max':
            return None  # 未限制
        return int(quota) / int(period)  # 返回CPU核心数

cgroup v2相比v1,接口更统一。我建议新项目直接上v2,省得后面迁移。

3.3 内存开销采集:RSS与共享内存的博弈

内存采集比CPU复杂一点。为什么?因为RSS(驻留内存)不等于实际消耗。

你想想看,两个特征节点共享了同一个模型文件,这部分内存算谁的?都算的话,总和就虚高了。

3.3.1 关键指标选择

指标 含义 适用场景
RSS 驻留物理内存 快速估算,包含共享内存
PSS 比例驻留内存 精确计算,共享内存按比例分摊
USS 独占物理内存 判断内存泄漏,不含共享部分
cgroup memory.current 容器总内存 容器场景,最准确

我个人习惯:成本核算用PSS,告警监控用USS。PSS更公平,USS更敏感。

3.3.2 采集实现

# 读取进程PSS(需要root或CAP_SYS_PTRACE)
def get_process_pss(pid):
    pss = 0
    with open(f'/proc/{pid}/smaps_rollup', 'r') as f:
        for line in f:
            if 'Pss' in line:
                pss = int(line.split()[1])  # 单位KB
                break
    return pss

小技巧:/proc/[pid]/smaps_rollup是Linux 4.14+才有的特性。如果内核版本低,只能遍历smaps文件,性能差很多。我建议先检查内核版本,再决定采集策略。

3.4 网络开销采集:不只是带宽

特征链路的网络开销,很多人只盯着带宽。其实连接数、重传率、延迟抖动才是成本大头。

我记得有个案例:某个特征节点带宽只用了200Mbps,但连接数高达5万,导致CPU软中断飙升。如果只看带宽,根本发现不了问题。

3.4.1 核心采集指标

  • 带宽使用率:通过/proc/net/dev读取字节数,计算差值
  • TCP连接数:通过ss -s或/proc/net/tcp统计
  • 重传率:/proc/net/snmp中的RetransSegs与OutSegs比值
  • 连接建立速率:每秒新建连接数,反映握手开销
# 采集网络重传率
def get_retrans_rate():
    with open('/proc/net/snmp', 'r') as f:
        for line in f:
            if line.startswith('Tcp:'):
                parts = line.split()
                retrans = int(parts[12])  # RetransSegs
                outseg = int(parts[11])   # OutSegs
                return retrans / outseg if outseg > 0 else 0

3.4.2 连接级细粒度采集

如果特征节点是长连接服务(比如gRPC),我建议用eBPF做连接级采集。虽然有点重,但能拿到每个连接的延迟和吞吐。

注意:eBPF需要内核版本5.4+,且需要CAP_BPF权限。如果环境不支持,退而求其次用ss -i也能拿到TCP重传和RTT信息。

3.5 整体采集架构

说了这么多,怎么串起来?我画了一张图,展示采集层的整体设计。

数据采集层架构 特征计算节点A Python / Java / C++ 特征计算节点B Python / Java / C++ 特征计算节点C Python / Java / C++ 采集代理 (DaemonSet) /proc 文件系统 cgroup v2 接口 /proc/net/dev eBPF (可选) ss / netstat 采样间隔: 1s 滑动平均: 3s 数据管道 Kafka Topic 格式: JSON 字段: node_id, timestamp cpu_usage, mem_pss net_bandwidth, tcp_conn retrans_rate 关键设计要点 1. 采集代理以DaemonSet方式部署,每个节点一个,不侵入业务Pod 2. 所有指标通过/proc和cgroup标准接口获取,无需业务进程配合 3. 数据统一写入Kafka,下游做聚合和成本分摊 4. 采集代理自身资源消耗控制在0.5核CPU + 256MB内存以内

3.6 避坑指南与最佳实践

最后,分享几个我踩过的坑,希望能帮你少走弯路。

避坑一:采集频率不是越高越好

我曾经把采集间隔设成100ms,结果采集代理自己占了5%的CPU。后来改成1秒,配合滑动平均,效果反而更好。记住:成本追踪不需要毫秒级精度,秒级足够。

避坑二:注意PID复用问题

Linux的PID会复用。如果你采集时进程刚退出,新进程可能用了同一个PID。我建议每次采集都校验进程启动时间(/proc/[pid]/stat的第22个字段),如果变了就重新初始化。

避坑三:容器场景别漏了pause容器

Kubernetes每个Pod都有一个pause容器,它也会消耗少量CPU和内存。如果不采集,成本核算会偏低。我一般把pause容器的开销按比例分摊到业务容器上。

我的推荐方案:如果团队资源充足,直接用Prometheus Node Exporter + cAdvisor,开箱即用。如果追求极致轻量,自己写一个200行左右的Go采集器,只采集你关心的指标。我个人偏向后者——可控,且没有依赖爆炸的风险。

数据采集层是成本追踪的基石。采集不准,后面所有的成本分摊都是空中楼阁。下一层,我们会聊数据清洗与标准化——把采集到的原始指标变成可计算的成本因子。嗯,那是另一个有意思的话题。