Redis

1、基础和应用篇

1、5种基础数据结构

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

下面分别介绍这5种数据结构的使用

(1)string

字符串string是Redis最简单的数据结构,他的内部表示就是一个数组。Redis的字符串是动态字符串,是可以修改的字符串,采用预分配冗余空间的方式来减少内存的频繁分配。当字符串的长度小于1MB时,扩容都是加倍现有的空间,如果字符串长度超过1MB,扩容时一次只会扩容多1MB的空间,且最大为512M。

下面演示一些常用的操作:

键值对

> set name codehole
ok
> get name
"codehole"
> exists name
(integer) 1
> del name
(integer) 1
> get name
(nil)

批量键值对,可以对多个字符串进行批量读写,节省网络资源开销

> set name1 codehole
ok
> set name2 holycoder
ok
> mget name1 name2 name3
1) "codehole"
2) "holycoder"
3) (nil)
> mset name1 boy name2 girl name2 unknown
1) "boy"
2) "girl"
3) "unknown"

过期和set命令扩展

> set name codehole
> get name
"codehole"

> expire name 5 #设置name的过期时间为5s
> get name #等候5s
(nil)

> setex name 5 codehole #5s后过期,等价于set+expire

> setnx name codehole #如果name不存在就执行set创建,如果已经存在则创建失败

计数器

> set age 30
> incr age
(integer) 31

>incrby age 5
(integer) 36

>incrby age -5
(integer) 31

>
(2)list

list相当于Java中的LinkedList,因此它是一个链表而不是数组,因此list的增删快,查找慢。list的底层数据结构不是一个简单的linkedList,而是称之为快速链表的结构,在元素较少的情况下,会使用一块连续的内存存储,这个结构是ziplist即压缩列表,当数据量较多时才会改成quicklist常用list来做异步队列使用,将需要延后处理的任务结构体序列化之后放进list,另一个线程从这个list中轮训数据进行处理。

下面演示一些常用的操作:

# 右边进左边出:队列
> rpush books py java golang
(integer) 3
> llen books
(integer) 3
> lpop books
"py"
> lpop books
"java"
> lpop books
"golang"
>lpop books
(nil)

# 右边进右边出:栈
> rpush books py java golang
(integer) 3
> rpop books
"golang"
> rpop books
"java"
> rpop books
"py"
>rpop books
(nil)

# linex相当于java中的get(int index),他需要对列表进行遍历,性能随着参数index增大而增大
> rpush books py java golang
(integer) 3
> lindex books 1 # O(n) 慎用
"java"
> lrange books 0 -1 # 获取所有元素,O(n) 慎用
"py"
"java"
"golang"
# ltrim(start,end),在这个区间内的数据保留,其他的全部清除
> ltrim books 1 -1
1) "java"
2) "golang"
(3)hash

Redis的字典相当于java中的HashMap,它是无序字典,内部存储了很多键值对。实现结构上也是数组加链表的结构。不同的是,Redis的字典的值只能是字符串,rhash采用的是一种叫做渐进式rehash策略的方式。

hash结构可以用来存储用户信息,与字符串需要一次性序列化整个对象不同,hash可以对用户结构中的某个字段单独存储,这样我们在获取用户信息的时候就可以部分获取,从而节省网络流量。

下面演示一些常用的操作:

> hset books java "think in java"
(integer) 1
> hset books golang "think in golang"
(integer) 1
> hset books py "think in py"
(integer) 1
> hgetall books
"java"
"hink in java"
"golang"
"think in golang"
"py"
"think in py"

> hlen books
(integer) 3

> hget books java
"think in java"

> hset books golang "do not think in golang" # 更新操作
(integer) 0 
(4)set

Redis的集合相当于Java中的HashSet,他的内部是无序的、唯一的键值对
set结构可以用来存储在某次活动中中奖用户的ID,因为有去重的功能,可以保证一个用户不会中奖两次。

下面演示一些常用的操作:

> sadd books py
(integer) 1
> sadd books python # 重复,添加失败
(integer) 0

> sadd books java golang
> smembers books # 注意:并不是有序的
"java"
"py"
"golang"

> sismember books java # 查询某个value是否存在,相当于contains(o)
(integer) 1

> scard books # 获取长度
(integer) 3

> spop books # 弹出一个
"java"
(5)zset

zst它类似于Java中SortedSer和HashMap的结合体,一方面它是一个set,保证了内部value的唯一性,另一方面他可以给每一个value赋予一个score,他代表了这个value的排序权重,他的内部实现用的是一种叫做跳跃列表的数据结构。

zset可以用来存储粉丝列表,value值是粉丝用户的ID,score是关注时间
zset还可以用来存储学生的成绩,value值是学生的ID,score是学生的考试成绩,我们可以对成绩按照分数进行排序

下面演示一些常用的操作:

> zadd books 9.0 "think in java"
(integer) 1
> zadd books 8.9 "java concurrency"
(integer) 1
> zadd books 8.6 "java cookbook"
(integer) 1

> zrange books 0 -1 # 按照score排序输出,参数区间为排名范围
"java cookbook"
"java concurrency"
"think in java"

> zrevrange books 0 -1 # 按照score逆序排列参数区间为排名范围
"think in java"
"java concurrency"
"java cookbook"

> zcard bookd # 相当于count()
(integer) 3

> zscore books "java concurrency" # 获取指定value的score
"8.9"

> zrank books "java concurrency" # 获取指定value的排名
(integer) 1

> zrangebyscore books -inf 8.91 withscore # 根据分值区间 (-无穷,8.91)遍历zset,同时返回分值。inf代表无穷大的意思。
1) "java cookbook"
2) "8.60"
3) "java concurrency"
4) "8.9"

> zrem books "java concurrency" # 删除value
(integer) 1

zset实现原理:点击查看

2、分布式锁

(1)分布式锁的奥义

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

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

# 冒号只是一个普通字符,没特别含义,可以是任何字符
> setnx lock:codehole true
OK
do something...
> del lock:codehole

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

> setnx lock:codehole true
OK
> expire lock:codehole 5
do something...
>del lock:codehole

但是这样还有一个问题,如果在setnx和expire之间服务器进程突然挂掉了,就会导致expire得不到执行,也会造成死锁。这种问题的根源就在于setnx和expire是两条指令而不是原子指令。

为了解决这个问题,在Redis2.8版本中,作者加入了set指令的扩展参数,是的setnx和expire指令可以一起执行,彻底解决了分布式锁。

> set lock:codehole true ex 5 nx
OK
do something...
> del lock:codehole

上面这个指令就是setnx和expire组合在一起的原子指令,他就是分布式锁的奥义所在

(2)超时问题

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

这个问题可以使用Lua脚本来处理,因为Lua脚本可以保证连续多个指令的原子执行,当然,这也不是一个终极解决方案。

(3)可重入性

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

3、位图(Redis高级数据结构之一)

在我们平时的开发过程中,会有一些bool型数据需要存取,比如用户一年的签到记录,签了是1,没签是0,要记录365天。如果使用普通的key/value,每个用户要记录365个,当用户上亿的时候,需要的存储空间是惊人的。

为了解决这个问题,Redis提供了位图数据结构,这样每天的签到记录值占据一个位,365天就是365个位,46个字节就可以完全放下。这就大大节约了存储空间。位图的最小单位是比特(bit),每个bit的取值只能是0或1

位图不是特殊的数据结构,他的内容其实就是个普通的字符串,也就是byte数组。

4、HyperLogLog(Redis高级数据结构之一)

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

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

你也许已经想到了一个简单的方案:那就是为每一个网页设置一个独立的set集合来存储所有当天访问过此页面的用户ID,当一个请求过来时,我们是用sadd将用户ID塞进去就可以了,通过scard可以取出这个集合的大小,这个数字就是这个网页的UV。但是访问量很大时,那就需要一个很大的set集合来统计,这就非常浪费空间。

HyperLogLog就可以解决这个问题,HyperLogLog提供不精确的去重计数方案,虽然不精确,但也不是非常离谱,标准误差是0.81%。

使用方法:

HyperLogLog提供了两个指令pfadd和pfcount,一个是增加计数,一个是获取计数。

> pfadd codehole user1
(integer) 1
> pfadd codehole user2
(integer) 1
> pfcount codehole
(integer) 2

HyperLogLog出了上面两个命令之外还提供了第三个命令:pfmerge,用于将多个pf计数值加在一起形成新的值。

HyperLogLog数据结构需要占据12KB的存储空间,所以不适合统计单个用户的相关逻辑,如果用户有上亿个,可以知道,所使用的的存储空间相比于set就是九牛一毛。

5、布隆过滤器(Redis高级数据结构之一)

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

通常使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次add的时候自动创建。Redis其实还提供了自定义参数的布隆过滤器,需要我们在add之前使用bf.reserve指令显示创建。如果对应的key已经存在,bf.reserve会报错。bf.reserve指令有三个参数:key,err_rate(错误率)和initial_size:

err_rete越低,需要的空间越大
initial_size表示预计放入的元素数量,当实际数量超过这数量时,误判率会上升,所以需要提前设置一个较大的数值避免误判率升高。

如果不使用bf.reserve,默认的err_rate是0.01,默认的initial_size是100。

当布隆过滤器说某个值存在时,这个值可能不存在,但当它说某个值不存在时,那就肯定不存在。

布隆过滤器有两个基本的指令:bf.add和bf.exists。bf.add添加元素,bf.exists查询元素是否存在。注意:bf.add一次只能添加一个元素如果一次要添加多个,就需要用到bf.madd指令,同样,如果需要查询多个元素是否存在,就需要用到bf.mexists指令。

下面演示一些常用的操作:

> bf.add codehole user1
(integer) 1
> bf.add codehole user2
(integer) 1
> bf.add codehole user3
(integer) 1
> bf.exists codehole user1 # 查询user1是否存在
(integer) 1
> bf.exists codehole user1 user2 user 4
(integer) 1
(integer) 1
(integer) 0
(1)布隆过滤器的原理

每个布隆过滤器对应到Redis的数据结构里面就是一个大型的位数组和几个不一样的无偏hash函数。所谓无篇就是能够把元素的hash值算的比较均匀,让元素被hash映射到位数组的情况比较随机。

像布隆过滤器中添加key时,会使用多个hash函数对key进行hash,算得一个数值索引值,然后对位数组长度进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置,再把位数组的这几个位置都为1,就完成了add操作。

向布隆过滤器询问key是否存在时,跟add一样,也会把hash的几个位置都算出来,看看位数组中这几个位置是否都为1,只要有一个位位0,那就说明布隆过滤器中这个值不存在,如果这几个位置都为1,并不能说明这个key就一定存在,只是极有可能存在,因为这些值位1可能是因为其他key存在所致。

6、GeoHash(Redis高级数据结构之一)

用于地理位置距离排序算法,如附近的人。

略…

7、scan

在平时的Redis维护工作中,有时候需要从Redis实例的成千上万个key中找出热定前缀的key,Redis提供了一个简单粗暴的指令keys来列出所有满足正则规则的key。

> set codehole1 a
0
> set codehole2 b
0
> set codehole3 c
0
> set code1hole d
0
> set code2hole e
0
> set code3hole f
0

> keys *
"codehole1"
"codehole2"
"codehole3"
"code1hole"
"code2hole"
"code3hole"

> keys codehole*
"codehole1"
"codehole2"
"codehole3"

> keys code*hole
"code1hole"
"code2hole"
"code3hole"

这个指令有两个非常明显的缺点:

1、没有offset、limit参数,一次吐出所以满足条件的key
2、keys算法是遍历算法,复杂度是O(n),因为Redis是单线程的,查询的过程中就会导致其他命令超时。

Redis为了解决这个问题,在2.9版本中加入了大海捞针的指令:scan,scan相比keys具备以下特点

  • 复杂度虽然也是O(n),但是他是通过游标分布进行的,不会阻塞线程
  • 提供limit参数,可以控制每次返回结果的最大条数
  • 同keys一样,他他也提供模式匹配功能
  • 返回的结果可能会有重复,需要客户端去重,这点很重要
  • 遍历的古城中如果数据有修改,改动后的数据能不能遍历到是不确定的
  • 单词返回的结果为空并不意味着遍历结束,而要看返回的游标是否为零
(1)scan基本用法

scan提供了三个参数:第一个是cursor整数值,第二个是key的正则模式,第三个是遍历的limit。第一次遍历时,curso值为0,然后将返回结果中第一个整数值作为下次遍历的cursor,一直遍历到cursor为0时结束。

# 先在Redis中存储10000个string值,key,value均从1递增
> scan 0 match key99* count 1000 # set cursor match 正则 count 单次遍历的字典槽位数
1) "13976"
2) "key9911"
   "key9974"
   "key9994"
   "key9907"
   "key9989"
   "key9971"
   "key99"
   "key9966"
   "key992"
   "key9903"
   "key9905"

虽然提供过的limit是1000,但是返回的结果却只有10个左右,因为这个limit不是限定返回结果的数量,而是限定服务器单词遍历的字典槽位数量。如果将limit10设置为10,你会发现返回结果是空的,但是游标值不为0,意味着遍历还没结束。

scan是一系列指令,出了可以遍历所有的key以外,还可以对指定容器就行遍历,比如zscan遍历zset集合元素,hscan遍历hash字典的元素,sscan遍历set集合的元素。

2、原理

1、线程IO模型

Redis是个单线程程序,这点必须牢记!类似的还有Node.js、Nginx。

Redis是单线程为什么还能这么快?

因为它所有的数据都在内存中,所有的运算都是内存级别的的运算。

Redis既然是单线程,如何处理那么多的并发客户端连接?答案就是“多路复用”、select系列事件的轮训API、非阻塞IO。

(1)非阻塞IO

当我们调用套接字的读写方法,默认他们是阻塞的,非阻塞IO在套接字对象上提供了一个选项Non_Blocking,当这个选项打开时,读写方法不会阻塞,而是能读多少读多少,能写多少写多少。有了非阻塞IO就意味着线程在IO时可以不必再阻塞了,读写可以瞬间完成,然后线程就可以继续干别的事了。

(2)多路复用

非阻塞IO有个问题,那就是线程要读数据,结果读了一部分就返回了,那么线程如何知道何时才能继续?也就是说,当数据到来时,线程如何得到通知,写也是一样,如果缓冲区满了,写不完,剩下的数据何时才应该继续写,线程也应该得到通知。

时间轮询API就是用来解决这个问题的,最简单的时间轮询API就是select函数。他是操作系统提供给用户程序的API。因为我们通过select系统调用同时处理多个通道描述符的读写事件,因此我们将这类系统调用称为多路复用API。现代操作系统的多路复用API已经不再使用select系统调用,而是epoll等。

2、持久化

Redis的持久化机制有两种,第一种是快照(RDB),第二种是AOF日志。快照是一次全量备份,AOF日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而AOF日志记录的是内存数据修改的指令记录文本。AOF日志在长期的运行过程中会变得无比庞大,数据库重启时需要加载AOF日志进行指令重放,这个时间会比较漫长,所以要定期进行AOF重写,给AOF日志瘦身。

(1)快照原理

Redis在持久化的时候会调用glibc的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求,子进程刚刚产生时,他和父进程共享内存里面的代码段和数据段。

问题:既然Redis是单线程的怎么fork产生子进程的呢?答案就是使用系统的多进程COW(Copy On Write)机制。

(2)AOF原理

AOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录。Redis在收到客户端修改指令后,进行参数校验、逻辑处理,如果没问题,就会立刻将指令文本存储到AOF中,也就是说:先执行指令再将日志存盘。

问题:当AOF日志文件很大了怎么办呢?答案就是Redis提供了bgrewriteaof指令对AOF日志进行瘦身,其原理就是开辟一个子进程对内存进行遍历,转成一系列Redis的操作指令,序列化到一个新的AOF日志文件中。

(3)混合持久化

重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据。我们会使用AOF日志重放,但是AOF日志重放相对于RDB慢很多,为了解决这个问题,Redis4.0为了解决这个问题,带来了新的持久化选项:混合持久化。其原理就是将RDB文件的内容和增量的AOF日志文件存放在一起,这里的AOF日志不再是全量日志,而是自持久化开始到持久化结束的这段时间发生的增量AOF日志,通常这部分AOF日志很小。于是在Redis重启的时候,可以先加载rdb的内容,然后再重放AOF日志,就可以完全替代之前的AOF全量文件重放,重启效率得到大幅提升。

3、事务

为了确保多个操作的原子性,Redis数据库同样有事务的支持,指令分别是:

  • multi:开始事务
  • exec:执行事务
  • discard:丢弃事务

下面演示一些常用的操作:

> multi
OK
> set books iamastring
OK
>incre books
QUEUED
> set poorman iamdesperate
QUEUED
>exec
1)OK
2)(error) ERR value is not an integer or range
3)OK
> get books
"iamastring"
> get poorman
"iamdesperate"

上面事务的例子是事务执行到中间时失败了,因为我们不能对字符串进行数学运算。事务在遇到指令执行失败后,后面的指令会继续执行,所以poorman的值能继续得到设置。

到这里,可以知道Redis的事务根本不具备“原子性”,而仅仅是满足了事务的“隔离性”中的串行化,当前执行的事务有着不被其他事务打断的权利。

Redis为事务提供了discard指令,用于丢弃事务缓存队列中的全部指令,在exec之前执行

> get books
(nil)
> multi
OK
> incre books
QUEUED
> discard
OK
> get books

可以看到,在discard之后,队列中所有的指令都没执行。就好像multi和discard中间的所有指令从未发生过一样

下面来考虑一个业务场景:Redis存储了我们的账户余额数据,它是一个整数。现在有两个并发的客户端要对账户余额进行修改操作。Redis可没有提供multiplyby这样的指令,我么需要先取出余额然后在内存里修改,再将结果写会Redis。

这样就会出现很多问题,因为有两个客户端并发进行操作,Redis有没有办法解决这种问题呢?

当然是有的,Redis提供了watch这种机制,它是一种乐观锁,有了watch之后,我们就可以解决并发修改的问题。

watch会在事务开始之前盯住一个或多个关键变量,当事务执行时,也就是服务器收到了exec指令要顺序执行缓存的事务队列时,Redis会检查关键变量自watch之后是否被修改了。如果关键变量被人动过了,exec指令就会返回NULL回复告知客户端事务执行失败。

> watch books
OK
> incr books # 被修改了
(integer) 1
> multi
OK
> incre books
QUEUED
> exec # 事务执行失败,因为在监控books期间,books值被修改了
(nil)

注意事项:Redis禁止在multi和exec之间执行watch指令,而必须在multi之前盯住关键变量,否则会报错!

3、拓展

1、过期策略

Redis的所有数据结构都可以设置过期时间,时间一到,就会被删除。那么会不会因为同一时间太多的key过期,以至于忙不过来删除?同时因为Redis是单线程的,删除的时间也会占用线程的处理时间,如果删除过于繁忙,会不会导致线上读写出现卡顿?

(1)过期的key集合

Redis会将每个设置了过期时间的key放入一个独立的字典中,以后会定时遍历这个字典删除到期的key。除了定时遍历以外,它还会使用惰性删除策略来删除过期的key。所谓惰性删除策略就是在客户端访问这个key的时候,Redis对key的过期时间进行检查,如果过期了就会立即删除。如果说定时删除时集中处理,那么惰性删除就是零散处理。

(2)定时扫描策略

Redis默认每秒进行10次过期扫描,过期扫描不会遍历过期字典中的所有key,而是采用了一种贪心策略:

  • 从过期字典种随机选出20个可以
  • 删除这20个key中已经过期的key
  • 如果过期的key的比例超过1/4,那就重复步骤第一个步骤

同时为了保证过期扫描不会出现循环过度,导致现成卡死的现象,算法还增加了扫描的时间上限,默认不会超过25ms。

2、LRU

当Redis内存超过物理内存限制时,内存的数据会开始和磁盘产生交换,此时redis的性能急剧下降,基本上等于不可用。所以Redis提供了配置参数maxmemory来限制内存超出期望的大小。当内存超出maxmemory时,Redis提供了几种可选策略来让用户决定该如何腾出空间来继续提供服务:

1、noeviction:不会继续服务写请求(del请求可以继续服务),读请求可以继续进行。这是默认的淘汰策略
2、volatile-lru:尝试淘汰设置了过期时间的key,最少使用key将会被优先淘汰。没有设置过期时间的key不会被淘汰。
3、volatile-ttl:跟上面几乎一样,不过淘汰的策略不是LRU,而是比较key的剩余寿命ttl的值,ttl越小越优先被淘汰
4、volatile-random:跟上面几乎一模一样,不过淘汰的key时过期key集合中随机的key。
5、allkeys-lru:区别于volatile-lru,这个策略要淘汰的key是全体的key的集合,而不是过期的key集合。这意味着没有设置过期时间的key也会被淘汰。
6、allkeys-random:跟上面几乎是一模一样,不过淘汰的是随机的key。

3、近似LRU

Redis使用的是一种近似LRU算法,他跟LRU算法还不太一样,之所以不用LRU算法,是因为其需要消耗大量的额外内存。Redis为实现近似LRU算法,给每个key增加了一个额外的小字段 ,这个字段的长度是24bit,也就是最后一次被访问的时间戳。

LRU淘汰方式只有惰性处理,当Redis执行写操作时,发现超出maxmemory,就会执行一次LRU淘汰算法,这个算法就是随机采样出5(可设置)个key,然后淘汰掉最旧的key,如果淘汰后还是超过maxmemory,那就继续随机采样淘汰,直到内存低于maxmemory为止。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值