最近为一个 Go 写的 OTLP 采集服务(server collector)设计并验证了 memory limiter 机制。整个过程沉淀出一套比较完整的压测方法论:负载模型怎么设计、指标怎么采、图怎么画、内存热点怎么归因。这篇博客做一个总结,希望对做同类验证的同学有参考价值。
背景:memory limiter 是什么
采集类服务有个典型痛点:流量突增或大报文涌入时,内存被临时对象打爆,容器 OOMKilled 后重启缓慢,可用性受损。memory limiter 就是为此设计的内存准入闸门:
- 后台每 1s 采样容器 working set(cgroup 口径:v2 取
memory.current − memory.stat.inactive_file,v1 取memory.usage_in_bytes − total_inactive_file,读失败回退runtime.MemStats.Alloc); - usage ≥ 阈值(默认总内存 60%)时立即在入口拒绝新请求:HTTP 返回 503 +
Connection: close,gRPC 返回ResourceExhausted; - 越限期间按需触发 forced GC(最小间隔 500ms,采样周期 1s,即越限期间每次采样都 GC 一次);
- 采样回落后清除拒绝状态,恢复接收。
有一个关键认知决定了整个压测设计的走向:limiter 只是入口闸门,无法撤销已经准入、正在读取/反序列化/下游处理的请求。所以切换为拒绝后,已准入请求产生的临时对象仍会推高内存,working set 峰值必然显著越过阈值线。压测要验证的不是"内存不超过阈值",而是"在持续高压下不会 OOM"。
一、压测流量模型怎么设计
1.1 先明确通用负载语义
发压工具(仓库里的 tools/loadgen,YAML 场景驱动)有几个容易踩坑的语义,设计模型前必须先对齐:
-
进程级有界 worker 池:
run.max_inflight是全部 stream 共享的在途请求上限,streams[].load.max_inflight是单 stream 上限。不要为每个 stream 建独立 worker 池。 -
rate / byte_rate 是聚合上限,不是单账号上限:stream 轮询同一 appkey 列表,各账号请求数近似相等。
-
不在 YAML 里配 item 大小:用
byte_rate / rate推导每请求 payload 字节数,再除以items得 item 大小;不够就往最多 5 个字段里填充字符串属性校准,单请求上限 64 MiB。 -
实际 RPS 受多重约束:
实际 RPS <= min(rate, byte_rate/S, 并发/延迟, 发压机 CPU/网卡, collector 容量)只加 appkey 或只加并发都不会放大负载。
-
appkey 数按 tenant/workspace 限额倒推:
N = ceil(R * I / (L * H)),推荐H=0.8留余量(整批AllowN(I)判断、背景流量、限流器误差都需要 buffer)。压测前必须查目标环境实际的 tenant/workspace 限额,不能假设默认值。
1.2 两种面向不同目标的模型
模型一:混合大包模型(压内存/GC)
面向 memory limiter、OOM 边界、GC/scavenge 观测:
- 大 payload(约 8 MiB/request)、低 RPS;
- appkey 数与并发同步阶梯放大,档位按整数倍 K=1/2/3/4/6/7 对应聚合 240/480/720/960/1440/1680 MiB/s;
- metrics/traces/logs 请求率 = 18K/6K/6K req/s,三信号流量 = 144K/48K/48K MiB/s;
- 模型与协议正交:模板是 gRPC,改
protocol: http+ target 到 80 端口即可复用(HTTP 默认 LZ4 只影响 transport 字节数,不改byte_rate口径)。
模型二:HTTP 512 KiB 固定连接模型(压连接池/模拟线上)
面向 keep-alive 连接池容量、固定请求形态下的可调 RPS:
- 每请求固定 10 items、
byte_rate = rate × 512KiB、compression: none; - 连接数和 RPS 是两个独立维度:
connections_per_target固定单连接槽位数,所有 stream 轮询复用;rate只控速率; - 三信号 RPS 按 70/20/10 配比;阶梯压测时保持连接数、items、配比不变,只同步缩放
rate和byte_rate; - 正式计时前用小请求预热全部连接槽位(不计入汇总),
idle_timeout要覆盖单轮时长,且要从发压机和被压端同时核对 ESTABLISHED 连接数。
选择规则很简单:压内存/GC 选大包模型,压连接池或固定 512KiB 请求处理选固定连接模型。本次验证两个都跑了:大包模型验证 limiter 防 OOM,小包多连接(512KiB × 200 连接 × 44 账号)模拟线上形态。
1.3 关键一步:内存准入估算
这是大包模型压测前必须算的账:
已准入 payload 上界 = inflight cap × 每请求 payload 字节数
余量 = cgroup limit − limiter limit
已准入 payload 只是风险下界——gRPC HTTP/2 DATA frame 副本、连续消息物化、protobuf unmarshal、下游 marshal/compression 可能同时存在。如果 已准入上界 × 放大系数 接近余量,即使已切换为拒绝,内存仍可能冲高到 OOM。这个估算直接决定了档位能不能安全地打上去,也解释了压测结果里 working set 峰值越过 60% 阈值十几个百分点是预期行为,而非 bug。
二、压测前环境准备
结论要可复现,环境基线必须先锁死:
| 项目 | 要求 |
|---|---|
| QoS | requests 与 limits 调整为一致,Pod QoS 提到 Guaranteed,避免 CPU 超售抖动 |
| 发压机 | 16C16G,保证发压侧不构成瓶颈 |
| 被压机 | 4C4G 单 Pod |
| 节点亲和性 | nodeAffinity 限定 g3i/g4i 规格节点,避开 g1(实测 g1 性能基线差) |
| GC 日志 | 被压端加 GODEBUG=gctrace=1,scavtrace=1 并滚动更新,确认 stderr 出现 gc .../scav ... 行后才发压 |
压测结束恢复观察窗口后,记得恢复原有 GODEBUG。
三、观测什么指标、口径怎么定
观测的核心原则是:从发压机与被压端同时采集,所有样本带 UTC 时间戳相互对齐,原始数据落盘保留。每个指标都有容易犯的口径错误,值得单独强调。
3.1 发压机出口带宽
- 按
ip route get <collector-ip>选定实际出接口,不默认 eth0; - 每秒读
/sys/class/net/<iface>/statistics/tx_bytes,按8 × Δbytes / Δt算 bit/s; - 不把 loadgen 的 payload_bytes/s 当作真实网卡带宽——网卡出口包含协议头、重传和同接口其他流量;
- 同步采
tx_dropped/tx_errors判断丢包和发压机瓶颈。
3.2 被压端内存
- 用被压 Pod 的 cgroup working set ÷ 有效 limit,不用 RSS 或 pprof 代替;
- 原始采样 ≤1s(实际 0.25s),生成秒级序列时按 UTC 自然秒分桶取秒内峰值,保留瞬时毛刺——内存尖刺恰恰是问题的证据,取平均会把它们抹掉。
3.3 GC / STW / scavenge
解析被压端 stderr 的 gctrace/scavtrace:
A->B->C MB中的 C 是该次 GC 的 live heap;ms clock拆成三段,只把 sweep termination + mark termination 两个 STW 阶段聚合为秒级 STW pause ms/s——禁止把含并发 mark 的 wall time 当 STW;- 秒级 active GC CPU 排除 idle marking 分量——gctrace 冒号前的
gc_cpu_percent是进程启动以来的累计百分比,绝不能当秒级 CPU 利用率; - scavenge 以
background_work + eager_work为归还量,零 work 事件也要保留。
3.4 memory limiter 自身
两条线同时采:
- 日志:每次采样的 usage(含
forcing_drop状态)、首次越限拒绝、forced GC 结果、恢复事件; - 自监控指标(前缀
apmplus.selfmonitor.collector.memory_limiter.):throughputcounter(passed=false即被拒绝流量)、usage_mib/total_memory_mib/hard_limit_mibgauge、gc_count/gc_pause_ms/forced_gc_countcounter。
四、可视化:六层时间轴图
压测报告最有说服力的是一张共享 UTC 时间轴的六层复合图,从上到下固定为:
- 主体:working set %(左轴)+ 发压机出口带宽 Gbit/s(右轴),soft/hard limit 画成贯穿的水平虚线;
- Limiter:接收/拒绝状态带(拒绝用半透明红色区间,切换时刻画竖线);
- GC heap:每次 GC 的 live heap 事件点(forced GC 用菱形区分);
- STW pause:UTC 自然秒聚合的 STW ms/s;
- GC CPU:秒级 GC 平均占用核数;
- Scavenge:归还量事件。
几个细节要求:
- 六层必须画在同一个 Canvas、同一个 xScale 上,不是六个各算横轴的 chart;hover 时用一条贯穿六层的纵向虚线标出当前采样位置;
- 横轴右边界固定为
压测结束 + 10s,不用恢复等待期扩展横轴(恢复数据保留在原始 CSV 里); - 缺样/计数器回绕处折线断开成 null,禁止跨缺口插值、禁止用 0 或前值填充;
- 导出交互式 HTML + 同数据同布局的 PNG。
这张图的价值在于因果对齐:limiter 什么时候拒绝、内存什么时候冲高、GC 有没有跟上、带宽什么时候跌零,一眼对上时间线。下面调优一节会看到这个能力有多关键。
下面是 gRPC 720 MiB/s 档压测的实际可视化结果(交互式,可 hover 查看各层对齐数据):
五、过程调优:压测暴露了两个设计问题
初版 limiter 在压测中暴露了很有趣的现象,也直接推动了设计迭代。
5.1 GC 退避导致的 working set 平台期
现象:working set 出现 50s 和 2 分钟两种平台期,平台期无 GC、发压机带宽跌 0。
根因:
- 初版设计了 GC 退避:一次 GC 回收 < 5% 就增大下次间隔,最大 50s——造成硬阈值以上的 50s 平台期;
- 软硬阈值之间不强制 GC,而发压机带宽已接近 0、新对象生成速率低,只剩 Go runtime 的 2 分钟定时 GC 生效——造成 2 分钟平台期;
- 带宽为什么跌 0?拒绝路径是"HTTP/2 reader 读 HEADERS → InTapHandle 拒绝 → 写回 early-abort/RST",没有 GC STW、RTT 又低,拒绝是瞬时完成的;发压机收到 RST 后在 HTTP/2 层放弃请求,body 根本不发——所以秒级带宽直接归零。
5.2 GOMEMLIMIT 的坑
尝试设 GOMEMLIMIT = 软阈值让 Go 自己积极 GC,结果内存接近阈值后 GC 过于激进但回收无效:观察到 GC 吃掉全部 4 核、GC 耗时 160ms/s。
5.3 最终方案
- 去掉软硬阈值,只保留一个阈值;
- 去掉 GC 退避,越限期间每次采样都 GC。
教训是通用的:对"回收无效"的惩罚机制(退避)在准入门场景里反而制造了恢复死区;把 GC 节奏外包给 GOMEMLIMIT 又会在边界上打满 CPU。固定节奏、立即拒绝、每次采样 GC,是最朴素也最可控的组合。
六、结果:limiter 顶住了
三轮 20 分钟压测,发压带宽均打到约 3Gbps:
| 轮次 | 档位 | 结果 |
|---|---|---|
| HTTP 大包 | 640 MiB/s | 0 重启、无 OOM;working set 峰值 84.34%;limiter 拒绝 179 次、累计 208s |
| gRPC 大包 | 720 MiB/s | 0 重启、无 OOM;working set 峰值 99.19%(贴顶但未死);forced GC 276 次;停止后约 3s 回落到 60% 以下 |
| HTTP 小包多连接 | 512KiB × 200 连接 × 44 账号 | 0 重启、无 OOM;阈值降到 40% 后 limiter 正常触发 |
几个值得记录的现象:
- gRPC 轮 working set 冲到 99.19% 仍未 OOM,正好验证了"准入估算余量充足"的预判;
- 小包轮 working set 峰值比触发线高约 17.5 个百分点,再次印证 limiter 只能拒新流量、不能撤销在途请求,1s 检查周期 + 256 个在途 worker 必然带来 overshoot;
- 各轮停止压测后 VmRSS 都回落到 300MiB 上下,Pod 全程 Ready。
七、内存分配热点怎么排查
压测中观察到"已拒绝新流量、内存仍继续冲高",即内存放大现象。排查方法是 heap pprof 的差分分析。
7.1 方法论
- 分配增量热点:同一进程前后两个 profile 做
alloc_space/alloc_objects的 base subtraction(必须用工具做差,不能把两个累计 top 表手工相减); - 存活热点:阈值时刻的
inuse_space/inuse_objects,必要时对比 pre-GC / post-GC; - 归因必须回溯到完整代码路径,分三类:业务路径(handler/ETL/exporter/producer 入队)、入站框架路径(HTTP/2 读帧、codec、protobuf 解码)、外部 client I/O 路径(Kafka/TLS exporter、连接池、异步队列)。
runtime.makeslice、strings.Builder.WriteString、json.Marshal、snappy.Encode这类叶子函数只能算分配机制证据,不能当业务热点结论; - 共享 producer(如 Kafka producer 被多个 signal 共享)无法唯一归因时,标为"共享外部 client 路径",不武断归给某个 signal;
- 注意 profile 是采样外推、释放记账最多滞后两个 GC 周期,不能等于瞬时 cgroup usage;
heap?gc=1会扰动压测现场,只能在非侵入快照完成后用。
7.2 案例一:HTTP 入站,1 字节 payload 产生 10.5 字节分配
基线(08:19:28Z)到峰值附近(08:28:13Z)约 525 秒区间:
- 增量分配 3.31 TiB,平均 6.46 GiB/s;同期 payload 仅 627 MiB/s——放大系数约 10.5 倍;
- 放大链路:
GetRawData → io.ReadAll(24.45%)+UncompressUseLZ4 → io.ReadAll(41.74%)+ 外层 protobuf 解码(4.93%),合计约 71%。根因是 LZ4 只复用了 reader,解压输出仍由io.ReadAll动态扩容,反复申请更大的 slice 并复制旧内容; - metrics 最严重(84 MiB 分配/请求):payload 375 MiB/s 与 transport 相同说明 LZ4 几乎没省带宽,却白制造了约 846 GiB 解压分配;内层
Label.Unmarshal又单独贡献 176 TiB 级分配、56.5M 对象; - GC 压力分两类:
io.ReadAll的少量巨大临时 buffer + labels/attributes/日志转换/自监控的海量小对象(约 154 万对象/秒)。
7.3 案例二:gRPC 入站,map key 用完整字符串
最明确的业务热点在 metrics 链路:
processOtlpMetrics → ot_metrics_etl.Process → sinkPrometheus
→ translator.FromOtelMetrics → timeSeriesSignatureWithDataType
它把指标名 + 每个 label 的 name/value 拼成完整字符串作 tsMap[sig] 的 key。压测报文含大合成属性值,产生大量 21~104 KiB 的签名字符串,不只是临时分配,还随 map 存活。优化方向是用稳定 fingerprint/hash 作 key 并处理冲突——只给 strings.Builder 预分配容量治不了"巨大 key 存活"的病。
logs 链路的问题类似:同一批日志属性同时存在 map、JSON 字符串、TLS protobuf 多份表示(SendLog 182 MiB + json.Marshal 91 MiB + TLS 消费线程 258 MiB),方向是减少 map 复制和重复序列化、限制队列中同时存活的数据量。
八、经验总结
- 先想清楚闸门语义再设计压测。limiter 是准入门不是在途内存上限,这决定了"验证目标是不 OOM"而非"不超过阈值",也决定了必须做准入估算。
- 负载模型面向目标选型:内存压力用大包低 RPS 阶梯放大,连接池/线上形态用固定连接小包。rate/byte_rate 推导请求形态,比手工拼报文可控得多。
- 口径错误比没数据更害人:把累计 GC CPU 当秒级、把含并发 mark 的 wall time 当 STW、把 RSS 当容器内存、把 payload bytes/s 当网卡带宽——每一个都会导出错误结论。
- 可视化要做因果对齐:内存、limiter 状态、GC、scavenge、带宽共用一条 UTC 时间轴,很多"灵异现象"(比如平台期带宽跌 0)在时间轴对齐后根因几乎自明。
- pprof 归因要回到业务路径:叶子分配函数只是证据链的最后一环,真正的结论要能落到"哪个 handler / 哪条转换链路 / 哪个 client 队列",优化方向才成立。
- 压测是设计迭代工具,不只是验收工具:GC 退避和 GOMEMLIMIT 两个坑都是压测逼出来的,最终的简单方案(单阈值 + 固定节奏 GC)也靠压测背书。