在工作中,需要将用户离线的推荐商品打分批量存储到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小时的时间。
直接使用repartition重新分区(spark提交的时候设置了50个executor,每个executor 8G内存,以下都是),代码如下:
sdf.rdd.repartition(50).foreachPartition(store_to_redis)
效果如下图:
速度来到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倍:
3、先拼接redis有序集合中的mapping
翻阅python中redis包的代码,zadd方法的第二个参数mapping是一个dict类型。
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的方式,效果如下:
使用pipeline+先行拼接mapping的方式,效果如下:
可见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秒,又一次质的提升:
这里笔者曾经在sparksql里面用concat_ws拼接skus,但是spark中的stage-0耗时比自己写的拼接方法(skus += sku+',' for sku in i[1])要长。
有些读者可能会问,这样改变数据结构后,在读取的时候,速度是否一致呢?笔者也做了一个对比,效果如下,上图为读取字符串进行解析的代码及运行时间,下图为读取有序集合的代码及运行时间:
在集合数据量为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分钟 |
总结如下几点:
- 使用redis-pipeline能大幅提升批量写redis的效率。
- 在批量写入redis有序集合的时候,可以先行构建value部分的mapping。
- 在批量写入的情景下(非更新部分元素),采用字符串拼接的方式代替有序集合,可提升性能。
- 后续可根据场景尝试改变redis的持久化策略,来提升写入速度。
团队介绍:我们是名创优品信息科技中心的智能应用部,是一支专业的数据科学团队,微信公众号(数据天团)每周日推送一篇原创数据科学文章。我们的作品都由项目经验丰富的工程师精心准备,分享结合实际业务的理论应用和心得体会。欢迎大家关注我们的微信公众号,关注我们的数据科学精品文章。