极客时间已完结课程限时免费阅读

10 | 存储模块:如何用Redis解决推荐系统特征的存储问题?

10 | 存储模块:如何用Redis解决推荐系统特征的存储问题?-极客时间

10 | 存储模块:如何用Redis解决推荐系统特征的存储问题?

讲述:王喆

时长13:24大小12.23M

你好,我是王喆。今天,我们来解决系统特征的存储问题。
在特征工程篇我们说过,在推荐系统这个大饭馆中,特征工程就是负责配料和食材的厨师,那我们上堂课搭建的推荐服务器就是准备做菜的大厨。配料和食材准备好了,做菜的大厨也已经开火热锅了,这时候我们得把食材及时传到大厨那啊。这个传菜的过程就是推荐系统特征的存储和获取过程。
可是我们知道,类似 Embedding 这样的特征是在离线环境下生成的,而推荐服务器是在线上环境中运行的,那这些离线的特征数据是如何导入到线上让推荐服务器使用的呢?
今天,我们先以 Netflix 的推荐系统架构为例,来讲一讲存储模块在整个系统中的位置,再详细来讲推荐系统存储方案的设计原则,最后以 Redis 为核心搭建起 Sparrow Recsys 的存储模块。

推荐系统存储模块的设计原则

你还记得,我曾在第 1 讲的课后题中贴出过 Netflix 推荐系统的架构图(如图 1)吗?Netflix 采用了非常经典的 Offline、Nearline、Online 三层推荐系统架构。架构图中最核心的位置就是我在图中用红框标出的部分,它们是三个数据库 Cassandra、MySQL 和 EVcache,这三个数据库就是 Netflix 解决特征和模型参数存储问题的钥匙。
图1 Netflix推荐系统架构中的特征与模型数据库
你可能会觉得,存储推荐特征和模型这件事情一点儿都不难啊。不就是找一个数据库把离线的特征存起来,然后再给推荐服务器写几个 SQL 让它取出来用不就行了吗?为什么还要像 Netflix 这样兴师动众地搞三个数据库呢?
想要搞明白这个问题,我们就得搞清楚设计推荐系统存储模块的原则。对于推荐服务器来说,由于线上的 QPS 压力巨大,每次有推荐请求到来,推荐服务器都需要把相关的特征取出。这就要求推荐服务器一定要“快”。
不仅如此,对于一个成熟的互联网应用来说,它的用户数和物品数一定是巨大的,几千万上亿的规模是十分常见的。所以对于存储模块来说,这么多用户和物品特征所需的存储量会特别大。这个时候,事情就很难办了,又要存储量大,又要查询快,还要面对高 QPS 的压力。很不幸,没有一个独立的数据库能经济又高效地单独完成这样复杂的任务。
因此,几乎所有的工业级推荐系统都会做一件事情,就是把特征的存储做成分级存储,把越频繁访问的数据放到越快的数据库甚至缓存中,把海量的全量数据放到便宜但是查询速度较慢的数据库中。
举个不恰当的例子,如果你把特征数据放到基于 HDFS 的 HBase 中,虽然你可以轻松放下所有的特征数据,但要让你的推荐服务器直接访问 HBase 进行特征查询,等到查询完成,这边用户的请求早就超时中断了,而 Netflix 的三个数据库正好满足了这样分级存储的需求。
图2 分级存储的设计
比如说,Netflix 使用的 Cassandra,它作为流行的 NoSQL 数据库,具备大数据存储的能力,但为支持推荐服务器高 QPS 的需求,我们还需要把最常用的特征和模型参数存入 EVcache 这类内存数据库。而对于更常用的数据,我们可以把它们存储在 Guava Cache 等服务器内部缓存,甚至是服务器的内存中。总之,对于一个工程师来说,我们经常需要做出技术上的权衡,达成一个在花销和效果上平衡最优的技术方案。
而对于 MySQL 来说,由于它是一个强一致性的关系型数据库,一般存储的是比较关键的要求强一致性的信息,比如物品是否可以被推荐这种控制类的信息,物品分类的层级关系,用户的注册信息等等。这类信息一般是由推荐服务器进行阶段性的拉取,或者利用分级缓存进行阶段性的更新,避免因为过于频繁的访问压垮 MySQL。
总的来说,推荐系统存储模块的设计原则就是“分级存储,把越频繁访问的数据放到越快的数据库甚至缓存中,把海量的全量数据放到廉价但是查询速度较慢的数据库中”。

SparrowRecsys 的存储系统方案

那在我们要实现的 SparrowRecsys 中,存储模块的设计原则又是怎么应用的呢?
在 SparrowRecsys 中,我们把存储模块的设计问题进行了一些简化,避免由于系统设计得过于复杂导致你不易上手。
我们使用基础的文件系统保存全量的离线特征和模型数据,用 Redis 保存线上所需特征和模型数据,使用服务器内存缓存频繁访问的特征。
在实现技术方案之前,对于问题的整体分析永远都是重要的。我们需要先确定具体的存储方案,这个方案必须精确到哪级存储对应哪些具体特征和模型数据。
存储的工具已经知道了,那特征和模型数据分别是什么呢?这里,我们直接应用特征工程篇为 SparrowRecsys 准备好的一些特征就可以了。我把它们的具体含义和数据量级整理成了表格,如下:
图3 特征和模型数据
根据上面的特征数据,我们一起做一个初步的分析。首先,用户特征的总数比较大,它们很难全部载入到服务器内存中,所以我们把用户特征载入到 Redis 之类的内存数据库中是合理的。其次,物品特征的总数比较小,而且每次用户请求,一般只会用到一个用户的特征,但为了物品排序,推荐服务器需要访问几乎所有候选物品的特征。针对这个特点,我们完全可以把所有物品特征阶段性地载入到服务器内存中,大大减少 Redis 的线上压力。
最后,我们还要找一个地方去存储特征历史数据、样本数据等体量比较大,但不要求实时获取的数据。这个时候分布式文件系统(单机环境下以本机文件系统为例)往往是最好的选择,由于类似 HDFS 之类的分布式文件系统具有近乎无限的存储空间,我们可以把每次处理的全量特征,每次训练的 Embedding 全部保存到分布式文件系统中,方便离线评估时使用。
经过上面的分析,我们就得到了具体的存储方案,如下表:
图4 SparrowRecsys的存储方案
此外,文件系统的存储操作非常简单,在 SparrowRecsys 中就是利用 Spark 的输出功能实现的,我们就不再重点介绍了。而服务器内部的存储操作主要是跟 Redis 进行交互,所以接下来,我们重点介绍 Redis 的特性以及写入和读取方法。

你需要知道的 Redis 基础知识

Redis 是当今业界最主流的内存数据库,那在使用它之前,我们应该清楚 Redis 的两个主要特点。
一是所有的数据都以 Key-value 的形式存储。 其中,Key 只能是字符串,value 可支持的数据结构包括 string(字符串)、list(链表)、set(集合)、zset(有序集合) 和 hash(哈希)。这个特点决定了 Redis 的使用方式,无论是存储还是获取,都应该以键值对的形式进行,并且根据你的数据特点,设计值的数据结构。
二是所有的数据都存储在内存中,磁盘只在持久化备份或恢复数据时起作用。这个特点决定了 Redis 的特性,一是 QPS 峰值可以很高,二是数据易丢失,所以我们在维护 Redis 时要充分考虑数据的备份问题,或者说,不应该把关键的业务数据唯一地放到 Redis 中。但对于可恢复,不关乎关键业务逻辑的推荐特征数据,就非常适合利用 Redis 提供高效的存储和查询服务。
在实际的 Sparrow Recsys 的 Redis 部分中,我们用到了 Redis 最基本的操作,set、get 和 keys,value 的数据类型用到了 string。

Sparrow Recsys 中的 Redis 部分的实践流程

Redis 的实践流程还是符合我们“把大象装冰箱”的三部曲,只不过,这三步变成了安装 Redis,把数据写进去,把数据读出来。下面,我们来逐一来讲。
首先是安装 Redis。 Redis 的安装过程在 linux/Unix 环境下非常简单,你参照官方网站的步骤依次执行就好。Windows 环境下的安装过程稍复杂一些,你可以参考这篇文章进行安装。
在启动 Redis 之后,如果没有特殊的设置,Redis 服务会默认运行在 6379 端口,没有特殊情况保留这个默认的设置就可以了,因为我们的 Sparrow RecSys 也是默认从 6379 端口存储和读取 Redis 数据的。
然后是运行离线程序,通过 jedis 客户端写入 Redis。 在 Redis 运行起来之后,我们就可以在离线 Spark 环境下把特征数据写入 Redis。这里我们以[第 8 讲 (https://time.geekbang.org/column/article/296932) 中生成的 Embedding 数据为例,来实现 Redis 的特征存储过程。
实际的过程非常简单,首先我们利用最常用的 Redis Java 客户端 Jedis 生成 redisClient,然后遍历训练好的 Embedding 向量,将 Embedding 向量以字符串的形式存入 Redis,并设置过期时间(ttl)。具体实现请参考下面的代码(代码参考 com.wzhe.sparrowrecsys.offline.spark.featureeng.Embedding 中的 trainItem2vec 函数):
if (saveToRedis) {
//创建redis client
val redisClient = new Jedis(redisEndpoint, redisPort)
val params = SetParams.setParams()
//设置ttl为24小时
params.ex(60 * 60 * 24)
//遍历存储embedding向量
for (movieId <- model.getVectors.keys) {
//key的形式为前缀+movieId,例如i2vEmb:361
//value的形式是由Embedding向量生成的字符串,例如 "0.1693846 0.2964318 -0.13044095 0.37574086 0.55175656 0.03217995 1.327348 -0.81346786 0.45146862 0.49406642"
redisClient.set(redisKeyPrefix + ":" + movieId, model.getVectors(movieId).mkString(" "), params)
}
//关闭客户端连接
redisClient.close()
}
最后是在推荐服务器中把 Redis 数据读取出来。
在服务器端,根据刚才梳理出的存储方案,我们希望服务器能够把所有物品 Embedding 阶段性地全部缓存在服务器内部,用户 Embedding 则进行实时查询。这里,我把缓存物品 Embedding 的代码放在了下面。
你可以看到,它的实现的过程也并不复杂,就是先用 keys 操作把所有物品 Embedding 前缀的键找出,然后依次将 Embedding 载入内存。
//创建redis client
Jedis redisClient = new Jedis(REDIS_END_POINT, REDIS_PORT);
//查询出所有以embKey为前缀的数据
Set<String> movieEmbKeys = redisClient.keys(embKey + "*");
int validEmbCount = 0;
//遍历查出的key
for (String movieEmbKey : movieEmbKeys){
String movieId = movieEmbKey.split(":")[1];
Movie m = getMovieById(Integer.parseInt(movieId));
if (null == m) {
continue;
}
//用redisClient的get方法查询出key对应的value,再set到内存中的movie结构中
m.setEmb(parseEmbStr(redisClient.get(movieEmbKey)));
validEmbCount++;
}
redisClient.close();
这样一来,在具体为用户推荐的过程中,我们再利用相似的接口查询出用户的 Embedding,与内存中的 Embedding 进行相似度的计算,就可以得到最终的推荐列表了。
如果你已经安装好了 Redis,我非常推荐你运行 SparrowRecsys 中 Offline 部分 Embedding 主函数,先把物品和用户 Embedding 生成并且插入 Redis(注意把 saveToRedis 变量改为 true)。然后再运行 Online 部分的 RecSysServer,看一下推荐服务器有没有正确地从 Redis 中读出物品和用户 Embedding 并产生正确的推荐结果(注意,记得要把 util.Config 中的 EMB_DATA_SOURCE 配置改为 DATA_SOURCE_REDIS)。
当然,除了 Redis,我们还提到了多种不同的缓存和数据库,如 Cassandra、EVcache、GuavaCache 等等,它们都是业界非常流行的存储特征的工具,你有兴趣的话也可以在课后查阅相关资料进行进一步的学习。在掌握了我们特征存储的基本原则之后,你也可以在业余时间尝试思考一下每个数据库的不同和它们最合适的应用场景。

小结

今天我们学习了推荐系统存储模块的设计原则和具体的解决方案,并且利用 Sparrow Recsys 进行了实战。
在设计推荐系统存储方案时,我们一般要遵循“分级存储”的原则,在开销和性能之间取得权衡。在 Sparrow Recsys 的实战中,我们安装并操作了内存数据库 Redis,你要记住 Redis 的特点“Key-value 形式存储”和“纯内存数据库”。在具体的特征存取过程中,我们应该熟悉利用 jedis 执行 SET,GET 等 Redis 常用操作的方法。
最后,我也把重要的知识点总结在了下面,你可以再回顾一下。
对于搭建一套完整的推荐服务来说,我们已经迈过了两大难关,分别是用 Jetty Server 搭建推荐服务器问题,以及用 Redis 解决特征存储的问题。下节课,我们会一起来挑战线上服务召回层的设计。

课后思考

你觉得课程中存储 Embedding 的方式还有优化的空间吗?除了 string,我们是不是还可以用其他 Redis value 的数据结构存储 Embedding 数据,那从效率的角度考虑,使用 string 和使用其他数据结构的优缺点有哪些?为什么?
欢迎把你的思考和答案写在留言区,也欢迎你把这节课分享给你的朋友,我们下节课见!
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 16

提建议

上一篇
09 | 线上服务:如何在线上提供高并发的推荐服务?
下一篇
11 | 召回层:如何快速又准确地筛选掉不相关物品?
 写留言

精选留言(25)

  • An
    2020-10-26
    redis keys命令不能用在生产环境中,如果数量过大效率十分低,导致redis长时间堵塞在keys上。

    作者回复: 非常好的点。生产环境我们一般选择提前载入一些warm up物品id的方式载入物品embedding。这里做了一个简化,推荐大家参考这条评论,多谢!

    共 2 条评论
    52
  • AIGeek
    2020-10-26
    Redis value 可以用pb格式存储, 存储上节省空间. 解析起来相比string, cpu的效率也应该会更高

    作者回复: 生产环境确实经常使用protobuf进行压缩,非常好的经验。

    共 4 条评论
    39
  • 范闲
    2020-11-26
    1.redis这种缓存中尽量放活跃的数据,存放全量的embedding数据,对内存消耗太大。尤其物品库,用户embedding特别多的情况下。 2.分布式kv可以做这种embedding的存储 3.关于embedding的编码可以用pb来解决。embedding维度太大的时候,redis里的数据结构占用空间会变大,因为除了embedding本身的空间,还有数据结构本身占用的空间。

    作者回复: 优秀。非常好的经验之谈,推荐其他同学学习。

    共 4 条评论
    31
  • fsc2016
    2020-11-04
    老师,有俩个问题 1,文中关于RecForYou,是来一个用户访问,就把用户的embding存入推荐服务器内存,如果一个短时间一下来百万级用户,都存入服务器内存,这样会不会出问题,优化的话应当也可以对用户分级,活跃用户存下来,非活跃其他还是从Redis实时读取用户特征。 2,RecForYou中,给用户推荐电影,使用的用户embding和候选电影embding的余弦距离来排序,这俩个不同维度embding计算余弦相似度有意义嘛,还是因为本例子中用户embding由其看过的电影embbding 相加来的。所以这么做嘛
    展开

    作者回复: 这两个问题都是非常好的问题,推荐其他同学思考。 1. 我们并没有把用户embedding保存在内存中,只是把item embedding提前load到内存里,所以其实不存在这样的情况。但你说的也是非常好的用户数据缓存的方案,我们一般会指定一个用户内存区域的大小,用FIFO的方案来缓存,这样内存用完了,就自动把早进来的用户pop出去。 另外分级的想法也非常好,如果有条件可以判断活跃用户,可以尽量选择活跃用户进行缓存。 2、你说的没错,用户emb和物品emb必须在一个向量空间内才能够做相似度计算。咱们项目中的用户emb是通过item emb平均生成的,所以可以这样计算。

    20
  • Lucifer
    2020-11-23
    向量索引faiss
    共 1 条评论
    7
  • Geek_ddf8b1
    2020-12-06
    用户特征分为长期兴趣特征和实时兴趣特征,长期兴趣特征一般是按天更新,实时特征可能按分钟或者秒级更新。请问实际项目中是长期特征按天更新写入redis,短期特征分钟级更新写入redis这样吗?

    作者回复: 是这样,长期兴趣或这说周期比较长的metadata特征,按天写入特征数据库,实时特征进行实时更新。

    6
  • shenhuaze
    2020-11-04
    王老师,想问一下关于全量特征存储的数据库选型。业界用来存储全量特征的最主流的数据库是什么?Cassandra吗?HBase是否合适?

    作者回复: 一般来说Cassandra的读性能会比HBase好很多,包括类似的AWS用的dynamoDB,现在用得多一些。 但也有对HBase的读性能做优化的,比如加缓存,做一些读取命令的优化,但作为服务线上的实时数据库,确实会用的少一些。

    6
  • Geek_b6bf29
    2021-01-08
    老师你好,关于这一步 “我们完全可以把所有物品特征阶段性地载入到服务器内存中,大大减少 Redis 的线上压力。” 该如何具体操作呢。比如离线计算每6个小时更新物品特征,是不是在线服务也要重启更新,把最新的物品特征载入服务器?还是有更好的方法,可以支持热更新,不用重启在线服务?

    作者回复: 在线服务内部可以有各种载入和维护feature的缓存逻辑。最简单比如设置一个timer去定期load热门的新feature。不用重启服务器。

    5
  • 张宏宇
    2021-03-17
    老师,我想问的是特征在更新的时候可能发生数据不一致的情况,比如用户特征先更新,物品特征后更新,在两个特征更新过程中线上服务读取特征数据的时候,可能用户特征是新的,物品特征是老的,不知道老师是否遇到过这样的问题以及如何解决的,谢谢!

    作者回复: 这个肯定会存在。但我觉得要点还得具体问题具体分析,要看一下物品和用户特征有没有必要完全协同的更新,比如物品历史ctr这个特征,完全可以独立更新。 如果一定要一起更新,那么就只能在streaming平台上每次都协同更新这些特征。 我个人觉得有一些秒级、分钟级的差异,影响不会那么大,没有那么关键。

    4
  • Jackie
    2020-11-03
    文中说把物品特征放到服务器中,“我们完全可以把所有物品特征阶段性地载入到服务器内存中,大大减少 Redis 的线上压力。”,那如果物品也特别多,不也放不下吗?

    作者回复: 那按照咱们这节课讲的分级存储的原则,内存里面放不下,应该怎么解决这个问题呢?能不能放一些经常访问的在内存里,长尾的放在其他地方?或者经常用的特征放内存里等等方法?

    共 2 条评论
    2
  • 浣熊当家
    2020-10-26
    有个很小白的问题请问老师, 我们在IntelliJ的Maven porject里用到的工具比如spark, redis, 这些需要我们额外下载安装到电脑上吗?还是说在Maven项目中已经通过代码添加依赖,就已经完成了安装?

    作者回复: spark本质上是一个java lib,所以可以被maven安装依赖。 redis是一个数据库,需要按照文中的方式安装到电脑上。

    2
  • 浣熊当家
    2020-10-26
    请问老师,文中的两部分redis相关的代码,可以在Maven项目中找到吗?老师可不可以提供以下路径信息方便找到?

    作者回复: 可以,请参照 com.wzhe.sparrowrecsys.offline.spark.embedding.Embedding中的trainItem2vec函数 以及com.wzhe.sparrowrecsys.online.datamanager.DataManager中的loadMovieEmb函数

    2
  • dandy
    2022-01-28
    老师你们这边是把item embedding存到内存,那用户存的是原始特征还是embedding后的存到redis呢,如果是原始特征是否需要特征抽取呢。一般现在业内是怎么处理的。 我们内部系统是item和user都是存的原始特征,后面做特征抽取,在embedding
    1
  • 胡译匀
    2021-01-10
    请问如何调试scala程序?网上找了半天也没靠谱的,谢谢。

    作者回复: scala 程序本质上就是java程序,在IDEA里面的调试方法和java一样

    1
  • romance
    2020-12-17
    老师,Embedding 主函数要装了Spark才能跑起来吧?

    作者回复: 只要正确安装了scala并且配置好了maven环境,能够引入spark的lib,就可以跑起来。项目的默认环境是单机环境,不需要任何外部spark环境

    1
  • Geek_ddf8b1
    2020-12-06
    为保证线上请求特征和线下日志特征数据一致性,用户线上请求时用户特征和物品特征是从redis查询得到后写到日志文件吗? 这时用户的实时特征比如过去几分钟点击的物品序列特征是从kafka读取还是从redis读取? 还是kafka发送用户的行为物品序列数据每隔几分钟写入redis,然后线上请求获取特征做预估打分和写特征数据到日志文件统一从redis读取?
    展开

    作者回复: 不可能直接从kafka读取的,线上系统不可能接入任何流处理系统。需要以redis这类特征数据库作为所有数据的线上中转存储。

    1
  • ꧁꫞꯭R꯭e꯭i꯭r꯭i...
    2023-01-20 来自江西
    ssdb如何?
  • 雨尽书声响
    2022-09-01 来自北京
    老师您好,文中说把物品特征放到服务器中,“我们完全可以把所有物品特征阶段性地载入到服务器内存中,大大减少 Redis 的线上压力。”,物品特征存入内存大约多少条会比较合适那
  • Calmness
    2022-02-26
    老师,服务器内存不够的时候放在内存数据库,但是内存数据库不也全部都是建立在内存的基础上的吗?那如果服务器内存不够了放在内存数据库里不也不够吗?还有那个阶段性的物品特征该怎么理解呀?能举个例子吗
  • Geek_ddf8b1
    2021-09-02
    老师 请教一下 推荐项目中用户有几千万,几千万用户的特征(部分是用户实时行为特征)全部存在redis中,key太多会导致redis查询性能直线下降,请问这个问题如何解决?

    作者回复: 分级cache,区分活跃用户非活跃用户