Redis的应用非常广泛,现在互联网公司基本都有用到。Redis可以用作数据库,缓存,以及消息中间件。下面会分几个章节,对Redis作详细介绍
一、Redis简介
Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。 Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。
二、Redis的存储类型
1.字符串类型
Redis中最基本的数据类型,可以存储任何形式的字符串,包括二进制数据。一个字符类型件允许存储的最大容量是512M
内部数据结构
String类型通过int,SDS(simple dynamic string)作为存储结构,int用来存放整型数据,SDS用来存放字符串,字节,浮点型数据。在Redis源码中,为SDS定义了5sdshdr类型。
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; //表示当前sds的长度(单位是字节)
uint8_t alloc; //表示已为sds分配的内存大小(单位是字节)
unsigned char flags; //用一个字节表示当前sdshdr的类型,因为有sdshdr有五种类型,所以至少需要3位来表示000:sdshdr5,001:sdshdr8,010:sdshdr16,011:sdshdr32,100:sdshdr64。
char buf[]; //sds实际存放的位置
};
以上只是其中一种,还有sdshdr5,sdshdr16,sdshdr32,sdshdr64,目的是为了满足不同长度字符串可以使用不同大小的Header,从而节省内存。
2.列表类型
列表类型可以存储有序的字符串列表,常用的操作是想列表两端添加元素或者获得某一片段。
内部数据结构
Redis3.2之前,list底层是采用ziplist加linkedlist,当数据量或元素长度小的时候会用ziplist减小内存占用,否则会用linkedlist
Redis3.2之后,list底层会采用quicklist,只是每个节点ziplist,其实就是ziplist和quicklist,结构图如下:
3、hash类型
内部数据结构
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;
dicht
哈希表的核心,实现一个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还不够,比如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;
4.集合类型
里面的存放的数据不能有重复的,是无序的。
内部数据结构
Set在的底层数据结构以intset或者hashtable来存储。当set中只包含整数型的元素时,采用intset来存储,否则,采用hashtable存储,但是对于set来说,该hashtable的value值用于为NULL。通过key来存储元素。这一点类似于Java里的HashSet
5.有序集合
顾名思义,就是多了有序的功能的集合。
内部数据结构
内部是以ziplist或者skiplist+hashtable来实现,这里面最核心的一个结构就是skiplist,也就是跳跃表。
三、内部原理
1.过期删除的设置和原理
语法: expire key seconds
原理:有两种方法:积极方法和消极方法
消极方法:主键访问时发现已失效,就删除它。
积极方法:周期性地从已经设置的失效时间的key中选取一部分失效的key删除
2.发布订阅模式
语法:publish channel message
如果发布端发布了消息,但是还没有消费端订阅,结果会返回0,并且之前的消息都收不到了。
消费端订阅的语法,语法是:subscribe channel
3.持久化
有两种方式:RDB和AOF
RDB:
类似于快照的方式,当符合一定条件时,Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到一个临时文件中,等到持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能。如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。RDB的缺点是最后一次持久化后的数据可能丢失
fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
Redis在以下几种情况会进行快照:
- 根据配置规则进行自动快照
- 用户执行SAVE或者GBSAVE命令
- 执行FLUSHALL命令
- 执行复制(replication)时
AOF:
当使用Redis存储非临时数据时,一般需要打开AOF持久化来降低进程终止导致的数据丢失。AOF可以将Redis执行的每一条写命令追加到硬盘文件中,这一过程会降低Redis的性能,但大部分情况下这个影响是能够接受的,另外使用较快的硬盘可以提高AOF的性能
4.内存回收策略
当内存空间不足的时候,会淘汰某些对象,释放这些对象占用的空间,默认策略是noeviction策略,当内存使用达到阈值的时候,所有引起申请内存的命令会报错
还有以下策略:
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
适合的场景: 如果我们的应用对缓存的访问都是相对热点数据,那么可以选择这个策略 - allkeys-random:随机移除某个key。
适合的场景:如果我们的应用对于缓存key的访问概率相等,则可以使用这个策略 - volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
适合场景:这种策略使得我们可以向Redis提示哪些key更适合被淘汰,我们可以自己控制
5.Redis是单线程的,为什么性能这么快?
官方的解释是,CPU并不是Redis的瓶颈所在,Redis的瓶颈主要在机器的内存和网络的带宽。
用到了异步阻塞,也就是IO多路复用。
6.在Redis中使用Lua脚本
在使用Redis的时候会产生以下问题:
原子性问题:
redis虽然是单一线程的,当时仍然会存在线程安全问题,当然,这个线程安全问题不是来源于Redis服务器内部。而是Redis作为数据服务器,是提供给多个客户端使用的。多个客户端的操作就相当于同一个进程下的多个线程,如果多个客户端之间没有做好数据的同步策略,就会产生数据不一致的问题。
效率问题:
redis本身的吞吐量是非常高的,因为它首先是基于内存的数据库。在实际使用过程中,有一个非常重要的因素影响redis的吞吐量,那就是网络。我们在使用redis实现某些特定功能的时候,很可能需要多个命令或者多个数据类型的交互才能完成,那么这种多次网络请求对性能影响比较大。当然redis也做了一些优化,比如提供了pipeline管道操作,但是它有一定的局限性,就是执行的多个命令和响应之间是不存在相互依赖关系的。所以我们需要一种机制能够编写一些具有业务逻辑的命令,减少网络请求。
这个时候,Lua脚本就再合适不过了。使用Lua脚本解决了上面两个问题,所有的命令放在一个脚本整体执行,中间不会被命令插入。减少了网络开销,所有命令在一个脚本中运行。
四、集群
实际生产中,我们会采用Redis集群,来避免单点故障
主从复制
全量复制
一般发生在slave初始化阶段,这是slave需要将master的所有数据复制一份。master/slave 复制策略是采用乐观复制,也就是说可以容忍在一定时间内master/slave数据的内容是不同的,但是两者的数据会最终同步。具体来说,redis的主从同步过程本身是异步的,意味着master执行完客户端请求的命令后会立即返回结果给客户端,然后异步的方式把命令同步给slave。
增量复制
主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份。
无硬盘复制
Redis复制的工作原理基于RDB方式的持久化实现的,也就是master在后台保存RDB快照,slave接收到rdb文件并载入,但是这种方式会存在一些问题。
- 当master禁用RDB时,如果执行了复制初始化操作,Redis依然会生成RDB快照,当master下次启动时执行该
RDB文件的恢复,但是因为复制发生的时间点不确定,所以恢复的数据可能是任何时间点的。就会造成数据出现问
题 - 当硬盘性能比较慢的情况下(网络硬盘),那初始化复制过程会对性能产生影响
因此2.8.18以后的版本,Redis引入了无硬盘复制选项,可以不需要通过RDB文件去同步,直接发送数据,通过以
下配置来开启该功能
repl-diskless-sync yes
master在内存中直接创建rdb,然后发送给slave,不会在自己本地落地磁盘了。
哨兵机制
内部原理
监控Redis的运行机制。一是监控master和slave是否正常运行。二是master如果挂掉,自动将slave升级为master。
哨兵是一个独立的进程,示意图如下:
但是哨兵的可用性如何保障呢?这又引出一个哨兵集群,用于解决哨兵的单点故障。如图:
这个时候就需要哨兵之间互相感知了。具体如何实现?
- 需要相互感知的sentinel都向他们共同监视的master节点订阅channel:sentinel:hello
- 新加入的sentinel节点向这个channel发布一条消息,包含自己本身的信息,这样订阅了这个channel的sentinel就可以发现这个新的sentinel
- 新加入得sentinel和其他sentinel节点建立长连接
配置实现
redis文件目录里有sentinel.conf文件,里面有所有的配置信息
…
配置很多,这里暂时先不细说。
五、Redis集群
使用了哨兵,Redis的每个节点仍然存有集群中所有的数据。这样集群中的总存储能力受限于最小存储量的节点,形成木桶效应。采用集群后,可以利用数据分区将数据存储在多的节点,每个节点负责整个数据的子集。
结构和分区
分布式数据库首要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整个数据的一个子集, Redis Cluster采用哈希分区规则,采用虚拟槽分区。
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有整数定义为槽(slot)。比如Redis Cluster槽的范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。
采用大范围的槽的主要目的是为了方便数据的拆分和集群的扩展,每个节点负责一定数量的槽。
HashTag
当我们要执行MSET命令,这是一个原子操作。在集群环境下,会存在某些指定的key被更新,而另外一些指定的key没有改变,原因是多key可能会被分配到不同的机器上。我们既想让key尽可能分散到不同机器上,又想让某些key出现在同一机器上,我们该如何解决这个矛盾的问题?
Redis中引入了HashTag的概念,可以让算法只对key的某一部分进行计算,让key落到相同数据分片。
举个例子:
person:person1:id、person:person1:name 那么通过hashtag的方式,
person:{person1}:id、person:{person1}.name; 表示
当一个key包含 {} 的时候,就不对整个key做hash,而仅对 {} 包括的字符串做hash。
分片迁移
在一个稳定的Redis cluster下,每一个slot对应的节点是确定的,但是在某些情况下,节点和分片对应的关系会发生变更
- 新加入master节点
- 某个节点宕机
也就是说当动态添加或减少node节点时,需要将16384个槽做个再分配,槽中的键值也要迁移。
当然,这一过程,在目前实现中,还处于半自动状态,需要人工介入。
新增一个主节点
新增一个节点D,redis cluster的这种做法是从各个节点的前面各拿取一部分slot到D上。大致就会变成这样:
节点A覆盖1365-5460
节点B覆盖6827-10922
节点C覆盖12288-16383
节点D覆盖0-1364,5461-6826,10923-12287
删除一个主节点
先将节点的数据移动到其他节点上,然后才能执行删除
槽迁移
简单的工作流程
- 向MasterB发送状态变更命令,吧Master B对应的slot状态设置为IMPORTING
- 向MasterA发送状态变更命令,将Master对应的slot状态设置为MIGRATING
MIGRATING状态
- 如果客户端访问的Key还没有迁移出去,则正常处理这个key
- 如果key已经迁移或者根本就不存在这个key,则回复客户端ASK信息让它跳转到MasterB去执行
IMPORTING状态
当MasterB的状态设置为IMPORTING后,表示对应的slot正在向MasterB迁入,及时Master仍然能对外提供该slot的读写服务,但和通常状态下也是有区别的
- 当来自客户端的正常访问不是从ASK跳转过来的,说明客户端还不知道迁移正在进行,很有可能操作了一个目前还没迁移完成的并且还存在于MasterA上的key,如果此时这个key在A上已经被修改了,那么B和A的修改则会发生冲突。所以对于MasterB上的slot上的所有非ASK跳转过来的操作,MasterB都不会去处理,而是通过MOVED命令让客户端跳转到MasterA上去执行
这样的状态控制保证了同一个key在迁移之前总是在源节点上执行,迁移后总是在目标节点上执行,防止出现两边同时写导致的冲突问题。而且迁移过程中新增的key一定会在目标节点上执行,源节点也不会新增key,是的整个迁移过程既能对外正常提供服务,又能在一定的时间点完成slot的迁移。
Redis实战
客户端
Redis客户端有很多开源产品,比如Jedis、Redission、lettuce,具体到代码上怎么实现,做的时候自己去查API
分布式锁的实现
Redission这个客户端封装了很多功能,比如分布式锁,原子操作,布隆过滤器,队列,我们可以通过他的API去实现(这里我想到了zookeeper中,利用其临时有序节点,可以实现分布式锁…)
管道
客户端发起请求,服务端返回数据,这个过程是阻塞的。在批量处理数据时,延迟问题就会比较严重,Redis为了弥补这个问题,引入了管道技术。
效果:服务端未响应时,客户端可以一直发送命令,服务端最终返回所有响应,大大提升响应效果。
Redis的应用架构
缓存与数据一致性问题
当数据发生变化时,我们是先操作数据库还是先操作缓存?是更新缓存还是让缓存失效?
首先,针对第一个问题,操作数据库和操作缓存是无法保证原子性的。所以我们需要根据业务场景来选择,那种方案对业务场景影响最小来决定。
同样,第二个问题,根据具体的场景,如果更新缓存的代价比较大,比如调用多个接口才实现,这个时候可以选择先让缓存失效。
缓存雪崩的解决方案
什么是缓存雪崩:大量的key设置了相同的过期时间(或者缓存服务器宕机),导致某一时刻缓存集体失效,大量的请求转发的数据库层面,导致崩溃。
解决方案:
- 对缓存的访问,如果发现从缓存中取不到值,那么通过加锁或者队列的方式保证缓存的单进程操作,从而避免失效时并发请求全部落到底层的存储系统上;但是这种方式会带来性能上的损耗。
- 将缓存失效的时间分散,降低每一个缓存过期时间的重复率。
- 如果是因为缓存服务器故障导致的问题,一方面需要保证缓存服务器的高可用;另一方面,应用程序中可以采用多级缓存。
缓存穿透的解决方案
什么是缓存穿透:查询一个根本不存在的值,缓存中没有,就会去后端数据库中去查找,如果大量请求过来,会对DB产生较大的压力。
解决方案:
- 如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。比如,”key” , “&&”。
- 根据缓存数据Key的设计规则,将不符合规则的key进行过滤。如布隆过滤器。
布隆过滤器
假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数组上面的一个点,然后将位数组对应的位置标记为1。查询W元素是否存在集合中的时候,同样的方法将W通过哈希映射到位数组上的3个点。如果3个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。反之,如果3个点都为1,则该元素可能存在集合中