企业在进行营销推广时,广告投放通常是必备环节之一。为了避免投放“乱烧钱”,在大规模投放前,企业和广告优化师都会希望在多种广告策略中,找准效果更好策略才进行投放。早期这样的方案决策只能通过“拍脑袋”,或者简易的分流投放测试来粗略进行。在火山引擎AB测试推出“广告投放AB实验”后,可逐步支撑企业快速、科学地验证不同投放策略的平均转化成本数据效果,并根据实验报告得到计划中不同素材、不同落地页、不同人群包、不同预算等变量到底哪种更好。

广告投放AB实验背后,所需的数据能力支撑繁琐而复杂,开启广告实验后,如果数据不能够及时准确的送达,会对报告结论造成影响,甚至影响最终决策,而这均依赖于AB实验平台底层的基础投放能力。

基础投放能力主要包括如下三块:账号授权管理、计划创编和数据查询。账号授权是将广告账号授权给开发者应用;计划创编包括物料管理、落地页管理、应用管理、广告编辑;数据查询指广告投放数据的事实查询分析。一个广告投放AB实验的顺利开展,需要上述三个模块的紧密配合,才可保证最终结果的准确性。

在早期,由于广告投放业务流程繁琐,火山引擎DataTester在广告投放AB实验项目的迭代中遇到了如下问题:

  1. 需要支持多个广告平台,授权逻辑日益杂乱;

  2. 授权、数据抓取和业务逻辑耦合严重,出现问题不易排查;

  3. 一类数据抓取就对应一个定时任务,导致定时任务过多,难以维护;

  4. 数据模型设计不合理,报表数据越来越多,查询变得缓慢;

  5. 定制特性太多,代码难以维护;

上述问题的积累导致实验平台的开发和维护成本越来越高,线上问题频发,为了保证平台实验数据的科学性和准确性,火山引擎DataTester决定对广告投放基础能力进行重构。

1. 火山引擎AB测试-广告投放项目架构

上图展示了火山引擎DataTester重构后的广告投放模块交互图,主要解决了以下问题:

  1. 针对耦合严重、定时任务过多问题服务拆分,根据业务功能拆分为授权服务、数据抓取服务、业务后端服务和少量定时任务,各类服务各司其职,职责单一;

  2. 针对查询缓慢问题:重新设计数据模型,使用 MySQL 和 ClickHouse 存储元数据和报表数据,兼顾修改和查询效率;

  3. 针对代码难以维护问题:引入DDD领域驱动设计思想,面向接口编程,不同广告平台分别实现接口,方便维护;

  4. 针对代码质量问题:严格控制单测覆盖率,保证代码质量;辅以CI/CD流水线,让bug无处可藏;

  5. 针对SaaS/私有化部署问题:使用同一套代码,底层利用环境变量做兼容,降低开发成本。

授权服务是使用投放的第一步,其主要作用就是对接各个广告平台的授权逻辑,将广告账号授权给预定义的开发者账号,保存Token或密码凭证,然后调用抓取服务下发账号粒度的抓取任务。

数据抓取服务的主要作用就是保证投放平台与广告平台数据一致性,对于授权的广告账户添加天粒度和小时粒度的数据抓取任务,保证元数据和报表数据的及时更新;对于Oauth2类型的渠道,提供自定义间隔时间的Access Token刷新任务;同时提供实时抓取接口,方便实时数据的获取。业务后端的主要作用就是使用授权的账号完成计划创编工作,对数据进行汇总查询。

2. 账号授权

2.1 授权分类

广告平台的账号授权方式可以分为两类:Oauth2授权账号密码授权。账号密码授权是比较简单的授权方式,填写所需的表单数据保存即可,弊端是容易造成密码的泄露;OAuth2 是基于令牌Token的授权,在无需暴露用户密码的情况下,使应用能获取对用户数据的有限访问权限。这种模式会为开发者的应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源。需要注意的是,每个平台的 Token 过期时间不同,需要定时刷新保证 Token 的可用性。

2.2 OAuth2 授权

对接不同的广告平台完成OAuth2授权,最主要的是阅读帮助文档,一步步完成授权流程。下面就以抖音集团旗下某业务平台授权为例,简单介绍授权流程。

  1. 注册开发者账号,将开发者信息预先保存至数据库中;

  2. 将权限信息、开发者账户信息以及需要希望回调时带回的数据,统一拼装至授权链接后跳转至广告平台;

  3. 用户点击授权,广告平台回调开发者账号填写的回调地址,并携带 auth_code;

  4. 回调地址对应的服务需要处理该请求,根据 auth_code 获取 Access Token 和 Refresh Token 并保存至数据库;

  5. 该业务平台的 Access Token 和 Refresh Token 失效时间分别是 24 小时和30天,在 Access Token 过期前,需要调用刷新接口,使用 Refresh Token 刷新 Access Token,此时会得到两个新的 Token。如此循环往复,Access Token 则永不过期,可以完成各类接口调用工作。

回到编码层面来看,由于对接各个渠道授权流程基本类似,如果每对接一个渠道都重写一遍的话,相似代码会越来越多,可以使用设计模式中的模板方法来避免此类问题。

如下图所示,模板方法模式定义了一个授权过程的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。对应到授权业务上,抽象类可以实现授权过程的不变部分,如接收回调、保存账号数据等,将可变的行为留给子类来实现,如生成授权URL、获取Auth Code和获取Token 等。

3. 数据抓取

数据抓取服务的定位是一个定时任务处理系统,用于完成小时级和天级的广告数据抓取。在该系统中,我们用DAG来定义任务对象,Manager 负责管理 DAG 的生成和写入,Scheduler 根据 DAG 中的参数和时间生成任务下发至消息队列,Worker 负责具体任务的执行。

3.1 数据模型

广告数据可以分为两类,元数据和报表数据。元数据是指广告各个层级的属性数据,包括ID、名称、创建时间等属性字段,而报表数据是指点击、展示、消耗等指标数据。对于各个广告平台的广告层级,各不相同。

对于元数据层级,各个广告平台各不相同。巨量引擎旧版曾使用账户-广告组-计划-创意四个层级,2.0则使用账号-项目-广告层级,百度是账户-推广计划-推广单元-创意四个层级,快手是账号-广告计划-广告组-广告创意。为了对接多个广告平台,需要拉齐广告数据。由于元数据需要经常的查询更新,可以存储在MySQL中。

对于报表数据,每个渠道的指标数量和名称差异更大,同时多账号、小时级+天级的数据拉取会保存大量数据,为了保证拓展性和查询效率,可以将投放报表数据存储在 ClickHouse 中,CLickHouse中的 Map 字段可以很好的支持报表类多字段的拓展性。

在最终的查询分析时,需要综合MySQL和CLickHouse数据得到报告。

3.2 DAG:

在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(DAG,Directed Acyclic Graph)。下图中,4→6→1→2是一条路径,4→6→5也是一条路径,并且图中不存在从顶点经过若干条边后能回到该点,这种图就可以称为有向无环图。

DAG 可以用来定义一组相互依赖的操作单元,并基于依赖性、容错性、并发及调度方式来扩展。在广告数据抓取中,报表数据是依赖于元数据的抓取,如果元数据不存在,报表数据则无从谈起,基于这种依赖关系我们可以构造DAG。DAG 中可以添加属性,如下列举了几个简单属性字段:

dag.schedule_interval

string

cron表达式

dag.dag_id

string

Dag id,唯一

dag.tasks

array

dag的详细任务

tasks[0].task_id

string

任务id,dag内唯一

tasks[0].upstream_task_ids

array[string]

上游依赖任务id

tasks[0].downstream_task_ids

array[string]

下游依赖任务id

tasks[0].is_dummy

bool

是否为空任务

tasks[0].operator_type

string

任务类型

tasks[0].operator_name

string

任务名称

利用JSON可以组织任务的依赖关系,在如下示例中,我们定义了四个任务,第一个任务为 dummy_task,它是一个空任务,利用它组织下游的 account_meta_task 和 ad_meta_task 并列关系,这两个任务是可以并发执行的;但是对于任务 ad_daily_insight_task,则需要等待 ad_meta_task 执行完后才会执行。

JSON
{
"schedule_interval":"*/60 * * * *",
"dag_id":"${account_id}_today_insights",
"tasks":[
{
"task_id":"dummy_task",
"downstream_task_ids":[
"account_meta_task",
"ad_meta_task"
],
"is_dummy":true,
"operator_name":"DummyOperator"
},
{
"task_id":"account_meta_task",
"operator_type":"basic",
"operator_name":"ad_meta_operator"
},
{
"task_id":"ad_meta_task",
"downstream_task_ids":[
"ad_daily_insight_task"
],
"operator_name":"ad_meta_operator"
},
{
"task_id":"ad_daily_insight_task",
"operator_name":"insight_operator"
}
]
}

如上的任务依赖关系,经过Scheduler 解析后得到如下流水线:

在实际的应用中,我们会针对每个广告平台都预先定义好 DAG 模板,授权完成后 Manager 填充模板数据并存储在数据库中,Scheduler 将待执行的 DAG生成任务后下发至 Worker。

3.3 时间轮算法:

从上面的 DAG 定义,可以看到 schedule_interval 这个属性,它以Cron表达式的形式定义了任务的运行频率。如此多的任务如何精确运行呢,时间轮算法就是一个很好的解法。

时间轮算法的核心是:轮询时不再遍历所有任务,而是仅仅遍历时间刻度。好比指针不断在时钟上旋转,如果发现某一时刻上有任务,那么就会执行该任务。显而易见,时间轮算法解决了遍历效率低的问题。如果以小时为单位,有 10w 个任务,我们不需要遍历所有任务,仅仅需要遍历 24 个时间刻度。

如果要将时间精度设为秒,那么整个时间轮将需要 86400 个单位的时间刻度,此时时间轮算法的遍历效率将会大打折扣。那么如何解决这个问题呢,可以采用分层时间轮算法,多个时间轮相互配合来完成任务。

数据抓取服务目前支持周粒度到秒粒度的任务,我们使用了一个 7*24 个刻度的天级时间轮 和 一个3600刻度的秒级时间轮,当需要添加一个任务时,先添加到天级时间轮上,当指针判断需要运行该任务时,再将其丢至秒级时轮,最终在精确的时间内完成任务的执行。目前有数以万计的定时任务在系统上有条不紊的运行,加上重试策略,可以保证平台的数据报告做到及时、准确,完全满足对实时性要求很高的广告数据。

4. 后端业务

对于业务代码来说,除了可用之外,稳定性和可拓展性是很重要的指标,它关乎着我们当前服务的可用性以及后续的业务迭代是否方便。除了分层解耦之外,也可以额外使用一些方法,增强可维护性和可拓展性。对于广告创建,存在紧密的前后逻辑关联和复杂逻辑校验,如果放在业务中去处理,会使得主要逻辑混乱不堪,甚至难以维护,领域驱动设计(Domain Driven Design,DDD) 方法可以很好的解决该问题。其次,单元测试的编写,也有助于降低代码间的耦合,减少bug,提高可拓展性。

4.1 领域驱动设计 DDD

领域驱动设计(Domain Driven Design,DDD) 是由 Eric Evans最早提出的综合软件系统分析和设计的面向对象建模方法,如今已经发展成为了一种针对大型复杂系统的领域建模与分析方法。它完全改变了传统软件开发工程师针对数据库进行的建模方法,将要解决的业务概念和业务规则转换为软件系统中的类型以及类型的属性与行为,通过合理运用面向对象的封装装、继承和多态等设计要素,降低或隐藏整个系统的业务复杂性,并使得系统具有更好的扩展性,应对纷繁多变的现实业务问题。

上图是DDD设计方法的封层结构,领域层将数据和行为绑定在一起,将对象变为充血模型,更方便的完成复杂业务逻辑的处理:

1.用户接口层

用户接口层主要负责接收外部输入并返回结果。该层不含业务逻辑,可以做一些简单的入参校验和返回数据的封装。

2.应用层

应用层通常是用户接口层的直接使用者。但是在应用层中并不实现真正的业务规则,而是根据实际的 use case 来调用领域层提供的能力,可以理解为工作编排。

3.领域层

领域层是整个业务的核心层。我们一般会使用充血模型来建模实际的对象,同时,由于业务的核心价值在于其运作模式,而不是具体的技术手段或实现方式。因此,领域层的编码是不允许依赖其他外部对象的。

4.基础设施层

基础设施层是在技术上具体的实现细节,它为上面各层提供通用的技术能力。

比如我们使用了哪种数据库,数据是怎么存储的,有没有用到缓存、消息队列等,都是在这一层要实现的。

4.2 单元测试

对于一个优秀的仓库来说,单元测试是必要的,有如下几个好处:

  1. 有利于提高开发/重构/协作效率:

    调试的时候更高效,有单元测试的时候能更快缩小Bug的范围。

    改动代码时不需要每次都回归全量测试。

  2. 有利于代码结构设计

    强迫你写出高内聚低耦合的代码。如果你发现你写不了单元测试,很可能说明代码结构混乱

    测试会让你从使用方的角度重新思考接口的设计划分是否合理

  3. 有利于提高代码质量

    有单元测试把关,能够避免很多“手误”出现的隐秘Bug

    重构的时候能避免将正确的功能修改出Bug

对于一个多人协作的项目,在项目创建时就需要规划单测,严格设置单测覆盖率和增量覆盖率门槛,没有达到目标则不允许合入。如果开了放行的口子,会逐渐导致单测成了一个摆设。同时在写单测时,有几点需要注意:

  • 破除外部依赖:单元测试一般不允许有任何外部依赖(文件依赖,网络依赖,数据库依赖等),这些依赖会分散我们的测试焦点,严重时可能还会因为依赖不稳定导致无法进行单元测试或测试结果不可复现。我们通过mock/stub来构造出真实依赖下的各种行为。在Go项目中,GoMock 和 GoMonkey 就是一个比较好的Mock工具。

  • 提高编写效率:Go项目中,原生的go test工具只能满足最基本的测试需求,可以使用一些断言工具来提高单测编写效率。如 testing/testify

  • MySQL 和 Redis依赖:如果对于数据库和缓存需要测试,可以使用 docker-compose ,构建 Service + MySQL + Redis 的镜像,可以完成真实的依赖测试。

  • 利用gitlab、github提供的流水线工具,每次提交时自动运行单测,生成全量覆盖率和增量覆盖率,提升开发效率

5. 总结

火山引擎AB测试DataTester源自字节跳动长期沉淀,截至2023年6月,字节已通过DataTester累计做过240万余次AB实验,日新增实验 4000余个,同时运行实验5万余个。广告实验是DataTester的重要一环,已经积累了丰富的广告场景实验经验。本文是火山引擎DataTester团队在广告实验基础能力上的重构实践分享,通过本次重构,服务的稳定性和可拓展性得到了大幅提升,并进一步增强了广告A/B实验能力。

DataTester目前服务了包括美的、得到、凯叔讲故事等在内的上百家企业,为业务的用户增长、转化、产品迭代、运营活动等各个环节提供科学的决策依据,将成熟的“数据驱动增长”经验赋能给各行业。