Redis系列之基础篇【上】

Redis系列之基础篇【上】

一. Redis单线程模型原理分析

在这里插入图片描述
Redis(全称:Remote Dictionary Server 远程字典服务)是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。要明白Redis是单线程的,但是为什么单线程还可以效率这么高并且支撑高并发呢?
如果想搞明白上面那个问题就要对上图有一定理解,现在我就开始讲讲上图的一些基本原理

  1. 首次连接,客户端与服务端进行通信是通过Socket的,用户请求与Redis的server socket 建立连接,当Redis的server socket收到连接请求时会产生一个AE_READABLE事件,Redis线程模型中的IO多路复用组件会监听server socket产生的事件,将监听到的事件压入到队列中,此时文件事件分派器得知队列中有事件后会根据事件连接到对应的事件处理器即连接应答处理器中进行处理,将socket 01的AE_READABLE和命令请求处理器关联。
  2. 由于上一步已经进行了连接,所以会维护一个socket01与客户端进行会话保持。当用户发送一个set key value 请求时,会产生一个AE_READABLE事件,Redis线程模型中的IO多路复用组件会监听server socket产生的事件,将监听到的事件压入到队列中,此时文件事件分派器得知队列中有事件后会根据事件连接到对应的事件处理器即命令请求处理器进行处理,在socket 01中读取出来的key和value,在自己的内存中完成keyhevalue的设置,并将socket 01的AE_WRITABLE和命令回复处理器关联。
  3. 当用户准备好去读取时,Redis的server socket会产生一个AE_WRITABLE事件,Redis线程模型中的IO多路复用组件会监听server socket产生的事件,将监听到的事件压入到队列中,此时文件事件分派器得知队列中有事件后会根据事件连接到对应的事件处理器即命令回复处理器进行处理,对socket 01输出本次操作的一个结果:ok,并将socket 01的AE_WRITABLE和命令回复处理器解除关联。
  4. 最后将结果返回给用户

上图的流程大体介绍到这里,现在我们来回答上面的那个问题,要真正搞懂上面的那个问题还需要理解一个概念:IO多路复用机制
IO多路复用机制可分为四种:

  • 同步阻塞IO(Blocking IO):即传统的IO模型。
  • 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
  • IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
  • 异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

现在是不是会有另外一个疑问什么是同步,什么是异步,什么是阻塞,什么是非阻塞
同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

Redis的IO多路复用采用的是同步非阻塞(NIO),所以它可以轮询多用户产生的事件,并且它是非阻塞,并不会等待一个IO操作彻底完成才返回到用户空间,它只是将用户产生的事件压入到队列中,又因为Redis是基于内存的单线程缓存,所以它不会像多线程一样频繁的切换上下文
解答上文问题:

  • 纯内存操作
  • 核心是基于非阻塞的IO多路复用机制
  • 避免了频繁的上下文切换

二. Redis的数据类型及内部数据结构解析

1.String

基本介绍
字符串类型是redis中最基本的数据类型,它能存储任何形式的字符串,包括二进制数据。你可以用它存储用户的
邮箱、json化的对象甚至是图片。一个字符类型键允许存储的最大容量是512M
常用命令
set key value 设置key value
get key 查看当前key的值
del key 删除key
append key value 如果key存在,则在指定的key末尾添加,如果key存在则类似set
strlen key 返回此key的长度

getrange key 0(开始位置) -1(结束位置) 获取指定区间范围内的值,类似between…and的关系 (0 -1)表示全部
setrange key 1(开始位置,从哪里开始设置) 具体值 设置(替换)指定区间范围内的值
setex 键 秒值 真实值 设置带过期时间的key,动态设置。
setnx key value 只有在 key 不存在时设置 key 的值。
mset key1 value key2 value 同时设置一个或多个 key-value 对。
mget key1 key 2 获取所有(一个或多个)给定 key 的值。
msetnx key1 value key2 value 同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在。
getset key value 将给定 key 的值设为 value ,并返回 key 的旧值(old value)。

以下几个命令只有在key值为数字的时候才能正常操作
incr key 为执定key的值加一
decr key 为指定key的值减一
incrby key 数值 为指定key的值增加数值
decrby key 数值 为指定key的值减数值

使用场景

  1. 计数器
      string类型的incr和decr命令的作用是将key中储存的数字值加一/减一,这两个操作具有原子性,总能安全地进行加减操作,因此可以用string类型进行计数,如微博的评论数、点赞数、分享数,抖音作品的收藏数,京东商品的销售量、评价数等。
  2. 分布式锁
      string类型的setnx的作用是“当key不存在时,设值并返回1,当key已经存在时,不设值并返回0”,“判断key是否存在”和“设值”两个操作是原子性地执行的,因此可以用string类型作为分布式锁,返回1表示获得锁,返回0表示没有获得锁。例如,为了保证定时任务的高可用,往往会同时部署多个具备相同定时任务的服务,但是业务上只希望其中的某一台服务执行定时任务,当定时任务的时间点触发时,多个服务同时竞争一个分布式锁,获取到锁的执行定时任务,没获取到的放弃执行定时任务。定时任务执行完时通过del命令删除key即释放锁,如果担心del命令操作失败而导致锁一直未释放,可以通过expire命令给锁设置一个合理的自动过期时间,确保即使del命令失败,锁也能被释放。不过expire命令同样存在失败的可能性,如果你用的是Java语言,建议使用JedisCommands接口提供的String set(String key, String value, String nxxx, String expx, long time)方法,这个方法可以将setnx和expire原子性地执行,具体使用方式如下(相信其它语言的Redis客户端也应当提供了类似的方法)。
jedisCommands.set("IAmAKey", "1", "NX", "EX", 60);//如果"IAmAKey"不存在,则将其设值为1,同时设置60秒的自动过期时间
  1. 存储对象
      利用JSON强大的兼容性、可读性和易用性,将对象转换为JSON字符串,再存储在string类型中,是个不错的选择,如用户信息、商品信息等。

  2. 共享session
    出于负载均衡的考虑,分布式服务会将用户信息的访问均衡到不同服务器上,用户刷新一次访问可能会需要重新登录,为避免这个问题可以用redis将用户session集中管理,在这种模式下只要保证redis的高可用和扩展性的,每次获取用户更新或查询登录信息都直接从redis中集中获取。

  3. 限速
    处于安全考虑,每次进行登录时让用户输入手机验证码,为了短信接口不被频繁访问,会限制用户每分钟获取验证码的频率。

内部数据结构

在Redis内部,String类型通过 int、SDS(simple dynamic string)作为结构存储,int用来存放整型数据,sds存放字
节/字符串和浮点型数据。在C的标准字符串结构下进行了封装,用来提升基本操作的性能,同时也充分利用已有的
C的标准库,简化实现逻辑。我们可以在redis的源码中【sds.h】中看到sds的结构如下;
typedef char *sds;
redis3.2分支引入了五种sdshdr类型,目的是为了满足不同长度字符串可以使用不同大小的Header,从而节省内
存,每次在创建一个sds时根据sds的实际长度判断应该选择什么类型的sdshdr,不同类型的sdshdr占用的内存空
间不同。这样细分一下可以省去很多不必要的内存开销,下面是3.2的sdshdr定义

`struct __attribute__ ((__packed__)) sdshdr8 {8表示字符串最大长度是2^8-1 (长度为255)`` uint8_t len;
//表示当前sds的长度(单位是字节)`` uint8_t alloc; 
//表示已为sds分配的内存大小(单 位是字节)`` unsigned char flags; 
//用一个字节表示当前sdshdr的类型,因为有sdshdr有五种类型,所 以至少需要3位来表示000:sdshdr5,001:sdshdr8,010:sdshdr16,011:sdshdr32,100:sdshdr64。高5位 用不到所以都为0。`` char buf[];
//sds实际存放的位置``
};`

在这里插入图片描述

2.List

基本介绍
列表类型(list)可以存储一个有序的字符串列表,常用的操作是向列表两端添加元素或者获得列表的某一个片段。
列表类型内部使用双向链表实现,所以向列表两端添加元素的时间复杂度为O(1), 获取越接近两端的元素速度就越
快。一个列表最多可以储存2的32次方-1个元素,这意味着即使是一个有几千万个元素的列表,获取头部或尾部的
10条记录也是很快的

常用命令
lpush key value1 value2 将一个或多个值加入到列表头部

rpush key value1 value2 将一个或多个值加入到列表底部

lrange key start end 获取列表指定范围的元素 (0 -1)表示全部

lpop key 移出并获取列表第一个元素

rpop key 移出并获取列表最后一个元素

lindex key index 通过索引获取列表中的元素

llen 获取列表长度

lrem key 0(数量) 值,表示删除全部给定的值。零个就是全部值 从left往right删除指定数量个值等于指定值的元素,返回的值为实际删除的数量

ltrim key start(从哪里开始截) end(结束位置) 截取指定索引区间的元素,格式是ltrim list的key 起始索引 结束索引

使用场景

  1. 消息队列
    list类型的lpop和rpush(或者反过来,lpush和rpop)能实现队列的功能,故而可以用Redis的list类型实现简单的点对点的消息队列。不过我不推荐在实战中这么使用,因为现在已经有Kafka、NSQ、RabbitMQ等成熟的消息队列了,它们的功能已经很完善了,除非是为了更深入地理解消息队列,不然我觉得没必要去重复造轮子。
  2. 文章列表
    每个用户都有属于自己的文章列表,现在需要分页展示文章列表,此时可以考虑使用列表,列表不但有序,同时支持按照索引范围获取元素。
  3. 列表技巧
  • lpush+lpop=Stack(栈)
  • lpush+rpop=Queue(队列)
  • lpush+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息队列)

内部数据结构
redis3.2之前,List类型的value对象内部以linkedlist或者ziplist来实现, 当list的元素个数和单个元素的长度比较小
的时候,Redis会采用ziplist(压缩列表)来实现来减少内存占用。否则就会采用linkedlist(双向链表)结构。
redis3.2之后,采用的一种叫quicklist的数据结构来存储list,列表的底层都由quicklist实现。
这两种存储方式都有优缺点,双向链表在链表两端进行push和pop操作,在插入节点上复杂度比较低,但是内存开
销比较大; ziplist存储在一段连续的内存上,所以存储效率很高,但是插入和删除都需要频繁申请和释放内存;
quicklist仍然是一个双向链表,只是列表的每个节点都是一个ziplist,其实就是linkedlist和ziplist的结合,quicklist
中每个节点ziplist都能够存储多个数据元素,在源码中的文件为【quicklist.c】,在源码第一行中有解释为:A
doubly linked list of ziplists意思为一个由ziplist组成的双向链表;
在这里插入图片描述

3.Set

基本介绍
集合类型中,每个元素都是不同的,也就是不能有重复数据,同时集合类型中的数据是无序的。一个集合类型键可
以存储至多232-1个 。集合类型和列表类型的最大的区别是有序性和唯一性
集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在。由于集合类型在redis内部是使用的值
为空的散列表(hash table),所以这些操作的时间复杂度都是O(1)
常用命令
sadd key value1 value 2 向集合中添加一个或多个成员

smembers key 返回集合中所有成员

sismembers key member 判断member元素是否是集合key的成员

scard key 获取集合里面的元素个数

srem key value 删除集合中指定元素

srandmember key 数值 从set集合里面随机取出指定数值个元素 如果超过最大数量就全部取出,

spop key 随机移出并返回集合中某个元素

smove key1 key2 value(key1中某个值) 作用是将key1中执定的值移除 加入到key2集合中

sdiff key1 key2 在第一个set里面而不在后面任何一个set里面的项(差集)

sinter key1 key2 在第一个set和第二个set中都有的 (交集)

sunion key1 key2 两个集合所有元素(并集)

使用场景

  1. 好友/关注/粉丝/感兴趣的人集合
      set类型唯一的特点使得其适合用于存储好友/关注/粉丝/感兴趣的人集合,集合中的元素数量可能很多,每次全部取出来成本不小,set类型提供了一些很实用的命令用于直接操作这些集合,如
        a. sinter命令可以获得A和B两个用户的共同好友
       b. sismember命令可以判断A是否是B的好友
      c. scard命令可以获取好友数量
      c. 关注时,smove命令可以将B从A的粉丝集合转移到A的好友集合
      
      需要注意的是,如果你用的是Redis Cluster集群,对于sinter、smove这种操作多个key的命令,要求这两个key必须存储在同一个slot(槽位)中,否则会报出 (error) CROSSSLOT Keys in request don’t hash to the same slot 错误。Redis Cluster一共有16384个slot,每个key都是通过哈希算法CRC16(key)获取数值哈希,再模16384来定位slot的。要使得两个key处于同一slot,除了两个key一模一样,还有没有别的方法呢?答案是肯定的,Redis提供了一种Hash Tag的功能,在key中使用{}括起key中的一部分,在进行 CRC16(key) mod 16384 的过程中,只会对{}内的字符串计算,例如friend_set:{123456}和fans_set:{123456},分别表示用户123456的好友集合和粉丝集合,在定位slot时,只对{}内的123456进行计算,所以这两个集合肯定是在同一个slot内的,当用户123456关注某个粉丝时,就可以通过smove命令将这个粉丝从用户123456的粉丝集合移动到好友集合。相比于通过srem命令先将这个粉丝从粉丝集合中删除,再通过sadd命令将这个粉丝加到好友集合,smove命令的优势是它是原子性的,不会出现这个粉丝从粉丝集合中被删除,却没有加到好友集合的情况。然而,对于通过sinter获取共同好友而言,Hash Tag则无能为力,例如,要用sinter去获取用户123456和456789两个用户的共同好友,除非我们将key定义为{friend_set}:123456和{friend_set}:456789,否则不能保证两个key会处于同一个slot,但是如果真这样做的话,所有用户的好友集合都会堆积在同一个slot中,数据分布会严重不均匀,不可取,所以,在实战中使用Redis Cluster时,sinter这个命令其实是不适合作用于两个不同用户对应的集合的(同理其它操作多个key的命令)。

  2. 黑名单/白名单
      经常有业务出于安全性方面的考虑,需要设置用户黑名单、ip黑名单、设备黑名单等,set类型适合存储这些黑名单数据,sismember命令可用于判断用户、ip、设备是否处于黑名单之中。

内部数据结构
Set在的底层数据结构以intset或者hashtable来存储。当set中只包含整数型的元素时,采用intset来存储,否则,
采用hashtable存储,但是对于set来说,该hashtable的value值用于为NULL。通过key来存储元素

4.Zset

基本介绍
有序集合类型,顾名思义,和前面讲的集合类型的区别就是多了有序的功能
在集合类型的基础上,有序集合类型为集合中的每个元素都关联了一个分数,这使得我们不仅可以完成插入、删除
和判断元素是否存在等集合类型支持的操作,还能获得分数最高(或最低)的前N个元素、获得指定分数范围内的元
素等与分数有关的操作。虽然集合中每个元素都是不同的,但是他们的分数却可以相同

常用命令
zadd key score 值 score 值 向集合中添加一个或多个成员

zrange key 0 -1 表示所有 返回指定集合中所有value

zrange key 0 -1 withscores 返回指定集合中所有value和score

zrangebyscore key 开始score 结束score 返回指定score间的值

zrem key score某个对应值(value),可以是多个值 删除元素

zcard key 获取集合中元素个数

zcount key 开始score 结束score 获取分数区间内元素个数

zrank key vlaue 获取value在zset中的下标位置(根据score排序)

zscore key value 按照值获得对应的分数

使用场景

  1. 排行榜

内部数据结构
zset类型的数据结构就比较复杂一点,内部是以ziplist或者skiplist+hashtable来实现,这里面最核心的一个结构就
是skiplist,也就是跳跃表
在这里插入图片描述

5.Hash

基本介绍
常用命令
使用场景

  1. 购物车
    以用户id为key,商品id为field,商品数量为value,恰好构成了购物车的3个要素,如下图所示。
    在这里插入图片描述
  2. 存储对象
    hash类型的(key, field, value)的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。在介绍string类型的应用场景时有所介绍,string + json也是存储对象的一种方式,那么存储对象时,到底用string + json还是用hash呢?两种存储方式的对比如下图表所示。
    在这里插入图片描述
    当对象的某个属性需要频繁修改时,不适合用string+json,因为它不够灵活,每次修改都需要重新将整个对象序列化并赋值,如果使用hash类型,则可以针对某个属性单独修改,没有序列化,也不需要修改整个对象。比如,商品的价格、销量、关注数、评价数等可能经常发生变化的属性,就适合存储在hash类型里。
      当然,不常变化的属性存储在hash类型里也没有问题,比如商品名称、商品描述、上市日期等。但是,当对象的某个属性不是基本类型或字符串时,使用hash类型就必须手动进行复杂序列化,比如,商品的标签是一个标签对象的列表,商品可领取的优惠券是一个优惠券对象的列表(如下图所示)等,即使以coupons(优惠券)作为field,value想存储优惠券对象列表也还是要使用json来序列化,这样的话序列化工作就太繁琐了,不如直接用string + json的方式存储商品信息来的简单。
      
    内部数据结构
    map提供两种结构来存储,一种是hashtable、另一种是前面讲的ziplist,数据量小的时候用ziplist. 在redis中,哈
    希表分为三层,分别是,源码地址【dict.h】
    dictEntry
    管理一个key-value,同时保留同一个桶中相邻元素的指针,用来维护哈希桶的内部链;
typedef struct dictEntry {
 void *key; 
 union { //因为value有多种类型,所以value用了union来存储
 void *val;
 uint64_t u64;
 int64_t s64;
 double d; } v; 
 struct dictEntry *next;//下一个节点的地址,用来处理碰撞,所有分配到同一索引的元素通过next指针 链接起来形成链表key和v都可以保存多种类型的数据 } dictEntry;

dictht
实现一个hash表会使用一个buckets存放dictEntry的地址,一般情况下通过hash(key)%len得到的值就是buckets的
索引,这个值决定了我们要将此dictEntry节点放入buckets的哪个索引里,这个buckets实际上就是我们说的hash
表。dict.h的dictht结构中table存放的就是buckets的地址

typedef struct dictht {
 dictEntry **table;//buckets的地址 
 unsigned long size;//buckets的大小,总保持为 2^n 
 unsigned long sizemask;//掩码,用来计算hash值对应的buckets索引 
 unsigned long used;//当前dictht有多少个dictEntry节点 
 } dictht;

dict
dictht实际上就是hash表的核心,但是只有一个dictht还不够,比如rehash、遍历hash等操作,所以redis定义了
一个叫dict的结构以支持字典的各种操作,当dictht需要扩容/缩容时,用来管理dictht的迁移,以下是它的数据结
构,源码在

typedef struct dict { 
dictType *type;
//dictType里存放的是一堆工具函数的函数指针,
 void *privdata;//保存type中的某些函数需要作为参数的数据 
 dictht ht[2];//两个dictht,ht[0]平时用,ht[1] rehash时用 
 long rehashidx; //当前rehash到buckets的哪个索引,-1时表示非rehash状态 
 int iterators; //安全迭代器的计数。 
 } dict;

比如我们要讲一个数据存储到hash表中,那么会先通过murmur计算key对应的hashcode,然后根据hashcode取
模得到bucket的位置,再插入到链表中

注:上文对于底层的数据结构进行了部分展示,如果读者想要深入研究可以去看Redis中对应的源码

发布了25 篇原创文章 · 获赞 30 · 访问量 1149
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章