在工作中,需要将用户离线的推荐商品打分批量存储到redis上,其数据量达到50亿(粒度为用户-商品),数据存储于hive或者来源于spark的DataFrame。本文将介绍如何用pyspark将数据存到redis,并优化缩短其运行时间。

1、开始的地方

在推荐场景中,通常需要取的是用户最喜欢的TOP-N个商品,首先想到的redis数据结构就是有序集合,通常使用zadd函数来添加元素。表tmp_user_sku存储了用户对商品的打分排名情况,处于测试需要,先把数据限制在1千万。代码如下:

sdf = spark.sql("""select a.user_id,a.sku_id,a.order_id                   from tmp_user_sku a                """) # 读取数据,每行数据格式为用户-商品-排名sdf.rdd.foreachPartition(store_to_redis) # 逐行存储至redisdef store_to_redis(x):    import redis    conn = redis.StrictRedis(host='xx', port=xx, db=0, password='xx')    for i in x:        user_id = i[0]        key = "s1:"+user_id        conn.zadd(key, {i[1]:i[2]})

此段代码提交运行后,不到20分钟,就被我手动停止了,原因是效率实在太慢。如下图是spark中stage的task的处理情况,这里表示有两个任务在并行向redis写入数据,records即已经写入的数据,由于表tmp_user_sku数据量比较小,spark只有两个分区,按这个速度,即使只有1千万的数据也需要将近3小时的时间。

spark redis通讯协议 spark 写入redis_spark

直接使用repartition重新分区(spark提交的时候设置了50个executor,每个executor 8G内存,以下都是),代码如下:

sdf.rdd.repartition(50).foreachPartition(store_to_redis)

效果如下图:

spark redis通讯协议 spark 写入redis_有序集合_02

速度来到1千万/10min,如果同步50亿数据,时间上仍然不能接受。

2、使用redis-pipeline

redis-pipeline的具体介绍见 https://redis.io/topics/pipelining ,使用pipeline可以使多条命令只调用一次操作系统读调用和操作系统写系统,从而达到提升性能的目的。具体代码也很简单:

sdf = spark.sql("""select a.user_id,a.sku_id,a.order_id                   from tmp_user_sku a                """) # 读取数据,每行数据格式为用户-商品-打分排名sdf.rdd.repartition(50).foreachPartition(store_to_redis)def store_to_redis(x):    import redis    conn = redis.StrictRedis(host='xx', port=xx, db=0, password='xx')    cnt = 0    pipe = conn.pipeline() # 使用redis的pipeline    for i in x:        cnt += 1        user_id = i[0]        key = "s1:"+user_id        pipe.zadd(key, {i[1]:i[2]})        # 每10000条命令提交一次        if cnt%10000==0:            cnt = 0            pipe.execute()    pipe.execute()

效果如下图,直接提升了10倍:

spark redis通讯协议 spark 写入redis_spark_03

3、先拼接redis有序集合中的mapping

翻阅python中redis包的代码,zadd方法的第二个参数mapping是一个dict类型。

spark redis通讯协议 spark 写入redis_redis 2m数据读取_04

python-redis包中zadd方法 

那我们可以先行拼接每个用户的{商品:打分排名,..}的dict,然后再zadd提交。注意sparkSql读取出来的数据格式的改变。代码如下:

sdf = spark.sql("""select                   a.user_id,                   collect_set(concat(sku_id,':',order_id)) as skus                   from tmp_user_sku a                   group by a.user_id                """) # 读取数据,每行数据格式为用户-商品集合,商品集合中的字段串格式为商品:打分排名sdf.rdd.foreachPartition(store_to_redis)def store_to_redis(x):    import redis    conn = redis.StrictRedis(host='xx', port=xx, db=0, password='xx')    cnt = 0    pipe = conn.pipeline()    for i in x:        cnt += 1        user_id = i[0]        key = "s1:"+user_id        skus = {}        for sku in i[1]:            sku_sp = sku.split(":")            skus[sku_sp[0]]=sku_sp[1]        pipe.zadd(key, skus)        # 每个用户200个商品,方便对比,所以设置成10000/200=50条提交一次        if cnt%50==0:            cnt = 0            pipe.execute()    pipe.execute()

为了方便对比,将表tmp_user_sku数据调整至1亿。只使用pipeline的方式,效果如下:

spark redis通讯协议 spark 写入redis_redis_05

使用pipeline+先行拼接mapping的方式,效果如下:

spark redis通讯协议 spark 写入redis_spark redis通讯协议_06

可见pipeline+先行拼接mapping有轻微的提升,具体原因并未找到对应说明。

4、用字符串类型代替redis的有序集合

这种方法,是无心插柳的结果,也是最后选定的方法。我们使用字符串构建一个集合,其中集合的元素间使用逗号分隔,每个元素的member及score使用member+":"+score的形式,由于这里的用户和商品均使用纯数字表示,无需担心被拼接对象包含逗号或者冒号,代码如下:

sdf = spark.sql("""select                   a.user_id,                   collect_set(concat(sku_id,':',order_id)) as skus                   from tmp_user_sku a                   group by a.user_id                """) # 读取数据,每行数据格式为用户-商品集合,商品集合中的字段串格式为商品:打分排名sdf.rdd.foreachPartition(store_to_redis)def store_to_redis(x):    import redis    conn = redis.StrictRedis(host='xx', port=xx, db=0, password='xx')    cnt = 0    pipe = conn.pipeline()    for i in x:        cnt += 1        user_id = i[0]        key = "s1:"+user_id        skus=""        for sku in i[1]:            skus += sku+','        pipe.set(key, skus)        if cnt%50==0:            cnt = 0            pipe.execute()    pipe.execute()

效果如下图,一亿数据只使用了将近30秒,又一次质的提升:

spark redis通讯协议 spark 写入redis_spark_07

这里笔者曾经在sparksql里面用concat_ws拼接skus,但是spark中的stage-0耗时比自己写的拼接方法(skus += sku+',' for sku in i[1])要长。

有些读者可能会问,这样改变数据结构后,在读取的时候,速度是否一致呢?笔者也做了一个对比,效果如下,上图为读取字符串进行解析的代码及运行时间,下图为读取有序集合的代码及运行时间:

spark redis通讯协议 spark 写入redis_有序集合_08

spark redis通讯协议 spark 写入redis_redis_09

在集合数据量为200的情况下,使用string类型,读取后自行解析成商品集合的速度居然比直接读取有序集合快!并且查看reids内存使用情况,使用string类型时空间较小!这里笔者也没有找到相关的文档说明,读者们可自行试验,有不同结果欢迎留言讨论。

5、总结

这里对之前做的种种试验的数据进行汇总:

方法

数据量

耗时

有序集合zadd直接提交(并行2个进程写入)

1千万

约180分钟

有序集合zadd直接提交(并行50个进程写入)

1千万

约9.1分钟

有序集合引入pipeline(并行50)

1千万

约1分钟

有序集合引入pipeline(并行50)

1亿

约7.6分钟

有序集合引入pipeline+提前拼接mapping(并行50)

1亿

约4.6分钟

改变有序集合的数据类型为string(并行50)

1亿

约26秒

改变有序集合的数据类型为string (并行50)

50亿

约8.6分钟

总结如下几点:

  1. 使用redis-pipeline能大幅提升批量写redis的效率。
  2. 在批量写入redis有序集合的时候,可以先行构建value部分的mapping。
  3. 在批量写入的情景下(非更新部分元素),采用字符串拼接的方式代替有序集合,可提升性能。
  4. 后续可根据场景尝试改变redis的持久化策略,来提升写入速度。

团队介绍:我们是名创优品信息科技中心的智能应用部,是一支专业的数据科学团队,微信公众号(数据天团)每周日推送一篇原创数据科学文章。我们的作品都由项目经验丰富的工程师精心准备,分享结合实际业务的理论应用和心得体会。欢迎大家关注我们的微信公众号,关注我们的数据科学精品文章。