Redis之应用篇

Redis基础入门篇可参考 Redis基础入门篇

一、分布式锁

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

分布式锁本质上要实现的目标就是在 Redis 里面占一个“位”,当别的进程也要来占时,发现已经有人坐在那里了,就只好放弃或者稍后再试。

占位一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占位。先来先占, 用完了,再调用 del 指令释放位置。

127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> del userName
(integer) 1

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。

127.0.0.1:6379> setnx userName alanchen
(integer) 1
127.0.0.1:6379> expire userName 5
(integer) 1
127.0.0.1:6379> del userName
(integer) 1

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

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if-else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。

Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,解决了分布式锁的问题。

127.0.0.1:6379> setex userName 5 alanchen
OK
127.0.0.1:6379> get userName
(nil)

setex等价于 set+expire。或者

127.0.0.1:6379> set userName alanchen ex 5 nx
OK
127.0.0.1:6379> get userName
(nil)
  • nx表示只有当userName对应的key值不存在的时候才能set成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • ex 5表示这个锁有一个5秒的自动过期时间。

超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。

为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。

二、消息队列

我们平时习惯于使用 Rabbitmq 和 Kafka 作为消息队列中间件,来给应用程序之间增加异步消息传递功能。这两个中间件都是专业的消息队列中间件,特性之多超出了大多数人的理解能力。

使用过 Rabbitmq 的同学知道它使用起来有多复杂,发消息之前要创建 Exchange,再创建 Queue,还要将 Queue 和 Exchange 通过某种规则绑定起来,发消息的时候要指定 routing-key,还要控制头部信息。消费者在消费消息之前也要进行上面一系列的繁琐过程。但是绝大多数情况下,虽然我们的消息队列只有一组消费者,但还是需要经历上面这些繁琐的过程。

有了 Redis,它就可以让我们解脱出来,对于那些只有一组消费者的消息队列,使用 Redis 就可以非常轻松的搞定。Redis 的消息队列不是专业的消息队列,它没有非常多的高级特性,没有 ack 保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用。

2.1 异步消息队列

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

2.2 队列空了怎么办?

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

可是如果队列空了,客户端就会陷入 pop 的死循环,不停地 pop,没有数据,接着再 pop,又没有数据。这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU,redis 的 QPS 也会被拉高,如果这样空轮询的客户端有几十来个,Redis 的慢查询可能会显著增多。

通常我们使用 sleep 来解决这个问题,让线程睡一会,睡个 1s 钟就可以了。不但客户端的 CPU 能降下来,Redis 的 QPS 也降下来了。

2.3 队列延迟

用上面睡眠的办法可以解决问题。但是有个小问题,那就是睡眠会导致消息的延迟增大。如果只有 1 个消费者,那么这个延迟就是 1s。如果有多个消费者,这个延迟会有所下降,因为每个消费者的睡觉时间是岔开来的。

有没有什么办法能显著降低延迟呢?你当然可以很快想到:那就把睡觉的时间缩短点。这种方式当然可以,不过有没有更好的解决方案呢?当然也有,那就是 blpop/brpop。这两个指令的前缀字符b代表的是blocking,也就是阻塞读。

阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为零。用blpop/brpop替代前面的lpop/rpop,就完美解决了上面的问题

Redis blpop 命令
Redis blpop 命令移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
Redis blpop 命令基本语法如下:

redis 127.0.0.1:6379> blpop list1 list2 .. listn  timeout

可用版本 Redis >= 2.0.0

返回值
如果列表为空,返回一个 nil 。 否则,返回一个含有两个元素的列表,第一个元素是被弹出元素所属的 key ,第二个元素是被弹出元素的值。

实例
为了演示生产者和消费者,我们开两个Redis 客户端来操作。
客户端一:生产者

127.0.0.1:6379> rpush message-queue Java C++ Go
(integer) 3
127.0.0.1:6379> llen message-queue
(integer) 3

生产者生产了三条消息供消费者消费,现在我们操作客户端二:消费者

lpop message-queue
"Java"
127.0.0.1:6379> llen message-queue
(integer) 2
127.0.0.1:6379> lpop message-queue
"C++"
127.0.0.1:6379> lpop message-queue
"Go"
127.0.0.1:6379> lpop message-queue
(nil)
127.0.0.1:6379> lpop message-queue
(nil)

消费者轮询消费,即使消息队列中没有消息了也会一直去消费。现在我们将
lpop换成blpop来进行消费,客户端二:消费者

127.0.0.1:6379> blpop message-queue 5
(nil)
(5.08s)

等5秒后继续消费,依然没有消息,返回nil。现在我们把等等时间加长一点,改成20秒,然后操作客户端一去产生一条新消息,操作如下,客户端二:消费者

127.0.0.1:6379> blpop message-queue 20

客户端一:生产者

127.0.0.1:6379> rpush message-queue Swift
(integer) 1

客户端二:消费者 读到新生成的消息

127.0.0.1:6379> blpop message-queue 20
1) "message-queue"
2) "Swift"
(2.49s)
2.4 空闲连接自动断开

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

三、位图

3.1 Redis位图基本概念

位图不是一个真实的数据类型,而是定义在字符串类型上的面向位的操作的集合。Redis中所有数据都是二进制形式存储的。Redis支持一个setbit和getbit操作,它支持在某个key的value上直接对某个二进制位操作,每个二进制位都只有0和1两种状态(默认为0),正好可以表示用户是否活跃两种状态。

Redis的位图可用于的场景有:
1、用户签到
2、用户在线状态
3、统计活跃用户
4、各种状态值

3.2 常用命令

SETBIT
对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。

setbit key offset value

offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。

GETBIT
对 key 所储存的字符串值,获取指定偏移量上的位(bit)。

getbit key offset

BITCOUNT
计算给定字符串中,被设置为 1 的比特位的数量。

bitcount key

BITPOS
返回位图中第一个值为 bit 的二进制位的位置。

bitpos key bit [start] [end]

BITOP
对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。

bitop operation destkey key [key …]

operation 可以是 and 、 or 、 not 、 xor 这四种操作中的任意一种
bitop and destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destroy 。

BITFIELD
bitfield 有三个子指令,分别是 get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理 64 个连续的位,如果超过 64 位,就得使用多个子指令,bitfield 可以一次执行多个子指令。

3.3 位图应用于用户在线状态

位图操作是用来操作比特位的,其优点是节省内存空间。为什么可以节省内存空间呢?假如我们需要存储100万个用户的登录状态,使用位图的话最少只需要100万个比特位(比特位1表示登录,比特位0表示未登录)就可以存储了,而如果以字符串的形式存储,比如说以userId为key,是否登录(字符串“1”表示登录,字符串“0”表示未登录)为value进行存储的话,就需要存储100万个字符串了,相比之下使用位图存储占用的空间要小得多,这就是位图存储的优势。

具体操作: 写入一个key,值是一亿个bit,足够容纳你的数据中最大offset就行,假如这个key的名称是user-login-status

127.0.0.1:6379> setbit user-login-status 20000 1
(integer) 0
127.0.0.1:6379> setbit user-login-status 20001 0
(integer) 0
127.0.0.1:6379> getbit user-login-status 20000
(integer) 1
127.0.0.1:6379> getbit user-login-status 20001
(integer) 0
127.0.0.1:6379> getbit user-login-status 20002
(integer) 0

1、设置id为20000的用户为登录状态
2、设置id为20001的用户为非登录状态
3、查看id为20000的用户的登录状态,返回值为1
4、查看id为20001的用户的登录状态,返回值为0
5、查看id为20002的用户的登录状态,返回值为0(默认为0)

3.4 位图应用于用户每日打卡

1、第一天2019-12-12打卡用户:20000、20001、20002

127.0.0.1:6379> setbit clock-in-2019-12-12 20000 1
(integer) 0
127.0.0.1:6379> setbit clock-in-2019-12-12 20001 1
(integer) 0
127.0.0.1:6379> setbit clock-in-2019-12-12 20002 1
(integer) 0

2、统计2019-12-12打卡用户数

127.0.0.1:6379> bitcount clock-in-2019-12-12
(integer) 3

3、第二天2019-12-13打卡用户:20001 、20002、20003

127.0.0.1:6379> setbit clock-in-2019-12-13 20001 1
(integer) 0
127.0.0.1:6379> setbit clock-in-2019-12-13 20002 1
(integer) 0
127.0.0.1:6379> setbit clock-in-2019-12-13 20003 1

4、统计两天都有打卡的用户数

127.0.0.1:6379> bitop and result-and clock-in-2019-12-12 clock-in-2019-12-13
(integer) 2501
127.0.0.1:6379> bitcount result-and
(integer) 2

结果为2,即20001 、20002这两个用户在这两天都有打卡。注意,不用看2501。

5、统计两天内总共的打卡人数

127.0.0.1:6379> bitop or result-or clock-in-2019-12-12 clock-in-2019-12-13
(integer) 2501
127.0.0.1:6379> bitcount result-or
(integer) 4

结果为4,即这两天打卡的用户是20000、20001、20002、20003,总共4人。

四、HyperLogLog

4.1 用HyperLogLog 统计UV

我们先思考一个常见的业务问题:如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站每个网页每天的 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 统计需求了。

4.2 HyperLogLog具体语法

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

127.0.0.1:6379> pfadd index-uv user1
(integer) 1
127.0.0.1:6379> pfcount index-uv
(integer) 1
127.0.0.1:6379> pfadd index-uv user2
(integer) 1
127.0.0.1:6379> pfcount index-uv
(integer) 2
127.0.0.1:6379> pfadd index-uv user2
(integer) 0
127.0.0.1:6379> pfcount index-uv
(integer) 2
127.0.0.1:6379> pfadd index-uv user3 user4 user5 user6
(integer) 1
127.0.0.1:6379> pfcount index-uv
(integer) 6

简单试了一下,发现还蛮精确的,一个没多也一个没少。但如果往里面灌更多数据误差就会产生。

4.3 pfadd 这个 pf 是什么意思

它是 HyperLogLog 这个数据结构的发明人 Philippe Flajolet 的首字母缩写,老师觉得他发型很酷,看起来是个佛系教授。

4.4 pfmerge 适合什么场合用?

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

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

4.5 HyperLogLog总结
  • Redis 在 2.8.9 版本添加了 HyperLogLog 结构
  • HyperLogLog是一种算法,并非Redis独有
  • 目的是做基数统计,故不是集合,不会保存元数据,只记录数量而不是数值。
  • 耗空间极小,支持输入非常体积的数据量
  • 核心是基数估算算法,主要表现为计算时内存的使用和数据合并的处理。最终数值存在一定误差
  • Redis中每个hyperloglog key占用了12K的内存用于标记基数(官方文档)
  • pfadd命令并不会一次性分配12k内存,而是随着基数的增加而逐渐增加内存分配;而pfmerge操作则会将sourcekey合并后存储在12k大小的key中,这由hyperloglog合并操作的原理(两个hyperloglog合并时需要单独比较每个桶的值)可以很容易理解。
  • 误差说明:基数估计的结果是一个带有 0.81% 标准错误(standard error)的近似值。是可接受的范围
  • Redis 对 HyperLogLog 的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间

五、布隆过滤器

使用 HyperLogLog 数据结构来进行估数,它非常有价值,可以解决很多精确度不高的统计需求。但是如果我们想知道某一个值是不是已经在 HyperLogLog 结构里面了,它就无能为力了,它只提供了 pfadd 和 pfcount 方法,没有提供 pfcontains 这种方法。

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

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

实际上,如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists 查询,当系统并发量很高时,数据库是很难扛住压力的。

你可能又想到了缓存,但是如此多的历史记录全部缓存起来,那得浪费多大存储空间啊?而且这个存储空间是随着时间线性增长,你撑得住一个月,你能撑得住几年么?但是不缓存的话,性能又跟不上,这该怎么办?

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

5.1 布隆过滤器是什么?

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

当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。

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

5.2 Redis 中的布隆过滤器

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

5.3 布隆过滤器基本使用

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

127.0.0.1:6379> bf.add codehole user1
(integer) 1
127.0.0.1:6379> bf.add codehole user2
(integer) 1
127.0.0.1:6379> bf.add codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user1
(integer) 1
127.0.0.1:6379> bf.exists codehole user2
(integer) 1
127.0.0.1:6379> bf.exists codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user4
(integer) 0
127.0.0.1:6379> bf.madd codehole user4 user5 user6
1) (integer) 1
2) (integer) 1
3) (integer) 1
127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0

资料来源:《Redis深度历险:核心原理与应用实践》

你可能感兴趣的:(Redis之应用篇)