1. 简介

腾讯游戏广告投放平台服务于腾讯旗下自研与代理游戏,提供了广告投放、素材管理、数据分析等功能。

平台地址:https://o2.qq.com/

投放平台对接海内外主流头部广告媒体平台,如国内的腾讯广告、巨量引擎(字节跳动)、磁力引擎(快手)、百度营销、苹果搜索广告等媒体平台,海外则有 Facebook、Google、TikTok、苹果搜索广告等媒体平台,并提供多种广告投放所需的投放工具。投放平台同时还提供素材管理、审核、上传功能,为广告投放提供丰富优质的素材库。此外,投放平台还支持多种维度的数据面板,从媒体、游戏、平台、广告、素材等维度提供投放数据和效果数据的数据分析和导出。

每一家广告媒体平台都提供独立的 MKTAPI 接口,有其单独的账户、广告配置、效果数据,它们大部分通过 oauth 2.0 的方式绑定到我们的投放平台,通过获取的 token 来调用其 MKTAPI 接口获取广告配置和效果数据。因此,对于每家广告媒体平台的接入,都需要单独的接入代码开发、单独的数据底表,然后通过共有的广告层级映射到我们投放平台中。

在广告媒体平台对接系统中,MKTAPI 数据服务作为广告媒体平台接入的基础,将所有的数据拉取和同步工作以定时任务的形式,根据配置的时间和频率每天定时执行,为投放平台提供数据基础。

为此,我们不断优化系统架构,持续进行架构升级与性能优化,建设高性能和高可用的数据服务。

MKTAPI 数据服务主要包含的任务分为以下几类:

  • 账号 token 刷新,通过生成的 token 可用于调用媒体平台的 MKTAPI 接口,大部分媒体平台是通过 oauth 2.0 的方式绑定到我们的投放平台,生成的 token 有有效期如 1 小时或 1 天内有效,还有些媒体平台是提供密钥以及几个 id,通过 JWT 的方式生成 token;
  • 账户数据初始化,实时的数据拉取任务只会拉取当日的广告配置和效果数据,对于新绑定的账户可以拉取其全部的广告配置和过去一段时间的效果数据,更方便用户查看和分析媒体账户最近的投放效果;
  • 账户活跃度探测,将媒体账户分为无效、不活跃、活跃三种,这是为了标记账户,以控制不同账户的数据的不同更新频率;
  • 广告配置数据拉取,大部分媒体平台将广告配置分为账户(account)、计划(campaign)、广告组(adgroup)、创意(creative)四层,每个媒体平台名称略有区别,我们需要将各层级广告配置以及其他关联配置,如图片、视频、定向等,定时通过接口拉取并写入我们的配置底表中;
  • 效果数据拉取,媒体平台会提供账户(account)、计划(campaign)、广告组(adgroup)、创意(creative)四个层级的详细效果数据,如消耗金额、曝光量、点击量、安装量、N日新进用户数、N日回流用户付费金额等,我们需要将各层级广告的效果数据,定时通过接口拉取并写入我们的数据底表中;
  • 公共效果数据表同步,我们将不同媒体平台的账户和创意层级的效果数据定时同步到公共的效果数据表,提供媒体、游戏、平台、素材等维度数据统计的数据;
  • 数据宽表同步,将通过媒体平台接口拉取的广告效果数据,与合作部们提供的游戏用户新近与回流数据、付费数据,基于广告配置写到一张数据宽表,用于每个媒体平台的账户、计划、广告组、创意层级的数据面板查询;
  • 各种辅助表的更新,如广告素材映射表根据广告创意配置解析与素材的关系,用于素材维度的数据统计,广告配置操作变更记录表,用于算法分析用户行为;
  • 一些辅助任务,如本地缓存信息刷新,定时任务配置检测重载,天级日志表的创建和删除等;

该系统面临许多的挑战和难点:

  • 随着绑定到账户平台的账户数量越来越多,一个任务所需拉取信息的账户数量也就越来越多,以腾讯广告这一媒体为例,从 4000 多个媒体账户增长到了 40000 多个媒体账户,引入账户活跃度后活跃账户最多时也达到了 8000 个;
  • 数据实时性要求越来越高,起初广告配置和效果数据的拉取频率为一小时,为了追求更高的数据实时性,逐渐将广告配置拉取频率提高到 10 分钟,将效果数据拉取频率提高到 2-5 分钟,将数据汇总同步频率提高到 1-5 分钟;
  • 任务量随着账户数量和任务频率的提高越来越多,平时一天任务量达到 500 万,而游戏大推高峰期间一天任务量最多达到 2800 万,平均每秒处理任务量 324,峰值任务量达到 500,Kafka 主题消费速度最高达到 30000 每分钟;
  • 随着数据拉取积累,数据表数据量也水涨船高,很多广告配置表数据量达到数百万级别,效果数据表达到数百万到数千万级别,每天数据量都有几万天级数据和几十万小时级数据,而各个媒体平台的汇总表更是达到了数千万和上亿级;

2. 系统架构演化

2.1 基于cronsun的Python脚本任务

cronsun 是一个分布式任务系统,相较于单台 Linux 机器上的 crontab,它支持在多台 Linux 机器上执行定时任务,同时提供任务高可用支持,具有网页配置和告警通知的功能。

cronsun 介绍文章:https://pandaychen.github.io/2022/06/20/A-GOALNG-CRONSUN-ANALYSIS/

cronsun 源码:https://github.com/shunfei/cronsun

在几台物理机上部署 cronsun 分布式任务系统,将所有媒体平台的账户 token 刷新、广告配置拉取、效果数据拉取、各种数据同步的任务,以 Python 脚本的形式实现,并通过 cronsun 定时执行任务脚本。

通过网页界面,可以配置一个任务的任务名称、执行脚本、执行时间 cron 配置,并选择要执行的机器。

cronsun 还提供界面查看所有任务的列表、配置的节点机器、每个任务的历史执行日志。

具体流程图如下:

该架构的优点有:

  • 任务开发和部署方便,将任务以 Python 脚本形式完成后,在 cronsun 系统中配置即可开始执行,无需经过部署发布的流程;
  • 架构简单,可以专注于业务代码的编写;

但这个架构的缺点比较明显:

  • Python 对于多线程和并发执行的实现较为麻烦,因此对于多个账户的某种配置拉取任务是在脚本中串行顺序执行的。在账户数较少和数据实时性要求不高的时候还能满足,但是会随着账户数增多花费越来越多时间,对数据实时性提高是一个瓶颈和阻碍;
  • 对媒体接口调用的频次控制较为粗糙,由于每个媒体平台对它的每个接口都有一定的频次限制,超过频次的频繁调用会触发请求错误。当前通过在每个接口调用之间 sleep 一定时间的方式来限制过于频繁的调用,如接口调用之间 sleep 100ms 来实现每秒 10 次的调用,而每个接口调用也是需要几十到几百毫秒时间的,实际频次只会更低。这也间接限制了调用频次的上限,无法充分利用支持的频率限制;
  • 机器扩缩容不方便,需要手动维护任务和机器的配置,每个定时任务在页面上配置了指定一到多台服务器执行。假如这时候我们想新增或减少若干服务器作为执行机器,就需要在每个任务的页面进行配置;

2.2 基于Go的单体数据服务

基于 cronsun 分布式任务系统的任务已经逐渐无法满足媒体账户数量的增长与更高数据实时性的追求,因此我们使用 Go 实现了一个定时任务执行服务。

具体流程图如下:

该服务为单体服务,部署在一台物理机器上,后面调整为构建镜像部署在容器平台上,但它仍然是单体服务。

相比于 cronsun 中任务对于多个媒体账户的串行执行,现在将任务分为了父任务和子任务两种任务类型,例如需要拉取 100 个账户的广告配置,父任务的工作就是创建这 100 个子任务,每个子任务的工作就是对应其中一个账户的广告配置拉取。子任务会被写入 Kafka,然后再从 Kafka 中读取消息执行子任务,实现生产子任务和消费子任务的解耦。该单体消息服务即是生产者也是消费者。

Go 的协程天然地满足高并发的执行,每个协程所占用的内存很小,对于每个子任务使用一个协程来执行处理。虽然一个协程内存占用很小,但是一味地创建协程也会令内存占用积少成多,且协程的创建和关闭也需要资源消耗,使用 ants 框架来实现协程池,可以限制协程数量上限和避免协程频繁创建关闭。

使用 MySQL 的定时任务表来配置定时任务的名称、执行时间频率 cron、对应的结构体和方法名称、任务等级、重试次数、开关状态等,使用 robfig/cron 框架实现 cron 解析和定时执行的调度器,使用 Go 的反射来将结构体和方法名称对应到具体执行的方法逻辑。按天创建日志表,记录了每个父任务和子任务的时间、状态和结果等信息。

使用 ratelimit 框架实现一个频率控制的限流器,该框架基于漏桶算法,可以对每个媒体接口单独地限制频率,避免过快调用触发限频,也能充分利用媒体接口支持的频次。

使用 go-cache 框架实现本地缓存,将所有账户配置信息、游戏配置信息等缓存到内存中,减少了数据库的压力。

还支持本地的 HTTP 接口调用,使用 Gin 框架实现,这是为了实现手动重跑某个任务,这在错误任务重心执行和历史任务执行很有用。

该架构弥补了基于 cronsun 的任务调度架构的许多缺点,改进之处有:

  • 子任务并发执行,使用 Kafka 解耦任务生产和消费,使用 ants 协程池管理协程,提高了任务执行的并发性能;
  • 实现了媒体接口粒度的频次控制,避免了接口调用触发限频;
  • 将一些高频率读取的数据缓存到本地内存中,减轻了数据库的负载压力;

但是该架构也存在一些缺点:

  • 单体服务存在单点问题,不满足高可用性要求,如果这个进程因为某种原因 crash 了,那整个服务就不可用了;
  • 虽然并发性能优于串行执行任务的方式,但是毕竟是在单机上运行,当到达性能上限时无法进行扩展;
  • 不同媒体平台的任务基于一个服务进行消费执行,一个媒体的任务出问题很容易影响别的媒体的任务执行,如媒体接口异常响应时间变长,就会导致大量协程处于等待阶段,协程池可用协程耗尽从而导致其它媒体的任务积压;

2.3 分布式数据服务

单体数据服务的种种缺陷,促使我们再次升级架构,将每个广告媒体平台的任务分别拆分为一个数据服务,且每个媒体的数据服务都改造为分布式服务。

具体流程图如下:

首先是对于接入的每个媒体平台,例如腾讯广告、巨量引擎、磁力引擎等,在代码仓库、镜像文件、服务部署上拆分为一个个单独的服务,使用的 Kafka 主题和日志表也一一进行拆分。对于账号数量较大、效果数据量较大的媒体腾讯广告和巨量引擎,不是使用单一的 Kafka 主题,而是将重要的实时任务和其它不重要实时任务、离线任务拆分使用不同的 Kafka 主题。

在代码方面,由于每个媒体平台拆分了一个代码仓库,将很多公共的逻辑封装在了一个 base 库中,如数据库连接、日志打印、监控指标上报、配置文件加载等,由各个仓库项目包含。

对于一个媒体平台对应的数据服务,我们区分了生产者节点与消费者节点,且对于不同的 Kafka 主题分为不同类型的消费者节点,消费者节点从 Kafka 主题消费消息从而执行任务,因此可以增加节点数量最多至等于 Kafka 主题的分区数。Kafka 主题分区数一般习惯设置为 6,这样 1、2、3、6 个消费者节点都可以满足平均分配分区。生产者节点与消费者节点基于同一套的代码构建,通过环境变量和配置文件区分它们的角色。

数据库方面也针对数据划分了数据库和使用 TDSQL。腾讯游戏广告投放平台的基础数据包含用户、游戏、媒体的配置信息、媒体账户的配置信息、素材数据信息,而从各个广告媒体平台拉取和同步的各种配置数据和效果数据的表,这两块原本使用了 MySQL 的同一个数据库。媒体平台拉取的各种数据表数据量大很多,有很多张表都达到了千万行级,因此将这两部分分为了两个数据库,其中后者使用了 TDSQL,其作为腾讯云的分布式企业级数据库,能够满足更高数据量的考验,且支持数据表的横向分片扩展。

数据库配置从库,将读写分离,降低主库的负载压力。根据 SQL 语句属于读或是写,连接不同的数据库实例,增删改操作使用主库,读操作使用从库。

缓存方面,由于节点分离,从本地缓存改造为了基于 Redis 的分布式缓存,能够进一步减轻数据库负载。

业务方面,对任务进行分级,精细化控制不同任务的拉取频率,保证重要数据得到更高的更新频率,以满足业务需求。对账户的活跃度进行了定期判断和分类,分为无效账户、不活跃账户与活跃账户,能够在不影响数据更新及时性的情况下显著降低任务数量,不足之处在于账户从无效或不活跃到活跃账户的状态改变,只能在定时判断的任务中进行调整,通过损失少量及时性来换取减少大量的任务数量。

优点:

  • 分布式服务再次提高了任务执行的并发性能,多消费者提供了更好的高可用性和稳定性;
  • Kafka 主题的拆分,减小了异常和任务消费积压的影响范围,避免了不同媒体平台服务之间的相互影响;
  • 数据库的拆分,切换 TDSQL,读写分离,这些操作降低了数据库的负载压力;

缺点:

  • 按媒体平台拆分之后,如果要接入一家新的媒体平台,就需要重新再走一遍代码仓库搭建、服务部署配置、对应的 Kafka 主题创建等流程;
  • 一个架构方面的升级改造,想要应用在每个媒体平台的数据服务上,需要一个个地修改代码,跑构建流水线,然后发布部署。目前将一些基础的通用代码,如连接数据库、Kafka 生产和消费消息、日志打印、监控指标上报等逻辑,封装在一个 base 库,每个媒体平台的数据服务通过使用不同 tag 的方式,以减少重复开发;
  • 对每个媒体平台的数据服务,生产者仍然只有一个,仍然存在单点问题;

现有架构基本满足了当前账户数量和数据量的支持,甚至在面对更大的账户数量和数据量,也可以通过调整配置的方式支持更大的并发性能。

  • 可以将当前 Kafka 主题设置更大的分区数,然后增加消费节点数量,来提高任务消费能力。还可以继续拆分出更多的 Kafka 主题来拆分消费者节点;

  • 数据方面,TDSQL 本身是支持分片的,当 TDSQL 的 CPU 负载越来越高时,可以通过调整数据库实例的性能和增加分片,来扩展数据库的能力;

  • 限制任务并发性能还有一个外部因素,就是广告媒体平台对他们的每个 API 接口都有设定的调用频率,如 100 次每秒或 600 次每分钟,调用频率超过会报错,这约束了我们并发的能力。可以通过运营同事对接媒体平台进行沟通,提高提供的调用频率;

3. 升级优化

从基于 cronsun 到 Go 重构的单体服务再到分布式服务架构,MKTAPI 数据服务经历了很多架构升级和优化。

下面就分不同方面介绍一下具体的优化点。

3.1 节点拆分

用 Go 重构消息服务时,开发的是一个单点服务,即一个 Go 程序,即充当生产者,也充当消费者,该程序内通过创建多个协程来并发地消费 Kafka,以及执行任务。这个单点服务将会执行所有接入的媒体平台的任务。

为了消除各个媒体平台之间任务的相互影响,我们对单点的数据服务进行了拆分,每个媒体平台拆分为一个代码仓库和独立部署,且分别使用独立的 Kafka 主题。

媒体账号数量较多、任务数量较多的媒体平台,如腾讯广告和巨量引擎,优先进行了节点拆分,除了原有的节点即生产任务消息也从 Kafka 消费任务,还增加了一类节点,这些节点只从 Kafka 中消费任务,而不生产任务。默认 Kafka 设置了 6 个分区,如果我们新增 1 个单纯的消费者节点,则原本节点和这个新节点就分别会消费 3 个分区的消息,如果新增 2 个消费者节点,则它们分别会消费 2 个分区的消息。生产者节点有且仅有一个,纯消费者节点可以有零到多个,上限是消息队列分区数减一。

慢慢我们发现,作为即是生产者又是消费者的那个节点,生产任务会影响到消费任务的性能,消费任务也会反过来影响生产任务的性能,于是我们控制这个节点只生产任务,然后补充一个消费者节点来消费任务。生产者节点有且仅有一个,纯消费者节点可以有零到多个,上限是消息队列分区数,再继续增加消费者节点将会无法分配到分区进行消费。

这个模式持续了一段时间,在一次游戏上线大推来临时,得知账户数量、广告和效果数据数量将会是之前大推的几倍时。为了提升任务并发性能提供稳定的支持,也为了保障其中一部分更加重要的任务的执行,我们按重要程度将任务分为了两部分,并且将 Kafka 主题也拆分为了两个,消费者节点分为两组,分别消费这两个 Kafka 主题的任务消息。

节点拆分的经历过程如下图所示:

后续可以增加 Kafka 主题的分区数量,再横向扩展节点数量,来继续提升任务消费能力,也可以继续对任务和 Kafka 主题拆分来提升消费能力。

3.2 消息队列

在最早的单点服务,只需要一个消息队列 Kafka 主题来进行任务的生产和消费。

随后,根据媒体平台进行了拆分,每个媒体平台的任务对应一个 Kafka 主题,避免了它们相互之间任务的影响。

再然后,就是其中一个媒体平台的数据服务,将任务拆分为了重要的任务和其它不那么重要的任务,生产者将它们的消息分别发送到两个对应的 Kafka 主题,再由两批消费者节点进行消息消费。

3.3 数据库

在开始,我们将所有的数据,包括投放平台相关的配置数据,和从各个广告媒体平台拉取的广告配置和效果数据,保存在了同一个 MySQL 数据库实例中的同一个数据库。

从各个广告媒体平台拉取的广告配置和效果数据,数据量是远大于投放平台相关的配置数据的,因此将它们拆分开存在了两个数据库中。

随着时间推移,我们从广告媒体平台拉取的广告配置和效果数据,某些表达到了数百万的量级,为了可预见的未来数据量的增长,将这部分数据保存到了 TDSQL 中。TDSQL 是腾讯云提供的企业级分布式数据库,支持自动水平拆分,能适应更大的数据量级与更高的访问并发量。设置了两个数据分片,且每个分片分别包含一主机一备机,未来需要扩展的时候可以增加数据分片数量,分摊请求负载。

很快,我们在使用中发现,对于两个分片,它们的 CPU 负载十分不均衡,一个是另一个的两三倍。平常时候一个分片的 CPU 使用率 50% 多而另一个分片只有 20% 左右,有时候一个分片的 CPU 使用率达到 90% 以上发生告警通知儿另一个分片 30% 都不到。这里要介绍一下 TDSQL 的数据分片规则,默认建表是指存放在第一个分片,设置 shardkey 则会根据分区键哈希值分配到各个分片上,设置 shardkey=noshardkey_allset 则是将表全量数据写到每一个分片。分区键的设置很关键,推荐使用日期、主键 id 等区分度比较高的字段,让数据尽可能均分到每一个分片上。定位发现这里 CPU 负载不均衡的原因是,很多数据量很大的表没有设置分区键,也就只写到了第一个数据分片上,造成第一个数据分片数据访问的压力较大。于是我们逐步将它们修改为分区表,两个数据分片的 CPU 负载逐渐变得差不多的水平。

我们将数据库配置了从库,控制读写分离,对于增删改操作使用主库的数据库连接,对于查询操作使用从库的数据库连接。通过读写分离,进一步降低了主库的负载压力,可以相应降低它的配置节省了一些成本。实际的主从延迟大概在几百毫秒到几秒内,偶尔异常情况可能达到分钟级延迟,但这对于我们的业务来说算是可以接受的范围。

3.4 限流

各个广告媒体平台会对它们的每个 API 接口设置频率控制,可能是 100 次每秒或者 3000 次每分钟,我们调用它们的接口就需要控制一下,不能过于频繁,否则将会触发频控错误。

在 cronsun 架构下,任务对于每个账号拉取某个接口,是串行的操作,接口请求本身大部分情况下就需要几十到几百毫秒,只要媒体接口的限频不是太低,都不会触发超频。如果有容易超频的任务,通过 sleep 一定时间的方式来降低调用接口的速度,这个方法依赖经验,而且对频率控制很不精确。

使用 Go 重构数据服务之后,通过 ratelimiter 框架实现频控限流,它基于漏桶算法,能在不同的层级如秒级、分钟级等实现精确的限流。按照媒体平台拆分数据服务后,这一套也同样适用。在区分生产者与消费者节点后,我们需要计算一下分配给每个节点的频次,如某个接口频次限制是 60 次每秒,我们有三个节点在消费,则需要在配置文件配置为 20 次每秒。当增加或减少节点时操作有点麻烦,需要重新计算一下平均分配的频次,然后修改配置文件并重启节点。

于是我们改造为了分布式频控,通过使用 ulule/limiter 框架,基于 Redis 保存频次信息。这样无论节点的增删,我们都不需要重新计算分配频次了。

对于基于 Redis 的分布式频控,每一次调用媒体接口,都需要访问一次 Redis,这对于 Redis 服务器也是不小的压力。我们可以考虑单独维护一个频控服务,通过一次申请授予一批频次的方式,根据实时申请同一个接口的节点数量动态调整分配,降低消费者节点与频次令牌授予者间的通信压力。

上面说的都是调用第三方媒体平台接口的限流,对于分账户生成子任务的频率也可以进行限流,假设不限制地一次过生成所有子任务,那将会有一大批任务等待消费,限制子任务生成的频率,让其不快于消费速度,可以避免任务消息的积压,防止不同任务之间的相互影响。

3.5 缓存

缓存的存在,即是通过将部分热数据存放在访问速度更快的地方,来减少访问速度相对更慢的地方的压力。

数据服务中需要频繁使用账户、游戏、媒体表的数据,使用这些数据来补充到数据底表,或者进行逻辑判断。使用 go-cache 框架将这些表的数据定时地保存在内存中,需要使用的时候直接读取内存,在少数需要修改的时候,修改数据表的同时也修改内存中的内容。

我们设定每 10 分钟刷新一次这几块数据的全量缓存,由于是本地缓存,我们将服务节点拆分为多个后,每一个节点就会在 10 分的时候分别读取一下这几张数据表的数据。虽然这几个数据表的数据量计并不大,平时不会有很大的数据库负载压力,但是当数据库本身 CPU 负载较高时,这些大量的不大不小的读操作就会加重数据库负载。临时解决方案是每个节点刷新缓存之前等待 0 - 3 分钟,将刷新缓存的时间随机打散。

我们读取的缓存,大部分是投放平台对于账户、游戏、媒体数据的读取,但还需要刷新账户的 token,它被用来在调用媒体平台的 API 接口时作为参数传递。这些账户 token 是需要刷新它的有效期的,大部分的媒体平台的 token 要么刷新后值不改变,要么刷新后改变但旧的 token 还保留一段时间有效性,这样我们在刷新账户缓存之前也能有效使用旧的 token。但是对于个别媒体平台如 B站,它刷新 token 之后旧的 token 就立即失效了,这样只有执行了刷新某个账户 token 任务的消费者节点持有了有效的那个 token,其它节点的本地缓存内都是无效的旧 token。为了解决这个问题,也为了进一步减轻数据库的读写压力,我们使用了 Redis 作为分布式缓存,定时从数据库的表刷新到 Redis,并从 Redis 中读取缓存信息。

3.6 媒体账户

当一个媒体账户绑定到我们的投放平台上,我们会开始拉取它当天的实时广告配置和效果数据,但是如果是一个投放了几天广告的广告账户,绑定到投放平台后,无法看到昨天之前的数据,需要我们手动去重新拉取一下之前的信息。我们标记一个账户是否初始化,并对初次绑定的媒体账户进行初始化,包括广告配置和过去一段时间的效果数据的拉取,这样账户绑定后即可以看到一段时间前的数据。

如果一个账户正在投放广告,那它投放的那些天是会有投放效果数据的,例如花了多少钱带来了多少曝光、点击、安装,前几天还会有新广告创建和修改。而很多游戏的账户,除了少量一直在持续投放广告的那些账户,大部分都是平时都会把它的广告删除或关闭,不会产生任何广告配置或者效果数据。因此,我们对账户进行了分级,根据最近 7 天有没有广告创建和修改以及有没有效果数据,将它们分为无效账户、不活跃账户、活跃账户,定期进行账户活跃性检测。对于活跃账户我们会对它们执行所有的任务,包括实时任务和每天凌晨的离线任务,对于不活跃账户我们只会对它们执行凌晨的离线任务,即拉取过去 7 天的效果数据,对于无效数据我们不会对它执行任何拉取任务,只执行账户检测任务。

任务分级使得我们在大致保证数据及时性的前提下,大大减少了任务的调用量。假设有 10000 个媒体账户,其中有 1000 个是活跃账户,对于每天只执行一两次的离线任务,原本会执行 10000 个子任务现在还会执行 10000 个子任务,对于每 2-10 分钟执行一次的实时任务,原本会执行 10000 个子任务现在只需要执行 1000 个子任务,任务量仅为原有的 10%,具体比例依活跃账户占总账户数量的比例而定,平常是会更低的,遇上游戏大推或节假日则会变高。前面提到了大致保证数据及时性,这个方案也是有缺点的,就是如果一个账户被判断为了不活跃账户,不会执行实时任务,但它突然开始有投放数据了,就只能在下一次账户检测时才把它改为活跃账户,这段时间就是这个账户数据实时性的延迟。

3.7 任务配置

我们通过一张定时任务表来保存任务的配置,包含定时任务的名称、执行时间频率 cron、对应的结构体和方法名称、任务等级、重试次数、开关状态等信息,服务启动时从中加载并定时执行任务。如果有任务开启或关闭,或者是一些配置修改,则需要重启服务来应用。

为了实现动态加载任务配置,只需要新增一个定时任务,每分钟执行一次,判断任务表记录最新的修改时间,如果晚于当前内存记录的最新修改时间,则重新加载定时任务,从而实现了动态加载任务配置,不需要重启服务。

任务执行的时间也可以调节配置,如有 3 个定时任务需要每十分钟执行一次,如果都放在整十分钟执行,那将会把三个任务都集中在一个时间段执行,我们可以通过配置它们分别为整十分钟、3 分开始每十分钟、6 分开始每十分钟执行,就将三个任务的执行分摊开了。

在游戏大推期间,我们预估账户数量和数据会是平时的几倍量,我们对于一些任务的实时性相比另一些更为看重,于是对不同任务的执行频率也进行了调整,对于账户和广告层级的效果数据实效性最为重要,设置为 2 分钟一次,对于一些广告配置不需要那么高要求,因为广告配置往往是近几天内调整好的,设置为 10 分钟一次,而数据汇总任务也需要频繁完成,且高层级的数据量不多汇总不到 1 秒,设置为 1 分钟一次。

3.8 数据

很多配置数据和效果数据的拉取,广告媒体平台提供的 API 接口一般都是有分页参数的,一次最多可以获取 100 或 1000 条数据。对于不同层级数据一天的数据量有几十到几万条之间,数据同步到汇总表时,如果全量查询一天的数据然后再全量插入汇总表,插入操作将有很长耗时与锁表且很可能死锁,如果从数据底表分页查询然后分页插入汇总表,则每个批次都可以较快完成,但查表总的时间和数据库负载很高。测试发现一次过查询几万条数据都是绰绰有余的,于是我们改为了一次性读取一天的数据,然后再按 100 条每批次插入汇总表,这样即有较小的查询压力,也避免了插入的耗时。为了防止查询一次数量万一越来越大出现其它错误,我们优化成了每次查询一万条数据,按照自增 id 作为游标升序排序,下一次从游标继续查询一万条数据,插入仍然按 100 条每次分批执行,兼顾了减少查询次数和避免查询过多行数的失败。

我们需要读取另外的部门提供的数据库实例中的数据,作为我们某些汇总表的上游,我们的查询会给他们造成较大压力,于是我们通过定时脚本将他们对应表的近 7 天那的数据,同步到我们自己的数据库实例的同名表中,可以直接查询我们的同步表,降低对方数据库的负载压力。

我们生产一个子任务时,需要插入一条记录到日志表,然后发送一条消息到 Kafka 主题中,将这个操作批量化,插入日志表和发送消息改为 100 次地批量操作,实际执行时间也就每次一条的几倍,但是总消耗时间大大减小,节约了大量的网络传输时间,对于数据库与 Kafka 的负载也大大降低。

我们在将效果数据汇总到汇总表时,某个天/小时时间内游戏/媒体/平台是唯一的,我们插入汇总表使用的是 insert into on duplicate 的逻辑,无则插入有则更新。在实际投放中,某个账户可能绑定的游戏/媒体/平台可能会选错,需要调整,那么如果某个游戏/媒体/平台组合本来有账户,修改后不存在这种账户了,那这一行数据就无法被识别和删除,排查定位问题耗时耗人力。我们给汇总表加了一个版本号 version 字段,每次汇总插入的时候,以当前时间戳作为版本号,然后再删除这次汇总的范围内版本号小于该时间戳的记录。这个方法解决了不再存在的游戏/媒体/平台组合的数据导致的脏数据问题。

但是上面版本号的记录和删除无效数据的方式,在实际线上运行一段时间后,可以发现删除记录的 SQL 经常会耗时异常久或者死锁报错结束。这个问题的原因是删除操作需要修改表中的数据,过滤条件只包含了时间以及版本号,无法命中索引,于是就锁了整张表,和其他插入删除操作在一起容易触发死锁。我们优化为先查询出这个数据的时间是否存在版本号小于最大版本号的数据,查出来的无效数据通常不会太多,然后按照主键进行逐行删除,精确命中了主键索引,也就没有再发生死锁了。

4. 未来规划

架构优化和升级永远没有终点,目前的架构还有很多值得升级改造之处。

由于按照媒体平台进行服务的拆分,每接入一个新的媒体平台就需要走一遍服务框架代码搭建、配置、部署流程,且对于架构的升级也需要一个个服务的代码去修改,可以考虑在代码仓库、部署层面合并更多公共的东西,减少重复工作。

然后是生产者的单点问题,目前对于每个媒体平台都有且只有一个生产者,出问题了会影响任务的生产和消费,可以创建多个生产者,然后基于 Redis 分布式锁的争夺,来确定哪个生产者会实际生产任务。

生产者正在生产子任务时,如果出现某些原因导致了其服务挂掉,后续的子任务就无法继续生成了,应当考虑以某种方式保存这种信息,可以自动地重新继续生产子任务。

很多的任务之间是存在先后关系的,如从拉取效果数据到同步汇总表、从拉取广告配置到写入映射表,可以考虑在任务之间加入级联逻辑,前任务执行完后立即执行后任务,可以减少数据流程的总延迟。

分布式限流每一次媒体接口调用都需要申请一次频次,也就是一次对 Redis 的访问,Redis 本身的负载压力会逐渐变大。可以考虑开发独立的频控服务,使用最大最小公平算法,动态地分配频次 quota,并且可以一次分配一批量的频次给消费者,减少频空请求次数。

目前支持在服务节点的终端通过 curl 手动执行任务,不仅效率较低且容易出错,可以考虑使用 web 界面操作来调用服务执行任务,包括各个任务的配置信息、执行记录、执行进度等也都可以通过网页界面来展示。