Skip to content
Charles Shao
Go back

Bidder 解析层(Parse):把原始竞价请求变成内部可算对象

Updated:
views

本文属于 Bidder 竞价服务架构 系列。

总览篇把 Bidder 拆成 解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充 七级漏斗。本篇深挖第一级 解析(Parse):它最便宜、却在关键路径上被每一个请求无差别地付一次成本,还决定了后面所有阶段的 deadline 起点。建议先读总览再回到这里。

解析是漏斗的入口,任务只有一句话:把一个来自网络的原始字节流,变成一个内部可算的请求对象,并给它盖上一个「最晚必须算完」的时间戳。 它不做任何竞价决策,却是整条链路的地基——解析慢一毫秒,后面每一级的预算都少一毫秒;解析错一个字段,后面所有打分与出价都建立在错误输入上。

Bidder 解析层子流水线示意图:原始竞价请求(HTTP/gRPC,可能带 gzip)先进入连接层做 keep-alive / HTTP2 长连接与 gzip 解压,再反序列化(Protobuf 优先、JSON 兜底),随后做方言适配与归一化把各 ADX 的 OpenRTB 转成内部统一模型,接着校验与快速失败(必填字段、体积上限、非法即拒),最后限时(打时间戳、tmax 减安全余量算出内部 deadline),产出携带剩余 deadline 的内部请求对象交给定向过滤;配置面通过 Nacos 热更新下发 ADX 适配规则与 Schema,非法或超限请求走快速拒绝 解析子流水线(自上而下):连接层 → gzip 解压 → 反序列化 → 方言适配归一化 → 校验与快速失败 → 限时,产出携带剩余 deadline 的内部请求对象交给定向过滤。橙色为 gzip 解压这一步,蓝色为解析其余各步,黄色为产出对象,紫色为配置面(Nacos)热更新下发,红色为非法/超限的快速拒绝路径。

TL;DR

Table of contents

Open Table of contents

一、解析为什么值得单独一篇

总览里我们反复强调一条排布原则:越便宜、能过滤越多流量的处理越靠前。解析是漏斗的第 0 级——它甚至不承担过滤职责,只做协议翻译,看似无足轻重。但它有两个特殊性,决定了它必须被认真对待:

换言之:解析不直接产生竞价价值,却决定了竞价价值的成本上限。 这正是它值得作为一个独立工程问题对待的原因。

二、协议解析与方言适配

2.1 各家 ADX 的 OpenRTB 并不完全一样

OpenRTB 是行业标准,但它留有大量可选与扩展空间。实际对接多个 ADX 时,通常会遇到:

如果让下游(召回、打分、出价)直接面对这些差异,每一级都需编写大量 if adx == X 的分支判断,复杂度会沿着漏斗层层扩散。

2.2 归一化:适配层 + 内部统一模型

正确做法是在解析层设一个适配层(adapter),把各家 ADX 的请求归一化成一个内部统一模型,下游只认这一种结构:

适配层归一化示意图:左侧是各 ADX 方言各异的 OpenRTB 请求(ext 路径、币种、版本各不相同),经过 per-ADX 规则的适配层做字段映射、默认值填充与扩展保留,输出与具体 ADX 无关的内部统一模型 BidContext,供下游定向、召回、打分、出价各级只面对一种结构;适配规则与 Schema 由 Nacos 热更新下发 适配层归一化:各 ADX 方言各异的 OpenRTB 经 per-ADX 适配规则收敛为与具体 ADX 无关的内部统一模型 BidContext,下游各级只面对这一种结构;适配规则与 Schema 由 Nacos 热更新下发。

适配层要处理好三件事:

// 内部统一模型(示意):下游只面对 BidContext,不感知 ADX 差异
message BidContext {
  string    request_id   = 1;   // 内部请求 ID(贯穿日志与回传)
  Exchange  exchange     = 2;   // 来源 ADX(用于差异化策略与计费)
  Imp       imp          = 3;   // 曝光位(尺寸、位置、底价、币种归一)
  Device    device       = 4;   // 设备(UA/结构化 UA 已归一)
  User      user         = 5;   // 用户(ID、人群标记)
  Geo       geo          = 6;
  int64     deadline_mono_ns = 20; // 见第三节:内部 deadline,进程内单调时刻(不跨进程传递)
}

2.3 序列化:为什么优先 Protobuf

序列化格式的选择直接影响解析的 CPU 与延迟。工程上优先 Protobuf(或同类二进制格式),JSON 仅作兜底

维度ProtobufJSON
报文体积小(二进制、字段用 tag 号)大(文本、重复 key)
解析速度快(按 tag 号直接定位,无需分词)慢(需分词与字符串处理)
CPU 开销高(GC 压力大:大量临时字符串)
Schema 约束强(.proto 强类型、演进规则清晰)弱(靠约定,易出错)
可读性 / 调试差(需借助工具)好(人工可直接阅读)

一个量级直觉(仅供估算,务必以自己的 benchmark 为准):一个典型的 OpenRTB Bid Request2–5 KB(JSON 文本);同等内容用 Protobuf 编码后,体积通常再小 30%–50%。解析耗时上,高性能 JSON 库处理这种报文大致在数十微秒量级,Protobuf 通常快 2–5 倍,且分配的临时对象更少、GC 压力更低。放到 QPS 十万级以上的 Bidder 上,序列化/反序列化常常占到单请求 CPU 的 10%–30%,是火焰图里最靠前的开销之一——这正是「能省则省」的动力所在。具体数字受报文大小、字段数、库实现、JIT 预热与硬件影响很大,此处只给量级。

现实约束是:许多 ADX 对外仍以 JSON 下发 OpenRTB,格式未必可选。因此实践策略通常是:

三、尽早限时:deadline 的起点

解析的第二个职责,是给请求盖上时间戳、算出内部 deadline。这一步必须尽早执行——在请求刚到达、甚至反序列化之前,就先记录到达时刻。

3.1 内部 deadline 怎么算

到达时刻 t0(进门即记录)
内部 deadline = t0 + (tmax − 安全余量)
             = t0 + 可用于本机计算的时间

tmaxADX 一端测量的截止时间,含网络往返;安全余量覆盖回程网络、来程已耗、时钟偏差与 GC/发送抖动(详见总览篇 2.1 节)。解析算出内部 deadline 后,把它放进请求上下文,每一级都据此判断「还有没有时间继续」

// 进门即限时:用单调时钟记录起点,把内部 deadline 全链路透传
long startNanos = System.nanoTime();                 // 尽早打点(单调时钟)
long budgetNanos = TimeUnit.MILLISECONDS.toNanos(tmax - safetyMarginMs(exchange)); // 按来源 ADX 差异化余量
long deadlineNanos = startNanos + budgetNanos;       // 本机可用截止时刻(仅本进程内有意义)
ctx.setDeadlineNanos(deadlineNanos);                 // 放进请求上下文,全链路透传

// 反序列化 / 归一化 / 校验 ... 每一级开工前先看预算
if (System.nanoTime() >= deadlineNanos) {
    return noBid(NobidReason.TIMEOUT);               // 已超时,立即降级 / no-bid
}

上面的 ctx.setDeadlineNanos(...) 为示意——重点是「用 System.nanoTime() 记录单调时刻、把内部 deadline 随请求上下文透传」。实际落地可以是把 deadlineNanos 存进自定义请求上下文对象,或借助框架/线程池的 deadline 传播机制,各级取用时统一与 System.nanoTime() 比较。

3.2 计时要用单调时钟

延迟计算必须用单调时钟(monotonic clock),而不是墙上时钟(wall clock)。二者回答的是不同的问题:

Bidder 算 deadline 的本质是 elapsed = 现在 − 到达时刻 t0,再拿它跟预算比。如果 t0 和「现在」都取墙上时钟,一旦中间发生一次时钟校正就会出事:

原则很简单:测「时间点 / 给人看 / 写日志」用墙上时钟,测「时间间隔 / 算超时 / 做限时」用单调时钟。 工程上还有两个易踩的点:

一张表收敛「测什么 → 用哪种时钟」:

场景该用哪种Java / Go API理由
算 elapsed / 超时 / deadline单调时钟System.nanoTime() / time.Since只增不减,NTP 回拨也不受影响
打日志 / 展示 / 落库时间戳墙上时钟System.currentTimeMillis() / time.Now()需对齐真实世界绝对时间
跨服务透传 deadline传「剩余毫秒」相对值,非绝对时刻单调时钟起点各机不同,不可跨机比较
定时任务 / cron 触发墙上时钟绝对时刻语义本就是「几点几分执行」

四、连接复用与传输优化

解析层还承担着传输层的成本控制。目标是:不为每个请求重复支付握手与建连的成本。

4.1 连接复用

在数十毫秒、紧场景低至 20ms 的预算里,建立连接本身就可能占去相当可观的一部分。要理解为什么必须复用连接,先看「新建一条连接」要付出哪些成本,以及「复用一条连接」能省掉什么:

连接复用时序图:对比冷连接与复用连接。首个请求走冷连接时,客户端 ADX 与服务端 Bidder 先做 TCP 三次握手(SYN、SYN-ACK、ACK,约 1 个 RTT),再做 TLS 握手(ClientHello、ServerHello 带证书与密钥参数、Finished,TLS 1.2 完整握手约 2 个 RTT),之后才发出 HTTP 竞价请求并收到响应;后续请求复用同一条长连接时,握手成本归零,直接在连接上收发竞价请求与响应,HTTP/2 还可在同一连接上多路复用并发多个请求 连接复用时序:橙色是「第一次连接」,要先完成 TCP 三次握手 + TLS 握手(约 3 个来回)才能开始发竞价请求;绿色是「后续请求复用同一条连接」,省掉所有握手、直接收发,HTTP/2 还能在一条连接上同时跑多个请求。

新建连接的三项固定开销

先说一个基本概念:RTT(round-trip time)就是「一个来回」的时间——消息从一端发到另一端、再回来一趟所花的时间。跨地域链路一个来回就可能几十毫秒。而新建一条连接,在真正开始发竞价数据之前,要先花掉好几个来回:

小结:一条全新连接,在发出第一个字节的业务数据之前,光是「接通 + 验身份 + 约暗号」就已经花掉约 3 个来回外加一次加密计算(TCP 握手 1 个来回 + TLS 1.2 握手约 2 个来回)。单看一次不算多,但 Bidder 每秒要处理十万到百万个请求,如果每个请求都重新建一次连接,这些来回会被成倍放大,迅速推高延迟、吃满 CPU。复用连接,就是把这些「开场白」省掉,让后续请求直接进入正题。

keep-alive 与 HTTP/2 多路复用

TLS 会话复用

TLS 握手是建连里最贵的一环(那次非对称运算)。复用连接本身就避免了重复握手;即便连接偶尔断开、需要重建,也应尽量复用上次的 TLS 会话而不是从零重来:

工程上:优先上 TLS 1.3,并在客户端/负载均衡开启会话票据(session ticket)复用。

连接池与预热:工程上怎么用

握手成本是「每条新连接付一次」,所以工程目标是让连接尽量少被新建、被复用得尽量久。落地就是一套连接池:

小结:keep-alive / HTTP2 使连接可复用,TLS 会话复用使重连成本可控,连接池与预热则让复用稳定落地、避免冷启动时的性能抖动。

4.2 gzip 解压:入站解析的必经一步

部分 ADX 会对竞价请求体做 gzip 压缩Content-Encoding: gzip)后再发出,以省跨地域带宽。此时解析的第一步不是反序列化,而是解压——拿到的是压缩字节流,必须先还原成原始 OpenRTB 报文,JSON / Protobuf 解析才有对象可解。落到入口代码里,就是按请求头分派出一条独立的解压分支:

// 入口按 Content-Encoding 分派:gzip 先解压还原成 JSON 文本,再走同一条 JSON 解析链路
HttpRequestDataType type = getHttpRequestDataType(request); // 仅凭 Content-Encoding: gzip 判定
switch (type) {
    case JSON:
        String json = getRequestPayload(request);
        if (!requestJsonCheck(json)) return null;      // 先做基本校验,非法即拒
        req = parseBidRequestJson(json);
        break;
    case GZIP:
        String unzipped = unGzip(request);             // 关键一步:先解压,还原出原始 JSON
        if (!requestJsonCheck(unzipped)) return null;
        req = parseBidRequestJson(unzipped);           // 复用与 JSON 分支完全相同的解析
        break;
    // PROTOBUF 通常按对端能力单独开启,多数外部 ADX 入口仍是 JSON / gzip-JSON
}

这里有两点值得留意:入站 gzip 实际包裹的往往还是 JSON,解压完仍回到同一条 JSON 解析链路,Content-Encoding: gzip 只是传输层的一层壳;而 Content-Encoding 也是唯一判据——判错要么把明文当压缩流报错,要么解出乱码。

上面这种「一次性 unGzip 成完整字符串再解析」的写法最常见、也最直接,但它把两处成本和风险默默留在了关键路径上,值得补上

4.3 gzip 压缩(出站):一次明确的权衡

入站是否解压由对端决定、你只能被动接受并做好防护;出站要不要压缩响应,则是你自己的一次权衡

工程判断:

五、高性能解析工程

解析处在关键路径最前端、被每个请求执行一次,它的实现细节会被 QPS 直接放大到整体尾延迟上。下面几条是压低解析耗时与抖动的核心手段。

5.1 按需 / 惰性解析

原理:一个 OpenRTB 请求有几十上百个字段,但下游真正用到的往往只是其中一部分。为不会被读到的字段构建对象、分配内存,是纯粹的浪费。

做法:只反序列化下游确实使用的字段,不为每个请求构建完整对象树。JSON 侧可用支持「局部/惰性解析」的库,按路径取值而非全量建树;Protobuf 侧字段本就是按 tag 号访问,天然支持只读取关心的字段。

注意:按需解析要与「字段校验」配合——被跳过的字段一旦下游临时需要,要有明确的补解析或缺省策略,避免「以为解析过、实际没解」的隐性 bug。

5.2 内存复用与降低 GC 压力

原理:解析会产生大量短命的中间对象(字符串、字节数组、临时节点)。在 JVM 实现里,这些垃圾累积会推高 GC 频率与停顿,一次 Full GC 就可能让整批在途请求集体超时(参见总览Grafana JVM 监控)。

做法:用对象池、预分配缓冲区(如 Netty 的 PooledByteBufAllocator)复用解析中间对象与字节缓冲;解析产物尽量复用同一组承载对象。目标是把解析路径做成近乎零分配,让 GC 压力与请求量脱钩。

注意:对象池本身也有复杂度与风险(对象泄漏、跨线程误用、复用前忘记重置状态),应采用成熟组件并配合压测验证,而非自行草率实现。

5.3 零拷贝

原理:从网络缓冲区到最终对象,数据每被拷贝一次都要付内存带宽与分配成本。竞价报文虽小,但乘以 QPS 后拷贝开销可观。

做法:优先引用原始 buffer 的切片(slice/ByteBuffer 视图)而非复制出新字符串;字段能以「偏移 + 长度」引用原始字节的就不 new String();避免在解析链路上做无谓的字节数组拷贝。

5.4 避免反射:走编译期生成代码

原理:运行时反射既慢又难被 JIT 优化,还会掩盖类型错误。

做法:JSON 与 Protobuf 都选择编译期生成代码的实现路径——Protobuf 用 protoc 生成的强类型类,JSON 选择基于代码生成/编译期绑定而非运行时反射的库。既快,又能在编译期暴露 schema 不匹配。

5.5 盯 P99,而非均值

原理:竞价是「过时即废」的业务,决定成败的是尾延迟而非平均值。均值好看、P99 踩线,一样会大面积丢单。

做法:解析耗时按 P99/P999 埋点观测;对 JVM 实现,还要把 JIT 预热纳入上线流程(预热后再进流量,避免冷代码路径的首批慢请求),并将 GC 停顿与解析尾延迟一起监控。

六、健壮性与安全

解析是不可信输入的第一道关:请求来自外部网络,必须假设它可能畸形、超大甚至恶意构造。这一层的健壮性不是锦上添花,而是防止个别坏请求拖垮整个集群的底线。

6.1 体积上限:入口即设闸

在解压前后都要设上限:对压缩前的请求体设大小上限,防止超大报文;对 gzip 解压设解压后字节数上限,防止解压炸弹(见 4.2)。两道上限缺一不可——只看压缩前大小挡不住高压缩比的膨胀攻击。超限一律立即拒绝,绝不进入后续阶段。

6.2 必填校验与快速失败

缺关键字段、类型不符、值越界的请求,应在解析阶段立即拒绝(HTTP 4xx 或 no-bid),不进入定向、召回等后续阶段、不占用下游资源。快速失败越靠前,被浪费的算力越少——这与漏斗「越便宜越靠前」的排布原则一致。校验本身也要轻量,避免为了校验又把整棵树全解出来。

6.3 未知字段与字段白名单

只信任已知字段:对已知字段按 schema 解析,未知字段忽略即可,避免为解析未知结构付出额外开销与风险。但要与「扩展保留」(见 2.2)区分——需要原样透传给下游或结算的扩展字段,要显式保留,不能连同未知字段一起丢弃。原则是:不解析不认识的东西,但不丢弃约定要透传的东西。

6.4 异常隔离:不因单请求崩溃

解析过程中的任何异常都必须被隔离在单个请求范围内(try/catch 或等价机制),转成一次 no-bid,绝不能让单个坏请求抛出的异常拖垮整个处理线程、连接乃至实例。同时对解析失败率、拒绝原因分布做埋点——畸形请求的突增往往是上游变更或攻击的第一信号

七、生产实战:常见问题

八、速查表


延伸阅读

规范与一手资料:

附录:术语表


views
Share this post on:

Previous Post
Bidder 定向过滤(Targeting Filter):竞价漏斗最前端的一票否决
Next Post
Bidder 竞价服务架构设计:高并发 · 低延迟 · 强一致的在线决策系统