Skip to content
Charles Shao
Go back

Bidder 预算 Pacing:PID 控制与消耗平滑

views

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

总览篇第六节把「预算与 Pacing」称作 Bidder 最难的一致性问题,并把它拆成两半:不超投(本地分片 + 异步回灌,那里已经讲了)与平滑消耗(Pacing 本身,当时明确说「后续单独成篇讲 PID」)。本篇就把欠下的这一篇补上——怎么让一笔日预算在投放时段内匀速花完,而不是前重后轻、上午花光下午停摆。建议先读总览第六节,再回到这里。

「不超投」只是预算控制的下限——它保证你不会花超。但一个只会「有钱就尽力花、花完就停」的 Bidder,会在流量高峰的上午把一天的预算烧光,下午和晚上彻底缺席。对广告主而言这几乎和超投一样糟:触达的人群被时段扭曲、单位成本被高峰期抬高、想看的晚间流量一个都没投到。Pacing(平滑投放)要解决的就是这件事:不仅花对总量,还要花对节奏。

这本质上是一个反馈控制问题——设定一条「此刻应该花到多少」的目标曲线,实时测量「实际花了多少」,用两者的误差去调节出价的快慢。而工业界最经典的反馈控制器,就是 PID。本篇会从「为什么需要闭环」讲到「PID 三项在 pacing 里分别对应什么」,再落到分布式 Bidder 上「谁来跑这个控制器、反馈延迟为什么是命门」。

一天内累计预算消耗曲线对比图:横轴为投放时段 0~24 小时、纵轴为累计消耗占日预算百分比,带 0/25/50/75/100 网格线与图例;灰色虚线为目标曲线 r(t)(计划,从 0 线性爬升到 100%),红色实线为无 Pacing 的 ASAP 消耗(上午高峰陡峭上冲、第 9 小时撞到 100% 并有竖直虚线标注『第 9h 预算烧光』,之后一整天平摆、顶部有浅红阴影带标注『ASAP 撞顶后一整天停投:下午/晚间缺席』),绿色实线为有 Pacing 的实际消耗(紧贴灰色目标线平滑爬升、到第 24 小时刚好花完),另有『无 Pacing:高峰猛冲』『Pacing:匀速贴合目标』两处内联标注 累计消耗曲线:红线(无 Pacing) 在上午高峰把预算烧光(第 9h 撞顶)后一整天停摆;绿线(有 Pacing) 紧贴 灰色目标线平滑消耗、到收盘刚好花完。Pacing 要做的,就是把红线掰成绿线——同样花完日预算,但摊平到整个投放时段。

TL;DR

Table of contents

Open Table of contents

一、Pacing 要解决什么:不做会怎样

先把问题定义清楚。一个 campaign 通常带两类预算约束:总预算(整个投放周期)与日预算(每天上限)。Bidder 要做的不只是「别花超」,还要回答一个时间维度的问题:这笔日预算,在今天这 24 小时里应该怎么分配着花?

如果完全不做 Pacing,Bidder 的行为是 ASAP(as soon as possible)——只要还有预算、只要能赢,就出价。后果是消耗曲线被流量分布牵着走:

一句话本质

不超投保证你花得不多,Pacing 保证你花得是时候。前者是金额的一致性,后者是时间分布的一致性——而时间分布直接决定了触达质量与单位成本。

反过来,Pacing 也要防欠投:投放时段结束时预算没花完,等于广告主的钱和曝光机会双双浪费,也是要被考核的 KPI。所以 Pacing 的目标是一条双向贴合——既不提前花光(超前),也不结尾剩余(滞后)。

二、第一步:定义目标消耗曲线 r(t)

要「贴合」,先得有条线可贴。目标消耗曲线 r(t) 定义了「到时刻 t,累计消耗应该达到日预算的百分之多少」。它是整个控制问题的设定值(setpoint)。常见三种:

曲线r(t) 形态适用代价
ASAP阶跃:能花就花到顶冷启动抢量、限时活动、预算远小于可用流量前重后轻、成本高、人群偏
均匀(Even)线性:r(t) = 预算 × t / T流量近似平稳、要求简单可解释高峰期可能因目标太低而错过便宜量、低谷期可能追不上目标
流量加权(Traffic-weighted)按各时段历史流量/赢率分布加权累积生产主流依赖流量预测,预测偏差会传导成 pacing 偏差

流量加权是重点:如果晚上 8 点的可竞价流量占全天 10%,那么这一小时的目标增量就分配 10% 的预算,而不是按钟表均分。写成公式就是把目标累计占比 r(t) 定义为归一化的累计流量权重

         Σ(τ ≤ t) w(τ)              w(τ) = τ 时段的预估可竞价流量(或流量 × 预估赢率)
r(t) = ──────────────────  ∈ [0,1]   分母 = 全天权重之和 → 保证 r(T) = 1(收盘恰好花完)
         Σ(all τ)  w(τ)

这样目标曲线本身就「顺着流量走」,控制器要纠的偏差更小、更平稳。均匀曲线只是 w(τ) ≡ 常数 的特例。

目标曲线是「计划」,控制器是「执行纠偏」。 计划定得越贴合真实流量,执行阶段要纠的偏差就越小。很多 pacing 不稳,根子在流量预测差,而不在控制器。

w(τ) 该正比于什么(纯流量?流量 × 赢率?还是再乘上转化价值?)、怎么按日型/时区分别建模、怎么在「向价值倾斜」与「保覆盖」之间取舍——这本身是一个独立的预测问题,已单独成篇:Pacing 目标曲线:时段消耗权重怎么设计。本篇后面默认目标曲线已经画好,专注控制。

值得强调:r(t) 不必写死为「金额」。更稳的做法是把它表达成累计消耗占比,因为日预算可能被中途调整、可能跨机房再分配(见第五节)——用占比表达,预算变化时曲线自动伸缩。

三、第二步:两种执行手段——节流 vs 出价系数

有了目标,控制器算出一个「该快还是该慢」的信号后,得有个旋钮去真正影响消耗速度。有两个旋钮:

Pacing 两种执行手段对比图,上下两栏面板。上栏「手段 A · 节流(Throttling):只调参不参与、出价不变」——横向流程:控制器输出 u → 投放概率 p(rand() < p 才竞价)→ 出价保持不变 → 优点『实现简单·不扭曲赢率曲线』/缺点『无差别丢弃·被丢的无反馈样本』。下栏「手段 B · 出价系数(Bid Modulation):只调出多少、每次都参与」——横向流程:控制器输出 u → final_bid = base × α(α 随消耗快慢浮动)→ 每次都参与竞价 → 优点『连续可调·顺响应曲线更省预算』/缺点『需懂赢率-出价曲线·与 bid shading 耦合』。底部旁注:生产多以出价系数为主、节流为辅 两个旋钮:节流调「参不参与竞价」(投放概率 p),出价系数调「出多少」(bid × α)。节流实现简单但丢流量是「无差别」的;出价系数顺着拍卖响应曲线走、更省钱,但更难调、且在一价下与 bid shading 纠缠。

3.1 节流(Throttling / Probabilistic Pacing)

最朴素:给每次竞价机会掷一次骰子,只有 rand() < p 才参与,其余直接放弃。控制器调的就是这个投放概率 p ∈ [0,1]。花太快就调低 p、太慢就调高。

3.2 出价系数(Bid Modulation / Bid-based Pacing)

每次都参与竞价,但把最终出价乘一个pacing 系数 αfinal_bid = base_bid × α。花太快就调低 α(出价变低 → 赢率下降 → 消耗变慢),太慢就调高。

3.3 实践怎么选

维度节流(Throttling)出价系数(Bid Modulation)
旋钮投放概率 p ∈ [0,1]出价系数 αfinal = base × α
可调性离散抽样,本质是开/关采样连续,随赢率-出价曲线平滑变化
赢率分布不扭曲(参与部分照常竞)主动重塑(低价可赢的流量被保住)
单位成本一般(无差别丢弃)更优(优先放弃要高价硬抢的贵量)
反馈样本被丢机会无样本每次参与、样本完整
复杂度极低需懂响应曲线,一价下与 shading 耦合

生产系统通常以出价系数为主(省钱、平滑),节流为辅(在 α 已到下限仍嫌快时,用低 p 做粗粒度限速兜底)。两者可叠加:α 管「出多少」,p 管「参不参与」,控制器输出映射到二者的组合。

四、第三步:为什么是 PID——从开环到闭环

4.1 开环为什么不行

最容易想到的是开环方案:预先按目标曲线算好「每 5 分钟花多少」,到点就放行那么多预算。问题是它没有反馈——它假设「放行 X 元预算就真的会花掉 X 元」,但现实里:

任何一个偏差,开环都无法自我修正,只会一路跑偏。必须闭环:实时测量真实消耗,与目标比对,用误差反过来调旋钮。

4.2 PID 是什么,为什么用它

PID 是工业界最成熟的闭环控制器,输出由三项相加构成。设误差 e(t) = 目标累计消耗 r(t) − 实际累计消耗 y(t)e>0 表示落后了、该加速):

u(t) = Kp·e(t)  +  Ki·∫e(τ)dτ  +  Kd·de(t)/dt
       └ 比例 P ┘   └── 积分 I ──┘   └── 微分 D ──┘
        看当前偏差      看历史累积        看变化趋势
看的是在 pacing 里的作用调大了会
P(比例)当前偏差多大落后越多,出价系数抬得越猛,快速纠偏反应快但易在目标附近来回震荡
I(积分)历史偏差累积消除稳态误差——纠正「长期总是差一点没追上」的系统性欠投/超投累积过头 → 过冲、windup
D(微分)偏差变化趋势看到「正在快速逼近目标」就提前收力,抑制过冲、抗流量突变放大高频噪声(消耗数据本就抖)

Pacing 的 PID 闭环控制框图,从左到右一条主链:目标曲线 r(t)(按流量分布加权)→ 求和点 Σ(作差得误差 e = r − y)→ PID 控制器(标注 P 快速纠偏、I 消稳态误差、D 抑制过冲)→ 执行 u(t)(出价系数 α / 概率 p)→ 经 Bidder 出价、竞得、曝光 → 实际累计消耗 y(t);y(t) 经一条带箭头的红色虚线反馈回 Σ,标注『回灌反馈:秒级延迟 = 控制回路的 dead time』。底部旁注三条:离散实现每周期算一次 u=Kp·e+Ki·Σe·Δt+Kd·Δe/Δt 并映射为 α=clamp(1+u,αmin,αmax);抗积分饱和需在饱和时冻结/回退积分项;在线路径只做一次 base_bid×α 乘法、不占 20ms 竞价预算 经典闭环:误差 → PID → 出价系数 → 消耗 → 反馈。注意那条带延迟的反馈线——真实消耗要经日志回灌才可见,这段 dead time 是分布式 pacing 一切麻烦的根源(第五节)。

为什么是 PID 而不是更花哨的控制器(MPC、RL):可解释、易调参、无需模型、久经考验。pacing 的被控对象(出价→消耗)虽非线性,但在工作点附近用 PID 足够稳;工程上更看重「出问题能一眼看懂、能手调」,而不是理论最优。很多团队实际只用 PI(去掉 D,因为消耗信号噪声大、微分弊大于利),效果已经很好。

4.3 离散实现(工程上就是这么写的)

真实系统是离散采样的:每隔一个控制周期 Δt(比如 10~60 秒)算一次。位置式 PID 的骨架:

# 每个控制周期执行一次(campaign 粒度)
def pacing_tick(camp, now):
    target = camp.target_spend_ratio(now)   # r(t):目标累计消耗占比
    actual = camp.actual_spend_ratio(now)   # y(t):回灌来的真实累计占比(有延迟!)
    e = target - actual                      # e>0 落后、该加速

    camp.integral += e * DT
    # ---- 抗积分饱和:见 4.4,这里先记住必须 clamp ----
    d = (e - camp.prev_e) / DT
    camp.prev_e = e

    u = KP * e + KI * camp.integral + KD * d

    # 输出映射到出价系数 α,并夹到合理区间
    alpha = clamp(1.0 + u, ALPHA_MIN, ALPHA_MAX)   # 例如 [0.1, 3.0]
    camp.alpha = alpha
    return alpha

上面是位置式(position form):直接累加积分、算出绝对输出 u。另一种常见写法是增量式(velocity form)——只算相邻两拍的输出增量 Δu = Kp·(e−e_prev) + Ki·e·Δt + Kd·(e−2e_prev+e_prev2)/Δt,再 α ← α + Δu。增量式不显式维护积分变量,对「无扰切换/热更新参数」更友好,但抗积分饱和要靠对 α 本身限幅来隐式实现。两种形式数学等价,pacing 里位置式 + 显式 anti-windup 更直观、更好排查,这里采用它。

在线出价路径只做一次乘法,零额外 IO

final_bid = base_bid × campaign.alpha        # α 由后台控制周期刷新,在线只读

这正好呼应总览的数据面原则——控制器在近线/后台跑,在线路径只读一个刷新好的系数,绝不在 20ms 预算里做反馈计算。

4.4 调参与三个必须处理的坑

坑一:积分饱和(integral windup)——最经典、最致命。 当流量极少、α 已经顶到 ALPHA_MAX 还是追不上目标时,误差 e 持续为正,积分项 ∫e一直往上累积成一个巨大的数。等晚些流量回来,实际消耗刚要追上,那个被撑大的积分项会让 α 继续飙高、剧烈过冲、瞬间超投。解法是 anti-windup:一旦输出被 clamp(饱和),就停止累积(甚至回退)积分项。

    raw = 1.0 + (KP*e + KI*camp.integral + KD*d)
    alpha = clamp(raw, ALPHA_MIN, ALPHA_MAX)
    if alpha != raw:                 # 发生了饱和
        camp.integral -= e * DT      # 回退这一步的积分累积(clamping anti-windup)

坑二:微分放大噪声。 消耗是离散事件、抖动大,直接对 e 求微分会得到满是毛刺的 d。要么去掉 D 只用 PI,要么对误差/微分做低通滤波(EMA)后再用。

坑三:系数不归一。 Kp/Ki/Kd 的合适取值和「预算规模、控制周期、流量量级」强相关。大预算 campaign 和小预算 campaign 用同一套系数,一个纹丝不动、一个剧烈震荡。工程上应把误差归一化(用「占日预算比例」而非绝对金额),让一套系数能跨 campaign 复用。

一句话本质

PID 在 pacing 里不是在追求「控制理论最优」,而是在用三个可解释的旋钮,把「落后就加速、超前就收力、别过头」这套朴素直觉,做成一个能自动纠偏、还能手调的闭环。真正的工程难度不在公式,而在 windup、噪声、系数归一这三件让它在生产里不炸的事。

五、分布式落地:dead time 决定了谁来跑 PID

前面的 PID 像是「一个 campaign、一个控制器、消耗立刻可见」。但真实 Bidder(见总览第六节)是多实例、无会话状态、真实消耗延迟可见的。把 PID 塞进这个环境,最大的变量是反馈延迟

分布式 Pacing 落地示意图:中心预算/Pacing 服务持有全局预算与目标曲线并聚合真实消耗,向下用粗箭头把预算分片和 Pacing 系数 α 秒级下发给 Bidder 实例集群(实例 A/B/N,均无状态、关键路径只读只算、本地按 α 出价);实例把曝光/计费日志异步写入 Kafka,Kafka 再把真实消耗聚合回中心服务,这条回灌链路的延迟即控制回路的 dead time;中心服务在 campaign 临近耗尽时向实例集群下发熔断;旁注说明中心跑 PID 以获得全局视角、避免各实例抢跑,实例只执行 α,本地仅保留令牌桶做亚秒级限速,且控制周期必须远大于回灌延迟否则震荡 分布式:中心跑 PID → 下发全局系数 α → 实例只执行。真实消耗经 Kafka 回灌有秒级延迟——这段 dead time 就是控制稳定性的命门。

5.1 dead time(纯滞后)是控制系统的头号杀手

真实消耗不是即时可见的:一次赢价(nurl)到真正产生曝光、再经计费事件(burl/曝光回传)回传、经 Kafka 聚合刷回控制器,中间有秒级到分钟级延迟。控制器此刻看到的 y(t),其实是过去某个时刻的消耗。

在控制理论里这叫纯滞后(dead time),是最难对付的一类扰动:你根据「过时的」测量去调节,很容易调过头——因为你的调节效果也要等一个 dead time 才反映到测量上,期间你可能已经连续加了好几次力。这会导致震荡甚至发散。

对策就一条核心原则:控制周期必须远大于反馈延迟。

5.2 谁来跑 PID:中心式 vs 本地式

方案怎么做优点缺点
中心式(主流)中心服务聚合全局真实消耗、跑一个 PID、算出全局 α 下发到所有实例全局视角,天然避免各实例「盲人摸象、各自抢跑」;系数一致依赖下发链路;α 有下发延迟
本地式每个实例用「本地分片消耗」各跑一个 PID无需下发、反应快每个实例只看到局部消耗,流量倾斜时各实例目标感知不一致,容易合起来跑偏;实例扩缩容时状态漂移

主流选择是中心式跑 PID:由中心用全局消耗做闭环、产出 α,随预算分片一起周期性下发。实例端不跑控制器,只做两件事:在线出价乘 α;本地保留一个令牌桶做亚秒级的硬限速兜底(防止下发间隙内某实例被热点流量瞬间打爆本地分片)。这样:

这也顺带回答了总览留的一个扣子——令牌桶和 PID 不是二选一,而是分工:PID 管「中长期节奏对不对」,令牌桶管「这一秒别爆」。

5.3 和「不超投」的分片如何协同

Pacing 系数 α 控制速度,本地预算分片 + 熔断控制总量上限。两者叠加:α 让日常消耗贴合曲线;当某 campaign 真的临近耗尽,中心直接下发熔断α→0 或停止参与),这是硬闸门,优先级高于 pacing 的平滑。换句话说,Pacing 是常态下的软调节,熔断是逼近边界时的硬保护

六、Pacing 不是孤岛:与其它模块的耦合

七、可观测性与生产实战经验

pacing 是那种「不看监控就不知道已经跑偏了」的模块,因为它的偏差是缓慢累积的。必看的曲线与经验:

八、速查表

Pacing 的本质,是把「预算不超投」这个静态约束,升级成一个带时间维度的动态控制问题:给定一条目标消耗曲线,用出价系数作旋钮、用真实消耗作反馈,靠一个 PID 闭环把实际消耗抹平到贴合目标。它的全部工程难度,都集中在两处——让控制器在生产里不炸(windup、噪声、归一),以及在一个反馈天然滞后的分布式系统里,让控制回路依然稳定(dead time 与控制周期的取舍)。理解了这两点,也就理解了总览第六节留下的那半个「最难的一致性问题」。


延伸阅读

一手资料与背景:

附录:术语表


views
Share this post on:

Previous Post
Pacing 目标曲线:时段消耗权重怎么设计
Next Post
Bidder 频次控制工程篇:多维度限频的存储选型与读写分离