RTA是最近两年在国内兴起的一种投放模式,目前国内主流的流量媒体方都推出该项能力。如腾讯/头条在2020年正式对外推出该项服务,信也科技作为其中的广告主一方也几乎在同期开始进行陆续接入,以此来帮助我们进一步提升广告的精准投放效果。
RTA
(全称Real-Time API
),实时API接口,是媒体和广告主之间通信的一套接口服务。在开通后在每次媒体将广告给用户曝光前,媒体将通过RTA接口来询问广告主是否参与本次曝光(参竞);广告主接受请求后结合自身的数据和策略信息返回是否进行曝光(参竞)以及进一步的决策结果;媒体结合广告主的结果信息进行优选,最终提升广告主的广告投放效果。
RTA有很多业务上的价值,比如可以针对场景做个性化的优选,也让广告主有了参与广告曝光决策的机会。
此外对广告主来说还有一个比较重要的价值----保护私有数据。通常情况下广告主想进行流量的筛选比如想圈定或者排除某一部分人群,常规做法是打包一些定向数据上传给媒体的DMP平台作为定向投放数据包。该方式下数据无法实时更新、而且操作繁琐,最重要的是还会将广告主的数据直接暴露给媒体方。很多时候,数据是一个公司非常重要的资产尤其对数据比较敏感的金融行业,某些数据不方便提供出去,RTA能很好地解决该类问题。
从背景的介绍中我们可以了解到RTA带来的一些业务价值,但媒体对可以接入RTA的广告主设有不小的门槛,在此我们主要讨论的是技术上的门槛。
因为要进行实时的参竞,媒体要求广告主方有一定的技术和数据能力,亦即面对高并发的流量时能快速作出决策进行实时答复。下面列举了腾讯要求广告主方必须达到的硬性技术指标:
其他媒体比如头条、快手、百度等的要求类似,接口的响应时间都要在60ms内,需要能支持高QPS。根据业务场景投放的不同,实际的流量上限会有所不同。但通常媒体方一侧都设有超时率门槛,针对不达标的情况媒体方会有降级和最终的清退机制(即关闭广告主的RTA接入通道)。
为了实现高并发且实时的指标,在前期调研阶段,我们的业务方和技术团队进行了反复沟通。业务方有很强的诉求,想借助RTA的能力来进一步提升广告效果以节省成本。技术上要求我们开发的系统能很好的满足媒体方的要求,服务好业务方。
API接口数据交换格式是基于http-protobuf
,我们接入的几家媒体(腾讯/头条/百度等)均采用该方式,protobuf序列化可以获得不错的压缩性价比,契约由媒体方提供,按照契约进行开发提供接口服务。
基于RTA高并发且实时的业务要求,我们在前期和运维/DBA/基础组件的同学沟通,确保该并发的条件下我们的基础设施可以有效地承载,同时在一些设施上面进行有效的资源隔离,以防止RTA影响到其他业务。另一方面,在系统设计上,我们的处理逻辑不能有依赖慢设备或者慢操作的行为,资源上也需要能支持高并发的访问。综合考量后,我们作出如下的选择和主要设计原则:
应用的核心视图如下图所示:
主要有3个服务,对于每个服务解释如下:
业务人员对每个RTA请求是否曝光(参竞)制订策略,不同媒体的策略规则和配置信息不一样,它们决定着媒体的RTA请求是否参竞以及对应的结果信息。其中,规则信息可简单理解为对逻辑运算的抽象(操作对象和操作符,以及操作结果之间的关系),可以让业务人员进行可视化的配置,配置后最终转换为一份规则契约,供RTA接口解析。借助配置中心的数据变更通知,API实例可以拉取到变更后的信息、并直接转为Java内存对象供业务使用,因此运行态API代码所需的数据均直接来源于JVM内存或者Redis,这样就保证了获取数据的速度。
下面是API接口的主要处理流程:
API直接依赖的2个数据源是Redis和JVM内存,为保证HTTP的线程不阻塞,尽可能优先采用异步处理方式。通过上面的各种手段,我们可以满足实时性的要求。
由于业务上的特点,我们会面临大量的数据存储需求,业务上一个很小的规则可能会使用很大的存储资源。这要求我们谨慎设计数据存储,寻找有效的存取结构。
业务上用的数据,可以归结为如下2类存储:
对于JVM本地存储,以对热key的处理为例进行说明。热key是指同一个设备号的曝光请求被媒体反复下发。在业务上线的初期,我们发现很多设备请求被下发很多次,有的每日可达上千万次,浪费了处理资源,需要某种策略进行应对。为此,我们设计了收集反馈的方式,具体流程:API实例本地LFU队列收集 -> 上报 -> 统计 -> 反馈到API实例
,如下图所示
LFU(Least Frequently Used) 算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
对于使用Redis存储的业务数据,早期上线一段时间后耗费的内存空间超过1.6TB,存储了超过60亿条的数据记录,数据稳定后还在以每日超过千万级的量增长。按这样的增长速度,不久之后成本将无法被覆盖。
出现该问题的主要原因一方面在于早期严重低估了数据量,所以直接采用了简单的k/v
结构进行存储,另一方面在于数据稳定后的增长速度超出预期。所以在和业务方沟通如何针对策略保留最有效数据的同时,寻找更有效的存取手段。
我们在Redis中存储数据时,使用protobuf
进行压缩,并让Redis使用ziplist
作为hash
和zset
的底层实现,从而大大节省了内存空间。技术细节,可参考Redis官方文档中的存储优化章节。
目前我们的很多场景下,都用到整型的k/v存储结果。测试结果表明,在使用了改进的存储结构后,1亿的数据只需要占用1.1G左右的Redis空间,存储优化率可高达10倍左右,效果很好,所以在大多数情况下我们尽量使用整型数据。对于protobuf
也是一样,尽可能的使用整型数据类型,可以达到最好的压缩效果。
结合业务上的数据特点,我们使用了hash/zset
存储结构,一方面节省内存,另一面将不同数据的keys划分到不同的连续区间内,方便管理数据。比如,知道某个范围内存储的是什么数据,就可以方便地进行key的清理,直接清理该区间即可。Redis的key区间划分大致如下:
业务信息 | 存储结构 | key区间 |
---|---|---|
业务数据1 | hash(ziplist) | 0~200w |
业务数据2 | hash(ziplist) | 200w~2000w |
业务数据3 | zset(ziplist) | 2000w~5000w |
业务数据4 | zset(ziplist) | 5000w~Nw |
下面举例说明Redis的存储场景。比如,我们需要对曝光的设备做一些策略,限制媒体每个设备每日到达多少量后不再进行曝光,这依赖于对设备进行计数。我们对接了多个媒体,总设备曝光请求数据每日高达上百亿次,预计每日会有数十亿的去重设备量。结合业务上的的特点,设计如下:
INCR
命令(string、hash、zset)Redis中对于整数类型采用的内部编码是int
编码,对应Java里的long
类型,占8个字节。在评估安全的并发进位问题后,我们将一个8字节拆开,取合适的bit数量作为某个媒体计数,结合hash存储后我们还可以获得数倍的空间节省,存储结构如下:
进制 | 备用 | 备用 | 媒体6 | 媒体5 | 媒体4 | 媒体3 | 媒体2 | 媒体1 |
---|---|---|---|---|---|---|---|---|
0x | 7F | FF | FF | FF | FF | FF | FF | FF |
计数规则如下:
我们上面提到接口的响应时间要在60ms以内,因此网络的问题影响很大。
事实上我们的时间大部分花费在网络上,距离的远近直接影响着网络时延。以目前对接的一些媒体来看,在接口消耗时间上,上海市内的来回网络耗时达7ms左右,深圳到上海的达35ms左右。在上线前差点因为网络耗时长的问题而上不了线,最后发现是测试工具默认没有开启http长连接导致测试不达标。
在上线后,当媒体方请求量增大时,接口超时严重无法达标,在反复确认业务代码的耗时没问题后,最后发现是负载均衡达到了瓶颈,在更换更大带宽的设备后,问题消失。
网络的延迟问题很难解决,但可以对接口返回的网络字节大小进行优化。除了用protobuf
对接口报文的body部分进行压缩外,还可以擦除不必要的http请求头,达到更好的优化效果。比如在protobuf数据格式下我们发现响应头里会默认塞入X-Protobuf-Schema
和X-Protobuf-Message
两个请求头的数据,在UTF-8的编码下,每个英文字母会占用1个字节,擦除这2个响应头后可以节省大量的网络带宽。开始时,我们尝试在请求的filter的里进行擦除,但没有成功;最后通过深入探究http的protobuf消息的处理过程,重写了protobuf消息转换逻辑,达到想要的效果,代码如下:
public ProtobufHttpMessageConverter protobufHttpMessageConverter() {
return new ProtobufHttpMessageConverter() {
@Override
protected void writeInternal(Message message, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
MediaType contentType = outputMessage.getHeaders().getContentType();
if (contentType == null) {
contentType = getDefaultContentType(message);
}
if (PROTOBUF.isCompatibleWith(contentType)) {
FileCopyUtils.copy(message.toByteArray(), outputMessage.getBody());
} else {
super.writeInternal(message, outputMessage);
}
}
};
}
对资源进行保护和有效降级(限流熔断)非常重要。保护点主要基于业务上考虑来确定,可以是任意的代码片段,并尽可能提供降级手段,以保证我们的主业务不受影响。
我们定义了很多的保护点,在生产上遇到过几次问题,这些保护点机制都发挥了很大的作用,具体过程不详细展开。
我们分享了前期在构建实时接口RTA方面的一些思考和实现点。目前RTA在生产上已经持续稳定运行了一段时间,每日请求数超过170亿次,QPS达到了30w/s,Redis的访问达到90w/s,处理的响应时间低于7ms,我们的健康指标多次得到了媒体方的认可和赞许。
文中提及的方法,尤其是对Redis的存储优化,可供感兴趣的技术人员参考采用,很多场景下,节省空间的同时也带来了时间和CPU的消耗,可谓鱼与熊掌不可兼得。RTA还在持续的构建和改进中,我们还面临着更多的数据存储和复杂的策略,适当的时候我们会继续分享所遇到的问题和解决方案。
yy,信也科技后端研发专家,目前负责市场信息流投放相关业务。如有问题随时沟通!