0x0 引子

无论做哪种业务都躲不开排行功能。Redis 的 Sorted Sets 结构就是为排行而生的。它简单易用,效率奇高。同时它也有坑,你真的了解它吗?

老规矩,先讲故事,后科普。这里是 Sorted Sets 知识点传送门。

0x1 好友推荐

事情要从这个需求开始。产品想让用户通过好友系统互动起来,那就需要个好友推荐系统,帮助用户成为好友。

具体的推荐规则大致如下:

每个用户都有自己的成就值,这个值随着时间和用户的行为而递增。成就值的大概范围是[1,3500+]。

假如用户U的成就值为A,我们就在成就值为[A-10,A+10]这个区间里,随机取10个用户,然后再按其他匹配规则筛选出来一个目标用户,推荐给用户U。

比如某用户的成就值是100,我们就在90-110这个范围内找10个用户,筛选后最终得到一个目标用户,推荐给他。

这个就是大概的需求。

我们最初的实现方式是这样的

  • 创建一个 Sorted Set 记录所有用户的 id 和其成就值,并用成就值排序
  • 每次用 zadd 去更新单个用户的成就值
  • 当需要计算推荐用户时,先用 zscore 取出其成就值A
  • 用 zcount 计算出来区间[A-10,A+10]里的用户数N
  • 如果N>10, offset 取值在[0,N-10]内随机
  • 如果N<=10, offset 取值0
  • 用 zrangebyscore A-10 A+10 limit offset 10 随机取出来10个用户
  • 筛选出目标用户

就这样,通过四个 Redis 命令,实现了这个需求的核心功能。

0x2 出事儿了

程序运行了很长一段时间都没有问题,各项指标都很正常,都没有过多地关注过它。

突然有一天夜里,线上发生了波动,触发了告警。追查了一下,发现这台 Redis 的主库 cpu 飙升到了100%,进而 failover 到了从库上。整个故障转移的过程造成了系统的波动。幸好有自动故障转移,要不然这一夜又要折腾了。

是福不是祸,是祸躲不过,还是排查问题吧。

0x3 追查真相

重点的怀疑对象就是上文中提到的四条 Redis 命令。

  • zadd
  • zscore
  • zcount
  • zrangebyscore

命令分析

这个 Sorted Set 的集合大小已经达到了两千万,我们在这个数量级上分析一下各条命令的复杂度。

zadd
  • 作用: 更新用户的成就值。
  • 时间复杂度: O(log(N)
  • 调用频率:一般
zsore
  • 作用:获取用户的成就值
  • 时间复杂度:O(1)
  • 调用频率:高
zcount
  • 作用:统计某个区间内的数量
  • 时间复杂度:O(log(N)
  • 调用频率:高
zrangebyscore
  • 作用:在指定的偏移量里取一批用户
  • 时间复杂度: O(log(N)+M)
  • 调用频率:高

通过官方对这个命令的解释发现,它的复杂度计算还挺复杂。

M指获取的用户数,这里取10,几乎可以忽略不计。

但是这个命令可以带 limit 参数,它的复杂度受 limit 应该很大。limit 很像 MySQL 里( SELECT LIMIT offset, count )的用法,如果 offset 比较大,其时间复杂度趋向于 O(N)。

这么分析下来,zrangebyscore 的嫌疑就很大了。

提审zrangebyscore

如果要证明 zrangebyscore 有问题,就得分析它的参数 offset 的可能取值是多少。

数据分析

根据上文对需求实现的描述,offset 的取值和当前用户的成就值有关系。如果这个成就值周围分布的用户比较多,那 offset 的取值就会很大。

成就段 用户数
1-10 4419701
10-20 3152015
21-30 1600366

结果发现大部分用户的成就值都分布在1-30。这也就意味着,当某个用户的成就值在1-30这个范围的时候,offset 的取值可能是500W-1KW,这个数量是巨大的。

仔细想想,这种数据是很合理的。大部分互联网用户的分布规律都是金字塔型的,越初级的用户数量越多。是我们对 Redis 认识的还不深,对用户认识的也不深。

slow log

Redis 的 showlog 命令可以获取最近一段的慢查询

showlog get 10

得到的结果全是 zrangebyscore,查询时间长达一分钟

到这里基本上可以断定是 zrangebyscore 的问题了。

0x4 优化方案

问题找到了,怎么优化呢?

为了简化问题,我们假设在算法运行的瞬间,Sorted Set 集合是不变的,也就说我们不考虑并行的问题。

如果替换掉 zrangebyscore,我们自己算排名并做跳转就行了,这里可以使用命令 zrange。

新的算法流程大致如下

  • 先用 zscore 取出其成就值A
  • 用 zcount 计算出区间[A-10,A+10]里的用户数N
  • 用 zcount 计算出区间[1,A-10)里的用户数M
  • 在[1,N-10]这个区间里随机出来一个数作为 offset
  • 用zrange取出来排名为[M+offset, M+offset+10]的10位用户
  • 筛选出目标用户

时间复杂度

根据上文,老算法的时间复杂度为 O(log(N)+offset)

新算法的时间复杂度取决与 zrange,zrange 的时间复杂度为 O(log(N))

新算法的时间复杂度为 O(log(N)),不再受 offset 的影响。

线上数据测试

从 Redis 备份里恢复一个测试用的实例,用来测试对比新旧算法。

测试 js 脚本实现如下


const MIN_SCORE_DIFF = -10;
const MAX_SCORE_DIFF = 10;
const COUNT = 10; const CACHE_KEY = 'zset_user_score'; async function oldRand(uid,random){
var score = await redisClient.zscoreAsync(CACHE_KEY, uid);
score = parseInt(score) || 0;
var min = _.max([score + MIN_SCORE_DIFF, 0]);
var max = score + MAX_SCORE_DIFF;
var cnt = await redisClient.zcountAsync(CACHE_KEY,min,max);
var offset = (cnt - COUNT > 0) ? Math.floor(random * (cnt - COUNT)) : 0;
return await redisClient.zrangebyscoreAsync(CACHE_KEY,min, max, 'limit', offset,COUNT);
} async function newRand(uid,random){
var score = await redisClient.zscoreAsync(CACHE_KEY, uid);
score = parseInt(score) || 0;
var min = _.max([score + MIN_SCORE_DIFF, 0]);
var max = score + MAX_SCORE_DIFF; var rangeCount = await redisClient.zcountAsync(CACHE_KEY,min,max);
var baseCount = 0; baseCount = await redisClient.zcountAsync(CACHE_KEY,0,min-1);
//计算随机区间
var diff = _.max(rangeCount - COUNT,0); var offset = Math.floor(random * diff);
indexStart = baseCount + offset;
var indexStop = indexStart + COUNT-1;
return await redisClient.zrangeAsync(CACHE_KEY,indexStart,indexStop); }
正确性

随机测试10次,两个算法的结果是一致的。

结果形如下面的输出

oldResult: [ 'f4973930-a48f-11e8-84e4-59b2d71a90a4',
'f49744d0-f188-11e7-a76b-c9a7594d1140',
'f4974590-c683-11e8-ae4e-0befefb1d545',
'f4975320-f94f-11e7-a09f-7ba9027c4b77',
'f4975430-83a9-11e8-81c5-332d769f65f7',
'f4976180-9c92-11e8-8e50-4b5cff93f149',
'f4979190-ef31-11e8-befa-194ed3a1a4d0',
'f49792f0-ad85-11e8-901a-c17ed1379e08',
'f497aed0-9cc2-11e8-8e50-4b5cff93f149',
'f497afa0-d6d1-11e7-8320-01f23f01ed8f' ]
time: 717 ms
newResult: [ 'f4973930-a48f-11e8-84e4-59b2d71a90a4',
'f49744d0-f188-11e7-a76b-c9a7594d1140',
'f4974590-c683-11e8-ae4e-0befefb1d545',
'f4975320-f94f-11e7-a09f-7ba9027c4b77',
'f4975430-83a9-11e8-81c5-332d769f65f7',
'f4976180-9c92-11e8-8e50-4b5cff93f149',
'f4979190-ef31-11e8-befa-194ed3a1a4d0',
'f49792f0-ad85-11e8-901a-c17ed1379e08',
'f497aed0-9cc2-11e8-8e50-4b5cff93f149',
'f497afa0-d6d1-11e7-8320-01f23f01ed8f' ]
time: 1 ms
执行效率

单位ms

old new
717 1
637 2
316 2
103 2
2 1
25 2
833 2
109 1

结论

新算法工作正常,且执行时间稳定在1-2毫秒这个量级的。是个可行的优化方案。

0x5 OPS 出手了

ops 组的同学根据上面的结论,实现了 lua 版,可以嵌入到 Redis 里执行,解决并行问题。

local score = redis.call('ZSCORE', KEYS[1], KEYS[2]) or 0
local min_score = math.max(score - 10, 0)
local max_score = score + 10
local r_count = redis.call('ZCOUNT', KEYS[1], min_score, max_score)
local b_count = redis.call('ZCOUNT', KEYS[1], 0, min_score-1)
local diff = math.max(r_count - 10, 0)
local offset = math.floor(KEYS[3]*diff)
local s_idx = b_count + offset
local e_idx = s_idx + 9
local list = redis.call('ZRANGE', KEYS[1], s_idx, e_idx)
return list

0x6 知识点

简介

Sorted Sets 是比较常用的结构。中文翻译为有序集合。英文名又叫 ZSets ,所以 Redis 以Z开头的命令都和这个结构有关系。

它和 Set 结构比较像,都不允许都重复的元素出现。它的每个元素都可以指定 score,这个 score 也是其排序的依据,如果 score 相同,就按元素的字典顺序排序。Sorted Sets 始终维护一个升序的有序集合。

相关的命令就不再做过多的介绍,大家可以去参考相关的文档。但是值得注意的是,随着 Redis 版本的变化,Sorted Sets 的某些功能也会发生变化,而且还可能有新功能出现,所以在阅读文档的时候,一定要注意其标注的版本号。编码的时候也要注意。

实现原理

Sorted Sets的内部实现依赖两种结构 ziplist 和 skiplist 。

在通过ZADD命令添加第一个元素到空 key 时,Redis 会通过检查输入的第一个元素来决定使用何种结构。

如果第一个元素符合以下条件的话, 就创建一个 ziplist 结构的 ZSet:

  • redis配置 zset_max_ziplist_entries 的值大于 0 (默认为 128 )。
  • 元素的 member 长度小于配置 zset_max_ziplist_value 的值(默认为 64 )。

否则,创建一个 skiplist 结构的 ZSet。

对于一个 ziplist 结构的 ZSet, 只要满足以下任一条件, 则会被转换为 skiplist 结构:

  • ziplist 所保存的元素数量超过配置 zset_max_ziplist_entries 的值(默认值为 128 )
  • 新添加元素的 member 的长度大于配置 zset_max_ziplist_value 的值(默认值为 64 )

ziplist 和 skiplist 这两种结构是怎么实现,如何工作的,可以参考我转载的两篇文章。

应用场景

Sorted Sets 的使用场景很多,常用的有三种。

  • 排行计算
  • 权重队列
  • 时间相关的计算
排行计算

这个是最直接的功能,而且效率很好。千万级的数据也能轻松搞定。

权重队列

这个就有点意思了,如果我们把 score 当做一种权重,把 member 当做任务id,那 Sorted Sets 就可以做为一个简单的带优先级的任务队列来用。

基于 Redis 的队列Kue,就是依赖这种结构实现的。

不过从5.0开始,Redis 推出了新结构 stream ,是专门用来做消息队列的,这是后话。

时间相关的计算

巧妙地处理基于时间的数据。

比如统计最近5分钟的活跃用户数,只需要把用户最后的活跃时间作为 score,就可以计算最近一段时间的活跃用户数了。

比如获取最新的5条评论,也是同样的道理。

比如实现 LRU 算法。

其他

其他的应用场景还有很多。

比如我们在好友推荐里使用的方式,其实就是查找一定区间内的不同用户。

还有一种用法是聚合,就是求交集、并集、差集。涉及到的命令有 ZINTERSTORE,ZUNIONSTORE 等。


更多redis系列

redis系列-要命的zrangebyscore的更多相关文章

  1. Redis系列(2)之数据类型

    Redis系列(2)之数据类型 <Redis系列(1)之安装>中介绍了Redis支持以下几种数据类型,那么本节主要介绍学习下这几种数据类型的基本操作 字符串类型,string 散列类型,h ...

  2. redis 系列14 有序集合对象

    一. 有序集合概述 Redis 有序集合对象和集合对象一样也是string类型元素的集合,且不允许重复的成员.不同的是每个元素都会关联一个double类型的分数.redis正是通过分数来为集合中的成员 ...

  3. Redis系列(二):Redis的数据类型及命令操作

    原文链接(转载请注明出处):Redis系列(二):Redis的数据类型及命令操作 Redis 中常用命令 Redis 官方的文档是英文版的,当然网上也有大量的中文翻译版,例如:Redis 命令参考.这 ...

  4. Redis 系列(02)数据结构

    目录 Redis 系列(02)数据结构 Redis 系列目录 1. String 1.1 基本操作 1.2 数据结构 1.3 Redis数据存储结构 2. Hash 2.1 基本操作 2.2 数据结构 ...

  5. Redis系列(二):Redis的5种数据结构及其常用命令

    上一篇博客,我们讲解了什么是Redis以及在Windows和Linux环境下安装Redis的方法, 没看过的同学可以点击以下链接查看: Redis系列(一):Redis简介及环境安装. 本篇博客我们来 ...

  6. redis系列-开篇

    0x0 缘起 笔者所在的公司有一款大DAU(日活)的休闲游戏.这款游戏的后端架构很简单,可以简单理解为通讯-逻辑-存储三层结构.其中存储层大量使用了redis和mysql. 虽然存量用户的增加,red ...

  7. Redis系列-存储篇sorted set主要操作命令

    Redis系列-存储篇sorted set主要操作函数小结 redis支持有序集合,即sorted set.sorted set在set的基础上,增加了排序属性,是set的升级版.这里简要谈谈sort ...

  8. Redis系列之key操作命令与Redis中的事务详解(六)

    序言 本篇主要目的有二: 1.展示所有数据类型中key的所有操作命令,以供大家学习,查阅,更深入的挖掘redis潜力. 2.掌握redis中的事务,让你的数据完整性一致性拥有更优的保障. redis命 ...

  9. Redis系列(六)-SortedSets设计技巧

    阅读目录: 介绍 Score占位 更多位信息 总结 介绍 Redis Sorted Sets是类似Redis Sets数据结构,不允许重复项的String集合.不同的是Sorted Sets中的每个成 ...

随机推荐

  1. 关于爬虫的日常复习(17)——scrapy系列2

  2. 公文流转系统v0.1

    河北金力集团公文流转系统 1.项目需求: 河北金力集团是我省机械加工的龙头企业,主要从事矿山机械制造及各种机械零部件加工.企业有3个厂区,主厂区位于省高新技术开发区,3个分厂分别在保定.邢台和唐山.为 ...

  3. pycharm 安装vue

    1.设置JS为ES6 2.安装vue.js 3.重启pycharm 4.检查

  4. Jenkins配置邮件发送测试报告

    前言 在之前的文章(Jenkins自动执行python脚本输出测试报告)中,我们已成功实现利用Jenkins自动执行python脚本,输出并可直接在界面上查看测试报告,这里我们还差最后一步,我们需要将 ...

  5. 动画 | 什么是平衡二分搜索树(AVL)?

    二分搜索树又名有序二叉查找树,它有一个特点是左子树的节点值要小于父节点值,右子树的节点值要大于父节点值.基于这样的特点,我们在查找某个节点的时候,可以采取二分查找的思想快速找到这个节点,时间复杂度期望 ...

  6. python条件与循环-循环

    1 while语句 while用于实现循环语句,通过判断条件是否为真,来决定是否继续执行. 1.1 一般语法 语法如下: while expression: suite_to_repeat 1.2 计 ...

  7. 「雅礼集训 2017 Day2」棋盘游戏

    祝各位圣诞后快乐(逃) 题目传送门 分析: 首先棋盘上的路径构成的图是一张二分图 那么对于一个二分图,先求出最大匹配,先手如果走到关键匹配点,只要后手顺着匹配边走,由于不再会出现增广路径,所以走到最后 ...

  8. 安装 Xen

    安装 Xen 安装支持 Xen 的相关工具: $ sudo apt-get install ubuntu-xen-server 下载和安装支持 Xen 的 Linux 内核: http://secur ...

  9. 亲测可用!在线购书系统项目分享(Java)

    项目简介 项目来源于:https://gitee.com/suimz_admin/BookShop 一个基于JSP+Servlet+Jdbc的书店系统.涉及技术少,易于理解,适合JavaWeb初学者学 ...

  10. python数据类型之字典操作

    Python字典是另一种可变容器模型,且可存储任意类型对象,如字符串.数字.元组等其他容器模型.一.创建字典字典由key和value成对组成.基本语法如下: infos = {"name&q ...