本文属于 Bidder 竞价服务架构 系列。
总览篇把 Bidder 拆成 解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充 七级漏斗。本篇深挖第一级 解析(Parse):它最便宜、却在关键路径上被每一个请求无差别地付一次成本,还决定了后面所有阶段的 deadline 起点。建议先读总览再回到这里。
解析是漏斗的入口,任务只有一句话:把一个来自网络的原始字节流,变成一个内部可算的请求对象,并给它盖上一个「最晚必须算完」的时间戳。 它不做任何竞价决策,却是整条链路的地基——解析慢一毫秒,后面每一级的预算都少一毫秒;解析错一个字段,后面所有打分与出价都建立在错误输入上。
解析子流水线(自上而下):连接层 → gzip 解压 → 反序列化 → 方言适配归一化 → 校验与快速失败 → 限时,产出携带剩余 deadline 的内部请求对象交给定向过滤。橙色为 gzip 解压这一步,蓝色为解析其余各步,黄色为产出对象,紫色为配置面(Nacos)热更新下发,红色为非法/超限的快速拒绝路径。
TL;DR
- 解析是「每个请求都要付一次」的固定成本:它在关键路径最前端,任何优化都会被 QPS 放大,任何浪费也一样。目标是极低且稳定的耗时(尤其 P99)。
- 协议解析 = 反序列化 + 方言适配:各 ADX 的 OpenRTB 存在字段、扩展、语义差异,用适配层归一化成内部统一模型,让下游只面对一种数据结构。
- 序列化优先 Protobuf:相比 JSON,Protobuf 报文更小、解析更快、更省 CPU,且 schema 强约束、演进可控;JSON 只在对端强制时作为兜底。
- 尽早限时:请求进门第一件事就是打时间戳、按
tmax减去安全余量算出内部 deadline,并作为context全链路透传。计时用单调时钟,不用会被 NTP 回拨的墙上时钟。 - 连接与传输要复用:HTTP keep-alive / gRPC(HTTP2)长连接 + TLS 会话复用,消除每请求握手。入站 gzip 必须先解压再反序列化(对端多是用 gzip 包裹 JSON),按
Content-Encoding判定;而限解压后体积防解压炸弹、流式解压是「一次性全量解出成字符串」的常见写法里最容易漏、最值得补的一环;出站 gzip 是一次权衡——省带宽但吃 CPU、增延迟,是否开启取决于报文大小与链路带宽。 - 健壮性是硬指标:体积上限、必填校验、非法即快速失败,绝不让一个畸形请求拖垮线程或污染下游。
Table of contents
Open Table of contents
一、解析为什么值得单独一篇
在总览里我们反复强调一条排布原则:越便宜、能过滤越多流量的处理越靠前。解析是漏斗的第 0 级——它甚至不承担过滤职责,只做协议翻译,看似无足轻重。但它有两个特殊性,决定了它必须被认真对待:
- 它被每一个请求无差别地执行。定向过滤会淘汰大部分流量,召回、打分只作用于存活候选;唯有解析是 100% 的请求都要完整执行一遍的。在十万至百万 QPS 量级下,解析每多耗 0.5ms,都会按流量规模放大成整个集群的 CPU 与尾延迟成本。
- 它定义了下游的时间起点。deadline 自此开始计量。解析自身耗时越长、抖动越大,留给核心决策(召回、打分、出价)的时间预算就越少、越不稳定。
换言之:解析不直接产生竞价价值,却决定了竞价价值的成本上限。 这正是它值得作为一个独立工程问题对待的原因。
二、协议解析与方言适配
2.1 各家 ADX 的 OpenRTB 并不完全一样
OpenRTB 是行业标准,但它留有大量可选与扩展空间。实际对接多个 ADX 时,通常会遇到:
- 扩展字段(
ext)各不相同:同一个语义(如媒体分类、竞价上下文、流量质量分)在不同 ADX 落在不同的ext路径下。 - 字段可选性与默认值不同:有的 ADX 必带
device.ua,有的靠device.sua(结构化 UA);有的省略cur(币种)默认美元,有的必须显式读取。 - 语义细节不同:
bidfloor的币种、tmax的计时口径、at(拍卖类型)的取值习惯,都可能有差异。 - 版本混用:OpenRTB 2.5 / 2.6 并存,字段增删需要兼容。
如果让下游(召回、打分、出价)直接面对这些差异,每一级都需编写大量 if adx == X 的分支判断,复杂度会沿着漏斗层层扩散。
2.2 归一化:适配层 + 内部统一模型
正确做法是在解析层设一个适配层(adapter),把各家 ADX 的请求归一化成一个内部统一模型,下游只认这一种结构:
适配层归一化:各 ADX 方言各异的 OpenRTB 经 per-ADX 适配规则收敛为与具体 ADX 无关的内部统一模型
BidContext,下游各级只面对这一种结构;适配规则与 Schema 由 Nacos 热更新下发。
适配层要处理好三件事:
- 字段映射:把各家的字段/扩展路径映射到内部字段,缺失字段填充明确的默认值或标记为「未知」,而不是留
null让下游取到空值。 - 扩展保留:内部模型要保留「透传字段」(如结算需要回填的
id、监测宏占位),避免填充响应时又要回查原始请求。 - 配置化而非硬编码:每家 ADX 的适配规则、字段映射、schema 版本,应由配置中心(如 Nacos)热更新下发,新增一家 ADX 或对方改字段时不必发版。
// 内部统一模型(示意):下游只面对 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 仅作兜底:
| 维度 | Protobuf | JSON |
|---|---|---|
| 报文体积 | 小(二进制、字段用 tag 号) | 大(文本、重复 key) |
| 解析速度 | 快(按 tag 号直接定位,无需分词) | 慢(需分词与字符串处理) |
| CPU 开销 | 低 | 高(GC 压力大:大量临时字符串) |
| Schema 约束 | 强(.proto 强类型、演进规则清晰) | 弱(靠约定,易出错) |
| 可读性 / 调试 | 差(需借助工具) | 好(人工可直接阅读) |
一个量级直觉(仅供估算,务必以自己的 benchmark 为准):一个典型的 OpenRTB Bid Request 约 2–5 KB(JSON 文本);同等内容用 Protobuf 编码后,体积通常再小 30%–50%。解析耗时上,高性能 JSON 库处理这种报文大致在数十微秒量级,Protobuf 通常快 2–5 倍,且分配的临时对象更少、GC 压力更低。放到 QPS 十万级以上的 Bidder 上,序列化/反序列化常常占到单请求 CPU 的 10%–30%,是火焰图里最靠前的开销之一——这正是「能省则省」的动力所在。具体数字受报文大小、字段数、库实现、JIT 预热与硬件影响很大,此处只给量级。
现实约束是:许多 ADX 对外仍以 JSON 下发 OpenRTB,格式未必可选。因此实践策略通常是:
- 对支持二进制的对端(如内部 RTA、部分 ADX 的 Protobuf 接口)优先走 Protobuf。
- 对只给 JSON 的对端,选一个高性能 JSON 库(如避免反射、支持流式与零拷贝的实现),并尽量只解析用得到的字段(惰性/按需解析),不把整棵树都建出来。
- 内部服务之间(Bidder ⇄ 缓存/结算)一律 Protobuf + gRPC,把 JSON 的开销挡在系统边界之外。
三、尽早限时:deadline 的起点
解析的第二个职责,是给请求盖上时间戳、算出内部 deadline。这一步必须尽早执行——在请求刚到达、甚至反序列化之前,就先记录到达时刻。
3.1 内部 deadline 怎么算
到达时刻 t0(进门即记录)
内部 deadline = t0 + (tmax − 安全余量)
= t0 + 可用于本机计算的时间
tmax 是 ADX 一端测量的截止时间,含网络往返;安全余量覆盖回程网络、来程已耗、时钟偏差与 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)。二者回答的是不同的问题:
- 墙上时钟回答「现在几点」,会跟现实世界的绝对时间对齐——也正因如此,它会被动态校正、可能往前跳也可能往回拨:NTP 校时、闰秒、手动改时间、时区/夏令时调整、虚拟机迁移或休眠唤醒后的时间跳变,都会让它不再单调。对应
System.currentTimeMillis()/time.Now()的绝对值 /CLOCK_REALTIME。 - 单调时钟回答「过了多久」,是一个只增不减的计数器,起点无意义(通常是开机时刻),只有两次读数的差值有意义,物理上保证不回退。对应
System.nanoTime()/ Gotime.Now()内含的 monotonic reading /CLOCK_MONOTONIC。
Bidder 算 deadline 的本质是 elapsed = 现在 − 到达时刻 t0,再拿它跟预算比。如果 t0 和「现在」都取墙上时钟,一旦中间发生一次时钟校正就会出事:
- 时钟回拨(往回退):
elapsed算出来变小甚至为负,一批本该判超时的请求被误判成「还有大把时间」继续往下算,最终集体踩线真超时,响应被 ADX 丢弃。 - 时钟前跳(快进):
elapsed凭空变大,一批还新鲜的请求被误判成「已超时」而无谓降级 / 丢弃,白白损失本可赢下的竞价。
原则很简单:测「时间点 / 给人看 / 写日志」用墙上时钟,测「时间间隔 / 算超时 / 做限时」用单调时钟。 工程上还有两个易踩的点:
- Java 用两套独立 API,别混用:
System.currentTimeMillis()是墙上时钟(会被回拨,只用于日志、展示等绝对时间场景);System.nanoTime()是单调时钟(用于测间隔、算超时)。凡是量耗时、算 deadline,一律用nanoTime()——它返回的绝对值没有意义,只能拿同一台机器内的两次读数相减。别用currentTimeMillis()之差来算 elapsed,一次 NTP 回拨就会算出负数或错误值。 - 单调时钟只在本机有意义:
nanoTime()的起点各机器不同、不可跨机比较。所以跨服务透传 deadline 时传「还剩多少毫秒」,而不是传一个绝对截止时刻。
一张表收敛「测什么 → 用哪种时钟」:
| 场景 | 该用哪种 | Java / Go API | 理由 |
|---|---|---|---|
| 算 elapsed / 超时 / deadline | 单调时钟 | System.nanoTime() / time.Since | 只增不减,NTP 回拨也不受影响 |
| 打日志 / 展示 / 落库时间戳 | 墙上时钟 | System.currentTimeMillis() / time.Now() | 需对齐真实世界绝对时间 |
| 跨服务透传 deadline | 传「剩余毫秒」 | 相对值,非绝对时刻 | 单调时钟起点各机不同,不可跨机比较 |
| 定时任务 / cron 触发 | 墙上时钟 | 绝对时刻 | 语义本就是「几点几分执行」 |
四、连接复用与传输优化
解析层还承担着传输层的成本控制。目标是:不为每个请求重复支付握手与建连的成本。
4.1 连接复用
在数十毫秒、紧场景低至 20ms 的预算里,建立连接本身就可能占去相当可观的一部分。要理解为什么必须复用连接,先看「新建一条连接」要付出哪些成本,以及「复用一条连接」能省掉什么:
连接复用时序:橙色是「第一次连接」,要先完成 TCP 三次握手 + TLS 握手(约 3 个来回)才能开始发竞价请求;绿色是「后续请求复用同一条连接」,省掉所有握手、直接收发,HTTP/2 还能在一条连接上同时跑多个请求。
新建连接的三项固定开销
先说一个基本概念:RTT(round-trip time)就是「一个来回」的时间——消息从一端发到另一端、再回来一趟所花的时间。跨地域链路一个来回就可能几十毫秒。而新建一条连接,在真正开始发竞价数据之前,要先花掉好几个来回:
- TCP 三次握手(1 个来回):正式通话前,双方先确认「彼此都能听到对方」。就像打电话开头那段「喂,能听到吗?」「能,你呢?」「能,那我说了」——
SYN → SYN-ACK → ACK三句一来一回,连接才算接通。 - TLS 握手(TLS 1.2 约 2 个来回):接通后还要「验明身份 + 约定暗号」才能加密通话:服务端出示证书证明「我确实是它」,双方再协商出这次通话专用的密钥。这一步还要做一次比较重的加密计算(消耗 CPU)。升级到 TLS 1.3 可以压缩到 1 个来回。
- TCP 慢启动(热身):连接刚建好时,为了不一下子压垮网络,发送方会「先小声试探、再逐渐放开嗓门」,前期发得慢、跑一会儿才提到全速。不过竞价报文通常很小(几 KB),一个响应往往一开始就发完了,所以慢启动影响有限——真正贵的是前面那几个来回的握手。
小结:一条全新连接,在发出第一个字节的业务数据之前,光是「接通 + 验身份 + 约暗号」就已经花掉约 3 个来回外加一次加密计算(TCP 握手 1 个来回 + TLS 1.2 握手约 2 个来回)。单看一次不算多,但 Bidder 每秒要处理十万到百万个请求,如果每个请求都重新建一次连接,这些来回会被成倍放大,迅速推高延迟、吃满 CPU。复用连接,就是把这些「开场白」省掉,让后续请求直接进入正题。
keep-alive 与 HTTP/2 多路复用
- HTTP keep-alive(长连接):请求处理完毕后不关闭 TCP 连接,保留给后续请求复用,从而摊销握手成本,并使连接持续处于慢启动之后的高吞吐状态。ADX ↔ Bidder 是长期、高频、固定的对端,天然适合长连接。
- HTTP/2(gRPC 底层):在长连接基础上再进一步——多路复用(一条连接上并发多个请求、互不阻塞)、头部压缩(HPACK)、二进制分帧。相比 HTTP/1.1 的「一条连接同一时刻只能跑一个请求」,HTTP/2 用更少的连接扛更多并发。内部服务间(Bidder ⇄ 缓存 / 结算)尤其推荐 gRPC。
- 注意 HTTP/1.1 长连接的队头阻塞:同一条连接上请求是串行的,慢请求会堵住后面的。所以 HTTP/1.1 要靠连接池用多条连接并发,而 HTTP/2 用一条连接的多路复用就能解决。
TLS 会话复用
TLS 握手是建连里最贵的一环(那次非对称运算)。复用连接本身就避免了重复握手;即便连接偶尔断开、需要重建,也应尽量复用上次的 TLS 会话而不是从零重来:
- 会话恢复(Session Resumption):TLS 1.2 用 Session ID / Session Ticket 记住上次协商的密钥材料,重连时跳过完整握手、省掉非对称运算。
- 0-RTT(TLS 1.3):对恢复的会话,客户端可以在握手的同时就带上业务数据,几乎不为 TLS 额外付 RTT(代价是 0-RTT 数据有重放风险,仅适合幂等请求)。
工程上:优先上 TLS 1.3,并在客户端/负载均衡开启会话票据(session ticket)复用。
连接池与预热:工程上怎么用
握手成本是「每条新连接付一次」,所以工程目标是让连接尽量少被新建、被复用得尽量久。落地就是一套连接池:
- 为每个对端维护一个长连接池:按 ADX(以及地域/机房)分别建池,池里持有若干条已完成 TCP + TLS 握手的热连接,请求来了直接借用、用完归还。
- 控制好池的关键参数:
- 最大/最小连接数:够扛峰值并发,又不至于把对端连接数打爆;
- 空闲回收(idle timeout)要略小于对端和中间设备(LB、NAT)的连接空闲上限,否则你以为连接还活着、发过去却是一个已被对端悄悄关掉的「半开连接」,触发一次报错重连;
- 保活(TCP keepalive / HTTP/2 PING):定期探活,及时剔除坏连接。
- 启动预热(pre-warming):服务刚启动或刚扩容时连接池为空,首批请求会集中承担建连与握手延迟,形成一段启动尖刺。做法是在实例就绪、被纳入流量之前,先主动建立并完成一批连接的握手(必要时发送若干探测/心跳请求预热连接),再纳入负载均衡承接流量。
- 与优雅上下线配合:扩缩容与滚动发布时,新实例先预热再接入流量、旧实例先摘除流量再关闭连接,避免「连接刚建立即被终止」「请求打到尚未预热的实例」这类抖动。
小结: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 成完整字符串再解析」的写法最常见、也最直接,但它把两处成本和风险默默留在了关键路径上,值得补上:
- 防解压炸弹(decompression bomb):gzip 压缩比极高,几十 KB 的恶意报文解压后可膨胀至数百 MB、耗尽内存。仅靠「压缩前请求体上限」无法防护——上限必须设在解压后的字节数上,边解压边计数、超过阈值立即中断并拒绝。一次性全量解出为
String的写法恰恰缺少这道防线。 - 流式解压:边读边解、直接把解压输出喂给反序列化器,避免「先在内存拼出完整解压结果、再从头解析」的两段式拷贝,减少大对象与 GC 压力。
- 解压计入解析成本:解压和反序列化一样吃 CPU、落在关键路径最前端、被每个请求付一次,同样要盯 P99、复用缓冲区。
4.3 gzip 压缩(出站):一次明确的权衡
入站是否解压由对端决定、你只能被动接受并做好防护;出站要不要压缩响应,则是你自己的一次权衡:
- 收益:报文变小 → 省带宽、省传输时间(跨地域、大报文时收益明显)。
- 成本:压缩吃 CPU,并引入额外延迟;对本来就很小的竞价响应,压缩省下的传输时间可能抵不过压缩本身的耗时。
工程判断:
- 响应体较大(如富媒体素材 markup)时,
Content-Encoding: gzip通常划算; - 响应体较小、链路带宽充足时,开 gzip 可能得不偿失,反而增延迟;
- 是否压缩应依据对端协商(
Accept-Encoding)并结合实测数据决定,而非一刀切。CPU 本就是 Bidder 的紧俏资源,不应为节省少量带宽而耗尽 CPU、抬高 P99。
五、高性能解析工程
解析处在关键路径最前端、被每个请求执行一次,它的实现细节会被 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,绝不能让单个坏请求抛出的异常拖垮整个处理线程、连接乃至实例。同时对解析失败率、拒绝原因分布做埋点——畸形请求的突增往往是上游变更或攻击的第一信号。
七、生产实战:常见问题
- 解析消耗的 CPU 常被低估:在高 QPS 下,JSON 解析往往是 CPU 火焰图中占比最高的部分之一。切换 Protobuf、按需解析、复用缓冲区,通常能显著降低整体延迟。
- 新接入 ADX 时字段映射易出错:币种、
tmax口径、ext路径最容易出错。应把适配规则做成配置项,并用真实流量回放验证,而非上线后再对账。 - 时钟问题最为隐蔽:误用墙上时钟平时难以察觉,一次 NTP 回拨即可制造一批莫名超时。统一使用单调时钟测量 elapsed。
- 入站 gzip 解压未设上限:对端压缩的请求体解压后可能膨胀数十至上百倍,少量精心构造的报文即可耗尽内存。上限须设在解压后字节数上,超过阈值即中断。
- 出站 gzip 一刀切开启反而变慢:对小响应体强制启用 gzip,会同时抬高 CPU 与 P99。应按报文大小与对端能力分别配置。
- 解析层未设体积上限:偶发的超大请求(异常流量或攻击)会耗尽内存并加剧 GC。入口处即须设定上限。
- 连接池空闲超时与对端不一致:己方连接池的 idle 超时若长于对端(或中间 LB)的,就会拿到一个「己方以为还活着、对端已关」的连接,首个请求必失败重试、平白多付一个 RTT。连接池空闲上限应设得比对端更短。
- JIT 预热前的冷启动毛刺:JVM / JIT 实现的解析层在实例刚拉起、热点方法尚未编译时,前几千个请求的解析延迟明显偏高。发布时应先用影子 / 回放流量预热,再切真实流量。
- Protobuf schema 演进要向后兼容:新增字段用新 tag,绝不复用旧编号;只弃用(
reserved)不删除。否则新旧版本混跑时会把某字段解成另一字段,产生静默的脏数据,比直接报错更难排查。
八、速查表
- 解析 = 反序列化 + 方言适配 + 限时 + 校验,产出一个带 deadline 的内部统一请求对象。
- 每个请求都要付一次:优化被 QPS 放大,务必盯 P99 而非均值。
- 序列化优先 Protobuf:省体积、省 CPU、schema 可控;JSON 仅兜底且按需解析。
- 进门即限时:
内部 deadline = 到达时刻 + (tmax − 安全余量),用单调时钟,context全链路透传。 - 连接全复用:keep-alive / gRPC 长连接 / TLS 会话复用 / 连接池预热。
- 入站 gzip 先解压再反序列化:按
Content-Encoding判定、流式解压、限解压后体积防解压炸弹。 - 出站 gzip 是权衡:大报文划算、小报文可能变慢,按对端与实测决定。
- 健壮第一:体积上限、必填校验、快速失败、异常隔离。
延伸阅读
- Bidder 竞价服务架构设计(总览):解析所在的七级漏斗全景、20ms 延迟预算与安全余量的完整推导。
- Header Bidding:Prebid.js vs Prebid Server 架构:OpenRTB
Bid Request/Bid Response与tmax的协议背景。 - RTA 精讲:竞价前实时 API 过滤:跑在出价之前、同样卡在 100ms 内的实时接口,与解析层一样对延迟极度敏感。
- Grafana JVM 监控分析:JVM 实现的 Bidder 如何盯 GC 停顿与尾延迟——解析层的对象复用直接影响这里。
规范与一手资料:
- IAB Tech Lab. OpenRTB 2.6 Specification:
Bid Request字段、ext扩展、tmax与cur的权威定义。 - Google. Protocol Buffers:二进制序列化格式与 schema 演进规则。
- IETF. RFC 9113 · HTTP/2:多路复用与头部压缩。
附录:术语表
- 解析(Parse):竞价漏斗第一级,把原始请求字节流转成内部统一请求对象并盖上 deadline。
- 方言适配(adapter):把各 ADX 略有差异的 OpenRTB 归一化到内部统一模型的适配层。
- 内部统一模型(BidContext):下游各级共同面对的、与具体 ADX 无关的请求数据结构。
- 内部 deadline:
到达时刻 + (tmax − 安全余量),本机可用于计算的截止时刻,全链路透传。 - 单调时钟(monotonic clock):只增不减、专用于测量时间间隔的时钟,不受 NTP 回拨影响。
- Protobuf:二进制序列化格式,报文小、解析快、schema 强约束。
- 连接复用:通过 keep-alive / 长连接 / TLS 会话复用,避免每请求握手与建连成本。
- gzip 解压(入站):把对端
Content-Encoding: gzip压缩的请求体在反序列化前还原成原始报文,需限解压后体积以防解压炸弹。 - 解压炸弹(decompression bomb):利用高压缩比构造的小体积报文,解压后急剧膨胀以耗尽内存的攻击手法。