Redis 深度历险: 核心原理和应用实践

目录

1.Redis 可以做什么? 

2.基础:万丈高楼平地起 ——Redis 基础数据结构 

string (字符串)  

list (列表) 

hash (字典) 

set (集合) 

zset (有序列表)  

容器型数据结构的通用规则 

过期时间 

应用 1:千帆竞发 —— 分布式锁 

分布式锁 

应用 2:缓兵之计 —— 延时队列

应用 3:节衣缩食 —— 位图 

应用 4:四两拨千斤 —— HyperLogLog 

应用 5:层峦叠嶂 —— 布隆过滤器 

应用 6:断尾求生 —— 简单限流 

应用 7:一毛不拔 —— 漏斗限流 

应用 8:近水楼台 —— GeoHash


1.Redis 可以做什么? 

Redis 的业务应用范围非常广泛,让我们以掘金技术社区(juejin.im)的帖子模块为实
例,梳理一下,Redis 可以用在哪些地方? 
1、记录帖子的点赞数、评论数和点击数 (hash)。  
2、记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。  
3、记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。  
4、记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。  
5、缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。  
6、记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。  
7、如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。  
8、收藏集和帖子之间的关系 (zset)。  
9、记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。  
10、缓存用户行为历史,进行恶意行为过滤 (zset,hash)

以上提到的只是 Redis 的基础应用,也是日常开发中最常见的应用

2.基础:万丈高楼平地起 ——Redis 基础数据结构 

Redis 基础数据结构 :  Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈
希) 和 zset (有序集合)。

string (字符串)  


字符串 string 是 Redis 最简单的数据结构。Redis 所有的数据结构都是以唯一的 key 
字符串作为名称,然后通过这个唯一 key 值来获取相应的 value 数据。不同类型的数据结
构的差异就在于 value 的结构不一样。  

字符串结构使用非常广泛,一个常见的用途就是缓存用户信息。我们将用户信息结构体
使用 JSON 序列化成字符串,然后将序列化后的字符串塞进 Redis 来缓存。同样,取用户
信息会经过一次反序列化的过程。 

Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的 
ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字
符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,
扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间
。需要注意的是
字符串最大长度为 512M

过期和 set 命令扩展

 可以对 key 设置过期时间,到点自动删除,这个功能常用来控制缓存的失效时间

> set name codehole  > get name "codehole"  > expire name 5 # 5s 后过期  ... # wait for 5s  > get name  (nil)  > setex name 5 codehole # 5s 后过期,等价于 set+expire  > get name  "codehole"  ... # wait for 5s  > get name  (nil)

计数

如果 value 值是一个整数,还可以对它进行自增操作。自增是有范围的,它的范围是 
signed long 的最大最小值,超过了这个值,Redis 会报错。 
> set age 30  OK  > incr age  (integer) 31  > incrby age 5  (integer) 36  > incrby age -5  (integer) 31 

list (列表) 

Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 
list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 
O(n),这点让人非常意外。

Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符
串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理

慢操作 

lindex 相当于 Java 链表的 get(int index)方法,它需要对链表进行遍历,性能随着参数
index 增大而变差。 ltrim 和字面上的含义不太一样,个人觉得它叫 lretain(保留) 更合适一
些,因为 ltrim 跟的两个参数 start_index 和 end_index 定义了一个区间,在这个区间内的值,
ltrim 要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常
有用。index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元
。  

快速列表

如果再深入一点,你会发现 Redis 底层存储的还不是一个简单的 linkedlist,而是称之为
快速链表 quicklist 的一个结构

hash (字典) 

Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 
Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞
时,就会将碰撞的元素使用链表串接起来。 
 不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为 
Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 
为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略

渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 
hash 结构,然后在后续的定时任务中以及 hash 的子指令中,循序渐进地将旧 hash 的内容
一点点迁移到新的 hash 结构中
hash 结构也可以用来存储用户信息,不同于字符串一次性需要全部序列化整个对象,
hash 可以对用户结构中的每个字段单独存储。这样当我们需要获取用户信息时可以进行部分
获取。
而以整个字符串的形式去保存用户信息的话就只能一次性全部读取,这样就会比较浪
费网络流量。 

hash 也有缺点,hash 结构的存储消耗要高于单个字符串,到底该使用 hash 还是字符
串,需要根据实际情况再三权衡

 

同字符串一样,hash 结构中的单个子 key 也可以进行计数,它对应的指令是 hincrby,
和 incr 使用基本一样

set (集合) 

Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的
内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL

当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来
存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户不会中奖两次

zset (有序列表)  

zset 可能是 Redis 提供的最为特色的数据结构,它也是在面试中面试官最爱问的数据结
构。它类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 
value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权
重。它的内部实现用的是一种叫着「跳跃列表」的数据结构。 

zset 可以用来存
粉丝列表,value 值是粉丝的用户 ID,score 是关注时间。我们可以对粉丝列表按关注时间
进行排序。 

zset 还可以用来存储学生的成绩,value 值是学生的 ID,score 是他的考试成绩。我们
可以对成绩按分数进行排序就可以得到他的名次

跳跃列表(类似java的跳表Skiplist)

zset 内部的排序功能是通过「跳跃列表」数据结构来实现的,它的结构非常特殊,也比
较复杂。 
因为 zset 要支持随机的插入和删除,所以它不好使用数组来表示。我们先看一个普通的
链表结构。 

跳跃列表就是类似于这种层级制,最下面一层所有的元素都会串起来。然后每隔几个元
素挑选出一个代表来,再将这几个代表使用另外一级指针串起来。然后在这些代表里再挑出
二级代表,再串起来。最终就形成了金字塔结构。 

容器型数据结构的通用规则 

list/set/hash/zset 这四种数据结构是容器型数据结构,它们共享下面两条通用规则:

1、create if not exists  
如果容器不存在,那就创建一个,再进行操作。比如 rpush 操作刚开始是没有列表的,
Redis 就会自动创建一个,然后再 rpush 进去新元素。  
2、drop if no elements  
如果容器里元素没有了,那么立即删除元素,释放内存。这意味着 lpop 操作到最后一
个元素,列表就消失了。

过期时间 

Redis 所有的数据结构都可以设置过期时间,时间到了,Redis 会自动删除相应的对象。
需要注意的是过期是以对象为单位,比如一个 hash 结构的过期是整个 hash 对象的过期,
而不是其中的某个子 key。  
还有一个需要特别注意的地方是如果一个字符串已经设置了过期时间,然后你调用了 
set 方法修改了它,它的过期时间会消失

应用 1:千帆竞发 —— 分布式锁 

分布式应用进行逻辑处理时经常会遇到并发问题。 
比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修
改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状
态这两个操作不是原子的。(Wiki 解释:所谓原子操作是指不会被线程调度机制打断的操
作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。) 
 这个时候就要使用到分布式锁来限制程序的并发执行。Redis 分布式锁使用非常广泛,
它是面试的重要考点之一

分布式锁 
 

分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占
时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。 
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用
完了,再调用 del 指令释放茅坑

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样
就会陷入死锁,锁永远得不到释放。 

于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也
可以保证 5 秒之后锁会自动释放

但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因
为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问
题。实现方法极为复杂,小白用户一般要费很大的精力才可以搞懂。如果你需要使用分布式锁,
意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。 

 为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 
expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁 
library 可以休息了。 > set lock:codehole true ex 5 nx OK ... do something critical ... > del 
lock:codehole 上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的
奥义所在

超时问题 

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至
于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,
但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻
辑执行完之间拿到了锁。 
为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数
据出现的小波错乱可能需要人工介入解决
有一个更加安全的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配
随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也
没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可
以保证连续多个指令的原子性执行。

可重入性  

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加
锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分
布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量
存储当前持有锁的计数。 

应用 2:缓兵之计 —— 延时队列

我们平时习惯于使用 Rabbitmq 和 Kafka 作为消息队列中间件,来给应用程序之间增加
异步消息传递功能。这两个中间件都是专业的消息队列中间件,特性之多超出了大多数人的理
解能力。 
使用过 Rabbitmq 的同学知道它使用起来有多复杂,发消息之前要创建 Exchange,再创
建 Queue,还要将 Queue 和 Exchange 通过某种规则绑定起来,发消息的时候要指定 routing
key,还要控制头部信息。消费者在消费消息之前也要进行上面一系列的繁琐过程。但是绝大
多数情况下,虽然我们的消息队列只有一组消费者,但还是需要经历上面这些繁琐的过程。 
有了 Redis,它就可以让我们解脱出来,对于那些只有一组消费者的消息队列,使用 Redis 
就可以非常轻松的搞定。Redis 的消息队列不是专业的消息队列,它没有非常多的高级特性,
没有 ack 保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用
 

异步消息队列 (服务器指定key,往list里面插入消息,客户端通过key去消费,消费一个值,list的那个值就消失了)

Redis 的 list(列表) 数据结构常用来作为异步消息队列使用,使用rpush/lpush操作入队列,
使用 lpop 和 rpop 来出队列。

队列空了怎么办?

客户端是通过队列的 pop 操作来获取消息,然后进行处理。处理完了再接着获取消息,
再进行处理。如此循环往复,这便是作为队列消费者的客户端的生命周期

可是如果队列空了,客户端就会陷入 pop 的死循环,不停地 pop,没有数据,接着再 pop,
又没有数据。这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU,redis 的 QPS 也
会被拉高,如果这样空轮询的客户端有几十来个,Redis 的慢查询可能会显著增多。 
通常我们使用 sleep 来解决这个问题,让线程睡一会,睡个 1s 钟就可以了。不但客户端
的 CPU 能降下来,Redis 的 QPS 也降下来了

   队列延迟

有没有什么办法能显著降低延迟呢?你当然可以很快想到:那就把睡觉的时间缩短点。这
种方式当然可以,不过有没有更好的解决方案呢?当然也有,那就是 blpop/brpop。 
这两个指令的前缀字符 b 代表的是 blocking,也就是阻塞读。 
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消
息的延迟几乎为零。用 blpop/brpop 替代前面的 lpop/rpop,就完美解决了上面的问题。...
 

空闲连接自动断开 

你以为上面的方案真的很完美么?先别急着开心,其实他还有个问题需要解决。 
什么问题?—— 空闲连接的问题。 
如果线程一直阻塞在哪里,Redis 的客户端连接就成了闲置连接,闲置过久,服务器一般
会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来。 
所以编写客户端消费者的时候要小心,注意捕获异常,还要重试。... 

锁冲突处理 

上节课我们讲了分布式锁的问题,但是没有提到客户端在处理请求时加锁没加成功怎么办。
一般有 3 种策略来处理加锁失败

1、直接抛出异常,通知用户稍后重试; 
2、sleep 一会再重试; 
3、将请求转移至延时队列,过一会再试

直接抛出特定类型的异常 
这种方式比较适合由用户直接发起的请求,用户看到错误对话框后,会先阅读对话框的内
容,再点击重试,这样就可以起到人工延时的效果。如果考虑到用户体验,可以由前端的代码
替代用户自己来进行延时重试控制。它本质上是对当前请求的放弃,由用户决定是否重新发起
新的请求。 
sleep 
sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。如果碰撞的比
较频繁或者队列里消息比较多,sleep 可能并不合适。如果因为个别死锁的 key 导致加锁不成
功,线程会彻底堵死,导致后续消息永远得不到及时处理。 
延时队列 
这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突

延时队列的实现

延时队列可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作
为 zset 的 value,这个消息的到期处理时间作为 score,然后用多个线程轮询 zset 获取到期
的任务进行处理,多个线程是为了保障可用性,万一挂了一个线程还有其它线程可以继续处
理。因为有多个线程,所以需要考虑并发争抢任务,确保任务不能被多次执行。 


应用 3:节衣缩食 —— 位图 

在我们平时开发过程中,会有一些 bool 型数据需要存取,比如用户一年的签到记录,
签了是 1,没签是 0,要记录 365 天。如果使用普通的 key/value,每个用户要记录 365 
个,当用户上亿的时候,需要的存储空间是惊人的。 
为了解决这个问题,Redis 提供了位图数据结构,这样每天的签到记录只占据一个位,
365 天就是 365 个位,46 个字节 (一个稍长一点的字符串) 就可以完全容纳下,这就大大
节约了存储空间。 
 

位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们
可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 
等将 byte 数组看成「位数组」来处理。 
以老钱的经验,在面试中有 Redis 位图使用经验的同学很少,如果你对 Redis 的位图有
所了解,它将会是你的面试加分项。 

基本使用

Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自
动将位数组进行零扩充。 
接下来我们使用位操作将字符串设置为 hello (不是直接使用 set 指令),首先我们需要得
到 hello 的 ASCII 码,用 Python 命令行可以很方便地得到每个字符的 ASCII 码的二进制
值。 

接下来我们使用 redis-cli 设置第一个字符,也就是位数组的前 8 位,我们只需要设置
值为 1 的位,如上图所示,h 字符只有 1/2/4 位需要设置,e 字符只有 9/10/13/15 位需要
设置。值得注意的是位数组的顺序和字符的位顺序是相反的。 

上面这个例子可以理解为「零存整取」,同样我们还也可以「零存零取」,「整存零
取」。「零存」就是使用 setbit 对位值进行逐个设置,「整存」就是使用字符串一次性填充
所有位数组,覆盖掉旧值。

\整存零取 

统计和查找 

Redis 提供了位图统计指令 bitcount 和位图查找指令 bitpos,bitcount 用来统计指定位
置范围内 1 的个数,bitpos 用来查找指定范围内出现的第一个 0 或 1。

比如我们可以通过 bitcount 统计用户一共签到了多少天,通过 bitpos 指令查找用户从
哪一天开始第一次签到。如果指定了范围参数[start, end],就可以统计在某个时间范围内用户
签到了多少天,用户自某天以后的哪天开始签到

魔术指令 bitfield 

前文我们设置 (setbit) 和获取 (getbit) 指定位的值都是单个位的,如果要一次操作多个
位,就必须使用管道来处理。 不过 Redis 的 3.2 版本以后新增了一个功能强大的指令,有
了这条指令,不用管道也可以一次进行多个位的操作。 bitfield 有三个子指令,分别是 
get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理 64 个连续的位,如果
超过 64 位,就得使用多个子指令,bitfield 可以一次执行多个子指令。 

应用 4:四两拨千斤 —— HyperLogLog 

在开始这一节之前,我们先思考一个常见的业务问题:如果你负责开发维护一个大型的
网站,有一天老板找产品经理要网站每个网页每天的 UV 数据,然后让你来开发这个统计模
块,你会如何实现

如果统计 PV 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器
的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 
数据。 

但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就
要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 
ID 来标识。 

你也许已经想到了一个简单的方案,那就是为每一个页面一个独立的 set 集合来存储所
有当天访问过此页面的用户 ID。当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可
以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。没错,这
是一个非常简单的方案。 
但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大
的 set 集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人
的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实老板需要的数据又不需要
太精确,105w 和 106w 这两个数字对于老板们来说并没有多大区别,So,有没有更好的解
决方案呢? 

这就是本节要引入的一个解决方案,Redis 提供了 HyperLogLog 数据结构就是用来解决
这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不
精确,标准误差是 0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。 
HyperLogLog 数据结构是 Redis 的高级数据结构,它非常有用,但是令人感到意外的
是,使用过它的人非常少
。 

使用方法 
HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加
计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用
户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值


 
简单试了一下,发现还蛮精确的,一个没多也一个没少。接下来我们使用脚本,往里面
灌更多的数据,看看它是否还可以继续精确下去,如果不能精确,差距有多大

使用java代码增加十万个用户,测试得到差了 277 个,按百分比是 0.277%,对于上面的 UV 统计需求来说,误差率也不算高。
然后我们把上面的脚本再跑一边,也就相当于将数据重复加入一边,查看输出,可以发现,
pfcount 的结果没有任何改变,还是 99723,说明它确实具备去重功能。

pfadd 这个 pf 是什么意思? 

它是 HyperLogLog 这个数据结构的发明人 Philippe Flajolet 的首字母缩写

pfmerge 适合什么场合用?

HyperLogLog 除了上面的 pfadd 和 pfcount 之外,还提供了第三个指令 pfmerge,用于
将多个 pf 计数值累加在一起形成一个新的 pf 值

比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。
其中页面的 UV 访问量也需要合并,那这个时候 pfmerge 就可以派上用场了。

 注意事项 :

HyperLogLog 这个数据结构不是免费的,不是说使用这个数据结构要花钱,它需要占据
一定 12k 的存储空间,所以它不适合统计单个用户相关的数据。如果你的用户上亿,可以算
算,这个空间成本是非常惊人的。但是相比 set 存储方案,HyperLogLog 所使用的空间那真
是可以使用千斤对比四两来形容了
不过你也不必过于当心,因为 Redis 对 HyperLogLog 的存储进行了优化,在计数比较
小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占
用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间

应用 5:层峦叠嶂 —— 布隆过滤器 

上一节我们学会了使用 HyperLogLog 数据结构来进行估数,它非常有价值,可以解决
很多精确度不高的统计需求

 但是如果我们想知道某一个值是不是已经在 HyperLogLog 结构里面了,它就无能为力
了,它只提供了 pfadd 和 pfcount 方法,没有提供 pfcontains 这种方法。 

讲个使用场景,比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内
容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何
实现推送去重的

你会想到服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户
的历史记录里进行筛选,过滤掉那些已经存在的记录。问题是当用户量很大,每个用户看过
的新闻又很多的情况下,这种方式,推荐系统的去重工作在性能上跟的上么

实际上,如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists 查
询,当系统并发量很高时,数据库是很难扛住压力的。 
你可能又想到了缓存,但是如此多的历史记录全部缓存起来,那得浪费多大存储空间
啊?而且这个存储空间是随着时间线性增长,你撑得住一个月,你能撑得住几年么?但是不
缓存的话,性能又跟不上,这该怎么办?

这时,布隆过滤器 (Bloom Filter) 闪亮登场了,它就是专门用来解决这种去重问题的。
它在起到去重的同时,在空间上还能节省 90% 以上,只是稍微有那么点不精确,也就是有
一定的误判概率

布隆过滤器是什么?

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某
个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合
理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。

 套在上面的使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过
的新内容,它也会过滤掉极小一部分 (误判),但是绝大多数新内容它都能准确识别。这样就
可以完全保证推荐给用户的内容都是无重复的

Redis 中的布隆过滤器

Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件(需要安装)功能之后才正式登场。布隆过滤
器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能

布隆过滤器基本使用 
布隆过滤器有二个基本指令,bf.add 添加元素,bf.exists 查询元素是否存在,它的用法
和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一个元素,如果想要
一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,就需
要用到 bf.mexists 指令

 执行上面的代码后,你会张大了嘴巴发现居然没有输出,塞进去了 100000 个元素,还
是没有误判,这是怎么回事?如果你不死心的话,可以将数字再加一个 0 试试,你会发现依
然没有误判。 
原因就在于布隆过滤器对于已经见过的元素肯定不会误判,它只会误判那些没见过的元

所以我们要稍微改一下上面的脚本,使用 bf.exists 去查找没见过的元素,看看它是不是
以为自己见过了
如何降低误差率 

我们上面使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次 add 的时候自
动创建。Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用 bf.reserve
指令显式创建。如果对应的 key 已经存在,bf.reserve 会报错。bf.reserve 有三个参数,分别 是 key, error_rate 和 initial_size。错误率越低,需要的空间越大。initial_size 参数表示预计放
入的元素数量,当实际数量超出这个数值时,误判率会上升。 

所以需要提前设置一个较大的数值避免超出导致误判率升高。如果不使用 bf.reserve,默
认的 error_rate 是 0.01,默认的 initial_size 是 100

接下来我们使用 bf.reserve 改造一下上面的脚本: 

我们看到了误判率大约 0.012%,比预计的 0.1% 低很多,不过布隆的概率是有误差
的,只要不比预计误判率高太多,都是正常现象

注意事项

布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确
率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避
免实际元素可能会意外高出估计值很多。 
布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合,
error_rate 设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文
章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。

布隆过滤器的其它应用 

邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平
时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低

在爬虫系统中,我们需要对 URL 进行去重,已经爬过的网页就可以不用爬了。但是 
URL 太多了,几千万几个亿,如果用一个集合装下这些 URL 地址那是非常浪费空间的。这
时候就可以考虑使用布隆过滤器。它可以大幅降低去重存储消耗,只不过也会使得爬虫系统
错过少量的页面。 

应用 6:断尾求生 —— 简单限流 

 除了控制流量,限流还有一个应用目的是用于控制用户行为,避免垃圾请求。比如在 
UGC 社区,用户的发帖、回复、点赞等行为都要严格受控,一般要严格限定某行为在规定
时间内允许的次数,超过了次数那就是非法行为。对非法行为,业务必须规定适当的惩处策
略。 

如何使用 Redis 来实现简单限流策略?

首先我们来看一个常见 的简单的限流策略。系统要限定用户的某个行为在指定的时间里
只能允许发生 N 次,如何使用 Redis 的数据结构来实现这个限流的功能? 

解决方案 

这个限流需求中存在一个滑动时间窗口,想想 zset 数据结构的 score 值,是不是可以
通过 score 来圈出这个时间窗口来。而且我们只需要保留这个时间窗口,窗口之外的数据都
可以砍掉。那这个 zset 的 value 填什么比较合适呢?它只需要保证唯一性即可,用 uuid 会
比较浪费空间,那就改用毫秒时间戳吧

如图所示,用一个 zset 结构记录用户的行为历史,每一个行为都会作为 zset 中的一个 
key 保存下来。同一个用户同一种行为用一个 zset 记录。 
为节省内存,我们只需要保留时间窗口内的行为记录,同时如果用户是冷用户,滑动时
间窗口内的行为是空记录,那么这个 zset 就可以从内存中移除,不再占用空间。 
通过统计滑动窗口内的行为数量与阈值 max_count 进行比较就可以得出当前的行为是否
允许。 

参考Redis的三种限流策略:setnx,zset以及令牌桶算法

https://blog.csdn.net/lmx125254/article/details/90700118

zset的缺点:。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这
个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流
的,因为会消耗大量的存储空间

下面的令牌算法过程:


 

应用 7:一毛不拔 —— 漏斗限流 

漏斗限流是最常用的限流方法之一,顾名思义,这个算法的灵感源于漏斗(funnel)的结
构。 
 

漏洞的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水,它就会变满,直至再也
装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后,就又可以继续往里面灌水。
如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水
的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾空

所以,漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着
系统允许该行为的最大频率

Funnel 对象的 make_space 方法是漏斗算法的核心,其在每次灌水前都会被调用以触发
漏水,给漏斗腾出空间来。能腾出多少空间取决于过去了多久以及流水的速率。Funnel 对象
占据的空间大小不再和行为的频率成正比,它的空间占用是一个常量。 
问题来了,分布式的漏斗算法该如何实现?能不能使用 Redis 的基础数据结构来搞定? 
我们观察 Funnel 对象的几个字段,我们发现可以将 Funnel 对象的内容按字段存储到一
个 hash 结构中,灌水的时候将 hash 结构的字段取出来进行逻辑运算后,再将新值回填到 
hash 结构中就完成了一次行为频度的检测。 
但是有个问题,我们无法保证整个过程的原子性。从 hash 结构中取值,然后在内存里
运算,再回填到 hash 结构,这三个过程无法原子化,意味着需要进行适当的加锁控制。而
一旦加锁,就意味着会有加锁失败,加锁失败就需要选择重试或者放弃。 
如果重试的话,就会导致性能下降。如果放弃的话,就会影响用户体验。同时,代码的
复杂度也跟着升高很多。这真是个艰难的选择,我们该如何解决这个问题呢?Redis-Cell 救
星来了!

Redis-Cell

Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并
提供了原子的限流指令。有了这个模块,限流问题就非常简单了

应用 8:近水楼台 —— GeoHash

Redis 在 3.2 版本以后增加了地理位置 GEO 模块,意味着我们可以使用 Redis 来实现
摩拜单车「附近的 Mobike」、美团和饿了么「附近的餐馆」这样的功能了

用数据库来算附近的人 

如果现在元素的经纬度坐标使用关系数据库 (元素 id, 经度 x, 纬度 y) 存储,你该如何
计算? 
首先,你不可能通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计
算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然
后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。如何划分矩形区域呢?可以指定一个半径 r,使用一条 SQL 就可以圈出来。当用户对筛出来的结果不满意,
那就扩大半径继续筛选

select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这
可能并不是一个很好的方案。 

GeoHash 算法

业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算
法。GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一
条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算「附
近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行

Redis 的 Geo 指令基本使用 

 Redis 提供的 Geo 指令只有 6 个,读者们瞬间就可以掌握。使用时,读者务必再次想
起,它只是一个普通的 zset 结构。 

 老钱也不是很能理解,为什么 Redis 没有提供 geo 删除指令,反正它就是没有提供

 
 
获取元素的 hash 值 

附近的公司 
georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元
素,它的参数非常复杂

小结 & 注意事项

在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用 
Redis 的 Geo 数据结构,它们将全部放在一个 zset 集合中。在 Redis 的集群环境中,集合
可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成
较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现
卡顿现象,影响线上服务的正常运行。 

所以,这里建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。 
如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按
市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。

发布了33 篇原创文章 · 获赞 1 · 访问量 5511

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104605173