广告引擎解析
广告引擎
总体设计
我们的基本架构是客户端请求API,然后由API发送RPC请求到我们的服务,服务通过注册中心来管理
检索服务,根据数据库中的广告建立索引,订阅Redis Channel通过通知机制来使内存中的广告与数据库保持一致
曝光服务,接收曝光和点击,聚合以后将聚合的结果推送到消息队列
计费服务,计费服务与支付系统进行交互,主要负责扣广告主的钱,发起余额不足,预算不足的下线
- 下面先分服务单独介绍
检索服务
广告检索流程
- 参数解析,主要是为了过滤掉一些非法请求
- 发起ADX广告请求,这步首先对要向哪一家DSP发请求有个简单的定向,然后再经过流量控制模块,确认可以发请求,会异步发送HTTP请求,然后立刻返回,继续下面的逻辑
- 广告定向,对自己的广告按照定向进行初筛,得到所有符合条件的广告ID
- 广告过滤,根据一些逻辑规则,过滤掉不符合条件的广告ID
- CTR计算,并按照ECPM排序,这部对所有广告的所有素材计算点击率,最后按照ECPM排序
- 选择各广告位广告,并计算二价
- 与ADX广告竞价,之前异步发送的广告请求,在这里需要等待每个请求的返回结果
- 广告内容格式化,按照不同类型的广告格式化成客户端的协议
- 广告曝光点击参数加密,对一些信息进行加密,尤其是这次广告的曝光价格和点击价格
广告定向
需要解决的问题
一个用户本身有一些属性,比如性别,年龄,网络环境,设备类型,地域信息等等,而广告主想让自己的广告投放在特定的人群中,根据用户的属性,检索出可用的广告过程就完成了广告定向
比如这个例子,广告2就不满足定向条件,广告3,广告1满足
定向维度下的选项很少,可以枚举,这类定向包括,性别,年龄段,网络,系统。
广告定向表
广告ID 性别 年龄段 网络 操作系统 广告1 不限 不限 不限 不限 广告2 女 0-18 不限 不限 广告3 男 0-18 不限 IOS
根据以上这张定向表,看上去有点像数据库,假设图中小人的流量来了,换成数据库的查询语句,应该是
where (性别=男 or 性别=不限)
and (年龄=0-18 or 年龄=不限)
and (网络=wifi or 网络=不限)
and (操作系统=ios or 操作系统=不限)
如果没有“不限”,那么看上去组合索引是最好的选择,我们把 性别-年龄-网络-操作系统 组合成一个索引,这样索引的空间是
- 2种性别 X 5段年龄 X 2种网络环境 X 2种操作系统 = 40种可能,每种可能对应一个ID的列表,为了用到组合索引,必须把or语句去掉,可以把不限的广告冗余写到所有的索引下,比如最极端的例子就是广告1,所有都不限,那么40种可能的组合下全部都有广告1的冗余
- 我们的系统最开始就是这样来检索,好处就是只需要查询一个索引就能取出定向的广告,不好的地方也显而易见就是会增加被索引数据的冗余
where语句只看性别一个维度,or左右两边是等值查询肯定可以利用索引求出两个集合再取并集,然后再和其他维度取交集
- 我们程序中也是模拟这种先并再交的操作,数据中使用的btree来组织索引,我们程序中使用的是哈希表,都是给定一个查询的Key,返回一个被索引的内容
集合操作
如何取并集可以参考两个有序数组的合并排序
如何取交集,一般有三种方法
- 通用方法,选择小集合作为base在大集合里做二分查找
- 大集合交小集合,小集合如果可以放到内存可以使用把小集合在内存里用构建hash索引,大集合作为base,在小集合里哈希查找
- 大集合交大集合,让两个都有序之后再交集M+N的复杂度,找到一个集合的最小,然后在另一个集合skip比这个数还小的所有数据
我们的系统现在是如何处理这类定向的
对每个维度在内存中建立倒排哈希索引,然后我们把某个维度下的不限做了冗余,这样就不需要做并集,每个维度用数据最少的维度数据做为驱动表,然后在其他并集的结果中做哈希检测,如果不存在就把这个广告删除掉
注意,我们的定向只做了一次adid内存拷贝到某个流量检索的上下文,而且拷贝的是最小的集合,其他要做的是不断的哈希检测,把这个集合根据其他维度定向再缩小范围
以上这种定向方法主要使用了倒排索引,还有一些求交集的技巧,倒排索引在处理等值查询和动态多维度组合的时候是非常适合的,但是在处理范围查询的时候就不太好使了,比如我们年龄要是支持任意年龄区间的定向,也就是要处理范围查询,一些有序结构比如平衡树会是更好的选择
定向维度有级联关系,省,市,区
广告在区域定向条件如下
广告区域定向表
广告ID 省 市 区 广告1 不限 不限 不限 广告2 北京 北京市区 朝阳 广告3 上海 上海市区 不限
假设一个用户在北京,北京市区,朝阳要检索广告,SQL如下:
where (省=不限 and 市=不限 and 区=不限)
or (省=北京 and 市=北京市区 and 区=不限)
or (省=北京 and 市=北京市区 and 区=朝阳)
之前我们说可把不限这种情况冗余到所有数据项下面,这样就避免了OR操作,省市区这种无法冗余的原因有两个
- 一个是不知道冗余到哪里,因为省市区的选项有可能都不固定
- 还有就是冗余的话数据在定向是省市区都不限的时候冗余量太大,要把全国每一个区县都冗余一遍
观察这个SQL是先AND再OR,之前分析过多个AND适合组合索引,所以假如我们可以把省-市-区看成一个索引中查询的值,相当于我们查3次组合索引,然后再取并集,得到的集合还需要和其他集合做交集,而且这个集合因为是OR出来的动态变化的所以不得不把广告ID的列表拷贝出来,弄个临时表。
如何优化临时表,我想了两种方法,但是目前还没有做
- 对临时表缓存固定查询条件缓存OR以后的临时表
- 根据集合运算定律A交(B 并 C) = (A交B) 并 (A交C),这样把这个维度在最后运算可以首先保证A比较小,B,C是永远不变的,做哈希过滤就可以了。
定向维度是按照某个坐标附近N公里定向
首先坐标可以转成GEOHASH,然后N公里可以再定向之后用过滤的方式计算
有几点需要注意:
- GEOHASH的精度,最小位应该能覆盖附近N公里,大概4位的GEOHASH可以覆盖20KM,3位可以覆盖78KM
- GEOHASH因为只能定位一个大概,所以需要把广告同时在附近的8个格子冗余写入广告ID,这样查询的时候只需要查一个格子,不然就需要检索9个格子
总的来说这种方式就是简单的在索引中做等值查询where geo=abcd 做一次初筛,然后再过滤
过滤并不一定很慢,在数据库中用索引反而更慢的例子,这个CASE最适合的是过滤
检索服务是数据库的副本
我们的检索服务在启动的时候会全量加载数据库中的广告,构建广告的正排数据和倒排索引数据,多个副本通过消息通知维护与数据库的一致
通过订阅redis的channel来获取消息,消息类型包括,下线广告主的广告,同步推广计划,同步广告,同步素材,重新全量加载数据
在重新加载数据的时候,线上服务还要继续运行,所以我们使用了引用替换的方式,为了保证全量加载是有足够内存,内存只能使用1/2
消息机制有可能会不可靠,每个小时检索服务与数据库重新全量同步一次
- 当内存中不足以装下所有广告的正倒排的时候,我们应该考虑的是两反面,一个是压缩存储尽量装载必要信息,正排数据压缩存储;另一个是数据分区,比如按照区域分区,但是分区的时候还需要考虑到一些数据倾斜问题,目前我们还没有碰到内存方面的平均。
CTR计算
每个广告素材有一个CTR,所以CTR的计算量是很大的,我们的CTR计算采用异步的方式,当查询一个广告素材的CTR缓存中没有的时候,就返回默认CTR,然后异步计算好这个素材的CTR填充到缓存中
计算二价
计算方式 CPC广告
- first.clickPrice = second.ecpm / first.quality / first.ctr + 0.01 * 1000
CPM广告
- first.displayPrice = second.ecpm / first.quality 0.01
ADX广告
面临问题
ADX发送广告请求是到外网,并且请求量大,在100ms以内返回,某一家DSP超时不能对整体的广告检索有影响
我们针对这些问题做了如下几方面的控制
- 流量控制,针对每家DSP有QPS的控制
- 使用NIO异步发送请求
- 长连接,尽量使用HTTP1.1的KEEPALIVE特性,不要让第三方使用HTTPS的竞价链接
- 与每家DSP建立长连接的数量有最大值
- 超时率大于40%折半降低流量,直到设置的QPS最小值,当请求成功率大于40%倍增流量,直到达到当前这家DSP这只的QPS值
- 对超时的控制,自己封装了DspFuture使用HashwheelTimer定时器来控制超时时间
针对ADX还需要加强的地方
- 链接的预热,可以预先对每家DSP建立好长连接以后再对外提供服务
- 网络链路的选择,与DSP之间的网络出现问题的时候,可以对某一家DSP尝试使用不同的链路,或者优先使用最经济的链路
曝光服务
接收到曝光点击以后在内存中做聚合,每分钟把聚合后信息推送到消息队列
计费服务
计费服务从消息队列消费曝光服务聚合后的曝光点击,其中曝光是每分钟消费一次,点击是实时消费。
曝光的消费是两个线程协作,一个线程负责从消息队列拉数据然后聚合N台曝光服务推入的内容,一个线程负责消费。
- 当消费线程发生阻塞的时候,计费服务如果把聚合后的曝光都存储在内存中有丢失数据的风险,所以我们队计费服务从消息队列中拉取消息的速度做了控制,最多只可以聚合几分钟的数据
计费服务主备
计费服务一主一备,通过redis实现租约
redis中有一个叫lock的key,value是当前工作节点的名字,有一个过期时间。
主服务,每秒钟先get(lock),然后判断是否与自己当前节点名字一致,如果一致的话,使用expire方法进行续约
备服务,每秒钟尝先尝试写lock这个key为自己节点的名称,使用redis的setex方法,因为key已经存在,所以会返回false
浮点数问题
因为计费经验做一些钱方面的检查,所以涉及浮点数精度问题
浮点数做减法使用BigDecimal.subtract()方法
浮点数做乘法使用BigDecimal.multiply()方法
浮点数做除法使用BigDecimal.divide()方法