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 整体采集架构
说了这么多,怎么串起来?我画了一张图,展示采集层的整体设计。
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采集器,只采集你关心的指标。我个人偏向后者——可控,且没有依赖爆炸的风险。
数据采集层是成本追踪的基石。采集不准,后面所有的成本分摊都是空中楼阁。下一层,我们会聊数据清洗与标准化——把采集到的原始指标变成可计算的成本因子。嗯,那是另一个有意思的话题。