在广告竞价系统里,真正难的往往不是“拿到一个报价”,而是怎样在极短时间内同时兼顾预算、节奏、并发隔离、素材可播性和最终收益。这篇文章接着前一部分往后讲,重点梳理 Tubi 如何用 Scala 与 Akka 体系处理广告请求后半程,并把每一步为什么要这样设计交代清楚,方便读者判断这些方案分别解决了什么问题。
投放控制:先决定广告能不能继续参与竞价
广告投放节奏控制,是整个广告服务里最关键的环节之一。广告主通常希望预算不要过早消耗完,而是能在一天内尽量均匀地触达更多、也更分散的受众。因此,在进入后续竞价前,系统必须先判断某个广告活动当前是否还适合继续参与这次请求。

Tubi 用 Akka Actor 维护各个广告活动的花销和预算信息。因为花销本身是单调递增的,而且这类数据在业务上可以接受最终一致性,所以 Akka Distributed Data 提供的 CRDT(Conflict-free Replicated Data Type,无冲突可复制数据结构)很适合承担这部分状态存储,哪怕是跨数据中心场景也能工作。
只校验“有没有超预算”还不够。系统在确认广告活动没有超出预算后,还会加载一份由 Spark 基于历史数据离线计算出的开销预测结果。在 Pacing 这个 Graph 中,Tubi 使用了一种从控制理论衍生出的简单方法,来判断某个广告是否应该参与当前这次竞价。
这份预测数据的作用,本质上是帮助系统分配全天流量。它会结合已知的主要投放目标、广告领域知识、流量变化模式以及可投放广告位数量来生成。不同终端的流量分布差异也会被纳入考虑,例如 App 运行在游戏主机和手机上时,流量模式就并不相同。
搜集出价:把第三方调用装进可控的 akka-stream 流
Tubi 的广告位一部分来自自有销售团队直接销售,另一部分则通过程序化购买与第三方需求方平台(DSP)集成完成。这意味着系统除了处理内部广告活动,还必须面对大量第三方网络调用,包括发送请求、等待响应,以及解析各家格式并不完全一致的返回内容。

这里 akka-stream 的价值,在于它不只是一个“流式处理框架”,而是把这类外部调用收拢进统一执行模型。Tubi 将不同第三方请求分别封装到各自的 akka-stream 流中,再通过公共 Graph 复用那些共性的复杂逻辑,例如异步网络调用、并发控制、错误处理、限流以及熔断。
这样一来,工程重点就从重复搭基础设施,转向真正的业务适配:如何对接不同平台、如何处理它们对 IAB(Interactive Advertising Bureau,互动广告局)标准理解不一致所带来的实现差异,以及如何为这些差异建立可测试的集成逻辑。
这种封装方式还有一个很实际的收益:第三方流量天然带有严格的并发上限,某个外部平台即便出现异常表现,也不会拖垮广告系统的其他部分。对于高并发广告请求链路来说,这种隔离能力比“单次请求多拿到一点报价”更重要。
广告视频转码:在竞价前补齐可播放与合规能力
当所有出价搜集完成后,系统还要检查对应广告视频是否已经完成转码;如果没有,就会触发一次转码操作。这里的“转码”并不只是格式转换,而是为了让广告真正能安全、稳定地被投放到不同平台上。
文章里提到转码主要服务于四类目标:
作为视频平台,Tubi 希望广告也能在所有 Tubi App 运行的环境里顺畅播放;
部分广告音量可能过大,需要进行调整,避免影响用户体验;
广告内容分级要和节目内容分级匹配,例如烟草广告不应出现在 PG-13 的内容里;
对于通过 DSP 进入的程序化广告,系统还希望识别品牌信息,以便用于报表统计和广告频次控制。
这几项能力并不是在一个模块里硬塞出来的。Tubi 将它们拆分为各自独立的服务和相关的机器学习模型,再由广告平台统一调用。和其他 Tubi 服务一样,这些能力通过 gRPC 通讯,对外提供低延迟接口;文中给出的指标是 99% 的请求 SLA 低于 10ms。
从系统设计角度看,这一步的意义在于把“报价存在”变成“素材可实际交付”。如果广告无法播放、音量不合适、内容分级不匹配,或者品牌无法识别,那么再高的报价也未必能安全进入最终展示阶段。
竞价求解:把多个出价和广告位写成约束优化问题
竞价是整条广告请求链路里最核心的一步之一。前面的处理包含了大量网络 I/O、缓存、并发和外部依赖,而到了这一阶段,问题会转成纯 CPU 计算:如何在很短时间内,从多个合作伙伴和内部广告活动提供的大量出价中,选出能够填充 1 到 5 个广告位的最优结果。

文中给了一个很直观的抽象方式:构造一个 n * k 的矩阵,其中 n 表示出价数量,k 表示这次广告请求中的广告位数量。矩阵的每个格子只有 1 或 0 两种取值,用来表示“某个出价是否被选中放到某个广告位上”。
有了这个矩阵后,很多投放限制就能被表达成约束条件:
如果要求某个广告主的多个出价最多只能赢一次,那么就可以限制该广告主对应所有行的和不能大于
1;如果要求每个广告位最多只有一个胜出出价,那么就限制每一列的和小于等于
1;类似的独立竞价隔离规则,或是为了保障用户体验而设置的广告竞争约束,也都可以按同样方式建模。
随后,系统中的解析器会根据这些预设好的限制条件去优化目标函数。这个目标函数本质上是一个营收最大化的线性规划问题,同时还会纳入某些特定的投放保证约束。
这也解释了为什么竞价逻辑通常不能只靠简单排序完成。出价高并不等于一定能赢,因为最终结果还必须满足预算、频控、广告主互斥、广告位唯一性以及体验约束。真正的难点,是在这些条件同时成立的情况下尽量逼近收益最优。
结果渲染:把赢家转换成不同平台可消费的广告格式
当竞价结果确定后,最后一步就是把胜出的出价渲染成各个平台可直接展示的广告格式。不同平台对输出内容的要求并不一致,有些更偏向使用 VAST,有些则需要 JSON。
Tubi 在这里使用了 akka-http 的渲染机制来定制所需模板。这样做的好处是,前面的竞价与决策逻辑可以保持统一,而最后的交付格式则根据终端平台灵活适配。对外部平台来说,它只看到符合自身要求的广告响应;对内部系统来说,渲染层只是链路末端的一次格式化输出。
从工程组织上看,这一步虽然不像竞价求解那样复杂,却决定了前面所有决策能否真正落地。没有稳定的渲染输出,再好的预算控制和竞价模型也无法完成最终展示。
这套 Scala 广告链路解决了什么问题
把这篇文章的后半段串起来看,Tubi 的思路并不是依赖某一个“万能组件”,而是把广告请求拆成几类截然不同的问题,再分别交给适合的机制处理:
状态与预算控制,交给 Akka Actor 与 Akka Distributed Data;
大量第三方网络交互,交给 akka-stream 做并发、隔离和错误控制;
素材处理与能力服务,拆成独立服务,通过 gRPC 低延迟调用;
最终选择问题,则落到带约束的线性规划优化上。
如果把它当成一篇 Scala 技术文章来看,真正值得关注的不是语法层面的技巧,而是响应式和分布式工具在广告场景里的分工方式:哪些环节接受最终一致性,哪些环节必须限制并发,哪些环节需要从 I/O 问题切换为计算问题。理解这些边界,比单独记住某个框架名更有参考价值。









