Bootstrap

Redis-学习笔记

Remote Dictionary Server(Redis) 是一个开源的使用 ANSI C 语言编写、遵守 BSD 协议、支持网络、可基于内存、分布式、可选持久性的键值对(Key-Value)存储数据库,并提供多种语言的 API,是跨平台的非关系型数据库。
Redis 通常被称为数据结构服务器,因为值(value)可以是字符串(String)、哈希(Hash)、列表(list)、集合(sets)和有序集合(sorted sets)等类型。

一、安装(Win)

一般来说Redis的安装都是在Linux上进行的,而Redis官网也是没有直接提供Windows的安装包,因此如果想在Win上使用,需要我们用点特殊手段

1、下载安装包

GtiHub上的tporadowski 大神提供了 支持 Windows平台的 Redis 安装包,目前仍在维护,目前最新版本是 5.0.14,但是更新速度跟Redis官网相差好几个大版本。

下载地址:Releases · tporadowski/redis · GitHub

(能用就行,要啥自行车)

2、解压

这里是zip压缩包,我直接解压即可,选择自己想要的路径

3、启动Redis

打开cmd窗口,切换到Redis安装路径,输入 redis-server 启动 redis 服务

4、测试功能

重新打开CMD窗口,进入redis窗口,输入:redis-cli,建立连接

 

二、Java客户端

1、引入依赖

SpringBoot的前置依赖大家自己引入一下,我们主要接收redis使用需要的依赖:Spring Data Redis

Spring Data Redis 是 Spring 的一部分,提供了在 Spring 应用中通过简单的配置就可以访问 Redis 服务,对 Redis 底层开发包进行了高度封装。在 Spring 项目中,可以使用Spring Data Redis来简化 Redis 操作。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Data Redis中提供了一个高度封装的类:RedisTemplate,对相关api进行了归类封装,将同一类型操作封装为operation接口,具体分类如下:

ValueOperations:string数据操作

SetOperations:set类型数据操作

ZSetOperations:zset类型数据操作

HashOperations:hash类型的数据操作

ListOperations:list类型的数据操作

2、设置配置类

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfiguration {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        //设置redis的连接工厂对象
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //设置redis key的序列化器
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

注:当前配置类不是必须的,因为 Spring Boot 框架会自动装配 RedisTemplate 对象,但是默认的key序列化器为JdkSerializationRedisSerializer,导致我们存到Redis中后的数据和原始数据有差别,故设置为StringRedisSerializer序列化器。

3、配置yml文件

server:
  port: 8080
spring:
  data:
    redis:
      host: localhost
      port: 6379

4、测试功能

创建一个test类,将RedisTemplate自动装配注入进来。

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class MainTest {

    @Autowired
    private RedisTemplate<Object, Object> redisTemplate;

    @Test
    public void test(){
        ValueOperations<Object, Object> valueOperations = redisTemplate.opsForValue();
        // 添加数据测试
        valueOperations.set("redis::test","Hello World");
        // 获取数据测试
        System.out.println(valueOperations.get("redis::test"));
    }
}

操作其他类型的数据同上,这里不做赘述,更换redisTemplate的方法获取不同的类型即可:

//string数据操作
ValueOperations valueOperations = redisTemplate.opsForValue();
//hash类型的数据操作
HashOperations hashOperations = redisTemplate.opsForHash();
//list类型的数据操作
ListOperations listOperations = redisTemplate.opsForList();
//set类型数据操作
SetOperations setOperations = redisTemplate.opsForSet();
//zset类型数据操作
ZSetOperations zSetOperations = redisTemplate.opsForZSet();

三、数据类型

Redis定义了丰富的原语命令,可以直接与Redis服务器交互。

但是,实际应用中,我们不太会直接使用这些原语命令,Redis提供了很多客户端,大多情况下我们是通过各式各样的客户端来操作Redis。

但是,任何语言的客户端实际上都是对Redis原语命令的封装,了解原语命令有助于理解客户端的设计原理,知其然,知其所以然。

众所周知,Redis支持五中数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及zset(sortedset:有序集合)

那么具体怎么使用他们呢?

1、String

简介:Redis的字符串是动态字符串,是可以修改的字符串,它的内部表示就是一个字符数组,内部结构的实现类似于Java的ArrayList,它的内部结构是一个带长度信息的字节数组。

特性:可以包含任何数据,比如jpg图片或者序列化的对象,规定字符串的长度不得超过512MB。Redis的字符串有两种存储方式,在长度特别短时,使用embstr形势存储,而长度超过44字节时候,使用raw形势存储

场景:

1、访问量统计:每次访问博客和文章使用 INCR 命令进行递增

2、将数据以二进制序列化的方式进行存储

String类型有三个特点:

1、String是Redis最基本的数据类型,结构为一个key对应一个value。

2、String类型是二进制安全的,意味着可以包含任何数据,比如jpg图片或者序列化的对象。
3、String类型的最大能存储512M。

Redis的原语命令很简单,而且有规律可循,一句话概括,就是干净利索脆,我们列举以下常用命令:

命令说明
SET key value设置指定 key 的值
GET key获取指定 key 的值
SETNX key value只有在 key 不存在时设置 key 的值
SETRANGE key offset value用 value 参数覆写给定 key 所储存的字符串值,从偏移量 offset 开始
GETRANGE key start end返回 key 中字符串值的子字符
MSET key value [key value …]Multi Set)同时设置一个或多个 key-value 对
MGET key1 [key2…]获取所有(一个或多个)给定 key 的值
SETEX key seconds value(Set Expire)将值 value 关联到 key ,并将 key 的过期时间设为 seconds (以秒为单位)
PSETEX key milliseconds value(Precise Set Expire)这个命令和 SETEX 命令相似,但它以毫秒为单位设置 key 的生存时间,而不是像 SETEX 命令那样,以秒为单位

比如我们想设置往Redis中存放一个用户名,用String类型存储:

127.0.0.1:6379> SET name chenlongfei

OK

“OK”是Redis返回的响应,代表设置成功。

取出这个name的值:

127.0.0.1:6379> GET name

“chenlongfei”

想修改name的值为“clf”,重新SET一遍,覆盖掉原来的值:

127.0.0.1:6379> SET name clf

OK

127.0.0.1:6379> GET name

“clf”

想删除该条数据:

127.0.0.1:6379> DEL name

(integer) 1 --该数字代表影响的记录总数

127.0.0.1:6379> GET name

(nil) --nil代表为空,不存在该对象

增删改查命令一分钟学会,想忘记都难,妈妈再也不用担心我的学习!

2、Hash

Redis的哈希是field和value之间的映射,即键值对的集合,所以特别适合用于存储对象。

Redis 中每个 hash 最多可以存储 232 - 1 键值对(40多亿)。

简介:Redis的字典相当于Java语言里面的HashMap,字典结构内部包含了两个Hashtable,通常情况下只有一个Hashtable是有值的,但是在字典扩容缩容时候,需要重新分配新的Hashtable,然后进行渐进式搬迁,这时候两个Hashtable存储的分别是旧的Hashtable和新的Hashtable;待搬迁结束后,旧的Hashtable被删除,新的Hashtable取而代之。

特性:适合存储对象,并且可以像数据库中update一个属性一样只修改某一项属性值(Memcached中需要取出整个字符串反序列化成对象修改完再序列化存回去)。大字典的扩容是比较耗时的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的Redis很难承受这样耗时的过程,所以Redis使用渐进式rehash小步搬迁虽然慢一点,但是肯定可以搬完。

场景:存储、读取、修改对象属性,比如:用户(姓名、性别、爱好),文章(标题、发布时间、作者、内容)

命令说明
HMSET key field1 value1 [field2 value2… ](Hash Multi Set)同时将多个 field-value 对设置到哈希表 key 中
HMGET key field1 [field2…]获取所有给定字段的值
HSET key field value将哈希表 key 中的字段 field 的值设为 value
HGET key field获取存储在哈希表中指定字段的值
HGETALL key获取在哈希表中指定 key 的所有字段和值
HDEL key field2 [field2]删除一个或多个哈希表字段
HSETNX key field value只有在字段 field 不存在时,设置哈希表字段的值
HKEYS key获取所有哈希表中的字段
HVALS key获取哈希表中所有值

例如,我们想在Redis中存储一个用户信息,包括用户ID,用户名,邮箱地址三个字段:

127.0.0.1:6379>HMSET user_1 userId 123 userName clf email [email protected]

OK

127.0.0.1:6379> HGETALL user_1

  1. “userId”

  2. “123”

  3. “userName”

  4. “clf”

  5. “email”

  6. [email protected]

3、List

Redis列表是简单的字符串列表,按照插入顺序排序。

支持添加一个元素到列表头部(左边)或者尾部(右边)的操作。

一个列表最多可以包含 232- 1 ,即超过40亿个元素。

简介:Redis的列表相当于Java的LinkedList,List的结构底层实现不是一个简单的LinkedList,而是快速链表(quicklist)。首先在列表元素较少的情况下,会使用一块连续的内存存储,这个结构是ziplist,即压缩列表。它将所有的元素彼此紧挨着一起存储,分配的是一块连续的内存;当数据量比较多的时候才会改成quicklist。

特性:增删快,提供了操作某一段元素的API,普通的链表需要的附加指针空间太大,会浪费空间,加重内存的碎片化。Redis将链表和ziplist结合起来组成了quicklist,也就是将多个ziplist使用双向指针串联起来使用,既满足了快速的插入删除性能,又不会出现太大的空间冗余。

场景:

1、最新消息排行等功能(比如朋友圈的时间线)

2、消息队列

我们列举以下它的命令:

在这里插入图片描述在这里插入图片描述
在这里插入图片描述

例如,我们想用一个名为“Continents”的列表盛放五大洲的名字:

127.0.0.1:6379> LPUSH Continents Asia Africa America Oceania Antarctica

(integer) 5

127.0.0.1:6379> LRANGE Continents 0 4 --获取下标为0~4的元素

  1. “Antarctica”

  2. “Oceania”

  3. “America”

  4. “Africa”

  5. “Asia”

需要注意的是,Redis列表虽然名为列表,其实从特性上来讲更像是栈,以最近放进去的元素为头,以最早放进去的元素为尾,所以,Redis列表的下标呈倒序排列。上例中依次放进去的五个元素:Asia、Africa、America、Oceania、Antarctica,下标分别为4、3、2、1、0。

这与Java中List的概念完全不一样,需要特别注意。

与栈类似,当执行POP操作时,Redis列表弹出的是最新放进去的元素,类似于栈顶元素。

Redis列表还支持一种阻塞式操作,比如BLPOP(Blockd List Pop之缩写),移出并获取列表的第一个元素,如果列表没有元素(或列表不存在)会阻塞列表直到等待超时或发现可弹出元素为止。

例如,我们对一个不存在的列表“myList”执行BLPOP命令:

27.0.0.1:6379> BLPOP myList 20 – 弹出myList列表的第一个元素,如果没有,阻塞20秒该客户端会进入阻塞状态,如果20秒之内该列表存入了元素,则弹出:

27.0.0.1:6379> BLPOP myList 20 --若无元素则进入阻塞状态,限时20秒

  1. “myList”

  2. “hello”

(6.20s)

如果超时后仍然没有等到元素,则结束阻塞,返回nil:

127.0.0.1:6379> BLPOP myList 20

(nil)

(20.07s)

4、set(集合)

Redis集合是String类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

Redis集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。

集合中最大的成员数为 2^32- 1 ,即每个集合最多可存储40多亿个成员。

Redis集合还有两大特点,一是支持随机获取元素,二是支持集合间的取差集、交集与并集操作。

简介:Redis的集合相当于Java语言里面的HashSet,内部的键值对是无须的、唯一的,Set的结构底层实现是字典,只不过所有的value都是NULL,其他的特性和字典一摸一样。

特性:

1、添加、删除、查找的复杂度都是O(1)

2、为集合提供了求交集、并集、差集等操作

当set集合容纳的元素都是整数并且元素个数较少时,Redis会使用intset来存储集合元素。intset是紧凑的数组结构,同时支持16位,32位和64位整数

场景:

1、共同好友

2、利用唯一性,统计访问网站的所有独立ip

3、好友推荐时,根据tag求交集,大于某个阈值就可以推荐

集合的一大特点就是不能有重复元素,如果插入重复元素,Redis会忽略该操作:

127.0.0.1:6379> SADD direction east west south north

(integer) 4

127.0.0.1:6379> SMEMBERS direction

  1. “west”

  2. “east”

  3. “north”

  4. “south”

127.0.0.1:6379> SADD direction east

(integer) 0 --east元素已经存在,该操作无效

127.0.0.1:6379> SMEMBERS direction

  1. “west”

  2. “east”

  3. “north”

  4. “south”

5、SortedSet(有序集合)

Redis 有序集合和集合一样也是String类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个double类型的分数。Redis正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。

集合中最大的成员数为 232- 1 ,即每个集合最多可存储40多亿个成员。

简介:Redis有序列表类似于Java的SortedSet和HashMap的结合体,一方面是一个set,保证内部value的唯一性,另一方面可以给每个value赋予一个score,代表这个value的排序权重。它的内部实现是一个Hash字典 + 一个跳表。

特性:

数据插入集合时,已经进行天然排序

Redis的跳表共有64层,能容纳2的64次方个元素。

Redis之所以用跳表来实现有序集合

  1. 插入、删除、查找以及迭代输出有序序列这几个操作,红黑树都能完成,时间复杂度跟跳表是一样的。但是按照区间来查找数据,红黑树的效率就没有跳表高

  2. 跳表更容易代码实现,比起红黑树来说还是好懂、好写很多,可读性好,不容易出错

  3. 跳表更加灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗

场景:

1、排行榜,取TopN操作

2、带权重的消息队列

例如,、使用有序列表来存储学生的成绩单:

127.0.0.1:6379> ZADD scoreList 82 Tom

(integer) 1

127.0.0.1:6379> ZADD scoreList 65.5 Jack

(integer) 1

127.0.0.1:6379> ZADD scoreList 43.5 Rubby

(integer) 1

127.0.0.1:6379> ZADD scoreList 99 Winner

(integer) 1

127.0.0.1:6379> ZADD scoreList 78 Linda

(integer) 1

127.0.0.1:6379> ZRANGE scoreList 0 100 WITHSCORES --获取名次在0~100之间的记录

1)“Rubby”

2)“43.5”

3)“Jack”

4)“65.5”

5)“Linda”

6)“78”

7)“Tom”

8)“82”

9)“Winner”

10)“100”

需要注意的是,Redis有序集合是默认升序的,score越低排名越靠前,即score越低的元素下标越小。

 四、存储结构

redis虽然是一种键值对存储的数据库,但是它有五种数据类型,其底层实现必然是不同的,那么redis的这五种数据类型都有哪些实现方式呢?

1、redis底层数据结构

redis的底层数据结构实际上只有六种,分别是动态字符串SDS、链表、字典、跳表、整数集合、压缩列表、快速列表。

1.1、SDS(动态字符串)

Redis 虽然是用 C 语言写的,但Redis没有直接使用C语言传统的字符串表示(以空字符 ‘\0’ 结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。
在Redis里面,C字符串只会作为字符串字面量(string literal)用在一些无须对字符串值进行修改的地方,比如打印日志。

SDS 的定义:

struct sdshdr{
     //记录buf数组中已使用字节的数量
     //等于 SDS 所保存字符串的长度
     int len;
     
     //记录 buf 数组中未使用字节的数量
     int free;
     
     //字节数组,用于保存字符串
     char buf[];
}

例如:

  • free 属性的值为 0,表示这个SDS没有分配任何未使用的空间。
  • len 属性的值为 5,表示这个SDS保存了一个五字节长的字符串。
  • buf 属性是一个char 类型的数组,数组前五个字节分别保存了 ‘R’、‘e’、‘d’、‘i’、‘s’ 五个字符,而最后一个字节则保存了空字符 ‘\0’ 。

 SDS与C字符串的区别:

1、常数复杂度获取字符串长度

因为 C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为 O(N) 。

和 C 字符串不同, 因为 SDS 在 len 属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为 O(1) 。

2、杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

3、减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

而对于SDS,由于len属性和free属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:

  1. 空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
  2. 惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间)。

4、二进制安全

因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

5、兼容部分 C 字符串函数

虽然 SDS 的 API 都是二进制安全的, 但它们一样遵循 C 字符串以空字符结尾的惯例: 这些 API 总会将 SDS 保存的数据的末尾设置为空字符, 并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符, 这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h> 库定义的函数。

1.2、链表

作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的 C 语言并没有内置这种数据结构,所以 Redis 构建了自己的链表结构。

每个链表节点使用一个 listNode 结构来表示:

typedef struct listNode {

    // 前置节点
    struct listNode *prev;

    // 后置节点
    struct listNode *next;

    // 节点的值
    void *value;

} listNode;

多个 listNode 可以通过 prev 和 next 指针组成双端链表, 如图 所示。

虽然仅仅使用多个 listNode 结构就可以组成链表, 但使用 list 来持有链表的话, 操作起来会更方便:

typedef struct list {

    // 表头节点
    listNode *head;

    // 表尾节点
    listNode *tail;

    // 链表所包含的节点数量
    unsigned long len;

    // 节点值复制函数
    void *(*dup)(void *ptr);

    // 节点值释放函数
    void (*free)(void *ptr);

    // 节点值对比函数
    int (*match)(void *ptr, void *key);

} list;

 list 结构为链表提供了表头指针 head 、表尾指针 tail , 以及链表长度计数器 len , 而 dup 、 free 和 match 成员则是用于实现多态链表所需的类型特定函数:
① dup 函数用于复制链表节点所保存的值;
② free 函数用于释放链表节点所保存的值;
③ match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。

Redis 的链表实现的特性可以总结如下:
① 双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是 O(1) 。
② 无环: 表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL , 对链表的访问以 NULL 为终点。
③ 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
④ 带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。
⑤ 多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、 match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。

 1.3、字典/哈希表

字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是 Redis 自己构建的。

Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。

Redis 字典所使用的哈希表由 dictht 结构定义:

typedef struct dictht {

    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;

    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;

    // 该哈希表已有节点的数量
    unsigned long used;

} dictht;

如图,一个空的哈希表:

哈希表节点使用 dictEntry 结构表示, 每个 dictEntry 结构都保存着一个键值对:

typedef struct dictEntry {

    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;

ey 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是uint64_t 整数,也可以是 int64_t 整数。

因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。) 

 Redis 中的字典由 dict 结构表示:

typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; 
    /* rehashing not in progress if rehashidx == -1 */

} dict;

ype 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:

● type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
● 而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。

ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。

 

1、哈希算法:Redis计算哈希值和索引值方法如下:

# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);

# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

2、解决哈希冲突:这个问题上面我们介绍了,方法是链地址法。通过字典里面的 *next 指针指向下一个具有相同索引值的哈希表节点。

3、扩容和收缩(rehash):当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:

1、为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是 ht[0].used 属性的值)

  • 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2n; (也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)
  • 如果执行的是收缩操作, 每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。

2、将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
3、当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

4、触发扩容与收缩的条件:

1、服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。

2、服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小。

3、另一方面, 当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。

5、渐近式 rehash
什么叫渐进式 rehash?也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。

1.4、跳表

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。

具有如下性质:

1、由很多层结构组成;

2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;

3、最底层的链表包含了所有的元素;

4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);

5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;

和链表、字典等数据结构被广泛地应用在 Redis 内部不同, Redis 只在两个地方用到了跳跃表, 一个是实现有序集合键, 另一个是在集群节点中用作内部数据结构, 除此之外, 跳跃表在 Redis 里面没有其他用途。 

redis中跳跃表定义由zskiplistNode和zskiplist两个结构定义zskiplistNode表示节点,zskiplist表示整个跳跃表信息。

struct zskiplist{
    //表头节点和表尾节点
    struct zskiplistNode *header,*tail;
    //表中节点的数量
    unsigned long length;
    //表中最大的层数
    int level;
}zskiplist;
struct zskiplistNode{
    //层级信息
    struct zskiplistLevel{
        //前进指针
        struct zskiplistNode *forward;
        //跨度
        unsigned int span;
    }level[];
    //后退指针
    struct zskiplistNode *backward;
    //分值
    double score;
    //成员对象
    robj *obj;
}zskiplistNode;

如图,一个跳表:

① header :指向跳跃表的表头节点。
② tail :指向跳跃表的表尾节点。
③ level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
④ length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。 

跳跃表节点(zskiplistNode)的特性:

1、层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。(所以说表头节点的高度就是32)

2、后退(backward)指针:节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。

3、分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。

4、成员对象(obj):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。

注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。

5、增删改节点操作

①、搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。

②、插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。

③、删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

1.5、整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用集合作为集合键的底层实现。

整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。

每个intset 结构表示一个整数集合:

typedef struct intset {

    // 编码方式
    uint32_t encoding;

    // 集合包含的元素数量
    uint32_t length;

    // 保存元素的数组
    int8_t contents[];

} intset;

整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。

length 属性记录了 contents 数组的大小。

需要注意的是虽然 contents 数组声明为 int8_t 类型,但是实际上contents 数组并不保存任何 int8_t 类型的值,其真正类型有 encoding 来决定。

1、升级(encoding int16_t -> int32_t -> int64_t)
当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。具体步骤:

  1. 根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
  3. 将新元素添加到整数集合中(保证有序)。

2、降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

1.6、压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。

当一个列表键只包含少量列表项, 并且每个列表项要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做列表键的底层实现。

因为哈希键里面包含的所有键和值都是小整数值或者短字符串。

压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。

一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。

每个压缩列表节点都由 previous_entry_length 、 encoding 、 content 三个部分组成 

1、previous_entry_ength:记录压缩列表前一个字节的长度。previous_entry_ength 的长度可能是1个字节或者是5个字节。如果上一个节点的长度小于254,则该节点只需要一个字节就可以表示前一个节点的长度了。如果前一个节点的长度大于等于254,则属性的第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历。这么做很有效地减少了内存的浪费。

2、encoding:节点的encoding保存的是节点的content的内容类型以及长度,encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长。

3、content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。

1.7、快速列表

由于使用链表的附加空间相对太高以及内存碎片化等缺点,Redis后续版本对列表数据结构进行改造,使用quicklist代替了ziplist和linkedlist。

快速列表有quicklistNode和quicklist结构组成

// 快速列表节点
struct quicklistNode {
    quicklistNode *prev;
    quicklistNode *next;
    ziplist *zl;         // 指向压缩列表
    int32 size;          // ziplist字节总数
    int16 count;         // ziplist中元素数量
    int2 encoding;       // 存储形式,表示原生字节数组还是LZF压缩存储
    ...
} quicklistNode;

// 快速列表
struct quicklist {
    quicklistNode *head;
    quicklistNode *next;
    long count;           // 元素总数
    int nodes;            // ziplist节点个数
    int compressDepth;    // LZF算法压缩深度
}
quicklist;

从代码可以看出,quicklist实际上是ziplist和linkedlist的混合体,它将linkedlist按段进行切分,每一段使用ziplist进行紧凑存储,多个ziplist之间使用双向指针进行串接。

以上就是redis的七种底层数据结构,那么redis的五种数据类型分别用的都是哪种数据结构呢?

2、redis存储结构

实际上,Redis中的每一个对象都是由redisObject结构表示,
在这里插入图片描述

redisObject是真正存储redis各种类型的结构,在Redis源码的redis.h文件中,定义了这些结构:

typedef struct redisObject {  
    unsigned type:4;  
    unsigned encoding:4;  
    unsigned lru:REDIS_LRU_BITS; /* lru time(relative to server.lruclock) */  
    intrefcount;  
    void*ptr;  
} robj;  

三个属性分别是type,encoding,ptr

type即Redis支持的逻辑类型,包括:

/* Object types*/  
#define REDIS_STRING 0  
#define REDIS_LIST 1  
#define REDIS_SET 2  
#define REDIS_ZSET 3  
#define REDIS_HASH 4  

即前面所列举的五种数据类型,可以在redis中用“type key”获取类型:

对象的ptr指针指向对象的底层实现数据结构,而数据结构是由encoding属性决定的。

也就是说type定义的只是逻辑类型,encoding才是物理存储方式,一种逻辑类型可以使用不同的存储方式,包括:

#define REDIS_ENCODING_RAW 0     /* Rawrepresentation */  
#define REDIS_ENCODING_INT 1     /* Encoded asinteger */  
#define REDIS_ENCODING_HT 2      /* Encoded as hash table */  
#define REDIS_ENCODING_ZIPMAP 3  /* Encoded aszipmap */  
#define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */  
#define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */  
#define REDIS_ENCODING_INTSET 6  /* Encoded as intset */  
#define REDIS_ENCODING_SKIPLIST 7  /* Encoded asskiplist */  
#define REDIS_ENCODING_EMBSTR 8  /* Embedded sdsstring encoding */  

(1) REDIS_ENCODING_RAW 即原生态的存储结构,就是以字符串形式存储,字符串类型在redis中用 sds ( s imple d ynamic s tring)封装,主要为了解决长度计算和追加效率的问题。
(2)REDIS_ENCODING_INT 代表整数,以long型存储。

(3) REDIS_ENCODING_HT 代表哈希表(Hash Table),以哈希表结构存储,与字典的实现方法一致。

(4)REDIS_ENCODING_ZIPMAP 其实质是用一个字符串数组来依次保存key和value,查询时是依次遍列每个key-value 对,直到查到为止。

(5)REDIS_ENCODING_LINKEDLIST 代表链表,以典型的链表结构存储。

(6) REDIS_ENCODING_ZIPLIST 代表一种双端列表,且通过特殊的格式定义,压缩内存适用,以时间换空间。ZIPLIST适合小数据量的读场景,不适合大数据量的多写/删除场景。

(7) REDIS_ENCODING_INTSET 是用一个有序的整数数组来实现的。

(8)REDIS_ENCODING_SKIPLIST 同时采用字典和有序集两种数据结构来保存数据元素。跳跃表(SkipList)是一个特殊的链表,相比一般的链表,有更高的查找效率,其效率可比拟于二叉查找树。缺点即浪费了空间,自古空间和时间两难全。

(9)REDIS_ENCODING_EMBSTR 代表使用embstr编码的简单动态字符串。好处有如下几点: embstr的创建只需分配一次内存,而raw为两次(一次为sds分配对象,另一次为objet分配对象,embstr省去了第一次)。相对地,释放内存的次数也由两次变为一次。embstr的objet和sds放在一起,更好地利用缓存带来的优势。需要注意的是,Redis并未提供任何修改embstr的方式,即embstr是只读的形式。对embstr的修改实际上是先转换为raw再进行修改。

使用object encoding key可以查看redis的键的值对象所使用的编码。

2.1、 String的存储结构

Redis的所有的key都采用字符串保存,而值可以是字符串,列表,哈希,集合和有序集合对象的其中一种。

字符串存储的逻辑类型即REDIS_STRING,其物理实现(enconding)可以为

  • REDIS_ENCODING_INT
  • REDIS_ENCODING_EMBSTR
  • REDIS_ENCODING_RAW

如果一个字符串对象保存的是整数值,此时使用的int编码

如果一个字符串对象保存的字符串长度大于32字节,使用的raw编码

如果一个字符串对象保存的字符串长度小于32字节,使用的是embstr编码,此编码与raw并无不同,只是底层结构不一样,如下图,其空间是连续的,而raw的redisObject和SDS是分开的。

2.2、 Hash的存储结构

REDIS_HASH可以有两种encoding方式:

  • REDIS_ENCODING_ZIPLIST 
  • REDIS_ENCODING_HT

Hash表默认的编码格式为REDIS_ENCODING_ZIPLIST,在收到来自用户的插入数据的命令时:

(1)调用hashTypeTryConversion函数检查键/值的长度大于配置的hash_max_ziplist_value(默认64)

(2)调用hashTypeSet判断节点数量大于配置的hash_max_ziplist_entries(默认512)

以上任意条件满足则将Hash表的数据结构从REDIS_ENCODING_ZIPLIST转为REDIS_ENCODING_HT。

例如:

当哈希对象保存的所有键对值的键和值的长度都是小于64字节并且键对值数量小于512个的时候,使用ziplist。

保存键对值的时候,现将键压至栈底,再将值压至栈底。

当不满足用ziplist的条件的时候,使用hashtable:

 

2.3、 List的存储结构

在redis的早期版本中,REDIS_LIST有两种encoding方式,REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST。

列表的默认编码格式为REDIS_ENCODING_ZIPLIST,当满足以下条件时,编码格式转换为REDIS_ENCODING_LINKEDLIST:

(1)元素大小大于list-max-ziplist-value(默认64)

(2)元素个数大于配置的list-max-ziplist-entries(默认512)

但是现在使用的是快速列表(quicklist)

2.4、 Set的存储结构

REDIS_SET有两种encoding方式:

  • REDIS_ENCODING_INTSET
  • REDIS_ENCODING_HT

集合的元素类型和数量决定了encoding方式,默认采用REDIS_ENCODING_INTSET ,当满足以下条件时,转换为REDIS_ENCODING_HT:

(1)元素类型不是整数

(2)元素个数超过配置的set-max-intset-entries(默认512)

例如:

当集合中所有元素都是整数并且元素数量小于512个,intset底层是使用整数集合实现的。

当不满足用intset的条件的时候,使用hashtable

 

2.5、SortedSet的存储结构

REDIS_ZSET有两种encoding方式:

  • REDIS_ENCODING_ZIPLIST
  • REDIS_ENCODING_SKIPLIST

由于有序集合每一个元素包括:<member,score>两个属性,为了保证对member和score都有很好的查询性能,REDIS_ENCODING_SKIPLIST同时采用字典和有序集两种数据结构来保存数据元素。字典和有序集通过指针指向同一个数据节点来避免数据冗余。

字典中使用member作为key,score作为value,从而保证在O(1)时间对member的查找跳跃表基于score做排序,从而保证在 O(logN) 时间内完成通过score对memer的查询。

有序集合默认也是采用REDIS_ENCODING_ZIPLIST的实现,当满足以下条件时,转换为REDIS_ENCODING_SKIPLIST:

(1)数据元素个数超过配置zset_max_ziplist_entries 的值(默认值为 128 )

(2)新添加元素的 member 的长度大于配置的zset_max_ziplist_value 的值(默认值为 64 )

当有序集合中元素小于128个并且所有元素的长度都小于64字节,使用ziplist,ziplist保存的方式也是先保存键,再保存值,键和值是挨着的,元素是按照值由小变大排序的。

 当不满足ziplist的两个条件的时候,使用的是skiplist,skiplist底层是zset结构,包含一个字典和一个跳跃表。

 

struct zset{
    //跳跃表
    zskiplist *zkl;
    //字典
    dict *dict;
}zset;

zsl属性是一个跳跃表,按分值从小到大保存所有集合元素,每个节点保存一个元素,节点的object属相保存元素的成员,scope属性保存元素的分值,通过跳跃表,可以实现范围的操作,例如ZRANK,ZRANGE等。

dict是一个字典,字典的每一个键对值保存着一个集合元素,键是元素,值是对应的分值。可以支持复杂度为O(1)的元素分值查找。

 

五、数据一致性问题

既然引入了缓存,那么在与数据库和缓存之间读写的过程,势必引起数据不一致问题,所以一致性需要我们各位关注。

1、同步策略

在关注一致性问题之前,我们不妨看看缓存与数据库之间的同步方式,一般来说,我们在更新数据的时候,有四种策略:

  • 先更新数据库,然后更新缓存
  • 先更新缓存,后更新数据库
  • 先删除缓存,后更新数据库
  • 先更新数据库,后删除缓存

1.1、更新库在更新缓存

存在问题:首先执行数据库更新的操作并且成功了,这时再去更新缓存并且失败了,那么此时数据库中是最新的值,而缓存中还是旧的数据值。

如果一个读请求过来,首先读取缓存中的数据,这时都是旧值,只有当缓存过期失效后,才能重新在数据库中得到新的值。

这时用户发现我明明修改了值,为什么看不到修改的值,而是过段时间数据才变更过来,对业务会有影响。

1.2、更新缓存再更新库

存在问题:首先执行缓存更新的操作并且成功了,这时再去更新数据库并且失败了,那么此时缓存中是最新的值,而数据库中还是旧的数据值。

虽然此时读请求可以命中缓存,拿到正确的值,但是缓存过期失效以后就会从数据库中读取到旧值,重新同步缓存也是这个旧值。

这时用户会发现自己之前修改的数据又变回旧的值了,对业务造成影响。

1.3、更新缓存带来并发问题

假设采用先更新数据库,再更新缓存的方案,并且两步都可以执行成功的前提下,如果存在并发,情况会是怎样的呢?

比如线程 A 和线程 B 两个线程,同时更新一条数据,会发生如下问题:

线程 A 更新数据库(X = 1)
线程 B 更新数据库(X = 2)
线程 B 更新缓存(X = 2)
线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
线程 A 虽然先于线程 B 发生,但线程 B 操作数据库和缓存的时间,却要比线程 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。

同样地,采用先更新缓存,再更新数据库的方案,也会有类似问题,这里不再详述。

很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,也有可能是先查询数据库,再经过一系列计算得出的一个值,才把这个值才写到缓存中。

由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。

所以此时我们需要考虑另外一种方案:删除缓存,删除缓存和上面的更新缓存一样,会有操作数据库和操作缓存两步后者失败带来的不一致问题,我们重点看并发问题。

1.4、先删缓存再更新库

 如果有 2 个线程 A 和 B 要并发读写数据,可能会发生如下问题:

线程 A 要更新 X = 2(原值 X = 1)
线程 A 先删除缓存
线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
线程 A 将新值写入数据库(X = 2)
线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是旧值 1,在数据库中是新值 2,发生不一致。
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情

1.5、先更新库再删缓存

依旧是 2 个线程 A 和 B 并发读写数据:

缓存中 X 不存在(数据库 X = 1)
线程 A 读取数据库,得到旧值(X = 1)
线程 B 更新数据库(X = 2)
线程 B 删除缓存
线程 A 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是旧值 1,在数据库中是新值 2,也发生不一致。
这种情况理论上来讲是可能发生的,但是概率很低,因为必须满足 3 个条件:

缓存刚好已失效
读请求 + 写请求并发
更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
操作写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的,这样看来,先更新数据库 + 再删除缓存的方案是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。(没有完美的系统,只有风险最小的系统)

2、重试

上述无论采用哪种方式,我们都需要进行两步走,一步操作数据库,一步操作缓存,后者失败必然导致数据不一致,因此我们需要进行重试机制。

2.1、异步重试

重试请求写到消息队列中,然后由专门的消费者来重试,直到成功,或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。

这里大家可能就会有一个问题:写消息队列也有可能会失败,而且,引入消息队列,又增加了更多的维护成本,这样做是否值得呢?

这里我们就需要依赖消息队列的特性了!

消息队列的特性:

消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失
消息队列保证消息成功投递:消费者从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者
至于写队列失败和消息队列的维护成本问题:

写队列失败:操作缓存和写消息队列,同时失败的概率是很小的
维护成本:因为我们的项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。

2.2、订阅数据库变更日志

我们的应用在修改数据时,只需要修改数据库,不用操作缓存,而操作缓存是交给数据库的变更日志实现。

比如,MySQL中修改一条数据,MySQL 就会产生一条变更日志(Bin Log),我们可以订阅这个日志,获取到具体的操作数据,然后再根据这条日志数据,去删除对应的缓存。

订阅变更日志比较比较成熟的开源中间件,比如阿里的 canal,这种方案的优点如下:

无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
自动投递到消费队列:canal 自动把数据库变更日志投递给消费的消息队列
想要保证数据库和缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。

3、延时双删

解决完两步走后者失败问题,其实上述中先更新数据库在删除缓存依旧存在不一致问题,这里就需要我们进行双删操作了,并且是延时双删!

为什么要延迟双删呢,有什么作用呢?

3.1、单删的弊端

就像上述例子说的,如果读数据库+读缓存的时间大于更新数据库和写缓存的时间,那么就会导致数据库与缓存不一致,虽然概率小,但是并发量很大的情况下,还是会发生。

3.2、非延时双删的弊端

同理,如果两次删除用时很短,依旧会导致最终数据不一致。

3.3、延时双删

在普通删除中,第二次清空缓存之前,多延时一会儿,等线程B更新缓存结束了,再删除缓存,这样就缓存就不存在了,其他线程查询到的为新缓存。

延时是确保 修改数据库 -> 清空缓存前,其他线程的更改缓存操作已经执行完。

这个延迟删除缓存,延迟时间到底设置要多久呢?

这个时间在分布式和高并发场景下,其实是很难评估的。

很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。

这样我们需要保证设置的延时时间适当,因为延时过程中缓存依旧是老的数据,所以我们需要在业务场景中切合实际找寻既能满足一致性,又不会对业务造成太大影响的延时时间点。

六、缓存穿透、击穿、雪崩

1、击穿

缓存击穿是指数据在缓存中过期从而查询数据库的一种场景,实际上是一种正常现象,但是在某些场景中依旧会发生问题,如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮:

应对方案:

1、使用互斥锁

缓存未命中时获取锁,建立新的缓存,其它线程等待锁释放后再查找数据

如此可以达到 同一时刻对同一数据只有一个请求在访问数据库

2、热点数据设置不同的过期时间,过期时间可以加一个随机值。

3、缓存不设置过期时间

2、穿透

缓存穿透指查询不存在的数据时,不会命中缓存(缓存层),便会向数据库发送查询请求,但同样查不到数据。

一般是恶意请求攻击或者误操作删除了缓存和业务数据,会给数据库性能带来压力!

 应对方案:

1、非法请求限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

2、缓存空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

3、布隆过滤器:写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

3、雪崩

大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。

七、Redis持久化

为什么要持久化?

众所周知,Redis本身运行时数据保存在内存中。

所以,那么在关闭redis的进程或者关闭计算机后数据肯定被会操作系统从内存中清掉。

而为了避免这一情况,redis默认采用了一种持久化方式,即RDB (Redis DataBase)——可以在redis的目录中找到dump.rdb文件,这就是使用RDB方式做持久化后生成的数据文件。

所以,redis如果没有做持久化,在重启redis后,数据会丢失,而redis默认就采用了一种持久化方式,即RDB。

1、持久化方式

redis的持久化方式有两种,RDB (Redis DataBase)和 AOF (Append Only File),下面分别对两种持久化方式做一个介绍。

1、 RDB

RDB(Redis DataBase)方式采用的思想是定时将内存中的数据进行快照,并写入dump.rdb文件当中,这个文件当中所存储的就是当前redis环境中的配置以及数据。

因此,每次当redis重启之后,redis会先读dump.rdb文件,将数据从硬盘写入到内存中。

1、RDB模式的配置方式

在redis的配置文件redis.conf中搜索save,这一个save可以设置在指定时间内,更新操作达到了固定次数,就将数据同步到数据文件,这里可以写多个save多条件配合使用。比如以下代码所示:

 # 表示900秒内有一次更改,或者900秒内有10次更改,或者60秒内有10000此更改执行同步操作
 save 900 1
 save 300 10
 save 60 10000

另外,如果想不使用RDB做持久化了,可以不配置任何的save,或者将save配成空字符串

2、dbfilename与dir

在redis的配置文件中如果使用RDB的方式做持久化,除了要注意save的配置外,还有两个配置需注意。

dbfilename:这一个配置表示存储的快照文件(数据文件)的文件名,redis默认为dump.rdb,所以我们看到redis目录中会有这样一个文件,这个文件中存放了二进制的内容。

dir:表示了dbfilename所配置的这一个文件的路径,这里最好配一个绝对路径,因为如果使用了相对路径,那么通过不同的方式其启动redis可能会出现文件找不到导致数据前后不一致的情况发生。

2、AOF

redis默认是关闭AOF(Append-only file)模式持久化的,如果要使用需要修改一下配置:

 # 默认为no,需修改为yes
 appendonly yes
 # AOF默认的持久化文件的文件名称
 appendfilename "appendonly.aof"

而指定更新日志条件的同步策略有三个可选条件:

 # 当操作系统进行数据缓存同步到磁盘文件
 # appendfsync no
 # 同步持久化,当数据发生变更时,立即同步到磁盘文件(效率慢些,能保证数据的完整性)
 # appendfsync always
 # 每秒同步一次(默认值,也是最佳的选择,速度快,可能会丢失一秒以内的数据(最多不过2秒))
 appendfsync everysec

另外,可以配置当持久化的文件到达一定程度后,进行重写,为什么要进行重写?由于AOF模式持久化记录的是操作命令,比如说当有这两个命令set key "value1", set key "value2"依次执行后,实际上要恢复数据只需要执行set key "value2"即可。经过类似的压缩,可以为原本已经很大的文件“瘦身”,以下的内容,即为执行此“瘦身”操作的配置。

 # 当AOF的持久化文件大小的增长率大于此配置时,自动开启重写,redis会自动执行“BGREWRITEAOF”命令;
 auto-aof-rewrite-percentage 100
 # 当AOF的持久化文件大小大于此配置时,自动开启重写,redis会自动执行“BGREWRITEAOF”命令;
 auto-aof-rewrite-min-size 3000mb

AOF模式的优缺点

AOF模式的优点如下:

(1)AOF模式可以更好的保护数据不丢失,在redis因为非正常原因挂掉时,其保存数据的完整度理论上高于RDB模式,因为采用appendfsync everysec去写入持久化文件,最多丢失一秒到两秒的数据;而RDB模式丢失的数据根据其配置的写入频率决定;

(2)AOF写入性能高,这归功于其是以append-only的方式写入;

而AOF的缺点如下:

(1)对于同样的数据,通常AOF文件的大小回比RDB的要大;

(2)因为AOF存的是命令而不是数据,所以恢复数据时可能较慢。

;