一个平均值看着健康、却因超时输掉竞价的 Go 出价方,一个症状下面藏着两个问题:时限属于广告交易平台并且覆盖整个往返,以及收集器把 mark assist 记在给出价打分的那个 goroutine 上。本文讲时限怎么拆分、哪些 Go 旋钮真的有用,以及 Rust 热路径修不好什么。
一个平均值看着健康、却因超时输掉竞价的出价方,是被错误的数字描述了。时限属于广告交易平台,并且覆盖两个方向的网络。收集器是进程级的属性,它的停顿会同时记到每一条连接上;只有一条连接出现毛刺,就得另找解释。
用 Rust 重写还是调运行时,是在为一个没人问过的问题挑两个答案:时限的哪一部分被花掉了,花在什么上。下文把预算与收集器分开,把收集器与调度器分开,也把重写与真正值得重写的那个组件分开。
简短的答案是结构性的。时限由广告交易平台设定,并且包含网络,所以第一步修复是把单条连接的 p99 拆成处理函数耗时、等待处理器的时间和网络链路上的时间。amBrain 可以公开证实的内容:我们构建了 RTBBidder,一个从零交付的需求方平台,其中每次出价决策都要对每一次展示评估数十个定向条件。我们作为实测公布的延迟数字来自交易路径,而不是来自广告出价方,下文没有任何一个数字是在我们自己的出价方上测得的。
OpenRTB 规范把 tmax 定义为广告交易平台允许收到出价的最长毫秒数,其中包含互联网延迟,并说明该值优先于任何先前的指引。这个预算是随每个请求一起到达的一次往返。
评判你的那个阈值,不是你仪表盘上的那个。Google Authorized Buyers 的文档要求,按交易所在地测量,85 percent 的响应要在时限内到达,达不到的出价方会被限流。它的时钟和你的时钟之间,隔着一切与计算无关的东西:
所以内部时限要比 tmax 低出一段,低多少由你自己的直方图说了算——那条连接上给出一个回答要花多少。它按广告交易平台分别重新推导,而不是给整个集群设一次。时限不是容量规划:在时限处被取消的工作,CPU 已经花掉了。
过载之下,这意味着为没人计数的响应付全价,所以缺的那一步修复是准入控制:读 tmax,与你实测的队列延迟做对比,算不过来时就回 no-bid。快速的 no-bid 计入那 85 percent;迟到的出价不计。
垃圾回收器是进程级的属性,所以任何一条连接触发的回收周期,都会记在每一条连接头上。最先错过的,是 tmax 最紧、请求最重的那一条。先排除那些不需要收集器就能造出同样画面的原因:
这四种没有一种能靠收集器的配置项修好,而这个拆分是有工具的。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 只有两条路:每个竞价请求的字节数更少,或者回收周期更长、让后台工作线程承担更多标记。只有第一条能扛住流量结构的变化。
Go 的收集器是并发的,官方指南明确指出停顿时长不随堆大小增长,所以 stop-the-world 切换很短。真正要紧的来源是 assist:分配速度快时,goroutine 会去协助收集器,因为后台标记只拿到固定的四分之一处理器,缺口就记在分配者头上。
速率让这件事成为一个阈值,而不是一条斜线。分配速率是 QPS 乘以每个竞价请求的字节数,对上的是一份固定的后台份额,所以在五分之一流量下从不触发 assist 的代码,到了 100K QPS 可能几乎每个请求都在 assist。
标记成本与存活指针图成正比,与垃圾无关,而出价方持有的正是最不利的那种形状:广告活动索引、受众分群、频次缓存。Discord 在 2020 年公布过同样的发现:收集器为了判断内存是否已空闲,扫遍了整个 LRU 缓存。一个说法底下藏着五种机制:
把它读成五张各自独立的账单。其中恰好一张能靠收集器的配置项结清,而在拆分被测出来之前,换语言一张也结不了。
Gil Tene 把这种失效命名为 coordinated omission:测量系统与被测系统以一种恰好避开离群值的方式协同,因为闭环会等待响应,并在停顿期间停止发送。ScyllaDB 在 2021 年公布过一次对比:同一个负载在闭环下报出的 p99 是 249 microseconds,而在带修正的开环压力下是 665 ms,相差约 2,700 倍。
等待响应的压测生成器,恰恰会在它本该发现的那次停顿期间停止发送,然后把这段沉默平均进结果里。它随后打印的分位数描述的是生成器,不是你的出价方。
调优之前先定下停止的数字,因为靠精疲力尽做出的重写不是决定。要两个数字,不是一个:每请求的堆分配量,它驱动 assist;以及存活堆,它驱动标记。
顺序是先治源头再设上限,而不是先挑收益最大的。从产生 assist 的地方开始:对出价路径做逃逸分析,缓冲区复用而不是重新分配,以及一个只读取你需要的字段、而不是物化出一整张新对象图的编解码器。让它的切片短命:指向请求缓冲区的切片,会在整个出价的生命周期里把那整块缓冲区钉住。
这条路有天花板:Uber 在 2021 年报告,按容器内存上限调 GOGC,在其关键业务服务上一共回收了约 70,000 个核心。那是一个成本结果,不是一个分位数结果。
RTB House 在 2025 年 6 月介绍过一个 JVM 出价服务:拆成微服务之后产生了大量小请求,新增的延迟必须留在 7 ms 以内,而平均请求约 2.5 ms,第 98 和第 99 分位在频繁的 G1 停顿下崩了。他们换成了分代 ZGC,代价付在内存上。
注意那次修复的前提:有第二个收集器可以切过去。Go 只带一个,而且不可插拔,所以 Go 这边的杠杆是分配速率、存活集的形状,以及 GOGC 与 GOMEMLIMIT 的搭配。Rust 热路径去掉的东西则很具体:没有 assist,没有后台标记,没有强制回收周期。去不掉的那部分,比多数团队预期的要长:
如果长尾出在解析请求、出在到广告交易平台的连接、或者出在调度器排队上,Rust 一毫秒也还不回来,重写错的组件就是花掉一个季度、换来同样的超时率。
所以要搬走的是拥有这些分配的最小那块,而不是整个服务:展示评估循环及其索引、候选筛选、定向、频次与预算查询、打分。按每次跨越给边界定价:每个竞价请求跨一次、用一个扁平缓冲区,绝不要每条定向规则跨一次。验证要放在被喂了镜像流量的独立实例上,绝不要放在被测进程内部——在那里,影子路径会让你正在测的那两个量翻倍。
两条路都适用的验收测试:关掉收集器跑一个短窗口,设一个你自己控制的内存上限,并在处理函数内部记录 p99.9。只在一个实例上、承接一小部分流量、带自动回滚地跑,因为 GOGC 关闭时堆撞上上限会让运行时进入背靠背的回收周期,而官方指南说这种停顿可能是无限期的。只有在 GC CPU 限制器从未介入时才读这个结果:一旦它介入,分位数描述的就是限制器。
问题的后半部分——谁来做这类工作——有一个不需要供应商名单的判别方法。修复 GC 引起的延迟的公司,和卖重写的公司,行为方式不一样:
这些都不需要信任:每一项都是你在第一次谈话里就能要来的文档,而含糊其辞的那一份说明诊断被跳过了。
所以第一个问题不是用哪种语言。而是在超时的那条连接上,处理函数、调度器、网络链路这三项之中,是哪一项占掉了消失的毫秒,以及当你去动它时,每个竞价请求分配的字节数会不会变。
amBrain 可以公开证实的内容:amBrain 是一家软件开发公司,专注交易平台、matching engine、实时竞价系统和博彩平台工程,我们自 2019 年起做软件开发;在 AdTech 领域,我们做的是 DSP 开发、实时竞价平台和广告交易平台工程。我们有三种合作方式:整体交付、专属团队,或工程师嵌入你的团队。如果你正在权衡重写还是做一轮调优,值得谈的那场对话,会先跑完拆分再挑语言。
带上您当前的架构和让您担心的故障场景,我们用半小时一起梳理。