基于Redis+Kafka的首页曝光过滤方案

猪小花1号2018-09-03 14:26


作者:李勇


  • 背景

  网易美学首页除了banner和四个固定位,大部分都是通过算法推荐获取的内容,其中的内容包括心得、合辑、视频及问答等。现在需要实现的是当推荐内容在用户屏幕曝光后(即用户一个屏幕内的内容),那么这些内容在一定时间内,如两周内都不能再出现,因此需要对这些已经曝光过的内容进行过滤。首页内容如下图所示:

 

  • 实现方案

  目前的实现方案是客户端对用户曝光的内容进行采集,然后通过DASDK将这些数据发送到Kafka Broker,然后再通过Kafka消费者去消费并解析这些数据,再将解析后的数据同步到Redis中。当用户再次获取数据时算法端会从Redis中获取需要过滤的数据,再将最终推荐内容返回服务端,然后服务端去业务数据库查询算法返回的数据对应的完整信息,最后将完整信息返回给客户端,客户端对数据进行渲染展现给用户。

  具体实现分为两道工序:一个是曝光数据的收集,另一个是对曝光数据的过滤。

  • 曝光数据的收集

  数据的收集步骤如下:

  1. 客户端收集用户的曝光内容;
  2. 客户端通过DA的SDK将收集到的用户曝光内容发送到Kafka集群;
  3. 实时计算工程实时拉取Kafka的内容;
  4. 提取出曝光内容再进行解析;
  5. 将解析后的内容以Sorted Set数据结构维护到Redis中。

  曝光数据的收集时序图如下图所示:

         

  • 曝光数据的过滤

  数据的过滤步骤如下:

  1. 用户使用客户端刷新首页数据;
  2. 客户端向服务端请求首页数据;
  3. 服务端在向算法端发送请求获取首页数据;
  4. 算法端根据服务端发送的用户个人信息,如用户id,用户设备id等信息,计算出推荐内容;
  5. 算法端从Redis中获取该用户最近两周的曝光数据进行过滤;
  6. 算法端将过滤后的推荐内容id和内容类型返回给服务端;
  7. 服务端根据算法返回的内容id和内容类型从数据库中查询详细信息并且组装数据;
  8. 服务端将最终的数据返回给客户端;
  9. 客户端受到服务端的数据将其展现给用户。

  曝光数据的过滤时序图如下图所示:

  • 为何使用Kafka和Redis?

  Kafka是有LinkedIn开源的,是一个快速、可扩展的、高吞吐、可容错的分布式发布订阅消息系统,适合大规模消息处理场景。由于用户的曝光数据量比较大,因此使用Kafka作为消息系统。Kafka的优点有以下几方面:

  1. 高性能:每秒钟能够处理数以千计生产者生成的消息。
  2. 分布式:消息可以来自数以千计的服务,使用分布式处理这些数据。
  3. 持久性:Kafka会将数据持久化到硬盘上,避免数据丢失。
  4. 高扩展性:当容量不足时,Kafka可以简单的通过增加服务器进行横向扩展。

  Redis是一个高性能的key-value数据库,处理数据的效率高,考虑到首页获取数据的响应要求较高,因此使用Redis作为缓存数据库。Redis的优点有以下几方面:

  1. 性能极高:Redis的读取速度是11万次/s,写的速度是8.1万/s。
  2. 数据类型丰富:Redis相比其他key-value数据库,能够支持很多丰富的数据结构如Strings、Hashes、Lists、Sets和Sorted Sets。
  3. 原子性:Redis支持事务,能够对所有操作进行原子操作。
  4. 数据持久化:Redis支持数据的持久化,可以将内存中的数据保存到磁盘中,避免丢失。

 

  • 使用Redis的Sorted Sets

  Redis与其他key-value产品的一个不同点是它提供了丰富的数据结构类型,包括StringsHashesListsSetsSorted Sets。用户曝光过的数据使用Soretd Sets数据结构进行维护,Sorted SetsSets类似之处是它不允许有重复的成员存在,不同的是每个元素都会关联一个double类型的分数。通过分数,Redis可以对集合中的成员进行从小到大的排序。Sorted Set的成员是唯一的,但是分数却是可以重复的,集合是通过哈希表实现的,因此,对Sorted Set进行添加、删除和查找时的时间复杂度都是O(1)Sorted Sets中最大的成员数为2^32-14294967295,差不多40多亿),score是一个64位浮点类型,范围在-90071992547409929007199254740992之间。

    既然使用Sorted Sets数据结构作为用户曝光数据的载体,那么就需要确定Sorted Setskeyscorevalue分别存储什么数据?由于key是唯一的,而每个用户的userId都是唯一的,因此可以用userId作为key来标识,但因为Redis不止一个工程用到,可能其他工程也共用该Redis集群,因此将使用“expose:userId”作为key。因为只保留用户最近两周的曝光数据,所以需要定时去删除两周前的数据,如果当前时间戳作为score,可以调用Rediszremrangebyscore命令对两周前的数据进行清理。value结构保存用户的曝光具体数据,如内容的id,内容的类型,曝光的时间戳,手机的型号等信息,由于value是字符串类型,因此将这些数据转化成json格式封装。

  • 使用到的Sorted Sets相关命令

  Sorted Sets相关命令有20多种,包括了添加、删除、排序、查询、并集等命令,本方案中使用到了3种命令:

  1. zadd:zadd命令用于添加一个或多个成员元素及其分数到有序集合中,如果某一个成员已经在Sorted Sets中存在,那么更新这个成员的分数值,并通过重新插入该成员元素,来确保成员能够在正确的位置上。zadd命令的基本语法是zadd key_name score1 value1 score2 value2 … scoren valuen。具体命令如zadd 'expose:123456' 1505524199461 'This is value',表示将key为'expose:123456',score为1505524199461,value为'This is value'的数据添加到Redis中。
  2. zremrangebyscore:zremrangebyscore命令用于移除Sorted Sets合中,指定分数区间内的成员。zremrangebyscore命令的基本语法是zremrangebyscore key_name min max,具体命令如:zremrangebyscore 'expose:123456' 1505524199461 1505634107000,表示将key为'expose:123456',并且1505524199461≤core≤1505634107000的数据删除。
  3. zrevrangebyscore:zrevrangebyscore命令用于返回Sorted Sets中指定分数区间内的所有成员数据。并且数据是按照分数值递减,即从大到小的顺序排列,让分数相同时,成员的排序是根据字典序逆向排序。具体命令如:zrevrangebyscore 'expose:123456' 1505524199461 1505524199461,表示查询key为'expose:123456',并且1505524199461≤core≤1505524199461的所有数据,返回的数据根据score逆序排序。

  • Sorted Sets的原理

  Sorted Sets内部是由字典(dict)和跳跃表(Skip List)来保证数据的存储有序。dict存放成员到score的映射,使用dict来实现的主要目的是为了保证查询复杂度为O(1),因为其内部是基于哈希表实现的。跳跃表则存放所有的成员,它的效率可以和平衡树相当,时间复杂度为O(logn),排序依据dict中的score,使用跳跃表的结构可以获得比较高的查询效率并且在实现上比较简单。

  Redis的数据结构如下所示:

//有序集数据结构
typedef struct zset {
    dict *dict;//字典存放成员和score的映射
    zskiplist *zsl;
} zset;

  由上述结构可知,zset有两个成员:dictzskiplist,其中的dict保存的是成员与score的映射,比如一个zset中存放了一个value“abc”score1505524199000的数据,则dict中的key存放“abc”value存放1505524199000

  跳跃表由zskiplistzskiplistNode组成,它们的数据结构如下所示:

//定义跳表的基本数据节点
typedef struct zskiplistNode {
    robj *obj; // zset value
    double score;// zset score
    struct zskiplistNode *backward;//后向指针
    struct zskiplistLevel {//前向指针
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

//跳跃表结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

  可以将上述的结构转化成图形,如下图所示:

  从上图可以看到header指针指向一个具有32层的表头节点,而定义成32层,因为理论上来说,2^32-1个元素的查询最优,并且2^32-1-1=4294967295,对于大部分应用来说这么多元素已经足够了。位于图片最左边的是zskiplist结构,该结构包含的属性有:

  1. header:指向跳跃表的表头节点。
  2. tail:指向跳跃表表尾的节点。
  3. level:记录目前跳跃表中,层数最大的那个节点的层次(不包括表头节点),如上图层数最大的那个节点是表尾节点,它有4层,因此level=4
  4. length:跳跃表所包含的节点数(不包括表头节点),上图中不包括表头,右侧有3个节点,因此length=3

  zskiplist右侧的四个元素为zskiplistNode结构,该机构包含以下属性:

  1. level(层):节点中L1L2等元素,L1代表第一层,L2代表第二次,以此类推。每层都有两个属性,前进指针(forward)和跨度(span),前进指针用于向后访问其他节点,跨度则记录前进指针所执行节点与当前节点的距离,用于记录节点在跳跃表中的排名。
  2. backward(后退指针):节点中的BW表示后退指针,它指向当前节点的前一个节点,后退指针用于从表尾向表头遍历。
  3. score(分值):上图节点中的20.040.040.0代表各节点的分值,score是一个double类型的浮点数,跳跃表中的所有节点都按照score从小到大排序。
  4. obj(成员对象):上图节点中的Object1Object2等代表节点所保存的成员对象,它是一个指针,指向一个字符串对象。

  跳跃表的遍历有个规则就是总是从高层开始往低层查找。例如当需要查找分值为40.0,成员对象为Object2时,查找的过程如下:

  1. 先从zskiplist中查找表头的位置;
  2. 再从表头的最上层查找,即先从L32开始查找,由于L32指向null,因此继续查找表头的L31,而L31依然指向null,依次往低层查找,直到找到L4,然后通过L4中的前进指针中到达Object3节点;
  3. 比较Object3节点的分数是否为40.0,再比较Object3对象是否为目标对象,继续往下层遍历;
  4. 通过表头的L3继续遍历,达到Object2,最后比较发现为目标对象,结束查找。

  • Sorted Sets的其他应用场景

  Sorted Sets的应用场景很多,下面介绍几种常见的场景:

  1. 关注列表和被关注者列表:可以设计两种队列,一种队列用于记录用户的关注列表,另一种用于记录关注者的列表。其中有序集合的成员为用户Id,而分值则记录了用户开始关注某人或者被某人关注的时间戳。key可以为队列标识符+当前用户id
  2. 游戏排行榜:使用排行榜名称作为key,用户的游戏分数作为score,然后使用用户的id作为value,通过zrevrange命令(因为大部分排行榜都是按照分数由高到底排序,而有序集是从小到大排序的,因此需要使用zrevrange命令)可以很快查出用户的排名信息。

网易云大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者李勇授权发布。