AdTechSep 10, 2026阅读需 10 分钟

RTB 出价方中的 Go GC 停顿:mark assist、时限与 Rust 决策

实时竞价Rust垃圾回收长尾延迟
图片加载失败

一个平均值看着健康、却因超时输掉竞价的 Go 出价方,一个症状下面藏着两个问题:时限属于广告交易平台并且覆盖整个往返,以及收集器把 mark assist 记在给出价打分的那个 goroutine 上。本文讲时限怎么拆分、哪些 Go 旋钮真的有用,以及 Rust 热路径修不好什么。

一个平均值看着健康、却因超时输掉竞价的出价方,是被错误的数字描述了。时限属于广告交易平台,并且覆盖两个方向的网络。收集器是进程级的属性,它的停顿会同时记到每一条连接上;只有一条连接出现毛刺,就得另找解释。

用 Rust 重写还是调运行时,是在为一个没人问过的问题挑两个答案:时限的哪一部分被花掉了,花在什么上。下文把预算与收集器分开,把收集器与调度器分开,也把重写与真正值得重写的那个组件分开。

简短的答案是结构性的。时限由广告交易平台设定,并且包含网络,所以第一步修复是把单条连接的 p99 拆成处理函数耗时、等待处理器的时间和网络链路上的时间。amBrain 可以公开证实的内容:我们构建了 RTBBidder,一个从零交付的需求方平台,其中每次出价决策都要对每一次展示评估数十个定向条件。我们作为实测公布的延迟数字来自交易路径,而不是来自广告出价方,下文没有任何一个数字是在我们自己的出价方上测得的。

时限属于广告交易平台,而且它覆盖整个往返

OpenRTB 规范把 tmax 定义为广告交易平台允许收到出价的最长毫秒数,其中包含互联网延迟,并说明该值优先于任何先前的指引。这个预算是随每个请求一起到达的一次往返。

评判你的那个阈值,不是你仪表盘上的那个。Google Authorized Buyers 的文档要求,按交易所在地测量,85 percent 的响应要在时限内到达,达不到的出价方会被限流。它的时钟和你的时钟之间,隔着一切与计算无关的东西:

  • 去往广告交易平台再返回的往返,它首先是地理位置和对等互联的问题,其次才是工程问题
  • keepalive 失效时的建连,因为在一次竞价预算之内做一次全新的 TLS 握手,就等于输掉这次竞价
  • 请求被你的处理函数看到之前,待在 accept 队列里的时间,它恰好在你最忙的时候变长
  • 反序列化:成本取决于你把请求中的多少内容变成了对象,而不取决于请求的大小

所以内部时限要比 tmax 低出一段,低多少由你自己的直方图说了算——那条连接上给出一个回答要花多少。它按广告交易平台分别重新推导,而不是给整个集群设一次。时限不是容量规划:在时限处被取消的工作,CPU 已经花掉了。

过载之下,这意味着为没人计数的响应付全价,所以缺的那一步修复是准入控制:读 tmax,与你实测的队列延迟做对比,算不过来时就回 no-bid。快速的 no-bid 计入那 85 percent;迟到的出价不计。

一个收集器服务所有连接,所以只有一条连接出问题是另一种故障

垃圾回收器是进程级的属性,所以任何一条连接触发的回收周期,都会记在每一条连接头上。最先错过的,是 tmax 最紧、请求最重的那一条。先排除那些不需要收集器就能造出同样画面的原因:

  • 来自某个广告交易平台的连接太少,因为 HTTP/1.1 一次只承载一个请求:每连接 QPS 乘以处理函数耗时接近一时,队列就排在连接上了
  • 单条 HTTP/2 连接:一个丢包会卡住共享它的每一条流——正是 RFC 9114 在 2022 年援引为 HTTP/3 存在理由的队头阻塞
  • keepalive 失效,多数时候是默认值而不是故障:http.Server.IdleTimeout 为零时会回退到 ReadTimeout,而 Google 要求 2.5-minute 的空闲超时,nginx 则在 75 seconds 关闭
  • 只有部分广告交易平台会触发的同步特征查询:长尾属于远端存储,而不属于你

这四种没有一种能靠收集器的配置项修好,而这个拆分是有工具的。CPU profile 按符号把收集器的账单分开:runtime.gcAssistAlloc 是记到处理函数上的部分,runtime.gcBgMarkWorker 是后台标记。Stop-the-world 时间是 /sched/pauses/total/gc:seconds,可运行等待是 /sched/latencies:seconds,而 accept 队列要在进程之外通过 ListenOverflows 读。

有一个区分决定了你需要哪种 Go 侧的修复。stop-the-world 停顿同时记在每一个 goroutine 上,所以它表现为所有连接上一致的毛刺。mark assist 记在做了分配的那个 goroutine 上,所以它落在分配最多的那些请求上。降低 assist 只有两条路:每个竞价请求的字节数更少,或者回收周期更长、让后台工作线程承担更多标记。只有第一条能扛住流量结构的变化。

mark assist 就是你的竞价请求要付的那张账单

Go 的收集器是并发的,官方指南明确指出停顿时长不随堆大小增长,所以 stop-the-world 切换很短。真正要紧的来源是 assist:分配速度快时,goroutine 会去协助收集器,因为后台标记只拿到固定的四分之一处理器,缺口就记在分配者头上。

速率让这件事成为一个阈值,而不是一条斜线。分配速率是 QPS 乘以每个竞价请求的字节数,对上的是一份固定的后台份额,所以在五分之一流量下从不触发 assist 的代码,到了 100K QPS 可能几乎每个请求都在 assist。

标记成本与存活指针图成正比,与垃圾无关,而出价方持有的正是最不利的那种形状:广告活动索引、受众分群、频次缓存。Discord 在 2020 年公布过同样的发现:收集器为了判断内存是否已空闲,扫遍了整个 LRU 缓存。一个说法底下藏着五种机制:

  • stop-the-world 切换:同一瞬间在每条连接上都出现的一致毛刺,单靠它很少长到能输掉一次竞价
  • mark assist:trace 里根本没有停顿,只有变长的处理函数耗时——要从 GC CPU 里的 assist 占比读出来
  • 标记成本:分配没有增加、但存活堆变大时抬升的 GC CPU;扁平数组能动它,减少垃圾不能
  • 调度器争用:请求处于可运行状态却没在执行,调度器延迟指标能看到,停顿指标看不到
  • 强制回收周期:在空闲实例上以大约两分钟为周期出现的毛刺,指向的是回收频率的下限,而不是你的流量

把它读成五张各自独立的账单。其中恰好一张能靠收集器的配置项结清,而在拆分被测出来之前,换语言一张也结不了。

闭环会删掉你需要的证据

Gil Tene 把这种失效命名为 coordinated omission:测量系统与被测系统以一种恰好避开离群值的方式协同,因为闭环会等待响应,并在停顿期间停止发送。ScyllaDB 在 2021 年公布过一次对比:同一个负载在闭环下报出的 p99 是 249 microseconds,而在带修正的开环压力下是 665 ms,相差约 2,700 倍。

  • 固定速率的开环压力,延迟从计划发送的时刻算起,而不是从请求实际离开的时刻算起
  • 只要生成器没有把没能发出去的请求排队补上,就按 HdrHistogram 的方式做修正
  • 用 p99 和 p99.9,而不是平均值,并且把你自己的 fan-out 算进去:Dean 与 Barroso 在 2013 年指出,在 p99 为一秒的情况下访问 100 台服务器,会有 63 percent 的请求变慢
  • 从出问题的那个广告交易平台复制来的流量画像,运行时间长于强制回收的间隔,并且在同样的 cgroup 配额下

等待响应的压测生成器,恰恰会在它本该发现的那次停顿期间停止发送,然后把这段沉默平均进结果里。它随后打印的分位数描述的是生成器,不是你的出价方。

先少分配,再拧三个旋钮

调优之前先定下停止的数字,因为靠精疲力尽做出的重写不是决定。要两个数字,不是一个:每请求的堆分配量,它驱动 assist;以及存活堆,它驱动标记。

顺序是先治源头再设上限,而不是先挑收益最大的。从产生 assist 的地方开始:对出价路径做逃逸分析,缓冲区复用而不是重新分配,以及一个只读取你需要的字段、而不是物化出一整张新对象图的编解码器。让它的切片短命:指向请求缓冲区的切片,会在整个出价的生命周期里把那整块缓冲区钉住。

  • sync.Pool 能缓解压力,但什么都不保证:其中的对象可能在任何时刻被移除且不作通知,而池里的对象在归还与被清除之间仍然会被标记
  • 存活集就是那张账单:把广告活动索引和分群索引在热路径之外重建成扁平数组,让被标记的图不再随广告活动数量增长
  • GOGC 用内存换收集器的 CPU,指南把兑换比例说得很直白:把它翻倍,GC 的 CPU 成本大致减半,assist 占比也随之下降
  • GOMEMLIMIT 就是把这笔交易压在一个上限之下,它按设计是软限制,因为硬限制会把一次堆尖峰变成无限期的停顿
  • 容器里的 GOMAXPROCS:Go 1.25 读取 cgroup 的 CPU limit,并且明确不读 CPU requests,所以只设了 requests、没设 limit 的 pod 保持旧行为

这条路有天花板:Uber 在 2021 年报告,按容器内存上限调 GOGC,在其关键业务服务上一共回收了约 70,000 个核心。那是一个成本结果,不是一个分位数结果。

Rust 热路径能给你什么,以及你要为它付出什么

RTB House 在 2025 年 6 月介绍过一个 JVM 出价服务:拆成微服务之后产生了大量小请求,新增的延迟必须留在 7 ms 以内,而平均请求约 2.5 ms,第 98 和第 99 分位在频繁的 G1 停顿下崩了。他们换成了分代 ZGC,代价付在内存上。

注意那次修复的前提:有第二个收集器可以切过去。Go 只带一个,而且不可插拔,所以 Go 这边的杠杆是分配速率、存活集的形状,以及 GOGC 与 GOMEMLIMIT 的搭配。Rust 热路径去掉的东西则很具体:没有 assist,没有后台标记,没有强制回收周期。去不掉的那部分,比多数团队预期的要长:

  • 分配器,加上缺页和 NUMA 放置:Rust 标准库明确说默认的全局分配器是未指定的,而多线程下的 malloc 是一个长尾来源
  • 调度,因为带工作线程池的异步运行时,只要有一个阻塞任务落到工作线程上,就会复现 Go 调度器的那些效应
  • 内存记账,因为 GOMEMLIMIT 只覆盖 Go 运行时的内存:同一进程里的 Rust 分配器在你的上限之外,约束就交给 OOM killer 了
  • 人,也就是夜里谁给热路径值班,以及在一个仓库里养两套工具链要付多少代价
  • 边界,如果 Go 还留在外面:Cockroach Labs 在 2015 年测得一次 cgo 调用是 171 ns,而一次 Go 调用是 1.83 ns,留下来的是这个比值

如果长尾出在解析请求、出在到广告交易平台的连接、或者出在调度器排队上,Rust 一毫秒也还不回来,重写错的组件就是花掉一个季度、换来同样的超时率。

所以要搬走的是拥有这些分配的最小那块,而不是整个服务:展示评估循环及其索引、候选筛选、定向、频次与预算查询、打分。按每次跨越给边界定价:每个竞价请求跨一次、用一个扁平缓冲区,绝不要每条定向规则跨一次。验证要放在被喂了镜像流量的独立实例上,绝不要放在被测进程内部——在那里,影子路径会让你正在测的那两个量翻倍。

两条路都适用的验收测试:关掉收集器跑一个短窗口,设一个你自己控制的内存上限,并在处理函数内部记录 p99.9。只在一个实例上、承接一小部分流量、带自动回滚地跑,因为 GOGC 关闭时堆撞上上限会让运行时进入背靠背的回收周期,而官方指南说这种停顿可能是无限期的。只有在 GC CPU 限制器从未介入时才读这个结果:一旦它介入,分位数描述的就是限制器。

能修好这个问题的公司,会先要超时报表

问题的后半部分——谁来做这类工作——有一个不需要供应商名单的判别方法。修复 GC 引起的延迟的公司,和卖重写的公司,行为方式不一样:

  • 先要 tmax 分布和按广告交易平台拆分的超时报表,再要代码仓库
  • 在提出语言之前,先说清楚这个拆分——处理函数、调度器、网络链路——以及它预期是三者中的哪一个占掉了那些毫秒
  • 自带开环压测生成器和流量画像,并且拒绝把闭环分位数当作证据
  • 提前写明退出标准:哪个广告交易平台、哪个分位数、相对它的 tmax 留多少余量、每千次请求占多少内存
  • 事后能给热路径配上人,因为夜里没人值班的重写就是第二起事故

这些都不需要信任:每一项都是你在第一次谈话里就能要来的文档,而含糊其辞的那一份说明诊断被跳过了。

所以第一个问题不是用哪种语言。而是在超时的那条连接上,处理函数、调度器、网络链路这三项之中,是哪一项占掉了消失的毫秒,以及当你去动它时,每个竞价请求分配的字节数会不会变。

amBrain 可以公开证实的内容:amBrain 是一家软件开发公司,专注交易平台、matching engine、实时竞价系统和博彩平台工程,我们自 2019 年起做软件开发;在 AdTech 领域,我们做的是 DSP 开发、实时竞价平台和广告交易平台工程。我们有三种合作方式:整体交付、专属团队,或工程师嵌入你的团队。如果你正在权衡重写还是做一轮调优,值得谈的那场对话,会先跑完拆分再挑语言。

手头有类似的设计?

带上您当前的架构和让您担心的故障场景,我们用半小时一起梳理。

相关文章

图片加载失败
AdTech
Sep 10, 2026阅读需 10 分钟

广告测量在高峰丢事件:接缝、重复键与出价方联表

阅读全文
图片加载失败
AdTech
Mar 5, 2026阅读时长7分钟

AI如何重塑2026年的程序化广告

阅读全文
图片加载失败
AdTech
Feb 14, 2026阅读时长6分钟

隐私优先的定向:不依赖第三方Cookie构建广告技术

阅读全文