Redis学习笔记

Redis学习笔记

Redis诞生于2009年,作为一个基于内存的键值型NoSQL数据库。其有如下特点

  • 键值(key-value)型,value支持多种不同数据结构,
  • 单线程,每个命令具有原子性。不存在很多并发带来的问题。但是此单线程只是指代命令是但线程执行的,其他模块还有各自的线程。6.0版本中引入了多线程,但指代的是 IO多线程,如:网络数据的读写和协议解析时多线程。
  • 低延迟、速度快(基于内存、IO多路复用、良好的编码)
  • 支持数据持久化
  • 支持主从集群、分片集群
  • 支持多语言客户端

一、Redis 的安装

1.1 Redis 安装(window)

下方提供 Redis 各个版本的下载页面,我这里下载的是 3.2.100 版本。

https://github.com/microsoftarchive/redis/releases

将下载包 解压到本地目录,然后在 redis目录下进行 cmd ,输入不同的命令进行不同的安装方式:

  • 临时服务安装如果你仅仅是用作学习使用,可以选择此安装方式。在 redis目录下 使用cmd 执行以下命令:redis-server.exe redis.windows.conf该命令会创建 Redis 临时服务,生成的信息表明了 redis 在本机的 6379 端口提供服务。该种方式,不能关闭此 cmd 窗口,如果关闭则会停止 Redis 服务。img保持 Redis 服务窗口开启状态,双击 redis目录下的 redis-cli.exe 即可使用 命令行操控 redis 。比如这里 使用 set 命令,存储了一个键值对 uid:1,然后通过 get 将键 uid 对应的值取出。img

  • 默认服务安装这种方式不用像临时安装方式一样,每次去打开 redis 临时服务,而且像正常服务一样开机自启。进入 Redis 目录下,通过cmd输入redis-server.exe –service-install redis.windows.conf –loglevel verboseimg通过命令行可以发现,我们已经将redis作为服务安装好了。但是你可能不能在window的服务列表中找到,redis服务必须通过命令行启动、暂停和卸载

    • 启动服务:redis-server.exe –service-start
    • 暂停服务redis-server.exe –service-stop
    • 卸载服务redis-server.exe –service-uninstall
  • 自定义服务安装自定义服务安装,就是将服务重命名。进入 Redis 安装包下,输入redis-server.exe –service-install redis.windows.conf –Service-name RedisServer1 –loglevel verbose这里起的名字是 RedisServer1 。与默认安装一样,不同的是在启动、暂停、卸载服务时 需要加上自定义的 Redis 服务名redis-server.exe –service-start –Service-name RedisServer1redis-server.exe –service-stop –Service-name RedisServer1redis-server.exe –service-uninstall –Service-name RedisServer1

  • 主从服务安装即像一般的数据库的主从库一样,redis也可以配置主从库。配置的方法很简单,就是通过自定义服务器安装方式安装两个服务。img修改两个服务里 redis.windows.conf 文件:主服务器(RedisServer1):保持其 port 6379从服务器(RedisServer2):修改

1
2
port 6380
slaveof 127.0.0.1 6379

修改配置文件后,依次启动服务。然后可以在 双击主服务文件夹下的 redis-cli,去执行一个添加键值操作。双击执行 从服务器文件夹下的 redis-cli,去取出键name 对应的值,你就发现可以取到。在 Window 上 直接删除服务的方法:使用管理员权限 打开 cmd ,然后输入sc delete 服务名

1.2 Redis 安装(docker)

  • 下拉最新的 redis 镜像,并检查是否下拉成功
1
2
docker pull redis:latest
docker images
  • 运行容器,并映射到宿主机端口docker run -it -d –name redis-test -p 6379:6379 redis
  • 查看是否运行成功(查看容器运行信息)docker ps
  • 通过 redis-cli 连接使用 redis 服务
1
2
docker exec -it redis-test /bin/bash
redis-cli

1.3 Redis 配置

redis.config 常见配置:

  • bind 0.0.0.0 监听的地址默认是127.0.0.1,这使得只能本地访问。如果修改成 0.0.0.0 则可以在任意 IP 地址访问。
  • daemonize yes守护进程,修改为 yes 之后,即可后台运行
  • requirepass 111111密码,设置后访问 Redis 必须输入密码
  • port 6379监听的端口,默认就是 6379
  • dir .工作目录,默认是当前目录,也就是运行 redis-server 时的命令,日志、持久化等文件都会保存在这个目录
  • databases 1数据库数量,设置为1,代表只使用1个库,默认有16个库,编号 0-15
  • maxmemory 512mb设置 redis 能够使用的最大内存
  • logfile “redis.log”日志文件,默认为空,不记录日志,可以指定日志文件名。

启动时,指定配置文件

redis-server redis.conf

二、Redis 的基础篇

2.1 五种基本数据结构

Redis 共有 5 种基本数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。

这 5 种数据结构是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Hash Table(哈希表)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。

Redis 基本数据结构的底层数据结构实现如下:

String List Hash Set Zset
SDS LinkedList/ZipList/QuickList Hash Table、ZipList ZipList、Intset ZipList、SkipList

Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。

你可以在 Redis 官网上找到 Redis 数据结构非常详细的介绍:

未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网对应的介绍,你总能获取到最靠谱的信息。

2.1.1 String

String 是 Redis 中最简单同时也是最常用的一个数据结构。

String 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。

虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。

2.1.2 List

许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

2.1.3 Hash

Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。

Hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 Hash 做了更多优化。

2.1.4 Set

Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。

你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。

2.1.5 SortSet

Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。

2.2 三种特殊数据结构

2.3 Redis命令

2.1.1 通用命令

Redis通用指令是不分数据类型的,都可以使用的指令,常见的有:

  • KEYS:查看符合模板的所有 key,不建议在生产环境设备上使用
  • DEL:删除一个指定的 key
  • EXISTS:判断 key 是否存在
  • EXPIRE:给一个 key 设置有效期,有效期到期时该 key 自动删除。可 通过 TTL KeyName,查看 key 的剩余有效期

通过 help [command] 可以查看一个命令的具体用法、

使用 redis.cli.exe 打开 redis的命令行,实操:

img

2.1.2 String类型

String 类型,也就是字符串类型,是 Redis 中最简单的存储类型。其 value 是字符串,不过根据字符串的格式不同,又可以分为3类:

  • String:普通字符串
  • int:整数类型,可以做自增、自减操作
  • float:浮点类型,可以做自增、自减操作,但是必须指定 增减的 值。

不管何种格式,底层都是字节数组形式存储,只不过是编码方式不同。

Redis的字符串是动态字符串,是可以修改的字符串,内部结构实现类似于Java的ArrayList,预分配冗余空间的方式来减少内存的频繁分配。字符串长度小于1M,扩容都是加倍现有空间,如果长度大于1M,每次扩容增加1M。字符串类型的最大空间不能超过 512M

String的常见命令有:

  • SET:添加或者修改 已经存在的一个 String 类型的键值对
  • GET:根据 key 获取 String 类型的 value
  • MSET:批量添加多个 String 类型的键值对
  • MGET:根据多个 key 获取多个 String 类型的 value
  • INCR:让一个整型的 key 自增 1
  • INCRBY:让一个整型的 key 自增并指定步长,例如:INCRBY num 2,即可让 key = num 的值,自增2
  • SETNX:添加一个 String 类型的键值对,前提是 这个 key 不存在,否则不执行
  • SETEX:添加一个String 类型的键值对,并指定有效期
2.1.3 Key的层级格式

Redis没有类似 MySQL 中的 Table 的概念,我们该如何区分不同类型的 key 呢?一般采用将 key 名称进行 分层设计。例如:学生的key,key 以 studen_ 开头。

Redis 的 key 允许有多个单词组成层级结构,多个单词之间用 “:” 隔开,格式如下:

项目名:业务名:类型:id

当然这种格式是可以自己定义的,有些公司是使用 ”__“线间隔。

如果存储对象是 Java对象,则可将对象转化为 JSON 字符串当作value存储下来。

2.1.4 Hash类型

Hash类型,也叫散列,其中value是一个无序字典,类型于 Java 中的 HashMap 结构

String 结构是将对象序列化为 JSON 字符串后 存储,当需要修改对象某个字段时 很不方便。Hash 结构可以将对象中的每个字段独立存储,可以针对 单个字段 CRUD

Hash类型常见命令有:

  • HSET key field value:添加或者修改 hash类型 key的field 的值
  • HGET key field:获取一个 hash 类型 key的field的值
  • HMSET:批量添加 多个 hash类型 key的field 的值HMSET student_1 name liming sex 男 //为key=student_1 的字段 添加属性 name、sex 值分别为 liming、男
  • HGETALL:获取一个 hash 类型的key中所有的 field和value
  • HKEYS:获取一个 hash 类型的key中所有的 field
  • HVALS:获取一个hash 类型的key中所有的 value
  • HINCRBY:让一个hash类型 key 的字段值自增,并指定步长
  • HSETNX:添加一个 hash 类型的 key 的 field值,前提时 这个 field 不存在,否则不执行

底层原理:

Java的HashMap在字典很大时,rehash是个耗时操作,需要一次性全部rehash。Redis为了高性能,不能堵塞服务,就采用了渐进式rehash策略

渐进式rehash:在rehash的同时,保留新旧两个hash结构,查询时会同时查询两个hash结构,然后再后续的定时任务中以及hash的子指令中,循序渐进地将旧hash的内容一点点迁移到新的hash结构中。

2.1.4 List类型

Redis中的 List 类型与 Java 中的LinkedList 类似,可以看做是一个双向链表结构。既可以支持正向检索,也支持反向检索。特点也和LinkedList类似:

  • 有序
  • 元素可以重复
  • 插入和删除快
  • 查询速度一般

List的常见命令:

  • LPUSH key element :像列表左侧插入一个或多个元素
  • LPOP key :移除并返回列表左侧的第一个元素,没有则返回nil
  • RPUSH key element:向列表右侧插入一个或多个元素
  • RPOP key:移除并返回列表右侧第一个元素
  • LRANGE key start end:返回一段角标范围内的所有元素
  • BLPOP和BRPOP:与LPOP、RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
  • lindex key index:lindex 相当于 Java 链表的 get(index) 方法,它需要对链表进行遍历。其性能随着参数index增大而变差。
  • ltrim key startIndex endIndex:使用startIndex 、endIndex定义了一个区间,保留着区间内的值,区间外统统去除。这样可以实现一个定长的链表。index可以为负数,-1表示倒数第一个元素,-2表示倒数第二个元素。

应用场景:常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进Redis的列表,另一个线程从这个列表中轮询数据进行处理。通过控制,右边进左边出,可以实现队列。通过控制,右边进右边出,可以实现栈。

原理:Redis列表底层存储不是一个简单的 linkedlist,而是成为快速列表quicklist的一个结构。首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是压缩列表ziplist。它将所有的元素紧紧挨在一起存储,分配的是一块连续的内存。当数据量比较多时,才会改成 quicklist。因为普通的链表需要的附件指针空间太大,会比较浪费空间,而且加重内存的碎片化。所以Redis将 多个 ziplist使用双向指针串起来使用。这样既满足了快速的插入删除性能,有不会出现太大的空间冗余。

2.1.5 Set类型

Redis的Set结构与 Java 中的HashSet类似,可以看做是一个 value 为 null 的 HashMap。因为也是一个 hash 表,因此具备与 HashSet类似的特征

  • 无序
  • 元素不可重复
  • 查找快
  • 支持交集、并集、差集等功能

Set常见命令:

  • SADD key member:向set中添加一个或多个元素
  • SREM key member:移除set中指定的元素
  • SCARD key:返回set中元素的个数
  • SISMEMBER key member:判断一个元素是否存在于 set 中
  • SMEMBERS:获取 set 中的所有元素
  • SINTER key1 key2 :求 key1 与 key2 的交集
  • SDIFF key1 key2:求 key1 与 key2 的差集
  • SUNION key1 key2 :求 key1 与 key2 的并集
2.1.6 SortedSet类型

Redis 的 SortedSet 是一个可排序的 set 集合,与 Java 中的 TreeSet 有些类似,但底层数据结构却差别很大。SortedSet 中的每个元素都带有一个 score 属性,可以基于 score 属性对 元素排序,底层的实现是一个跳表(SkipList)加hash表。

SortedSet具备下列特性:

  • 可排序
  • 元素不重复
  • 查询速度快

因为 SortedSet 的可排序特性,经常被用来实现排行榜这样的功能。

SortedSet的常见命令有:

  • ZADD key score member:添加一个或多个元素到 SortedSet,如果已经存在则更新其 score 值
  • ZREM key member:删除 SortedSet中的一个指定元素
  • ZSCORE key member:获取 SortedSet 中的指定元素的 score 值
  • ZRANK key member:获取 SortedSet 中的指定元素的排名
  • ZCAED key:获取 SortedSet 中的元素个数
  • ZCOUNT key min max:统计 score 值在给定范围内的所有元素的个数
  • ZINCRBY key increment member:让 SortedSet中的指定元素自增,步长为指定的increment值
  • ZRANGE key min max:按照 score 排序后,获取指定 score 范围内的元素
  • ZRANGEBYSCORE key min max:按照 score 排序后,获取指定 score 范围内的元素
  • ZDIFF、ZINTER、ZUNION:求差集、交集、并集

注意:所有的排名默认都是 升序,如果要降序则在命令的Z后面添加REV即可

2.2 Redis客户端

Redis的客户端主要有

  • Jedis:以Redis命令作为方法名称,学习成本低,简单实用。但是Jedis实例是线程不安全的,多线程情况下需要基于连接池实用
  • Lettuce:基于Netty实现的,支持同步、异步和响应式编程方式,并且是线程安全的。支持Redis的哨兵模式、集群模式和管道模式。
  • Redisson:是一个基于Redis实现的分布式、可伸缩的 Java 数据结构集合。包含了诸如 Map、Queue、Lock、Semaphore、AtomicLong等强大功能。

而 SpringData Redis 集成了 Jedis、Lettuce

2.2.1 Jedis 客户端

https://github.com/kongxiaoran/redisDemo

可以下拉该项目的 Jedis 分支,该分支已经 实现了 springboot 整合 jedis 。可以下拉看看

Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此我们推荐大家使用 Jedis 连接池代替 Jedis 的直连方式。

2.2.2 SpringDataRedis

SpringData 是 Spring 中数据操作的模块,包含对各种数据库的集成,其中对 Redis 的集成模块就叫做 SpringDataRedis,官网地址:

  • 提供了对不同 Redis 客户端的整合(Lettuce、Jedis)
  • 提供了 RedisTemplate 统一 API 来操作
  • 支持 Redis 的发布订阅模型
  • 支持 Redis 哨兵和 Redis 集群
  • 支持基于 Lettuce 的响应式编程
  • 支持基于 JDK、JSON、字符串、Spring对象的数据序列化及反序列化
  • 支持基于 Redistribution的 JDK Collection实现

2.3 SpringDataRedis 客户端使用

SpringDataRedis 提供了 RedisTemplate 工具类,其中封装了各种对 Redis 的操作。并且将不同数据类型的操作API 封装到了不同类型中:

API 返回值类型 说明
redisTemplate.opsForValue() ValueOperations 操作 String 类型数据
redisTemplate.opsForHash() HashOperations 操作 Hash 类型数据
redisTemplate.opsForList() ListIOperations 操作 List 类型数据
redisTemplate.opsForSet() SetOperations 操作 Set 类型数据
redisTemplate.opsForZSet() ZSetOperations 操作 SortedSet 类型数据
redisTemplate 通用命令

https://github.com/kongxiaoran/redisDemo

该项目的 master 分支,使用 SpringBoot 整合了 SpringDataRedis,可以自行下拉运行。

2.3.1 SpringDataRedis 的默认序列化

RedisTemplate 可以接收任意 Object 作为值 写入 Redis,只不过写入 前会把 Object 序列化为字节形式,默认是采用 JDK 序列化。

1
2
3
redisTemplate.opsForValue().set("springboot","你好呀,springboot");   
String springboot = (String) redisTemplate.opsForValue().get("springboot");
System.out.println(springboot);

得到的结果是这样的:

img

缺点很明显:

  • 可读性差
  • 内存占用较大

这是因为什么呢?查看 RedisTemplate 类,可以知道 当没有特别配置 key、value、hashKey 的 序列化策略时,

RedisTemplate 会选择使用 JDK序列化器(JdkSerializationRedisSerializer),而此序列化器是不是适合字符串的序列化的。所以如果你的 key 通常是用 字符串格式,那么可以考虑 在序列化key时,采用其他序列化器。比如:String

img

img

2.3.2 SpringDataRedis 提供的序列化器

查看 RedisSerializer 的实现,可以看到有 7 种序列化器:

  • ByteArrayRedisSerializer:字节数组序列化

  • GenericJackson2JsonRedisSerializer:同 FastJsonRedisSerializer 类似,而 FastJsonRedisSerializer 是由阿里巴巴FastJson包提供。具有:1. 速度快 2. 兼容性强 3. 占用内存小

    • 底层使用Jackson进行序列化并存入Redis。对于普通类型(如数值类型,字符
    • 存入对象时由于没有存入类信息,则无法反序列化。
  • GenericToStringSerializer:同StringRedisSerializer一样,但它可以将任何对象泛化为字符串并序列化。注意事项:GenericToStringSerializer需要调用者给传一个对象到字符串互转的Converter,使用起来其比较麻烦,所以不太推荐使用。

  • Jackson2JsonRedisSerializer:将对象序列化为json字符串

    • ·优点:速度快、序列化后的字符串短小精悍、不需要实现 Serializable
    • 缺点:必须要提供要序列化对象的类型信息(.class对象)
  • JdkSerializationRedisSerializer:使用Java自带的序列化机制将对象序列化为一个字符串。

    • 优点在于:通用性强、反序列化时不需要提供类型信息。、
    • 缺点在于:序列化速度慢、序列化内存占用大、序列化对象必须实现 Serializable 接口、可读性差
  • OxmSerializer:将对象序列化为xml字符串。以 xml 格式存储(但还是String类型),解析起来比较复杂,且占用空间大

  • StringRedisSerializer:StringRedisTemplate默认的序列化器。

    • 优点:可读性强、不需要转换
    • 缺点:只能对字符串序列化,不能对 对象 序列化
2.3.3 自定义序列化器

所以我们可以针对自己的需要,自定义 RedisTemplate,来实现对不同 key、value 使用不同的序列化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
throws UnknownHostException{

// 创建 Template
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();

// 设置连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);

// 设置序列化工具
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer =
new GenericJackson2JsonRedisSerializer();

// key 和 hashKey 采用 String序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());

// value 和 hashValue 采用 JOSN序列化
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

return redisTemplate;
}
2.3.4 使用自定义序列化器存储对象
1
2
3
4
5
6
7
8
9
10
@Test
public void testEmployee(){
Employee employee = new Employee(3,"羽","28235x02x7",1,1);
// 写入数据
redisTemplate.opsForValue().set("user_3",employee);

// 获取数据
Employee getEmployee = (Employee) redisTemplate.opsForValue().get("user_3");
System.out.println(getEmployee.toString());
}

img

由图可以知道,该序列化器在序列化对象,也会将 对象的字节码名称写入。这样在我们反序列化时知道对象的类型,从而反序列化成对应对象。

2.3.5 使用StringRedisTemplate存储JSON对象

但是这一个存在一个问题,JSON序列化器将类的class类型写入了 JSON结果中,存入了 Redis ,会带来额外的内存开销。为了节省内存空间,我们并不会使用 JSON 序列化器来处理 value,而是统一使用 String 序列化器,要求只能存储 String 类型的 key 和 value。当需要存储 Java 对象时,手动完成对象的序列化和反序列化

所以还是建议手动完成对象的序列化:

1
2
3
4
5
6
7
8
9
@Test
public void testEmployeeStringRedisTemplate(){
Employee employee = new Employee(3,"羽","2823x302x7",1,1);
// 写入数据 (这里使用的时 fastJson2进行序列化)
stringRedisTemplate.opsForValue().set("user_4", JSON.toJSONString(employee));
// 获取数据
Employee getEmployee = JSON.parseObject(stringRedisTemplate.opsForValue().get("user_4"), Employee.class);
System.out.println(getEmployee.toString());
}
2.3.6 RedisTemplate 操作 Hash 类型
1
2
3
4
5
6
7
8
@Test
public void testHash(){
stringRedisTemplate.opsForHash().put("xiucheng","user1","凌霄");
stringRedisTemplate.opsForHash().put("xiucheng","user2","羽");

Map<Object, Object> xiucheng = stringRedisTemplate.opsForHash().entries("xiucheng");
System.out.println(xiucheng.toString());
}

三、Redis 实战

3.1 Redis与MySQL双写一致性如何保证?

3.1.1 一致性

一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。

  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

3.1.2 三种经典的缓存模式

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存模式:

  • Cache-Aside PatternCache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。读:写:更新的时候,先更新数据库,然后再删除缓存
    1. 读的时候,先读缓存,缓存命中的话,直接返回数据
    2. 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。
  • Read-Through/Write throughRead/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。读:img这个简要流程是不是跟Cache-Aside很像呢?其实Read-Through就是多了一层Cache-Provider。写:当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新:先更新数据源,再更新缓存。
    1. 从缓存读取数据,读到直接返回
    2. 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。
  • Write behind****Write behindRead-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

3.1.3 操作缓存的时候,删除缓存呢,还是更新缓存?

一般业务场景,我们使用的就是Cache-Aside模式。 有些小伙伴可能会问, Cache-Aside在写入请求的时候,为什么是删除缓存而不是更新缓存呢?我们在操作缓存的时候,到底应该删除缓存还是更新缓存呢?我们先来看个例子:

img

  1. 线程A先发起一个写操作,第一步先更新数据库
  2. 线程B再发起一个写操作,第二步更新了数据库
  3. 由于网络等原因,线程B先更新了缓存
  4. 线程A后更新缓存。

这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。

更新缓存相对于删除缓存,还有两点劣势:

  • 如果你写入的缓存值,是经过复杂计算才得到的话。更新缓存频率高的话,就浪费性能啦。
  • 在写数据库场景多,读数据场景少的情况下,数据很多时候还没被读取到,又被更新了,这也浪费了性能呢(实际上,写多的场景,用缓存也不是很划算了)

3.1.3 双写的情况下,先操作数据库还是先操作缓存?

Cache-Aside缓存模式中,有些小伙伴还是有疑问,在写入请求的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?

img

  1. 线程A发起一个写操作,第一步del cache
  2. 此时线程B发起一个读操作,cache miss
  3. 线程B继续读DB,读出来一个老数据
  4. 然后线程B把老数据设置入cache
  5. 线程A写入DB最新的数据

酱紫就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据,数据库保存的是新数据。因此,Cache-Aside 缓存模式,选择了先操作数据库而不是先操作缓存。

3.1.4 缓存延时双删

有些小伙伴可能会说,不一定要先操作数据库呀,采用缓存延时双删策略就好啦?什么是延时双删呢?

  1. 先删除缓存
  2. 再更新数据库
  3. 休眠一会(比如1秒),再次删除缓存。

这个休眠一会,一般多久呢?都是1秒?

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

3.1.5 删除缓存重试机制

不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,如果第二步的删除缓存失败呢,删除失败会导致脏数据哦~

img

删除缓存重试机制,会造成很多业务代码入侵。其实也可以通过 数据库的CDC 来异步淘汰 Key。

所以我们需要一些重试机制,确保 redis key 被删除了

队列+重试机制

img

流程如下所示

  • 更新数据库数据
  • 缓存因为种种问题删除失败
  • 将需要删除的key发送至消息队列
  • 自己消费消息,获得需要删除的key
  • 继续重试删除操作,直到成功

然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

基于订阅binlog的同步机制

img技术整体思路

MySQL binlog增量订阅消费+消息队列+增量数据更新到redis

1)读Redis:热数据基本都在Redis

2)写MySQL: 增删改都是操作MySQL

3)更新Redis数据:MySQ的数据操作binlog,来更新到Redis

Redis更新

1)数据操作主要分为两大块:

  • 一个是全量(将全部数据一次写入到redis)
  • 一个是增量(实时更新)

这里说的是增量,指的是mysql的update、insert、delate变更数据。

2)读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据

这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。

其实这种机制,很类似MySQL的主从备份机制,因为MySQL的主备也是通过binlog来实现的数据一致性。

这里可以结合使用canal(阿里的一款开源框架),通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。

当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ等来实现推送更新Redis。

3.2 缓存雪崩、穿透、击穿、污染

img

3.2.1 缓存雪崩

对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。或者缓存中 数据大批量到过期时间,大批量数据同时查询数据库,引起数据库压力过大甚至宕机。

这就是缓存雪崩。

img

大约在 3 年前,国内比较知名的一个互联网公司,曾因为缓存事故,导致雪崩,后台系统全部崩溃,事故从当天下午持续到晚上凌晨 3~4 点,公司损失了几千万。

缓存雪崩的事前事中事后的解决方案如下:

  • 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
  • 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

除此之外实际使用时:

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  • 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
  • 热点数据的过期时间尽量设置长

img

用户发送一个请求,系统 A 收到请求后,先查本地 ehcache 缓存,如果没查到再查 Redis。如果 ehcache 和 Redis 都没有,再查数据库,将数据库中的结果,写入 ehcache 和 Redis 中。

限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空值。

好处:

  • 数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。
  • 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。
  • 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来了。

3.2.2 缓存穿透

对于系统 A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。

黑客发出的那 4000 个攻击,缓存中查不到,每次你去数据库里查,也查不到。

举个栗子。数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

img

解决方案:

  • 设置空值解决方式很简单,每次系统 A 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN 。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。

img

  • 布隆过滤器。当然,如果黑客如果每次使用不同的负数 id 来攻击,写空值的方法可能就不奏效了。更为经常的做法是在缓存之前增加布隆过滤器,将数据库中所有可能的数据哈希映射到布隆过滤器中。然后对每个请求进行如下判断:使用布隆过滤器能够对访问的请求起到了一定的初筛作用,避免了因数据不存在引起的查询压力。

    • 请求数据的 key 不存在于布隆过滤器中,可以确定数据就一定不会存在于数据库中,系统可以立即返回不存在。
    • 请求数据的 key 存在于布隆过滤器中,则继续再向缓存中查询。
  • Key校验对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。

3.2.3 缓存击穿

缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

不同场景下的解决方式可如下:

  • 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期。
  • 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
  • 若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。

3.2.4 缓存污染

缓存污染问题说的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。

缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响Redis性能。这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。

缓存淘汰策略

Redis共支持八种淘汰策略,分别是noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。

怎么理解呢?主要看分三类看:

  • 不淘汰

    • noeviction (v4.0后默认的)
  • 对设置了过期时间的数据中进行淘汰

    • 随机:volatile-random
    • ttl:volatile-ttl
    • lru:volatile-lru
    • lfu:volatile-lfu
  • 全部数据进行淘汰

    • 随机:allkeys-random
    • lru:allkeys-lru
    • lfu:allkeys-lfu
  1. noeviction该策略是Redis的默认策略。在这种策略下,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。这种策略不会淘汰数据,所以无法解决缓存污染问题。一般生产环境不建议使用。其他七种规则都会根据自己相应的规则来选择数据进行删除操作。
  2. volatile-random。这个算法比较简单,在设置了过期时间的键值对中,进行随机删除。因为是随机删除,无法把不再访问的数据筛选出来,所以可能依然会存在缓存污染现象,无法解决缓存污染问题。
  3. volatile-ttl。这种算法判断淘汰数据时参考的指标比随机删除时多进行一步过期时间的排序。Redis在筛选需删除的数据时,越早过期的数据越优先被选择。
  4. volatile-lru。LRU算法:LRU 算法的全称是 Least Recently Used,按照最近最少使用的原则来筛选数据。这种模式下会使用 LRU 算法筛选设置了过期时间的键值对。Redis优化的 LRU算法实现:Redis会记录每个数据的最近一次被访问的时间戳。在Redis在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。通过随机读取待删除集合,可以让Redis不用维护一个巨大的链表,也不用操作链表,进而提升性能。Redis 选出的数据个数 N,通过 配置参数 maxmemory-samples 进行配置。个数N越大,则候选集合越大,选择到的最久未被使用的就更准确,N越小,选择到最久未被使用的数据的概率也会随之减小。
  5. volatile-lfu。会使用 LFU 算法选择设置了过期时间的键值对。LFU 算法:LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。 Redis的LFU算法实现:当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255,这样在访问快速的情况下,如果每次被访问就将访问次数加一,很快某条数据就达到最大值255,可能很多数据都是255,那么退化成LRU算法了。所以Redis为了解决这个问题,实现了一个更优的计数规则,并可以通过配置项,来控制计数器增加的速度。参数 :lfu-log-factor ,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。lfu-decay-time, 控制访问次数衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。lfu-log-factor设置越大,递增概率越低,lfu-decay-time设置越大,衰减速度会越慢。我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10。 如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1。可以快速衰减访问次数。volatile-lfu 策略是 Redis 4.0 后新增。
  6. allkeys-lru使用 LRU 算法在所有数据中进行筛选。具体LFU算法跟上述 volatile-lru 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。
  7. allkeys-random从所有键值对中随机选择并删除数据。volatile-random 跟 allkeys-random算法一样,随机删除就无法解决缓存污染问题。
  8. allkeys-lfu使用 LFU 算法在所有数据中进行筛选。具体LFU算法跟上述 volatile-lfu 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。

allkeys-lfu 策略是 Redis 4.0 后新增。

3.3 I/O多路复用

3.3.1 有哪几种I/O模型

为什么 Redis 中要使用 I/O 多路复用这种技术呢?

首先,Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。

  • Blocking I/O。先来看一下传统的阻塞 I/O 模型到底是如何工作的:当使用 read 或者 write 对某一个**文件描述符(File Descriptor 以下简称 FD)**进行读写时,如果当前 FD 不可读或不可写,整个 Redis 服务就不会对其它的操作作出响应,导致整个服务不可用。这也就是传统意义上的,也就是我们在编程中使用最多的阻塞模型。但是由于它会影响其他 FD 对应的服务,所以需要处理多个客户端任务的时候,往往都不会使用阻塞模型。
  • I/O多路复用。阻塞式的 I/O 模型并不能满足这里的需求,我们需要一种效率更高的 I/O 模型来支撑 Redis 的多个客户(redis-cli),这里涉及的就是 I/O 多路复用模型了。在 I/O 多路复用模型中,最重要的函数调用就是 select,该方法的能够同时监控多个文件描述符的可读可写情况,当其中的某些文件描述符可读或者可写时,select 方法就会返回可读以及可写的文件描述符个数。与此同时也有其它的 I/O 多路复用函数 epoll/kqueue/evport,它们相比 select 性能更优秀,同时也能支撑更多的服务。

3.3.2 Reactor设计模式

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)

img

文件事件处理器使用 I/O 多路复用模块同时监听多个 FD,当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器。

虽然整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,提高了网络通信模型的性能,同时也可以保证整个 Redis 服务实现的简单。

3.3.3 I/O多路复用模块

I/O 多路复用模块封装了底层的 select、epoll、avport 以及 kqueue 这些 I/O 多路复用函数,为上层提供了相同的接口。

因为 Redis 需要在多个平台上运行,同时为了最大化执行的效率与性能,所以会根据编译平台的不同选择不同的 I/O 多路复用函数作为子模块,提供给上层统一的接口;在 Redis 中,我们通过宏定义的使用,合理的选择不同的子模块。

img

Redis 会优先选择时间复杂度为 O(1) 的 I/O 多路复用函数作为底层实现,包括 Solaries 10 中的 evport、Linux 中的 epoll 和 macOS/FreeBSD 中的 kqueue,上述的这些函数都使用了内核内部的结构,并且能够服务几十万的文件描述符。

但是如果当前编译环境没有上述函数,就会选择 select 作为备选方案,由于其在使用时会扫描全部监听的描述符,所以其时间复杂度较差 O(n)O(n),并且只能同时服务 1024 个文件描述符,所以一般并不会以 select 作为第一方案使用。

3.4 脑裂问题

如果在 Redis 中,形式上就是有了两个 master,记住了两个 master 才是脑裂的前提

3.4.1 哨兵模式下的脑裂

1个 master 与 3个 slave组成的哨兵模式(哨兵独立部署于其他节点)。两个客户端 server1、server2 都连接上了 master。但是如果 master 与 slave 及哨兵之间 网络发生了故障,但是哨兵与slave之间通讯正常,这时3个slave其中1个经过哨兵投票后,提升为新master。如果恰好此时 server1 仍然连接的是旧的master,而server2连接到了新的master。

数据就不一致了,基于 setNX 指令的分布式锁,可能会拿到相同的锁;基于 incr 生成的全局唯一 id,也可能出现重复。

3.4.2 cluster 模式下的脑裂

img

cluster 模式下,这种情况要更复杂,例如集群中有 6 组分片,每给分片节点都有 1 主 1 从,如果出现网络分区时,各种节点之间的分区组合都有可能。

手动解决问题

在正常情况下,如果 master 挂了,那么写入就会失败,如果是手动解决,那么人为会检测 master 以及 slave 的网络状况,然后视情况,如果是 master 挂了,重启 master,如果是 master 与 slave 之间的连接断了,可以调试网络,这样虽然麻烦,但是是可以保证只有一个 master 的,所以只要认真负责,不会出现脑裂。

自动解决问题

Redis 中有一个哨兵机制,哨兵机制的作用就是通过 redis 哨兵来检测 redis 服务的状态,如果一旦发现 master 挂了,就在 slave 中选举新的 master 节点以实现故障自动转移。

如何避免脑裂

合理设置 min-slaves-to-write、min-slaves-max-lag两个参数

  • 第一个参数标识连接到 master 的最少 slave 数量
  • 第二个参数标识 slave连接到 master 的最大延迟时间

问题,就出现在这个自动故障转移上,如果是哨兵和 slave 同时与 master 断了联系,即哨兵可以监测到 slave,但是监测不到 master,而 master 虽然连接不上 slave 和哨兵,但是还是在正常运行,这样如果哨兵因为监测不到 master,认为它挂了,会在 slave 中选举新的 master,而有一部分应用仍然与旧的 master 交互。当旧的 master 与新的 master 重新建立连接,旧的 master 会同步新的 master 中的数据,而旧的 master 中的数据就会丢失。所以我认为 redis 脑裂就是自动故障转移造成的。

3.4 搭建哨兵集群

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
[root@VM-4-9-centos ~]# docker run -it -d --name redis3 -p 6378:6378 redis
1d3ab7315ac93a217136fe0fb0837104ca4e5500b0671d2acb989f92ecd8e38b
[root@VM-4-9-centos ~]# docker inspect -f '{{.Name}} - {{.NetworkSettings.IPAddress }}' $(docker ps -aq)
/redis3 - 172.17.0.6
/redis2 - 172.17.0.5
/redis1 - 172.17.0.4
[root@VM-4-9-centos ~]# docker exec -it redis3 /bin/bash
root@1d3ab7315ac9:/data# redis-cli
127.0.0.1:6379> replicaof 172.17.0.4 6379
OK
127.0.0.1:6379> get name
"kongxr"
# https://segmentfault.com/a/1190000040755506
#1.新建一个文件: docker-compose.yml 内容如下:
version: '3.7'
services:
sentinel1:
image: redis
container_name: redis-sentinel-1
restart: always
ports:
- 26379:26379
command: redis-sentinel /usr/local/etc/redis/sentinel.conf
volumes:
- ./sentinel1.conf:/usr/local/etc/redis/sentinel.conf
sentinel2:
image: redis
container_name: redis-sentinel-2
restart: always
ports:
- 26380:26379
command: redis-sentinel /usr/local/etc/redis/sentinel.conf
volumes:
- ./sentinel2.conf:/usr/local/etc/redis/sentinel.conf
sentinel3:
image: redis
container_name: redis-sentinel-3
ports:
- 26381:26379
command: redis-sentinel /usr/local/etc/redis/sentinel.conf
volumes:
- ./sentinel3.conf:/usr/local/etc/redis/sentinel.conf

#2.分别新建三个文件: sentinel1.conf、sentinel2.conf、sentinel1.conf 内容都如下:
# 自定义集群名,其中172.17.0.4 为 redis-master 的 ip,6380 为 redis-master 的端口,2 为最小投票数(因为有 3 台 Sentinel 所以可以设置成 2)

port 26379
dir /tmp
sentinel monitor mymaster 172.17.0.4 6380 2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel auth-pass mymaster redispwd
sentinel failover-timeout mymaster 180000
sentinel deny-scripts-reconfig yes

#3.四个文件都放在同义目录下,并使用命令
[root@VM-4-9-centos redis-sentinel]# docker-compose up -d
Creating network "redis-sentinel_default" with the default driver
Creating redis-sentinel-1 ... done
Creating redis-sentinel-3 ... done
Creating redis-sentinel-2 ... done

#4.测试:进入redis1 发现,当前redis为主节点。然后将该redis关闭。
[root@VM-4-9-centos redis-sentinel]# docker exec -it redis1 /bin/bash
root@f21ca2cacfea:/data# redis-cli
127.0.0.1:6379> info replication
# Replication
role:master
[root@VM-4-9-centos redis-sentinel]# docker stop redis1
redis1

四、Redis应用

4.1 分布式锁

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

占坑一般是使用 setnx 指令,只允许被一个客户端占坑。先来先占,用完了,再调用 del 指令释放茅坑。

1
2
3
4
5
> setnx lock01 true
OK
... do something critical ...
>del lock01
(integer)1

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

1
2
3
4
5
6
> setnx lock01 true
OK
>expire lock01 5
... do something critical
>del lock01
(integer)1

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

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

为了解决这个问题,Redis开源社区涌现出很大分布式锁的library,专门用来解决这个问题,其实现方式极为复杂。如果需要使用分布式锁,不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。为了治理这个乱象,Redis2.8版本中 加入了 set 指令的扩展参数,是的 setnx 和 expire 可以一起执行,彻底解决了分布式锁的乱象。

1
2
3
> set lock01 true ex 5 nx
OK
> del lock01

Redis 的分布式锁不能解决超时问题,如果在加锁和释放之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程执行完之前就拿到了锁。

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

有一个更安全的方案是为 set 指令的 value 参数设置为 一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。

1
2
# delifequals
if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end

可重入性

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

4.2 延时队列

平时习惯使用 RabbitMQ和Kafka作为消息队列中间件,来给应用程序之间增加异步消息传递功能。这个两个中间件都是专业的消息队列中间件,其能力很强,但是使用起来也较为繁琐。Redis的消息队列实现很简单,但是并不是专业的消息队列,它没有非常多的高级特性,没有ack保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用。

4.2.1 异步消息队列

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

客户端是通过队列的 pop 操作来获取消息,然后进行处理。处理完了再接着获取消息,再进行处理,如此往复。

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

通常使用 sleep 来解决这个问题,让线程休眠一会。但是这样会造成消费者的延迟。可以有更好的解决方案:使用 blpop、brpop,前缀字符b代表的就是 blocking,即堵塞读。堵塞读在队列没有数据的时候,会立即进行休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为0。这个方案有个弊端,就是注意空连接问题。因为线程一直堵塞在那,Redis的客户端连接就成了闲置连接,闲置过久,服务器一般就会主动断开连接,减少闲置资源的占用。这时 blpop、brpop 会抛出异常。所以在 编写客户端消费者时,注意捕获异常和重试。

4.2.2 延迟队列的实现

上一节提及的 分布式锁。当客户端在处理请求时 加锁没加成功 怎么办。一般是有 3种 策略来处理加锁失败:

  • 直接抛出异常,通知用户稍后重试。
  • sleep,一会再重试。这种方式,会堵塞当前的消息处理线程,导致队列的后续消息处理出现延迟。如果碰撞出现较多或者队列里的消息较多,sleep 可能并不合适。因为个别 死锁的key 导致加锁不成功,线程会彻底堵死,导致后续消息永远得不到及时处理。
  • 将请求转移到延迟队列,过会再试。这种方式较好。

延时队列可以通过 Redis的 zset(有序列表)来实现。我们将消息序列化成一个字符串作为 zset 的 value,这个消息的到期处理时间作为 score,然后用多个线程轮询 zset 获取到期的任务进行处理,多个线程是为了保障可用性,万一挂了一个线程还有其他线程可以继续处理。因为有多个线程,所以需要考虑并发争抢任务,确保任务不能被多次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def delay(msg):
msg.id = str(uuid.uuid4())
value = json.dumps(msg)
retry_ts = time.time() + 5
redis.zadd("delay-queue",retry_ts,value)
def loop():
while True:
values = redis.zrangebyscore("delay-queue",0,time.time(),start=0,num=1)
if not values:
time.sleep(1) #延迟队列是空当,休息1s
continue
value = value[0]
success = redis.zrem("delay-queue",value)
if success:
msg = json.loads(value)
handle_msg(msg)

Redis 的 zrem 方法是多线程多进程抢任务的关键,它的返回值决定了当前实例有没有抢到任务,因为loop方法可能被多个线程、多个进程调用,同一任务可能会被多个进程线程抢到,通过 zrem 来决定唯一的属主。同时注意对 handle_msg 进行异常捕获。

上述方案还是存在明显缺点:1.原子性问题:先查询再删除 这两个操作不是原子的,明显会出现并发问题,虽然我这里判断了 zrem 的数量,但是可能会出现部分 key 被其他机器给消费的情况;2.性能问题:zrangebyscore还好,但是如果在时间间隔内产生了大量消息,如果同时处理,zrem 的性能会急剧下降。

性能问题解决:

  • 多线程并发消费
  • 将定时任务的启动延迟时间或者每次循环的时间随机,让每台机器处理消息点有一定间隔,这样单次时间间隔内要处理的消息的数据会大大减少。
  • zrangebyscore 命令设置 limit,限制单次处理消息的数据

原子性问题解决:

使用Lua脚本 解决zrangebyscore 和 zrem 不是原子化操作的问题。

1
2
3
4
5
6
7
8
9
10
11
12
local key = KEYS[1]
local min = ARGV[1]
local max = ARGV[2]
local result = redis.call('zrangebyscore',key,min,max,'LIMIT',0,10)
if next(result) ~= nil and #result > 0 then
local re = redis.call('zrem',key,unpack(reslut));
if(re > 0) then
return result;
end
else
return {}
end

4.3 位图

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

位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用 位图操作 getbit/setbit 等 将byte数组看成 位数组 来处理。

Redis 的位数组是自动扩展,如果设置了某个偏移位置超过了现有的内容范围,就会自动将位数组进行零扩充。

4.3.1 基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> setbit bitArray01 1 1
(integer)0
> setbit bitArray01 2 1
(integer)0
> setbit bitArray01 4 1
(integer)0
> setbit bitArray01 9 1
(integer)0
> setbit bitArray01 10 1
(integer)0
> setbit bitArray01 13 1
(integer)0
> setbit bitArray01 15 1
(integer)0
> get bitArray01
"he"

上面的例子,可以理解为 零存整取,同样也可以 零存零取,整存整取。零存:就是使用 setbit 对位值 进行逐个设置。整存:就是使用字符串一次性填充所有位数组,覆盖掉旧值。

4.3.2 统计和查找

Redis 提供了位图统计指令 bitcount 和位图查找指令 bitpos,bitcount 用来统计指定位置范围内 1 的个数,bitops 用来查找指定范围内出现的第一个 0或1。

遗憾的是,start 和 end 参数是 字节索引,也就是说指定的位范围必须是 8的倍数,而不能任意指定。

1
2
3
4
5
set w hello			# 整存
bitcount w 0 0 # 第一个字符中1的位数
bitcount w 0 1 # 前两个字符中1的位数
bitops w 0 # 第一个零位
bitops w 1 0 1 2 # 第二到第三字符中 第一个出现1的位置

4.3.3 魔术指令 bitfield

之前我们设置或者获取 指定位的值 都是单个位的,如果要一次操作多个位,就必须要使用管道来处理。Redis3.2之后,新增命令 bitfield 可以使用。其下有三个子指令分别是 get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理64个连续的位,如果超过64位,就得使用多个子指令,bitfield 可以一次执行多个子指令。

1
2
3
4
5
set w hello
bitfield w get u4 0 # 从第一位开始取4个位,取出结果为无符号数(u)
bitfield w get i3 2 # 从第三位开始取3个位,取出结果为有符号数(i)
bitfield w get u4 0 get i3 2 # 可以一次执行多个子指令
bitfield w et u8 8 97 #从第8个位开始,将接下来的8个位 用无符号数97 替换

所谓有符号数是指 取出来的位数组中第一个位是当作符号位,剩下的才是值。如果第一位是1,那就是负数。无符号数表示非负数,没有符号位,获取到位数组全部都是值。有符号数 最多可以获取64位,无符号数 只能获取63位。

第三个指令 incrby,它用来对指定范围的位进行自增操作。既然提到了自增,就有可能出现溢出。如果增加了正数,会出现上溢。如果增加负数,会出现下溢出。如果出现溢出,就将溢出的符号位丢掉。如果是8位无符号数255,加1就变成 0。

4.4 HyperLogLog

HyperLogLog提供的是一个不精确但是节省空间的去重计数方案:如果页面访问量非常大,比如一个爆款页面几千万的 UV,就需要一个很大的 Set集合 来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。如果对统计精确度不需要太精确,就可以使用HyperLogLog,它的标准误差是0.81%。

4.4.1 使用方法

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

1
2
3
4
> pfadd w user1
(integer)1
> pcount w
(integer)1

除了上面两个指令,还有一个 pfmerge,用于将多个 pf 计数值累计在一起形成一个新的 pf 值。

4.4.2 数学原理

极大似然估计的直观理解

其使用的数学原理是统计学中的极大似然估计。接下去我将用多个场景逐步深入解析。
场景1:现在有2个不透明的口袋,其中都装有100个球,A口袋中是99个白球1个黑球,B口袋中是99个黑球1个白球。当我们随机挑选一个口袋,然后从中拿出一个球。如果拿出的球是白色的,那么我们可以说“大概率”我们取出的是A口袋。这种直觉的推测其实就包含了“极大似然估计”的思想。

场景2:我们只保留A口袋,其中99个白球,1个黑球。很容易我们就可以得出结论,从中取出任意一个球,是白球的概率为99%,是黑球的概率为1%。这是一种正向的推测
我们知道了**条件(99个白球,1个黑球)*,从而推测出结果(取出任意一个球,是白球的概率为99%)***。
但这只是理论上的推测,如果实际取球100次,每次都放回,那么取出黑球的次数并不一定是1次,可能是0次,也可能超过1次。我们取球的次数越多,实际情况将越符合理论情况。

场景3:还是A口袋,只不过此时其中白球和黑球的数量我们并不知晓。于是我们开始从中拿球,每拿出一个球都记录下结果,并将其放回。如果我们取球100次,其中99次是白球,1次是黑球,我们可以说A口袋中可能是99个白球,但并不能非常肯定。当我们取球10000次的时候,其中9900次是白球,100次是黑球,此时我们就可以大概率确定A口袋中是99个白球,而这种确定程度随着我们实际取球次数的增加也将不断增加。这就是一种反向的推测
我们观察了**结果(取10000次球,9900次是白球,100次是黑球)*,可以推测出条件(A口袋中放了99个白球,1个黑球)***。
当然这种推测的结果并非是准确的,而是一种大概率的估计。
无论是正向推测或是反向推测,只有当实际执行操作的次数足够多的时候,才能使得实际情况更接近理论推测。这就非常符合hyperloglog的特点,只有当数据量足够大的时候,误差才会足够小。

因此极大似然估计的本质就是:当能观察的结果数量足够多时,我们就可以大概率确定产生相应结果所需要的条件的状态。这种通过大量结果反向估计条件的数学方法就是极大似然估计。

伯努利实验与极大似然估计

了解极大似然估计之后,我们就需要引入第二个数学概念,伯努利实验。
不要被这个名字唬住,伯努利实验其实就是扔硬币,接下去我们就来了解下这枚硬币要怎么扔。下文所说的硬币都是最普通的硬币,只有正反两面,且每一面朝上的概率都是50%。
场景1:我们随机扔一次硬币,那么得到正面或反面的可能性是相同的。如果我们扔10000次硬币,那么可以估计到大概率是接近5000次正面,5000次反面。这是最简单的正向推测。

场景2:如果我们扔2次硬币,是否可能2次都是正面?当然有可能,并且概率为1/4。如果我们扔10次硬币呢,是否可能10次都是正面?虽然概率很小,但依然是有可能的,概率为1/1024。同样的,无论是100次、1000次,即使概率很小,也依然存在全部都是正面朝上的情况,假如扔了n次,那么n次都是正面的概率为12𝑛12�。这也是正向的推测,只不过增加了全都是正面朝上的限定。

场景3:现在我们按下面这种规则扔硬币:不断扔硬币,如果是正面朝上,那么就继续扔,直到出现反面朝上,此时记录下扔硬币的总次数。例如我们抛了5次硬币,前4次都是正面朝上,第5次是反面朝上,我们就记录下次数5。通过场景2,我们可以知道这种情况发生的概率为1/32。按我们的直觉可以推测,如果一个结果发生的概率是1/32,那么我们大体上就需要做32次同样的事情才能得到这个结果(当然从更严谨的数学角度,并不能这么说,但本文不想涉及专业的数学描述,所以姑且这么理解,其实也挺符合一般常识判断的)。
那么假如张三做了若干次这种实验,我观察结果,发现记录下的总次数的最大值是5,那就说明在这若干次实验中,至少发生了一次4次正面朝上,第5次反面朝上的情况,而这种情况发生的概率是1/32,于是我推测,张三大概率总共做了32次实验。这就是一种反向推测:
即根据结果(发生了一次1/32概率才会出现的结果)*,推测条件(大概率做了32次实验)*
更通俗来说,如果一个结果出现的概率很小,但却实际发生了了,就可以推测这件事情被重复执行了很多次。结果出现的概率越小,事情被重复执行的次数就应当越多。就像生活中中彩票的概率很低,普通人如果想中那可不就得买很多次嘛,中奖概率越低,一般需要购买彩票的次数就越多。相应的如果一个人中奖了,我们可以说这个人
大概率
上购买了非常多次彩票。这就是伯努利实验与极大似然估计结合的通俗理解。

另外特别注意的,我们推测条件时,需要观察的总次数的最大值,因为最大值代表了最小概率,而最小概率才是推测条件的依据。下文redis同理。

4.4.3 redis实现

redis实现本质也是利用了“扔硬币”产生的“极大似然估计”原理,因此接下去我们就详细看看redis是怎么扔硬币的。
在伯努利试验的场景3中,我们做的实验有3个特点:
1.硬币只有正反两面。
2.硬币正反面出现的概率相同。
2.单次实验需要投掷多次硬币。

而计算机中的hash算法正好可以满足这3个条件:
1.hash结果的每一个bit只有0和1,代表硬币的正反两面。
2.如果hash算法足够好,得到的结果就足够随机,可以近似认为每一个bit的0和1产生的概率是相同的。
3.hash的结果如果是64个bit,正好代表投掷了64次硬币。

因此执行一次hash,就相当于完整地进行了一次场景3中的投币实验。按照约定,实验完成后,我们需要记录硬币投掷的结果。
假定现在有2个用户id;user1、user2
先对user1进行hash,假定得到如下8个bit的结果:
10100100
此时从右到左,我们约定0表示反面,1表示正面,于是在这次实验中,第一个为1的bit出现在第三位,相当于先投出了2次反面,然后投出1次正面,于是我们记录下这次实验的投掷次数为3。因为约定只要投出正面,当次实验就结束,所以第一个1左边的所有bit就不再考虑了。
再对user2进行hash,假定得到:
01101000
第一个为1的bit出现在第4位,于是记录下4。
对于每个用户的访问请求,我们都可以对用户的id进行hash(相当于场景3中进行一次实验),并记录下第一个为1的bit出现的位数(相当于场景3中记录下硬币的投掷次数),那么通过记录到的位数的最大值,我们就可以大概估计出一共进行了多少次实验(相当于场景3中的反向推测),也就是有多少个不同的用户发生了访问。
例如某个页面有若干个用户进行了访问,我们观察记录下的数据,发现记录下的最大值是10,就意味着hash的结果至少出现了一次右边9个bit都为0的情况。而这种情况发生的概率为1/1024,于是我们可以推测大概有1024个用户访问过该页面,才有可能出现一次这种结果。

所以其实可以这样理解:

每个用户ID的 hash结果相当于此用户的投币结果,我们看下 hash值从右向左第一次出现1的位置。如果比之前用户hash记录出现1的位置更靠左,则记录。这样如果最后记录的最大值是10,则可以推测1024个用户访问过。

又因为同一用户ID hash结果是唯一的,所以同一个用户ID即使多次实验,也不会影响精准性。当用户越多,则我们通过概率推测的用户数量 越接近实际情况。

4.5 布隆过滤器

HyperLogLog 可以用来进行估值,它非常有价值,可以解决很多精确度要求不高的统计需求。但是如果我们想要知道某一个值是不是已经不在 HyperLogLog 结构里面了,它就无能为力的。

现实中,比如推荐系统:用户的视频推荐系统,每次推荐 需要查看用户是否观看过此视频。问题是,当用户量很大,每个用户观看过的视频总数又很大的情况下,去重工作在在性能上考验很大。如果数据存储在 关系数据库中,去重就需要频繁地对数据库进行 exists 查询。

如果使用缓存,但是这么多历史记录全部缓存起来,就得浪费很多存储空间。布隆过滤器可以解决此问题,它可以起到去重的同时,在空间上还能节省90%以上,只是稍微那么不精确。

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

那么可以使用布隆过滤器 判断 需要推荐的时候,是否在用户观看历史记录集合中。如果不在,则推荐。如果判断在历史记录中,实际可能在 也可能不在,因为会有概率误判。所以 可以保证推荐的内容肯定是用户没看过的,但可能 也会把极少量用户没有看过的内容 误判成用户看过,而过滤掉。

4.5.1 基本使用

Redis官方提供的布隆过滤器到了Redis4.0提供了插件功能之后正式登场。可以通过docker直接体验

1
2
3
> docker pull redislabs/rebloom
> docker run -p 6379:6379 redislabs/rebloom
> redis-cli

布隆过滤器基本指令:

  • bf.add [collection] [element]:添加 元素element 进入 过滤器collection
  • bf.exists [collection] [element]:查询 元素element 是否存在,返回1表示存在,0表示不存在
  • bf.madd [collection] [element01] [element02]:一次 添加多个 元素进入 过滤器
  • bf.mexists:一次 查询多个元素 是否在过滤器

上面指令使用的布隆过滤器只是默认参数的布隆过滤器,它在外面第一次add的时候被自动创建。Redis还提供了自定义参数的布隆过滤器,需要我们在 add 之前,使用 bf.reserve 指令显式创建。如果对应的key已经存在了,bf.reserve 会报错。bf.reserve 有三个参数,分别是 key,error_rate 和 initial_size。错误率越低,需要的空间越大。initial_size 参数表示预计放入的元素数量,当实际数量超过这个值,误判率会上升。所以一般 initial_size 需要设置一个较大的数值,避免超过,导致误判率升高。如果不使用 bf.reserve,默认的 error_rate 是 0.01,默认的 initial_size 是 100。

注意:如果 initial_size 估计的过大,也会浪费存储空间,估计的过小,就会影响准确率。

4.5.2 原理

每个布隆过滤器在Redis的数据结构里面就是 一个大型的位数组和几个不一样的无偏hash函数。所以无偏就是能够把元素的 hash值 算的比较均匀。

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

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

如果这个 位数组比较稀疏,这个误判的概率就很小,如果这个数组比较拥挤,误判的概率就会变大。使用时如果实际元素开始超过初始化大小,应该对布隆过滤器进行重建,重新分配一个size更大的过滤器,再将所有历史元素批量 add 进去。

4.5.3 空间占用估计

布隆过滤器有两个参数:第一个是预计元素的数量n,第二个是错误率 f。公式根据这两个输入 得到两个输出,第一个输出是 位数组的长度i,也就是需要的存储空间大小(bit),第二个输出是 hash 函数的最佳数量 k。hash函数的数量也会直接影响到错误率,最佳的数据会有最低的错误率。

1
2
k = 0.7 * (i/n)  # 约等于
f = 0.6185^(i/n) # ^表示次方计算

从公式可以看出:

  • 位数组 相对越长(i/n),错误率 f 越低
  • 位数组 相对越长(i/n),hash函数需要的最佳数量也越多,影响计算效率
  • 当一个元素平均需要 1个字节(8 bit)的指纹空间(i/n=8),错误率大约2%
  • 错误率为10%,一个元素需要的平均指纹空间为 4.792个bit
  • 错误率为0.1%,一个元素需要的平均指纹空间为 14.377个bit

从上面可以看到,一个元素需要占据15bit,那相对set集合的空间优势是不是就没有那么明显了?set中会存储每个元素的内容,而布隆过滤器仅仅存储元素的指纹。元素的内容大小就是字符串的长度,它一般有多个字节甚至几十个字节,每个元素本身还需要一个指针被set集合来引用。

4.6 简单限流

在Redis中,可以使用 ZSet 数据结构 实现该功能。可以把 zset 中的 score 值设置为 时间戳 ,这样就可以圈出一个时间段内的所有数据。即 只要 时间窗口内的数据,时间窗口外的数据都可以砍掉。那么 zset 的value 填什么值呢,也可以填时间戳,只需保证其唯一性就行。

这样就可以 用 ZSet 记录用户的行为历史,每个行为都会作为一个 zset 中的一个 key 保存下来。同一个用户同一种行为 会使用一个 zset 记录。为了节省内存,我们只需要保留时间窗口内的行为记录,同时如果用户是冷用户,滑动时间窗口内的行为是空记录,那么这个 zset 就可以从内存中移除,不再占用空间。

通过统计滑动窗口内的行为数量与阈值 max_count 进行比较就可以得出当前的行为是否允许。

整体思路:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。zset集合中 只有 score 值非常重要,value没有特别的意义。

缺点:要记录时间窗口内所有的行为记录。如果这个量很大,比如限定 60s 内操作不得超过 100w 次,那么这就不适合这样做限流了,因为会消耗大量的存储空间。

4.7 漏斗限流

Redis4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。

该模块只有1条指令 cl.throttle ,它的参数和返回值都略显复杂。

1
2
3
4
5
6
7
8
9
10
11
12
> cl.throttle key 15 30 60 1
# key是键名
# 第二个参数是 漏斗容量
# 第三个参数、第四个参数 表示 60s内 最多 30次(可以当作漏斗的流速)
# 第五个参数为 可选参数,默认为1.

指令会返回五个参数,分别表示:
# 0表示允许,1表示拒接
# 漏斗容量
# 漏斗剩余空间
# 如果拒接了,需要多长时间之后再试(多久后漏斗有空间,单位秒)
# 多长时间后,漏斗完全空出来(单位秒)

在执行限流指令时,如果被拒绝了,就需要丢弃或重试,cl.throttle 指令考虑的非常周到,连重试时间给我们了,直接取返回结果数组的第四个值进行 sleep 即可,如果不想堵塞线程,也可以异步定时任务来重试。

4.8 近水楼台-GeoHash

Redis在 3.2 版本以后增加了 GEO 模块,意味着我们可以使用 Redis 来实现 微信[附近的人]、美团[附件的餐馆]这样的功能了。

4.8.1 用数据库来算附近的人

地图元素的位置数据使用二维的经纬度表示,经度范围 (-180,180],纬度范围 (-90,90],纬度正负以赤道为界,北正南负,经度正负以本初子午线为界,东正西负。

当两个距离不是很远时,可以直接使用勾股定理就能算得元素之间的距离。平时使用的 [附近的人] 的功能,元素距离都不是很大,勾股定理算距离足以。不过需要注意的是,经纬度坐标的密度不一样(经度总共360度,纬度总共180度),勾股定理计算平方差时之后再求和时,需要按一定的系数比加权求和。

如果使用关系型数据库,基本采用(元素ID,经度,纬度)存储。那此时就很难通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然后对区域内的元素进行 全量距离 计算再排序。

为了满足高性能的矩形区域算法,数据表需要在经纬度坐标上加上双向复合索引(x,y),这样可以最大优化查询性能。但是数据库查询性能毕竟有限,如果 附近的人 查询请求非常多,在高并发场合,这可能并不是一个很好的方案。

4.8.2 GeoHash算法

业界比较通用的地理距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很近。当我们想要计算 [附近的人时],首先将目标的位置 映射到这条线上,然后在这个一维的线上获取附近的点就行了。

那这个映射算法具体是怎么计算的?它将整个地球看成一个二维平面,然后划分成了一系列正方形的方格,就好比围棋棋盘。所有的地图元素坐标都将放置于唯一的方格中。方格越小,坐标越精确。然后对这些方格进行整数编码,越是靠近的方格编码越是接近。那如何编码呢?一个最简单的方案就是切蛋糕法。设想一个正方形的蛋糕摆在你面前,二刀下去均分分成四个小正方形,这四个小正方形可以分别标记为00,01,10,11四个二进制整数。然后对每个小正方形继续用二分刀法切割一下,这时每个小小正方形使用4bit的二进制整数予以表示。然后继续切下去,正方形就会越来越小,二进制整数也会越来越长,精确度就会越来越高。

上面使用的是二刀法,进行编码。实际上还有其他很多方法进行编码。

编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。GeoHash算法会继续对这个整数做一次 base32 编码(0-9,a-z去掉a,i,l,o四个字母)变成一个字符串。在Redis里面,经纬度使用52位的整数进行编码,放进了zset里面,zset的 value 元素的key,score 是 GeoHash 的52位的整数值。zset 的 score 虽然是浮点数,但是对于 52位的整数值,它可以无损存储。

在使用 Redis 进行 Geo 查询时,我们要时刻想到它的内部结构实际上只是一个 zset(skiplist)。通过 zset 的 score 排序就要可以得到坐标附近的其他元素(实际情况要复杂一点),通过将 score 还原成坐标值就可以得到元素的原始坐标。

4.8.3 基本使用

Redis 提供的 Geo 指令只有 6 个。

  • 增加geoadd 指令携带 集合名称以及多个经纬度名称三元组。这里也可以一次性 添加多个元组。
1
2
3
4
5
6
7
8
9
# 实际使用
127.0.0.1:6379> geoadd company 116.48105 39.996794 x
(integer) 1
127.0.0.1:6379> geoadd company 116.48105 39.996794 xr
(integer) 1
127.0.0.1:6379> geoadd company 116.48110 39.996894 xrt
(integer) 1
127.0.0.1:6379> geoadd company 116.48410 39.996294 xrty 112.14517 38.12541 xrtu
(integer) 2

Redis 没有提供 geo 删除指令,但是因为 geo 的底层实现是 zset,所以可以使用 zrem key member 命令实现对 地理位置信息的删除。

  • 查看距离geodist 可以用来计算两个元素之间的距离,携带 集合名称、2个名称和距离单位
1
2
127.0.0.1:6379> geodist company xr xrt km
"0.0120"
  • 获取元素位置geopos 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。
1
2
3
4
5
6
7
8
127.0.0.1:6379> geopos company xr
1) 1) "116.48104995489120483"
2) "39.99679348858259686"
127.0.0.1:6379> geopos company xr xrt
1) 1) "116.48104995489120483"
2) "39.99679348858259686"
2) 1) "116.4810982346534729"
2) "39.99689487742897143"

我们观察到获取的经纬度坐标和 geoadd 进去的坐标有轻微的误差,原因是 geohash 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。

  • 获取元素的 Hash 值geohash 可以获取元素的经纬度编码字符串,上面说过它是 base32 编码。你可以使用这个编码值去 http://geohash.org/${hash} 中直接定位,它是 geohash 的标准编码值。
1
2
127.0.0.1:6379> geohash company xr
1) "wx4gd94yjn0"
  • 附近的georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其他元素,它的参数非常复杂。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 范围20公里以内最多3个元素按距离正排,它不会排除自身
127.0.0.1:6379> georadiusbymember company xr 20 km count 3 asc
1) "finchina"
2) "xr"
3) "xrt"
# 三个可选参数 withcoord withdist withhash 用来携带附加参数
127.0.0.1:6379> georadiusbymember company xr 20 km withcoord withdist withhash count 3 asc
1) 1) "finchina"
2) "0.0000"
3) (integer) 4069887154388167
4) 1) "116.48104995489120483"
2) "39.99679348858259686"
2) 1) "xr"
2) "0.0000"
3) (integer) 4069887154388167
4) 1) "116.48104995489120483"
2) "39.99679348858259686"
3) 1) "xrt"
2) "0.0120"
3) (integer) 4069887154432781
4) 1) "116.4810982346534729"
2) "39.99689487742897143"
  • 查询指定坐标附近的元素除了 georadiusbymember 指令根据元素查询附近的元素,Redis还提供了根据坐标值来查询附近的元素 georadius,这个指令更加有用,它可以根据用户的定位来计算。它的参数和 georadiusbymember 基本一致,除了将目标元素改成经纬度坐标值
1
2
3
4
5
127.0.0.1:6379> georadius company 116.18119895 39.9977934 100 km withdist count 2 desc
1) 1) "xrty"
2) "25.8103"
2) 1) "xrt"
2) "25.5539"

在一个地图应用中,车的数据、餐馆的数据、人的数据 可能会有百万千万条,如果使用 Redis 的 geo 数据结构,它们将全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能从一个节点迁移到另一个节点,如果单个key的数据过大,会对集群的迁移工作造成较大影响,在集群环境中的单个key对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。

所以,这里建议 geo 的数据使用单独的 Redis 实例部署,不使用集群环境。

如果数据量过亿甚至更大,就需要对 geo 数据进行拆分。在人口特大的城市,甚至可以按区划分,这样就可以显著降低单个 zset 集合的大小。

4.9 大海捞针 scan

有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除key。这里就有一个问题,如何从海量 key 中找到满足特定前缀的 key 列表?

Redis 提供了一个简单暴力的指令 keys 用来列出所有满足 特定正则字符串规则的 key。

1
2
3
4
5
6
7
8
9
10
127.0.0.1:6379> set code1 a
OK
127.0.0.1:6379> mset code2 2 code3 3 code4 4 code5 5
OK
127.0.0.1:6379> keys code*
1) "code5"
2) "code4"
3) "code1"
4) "code3"
5) "code2"

这个指令非常简单,提供一个简单的正则字符串即可,但是有很明显的两个缺点。

1.没有 offset、limit参数,一次性吐出所有满足条件的 key,万一实例中有几百万个 key 满足条件,则打印字符串太多。

2.keys 算法是 遍历算法,复杂度是 O(n),如果实例中有上千万级以上的 key,这个指令就会导致 redis 服务卡顿,所有读写 redis 的其他指令都会被延后甚至超时,因为redis是单线程程序,顺序执行所有指令,其他指令必须等到当前 keys 指令执行完成后才可以继续。

Redis为解决这个问题,在2.8版本加入了 scan 。scan 相比 keys具备以下优点:

  • 复杂度虽然也是0(n),但是它是通过游标分布进行的,不会堵塞线程。
  • 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是要给 hint,返回的参数可多可少。
  • 同 keys 一样提供 模式匹配功能。
  • 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数。
  • 遍历的过程中,如果有数据修改,改动后的数据能不能被遍历到是不确定的。
  • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零。

4.9.1 scan 基础使用

往redis插入了10条数据,code1到code10。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
127.0.0.1:6379> scan 0 match code* count 4
1) "6"
2) 1) "code8"
2) "code1"
3) "code4"

127.0.0.1:6379> scan 6 match code* count 4
1) "9"
2) 1) "code2"
2) "code6"
3) "code9"
4) "codex"

127.0.0.1:6379> scan 9 match code* count 4
1) "7"
2) 1) "code5"
2) "code3"
3) "code10"
4) "code7"

127.0.0.1:6379> scan 7 match code* count 4
1) "0"
2) 1) "code11"

从上面实际测试中可以知道,游标不是每次递增,并且所填的 limit hint 不是指代返回的结果数量,而是单次遍历的字典槽位数量(约等于)。可能 单次的 返回结果为空,但是这并不意味着 遍历已经结束。只有当返回的游标值为 0 ,才算整个遍历结束。

4.9.2 字典的结构

在 Redis 中所有的 key 都存储在一个很大的字典中,整个字典的结构和 Java中的 HashMap 一样,是一维数组+二维链表结构。第一维数组的大小总是 2^n (n>=0),扩容一次数组大小空间加倍,也就是 n++

scan 指令返回的游标就是第一个维数组的位置索引,我们将整个位置索引称作槽。如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。limit 参数就表示需要遍历的槽位数,之所以返回的结果可能多可能少,是因为不是所有的槽位都会挂接 链表,有些槽位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。每次遍历都会将 limit 数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端。

4.9.3 scan 遍历顺序

img

scan的遍历顺序非常特别。它不是从第一维数组的第0位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历。是考虑到字典的扩容和缩容时避免槽位和遍历重复和遗漏(后面有具体分析)。高位进位法从左边加,进位往右边移动,同普通加法正好相反。但是它们都会遍历所有的槽位并且没有重复。

4.9.4 字典扩容

Java 中的 HashMap 有扩容的概念,当 loadFactor 达到阈值时,需要重新分配一个新的 2 倍大小的数组,然后将所有的元素全部 rehash 挂到新的数组下面。rehash 就是将元素的 hash值对数组长度进行取模运算,因为长度变了,所以每个元素挂接的槽位可能也发生了变化。有因为数组的长度是 2^n 次方,所以取模运算等价于 位与 操作。

a%8 = a&(8-1) = a&7

a%16 = a&(16-1) = a&15

a%32 = a&(32-1) = a&31

这里的 7、15、31 又称之为字典的 mask值,mask的作用就是保留 hash 值的低位,高位都被设置为 0。

看看 rehash 前后元素槽位的变化

假设当前的字段的数组长度由 8 位扩容到 16位,那么 3号槽位 011 将会被 rehash 到3号槽位和11号槽位,也就是说该槽位链表中大约有一半的元素还是3号槽位,其它的元素会放到11号槽位,11这个数字的二进制是 1011,就是对 3 的二进制 011 增加了一个高位1。

抽象一点说,假设开始槽位的二进制是 xxx,那么该槽位中的元素将被 rehash 到 0xxx 和 1xxx 即 xxx+8中。如果字典长度由16位扩容到32位,那么对于二进制槽位 xxxx 中的元素将被 rehash 到 0xxxx 和 1xxxx中。

对比扩容前后的遍历顺序:

img

观察这张图片,我们发现采用高位进位加法的遍历顺序,rehash 后的槽位 在遍历顺序上是相邻的。

假设当前即将遍历 110这个位置,那么扩容后,当前槽位上所有的元素对应的新槽位是 0110 和 1110,也就是在槽位的二进制数增加一个高位0或1.这时我们可以i直接从 0110 这个槽位开始往后继续遍历,0110 槽位之前的所有槽位都是已经遍历过的,这样就可以避免扩容后对已经遍历过的槽位进行重复遍历。

再考虑缩容,假设当前即将遍历 110 这个位置,那么缩容后,当前槽位所有的元素对应的新槽位是 10,也就是去掉槽位二进制最高位。这时我们可以直接从10这个槽位继续往后遍历,10槽位之前的所有槽位都是遍历过的,这样可以避免缩容的重复遍历。不顾缩容还是不太一样,它会对图中 010 这个槽位上的元素进行重复遍历,因为缩容后 10 槽位的元素是 010 和 110上挂接的元素的融合。

4.9.5 scan 考虑 渐进式 rehash

Java的 HashMap 在扩容时会一次性将旧数组下挂接的元素全部转移到新的数组下面。如果 Map 中元素特别多,线程就会出现卡顿现象。Redis为了解决这个问题,它采用渐进式 rehash。

它同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐将旧的数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面寻找。

scan 也需要考虑这个问题,对于 rehash中的字典,它需要同时扫描新旧槽位,然后将结果融合后返回给客户端。

4.9.6 更多 scan 指令

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

4.9.7 大Key的扫描

因为业务人员的使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的 hash,一个很大的 zset。这样的对象对Redis的集群数据迁移带来了很大问题,因为在集群环境下,如果某一个key太大,会导致数据迁移卡顿。另外在内存分配上,如果一个key太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大key被删除,内存会一次性回收,卡顿现象再一次产生。

所以在开发中请避免大key的产生。如何定位到 大key呢?可以使用 scan 命令,对于扫描出来的每一个key,使用 type 指令获取类型,然后使用相应的数据结构的 size 或者 len 方法来得到 它的大小,对于每一种类型,保留大小的前 N名作为扫描结果展示出来。

Redis 官方已经提供了 实现上面功能的 指令:redis-cli -h 127.0.0.1 -p 6379 –bigkeys 。如果担心这个指令会大幅抬升 Redis 的 ops,还可以增加一个休眠参数。redis-cli -h 127.0.0.1 -p 6379 –bigkeys -i 0.1,这个指令每隔100条 scan 指令就会休眠 0.1s,ops 就不会剧烈抬升,但是扫描的时间会变长。

五、Redis 原理

5.1 线程 IO 模型

记住高并发的 Redis 中间件是 单线程的,除此之外,Node.js、Nginx 也是单线程,但是它们都是服务器高性能的典范。

详细可以看 3.3

5.2 通信协议

Redis 的作者认为 数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。所以即使 redis 使用了浪费流量的文本协议,依然可以取得极高的访问性能。

5.2.1 RESP (Redis Serialization Protocol)

RESP 是 Redis 序列化协议的简写。它是一种直观的文本协议,优势在于实现异常简单,解析性能极好。Redis 协议将传输的结构数据 分为5种单元类型,单元结束时统一加上回车换行符号\r\n。

  • 单行字符串 以 + 符号开头。
  • 多行字符串 以 $ 符号开头,后跟字符串长度
  • 整数值 以 : 符号开头,后跟整数的字符串形式
  • 错误信息 以 - 符号开头
  • 数组 以 * 号开头,后跟数组长度

5.2.2 小结

Redis 协议里有大量冗余的回车换行符,但是这个并不影响它成为互联网技术领域非常受欢迎的一个文本协议。有很多开源项目使用 RESP 作为它的通讯协议。在技术领域性能并不总是一切,还有简单性、易理解性和易实现性,这些都需要进行适当权衡。

5.3 持久化

Redis 的数据全部在内存中,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。

Redis 持久化机制有两种,第一种是快照,第二种是AOF日志。

  • RDB 将数据库的快照(snapshot)以二进制的方式保存到磁盘中。
  • AOF 则以协议文本的方式,将所有对数据库进行过写入的命令(及其参数)记录到 AOF 文件,以此达到记录数据库状态的目的。

AOF 日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。所以需要定期进行AOF重写,给AOF日志进行瘦身。

5.3.1 快照

我们都知道 Redis 是单线程程序,这个线程要同时负责多个客户端套接字的并发读写操作和内存数据结构的逻辑读写。在服务线上请求的同时,Redis 如果还需要进行内存快照(需要使用 文件IO操作),那就很难保持不堵塞。除此之外,持久化的同时,内存数据结构还在改变。这如何应对?

Redis 使用操作系统的多进程 COW (copy on write) 机制来实现快照持久化。

Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。(这是Linux为节约内存资源,所以让其共享起来,在子进程创建时,内存增长几乎没有明显变化)

子进程做数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写入到磁盘。但是父进程不一样,它必须持续接受客户端请求,然后对内存数据结构进行不间断修改。

这时候就会使用操作系统的 COW 机制来进行数据段页面的分离。数据段页面是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。

随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长。但是也不会超过原有数据内存的2倍大小。另一个Redis实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都会被分离,被分离的往往只有其中一部分页面。每个页面的大小只有 4k,一个 Redis 实例里面一般都会有成千上万的页面。

子进程因为数据没有变化,它能看到的内存里的数据在进程产生的那一瞬间就凝固了,再也不会改变,这也是为什么 Redis 的持久化叫 快照的原因。

5.3.2 AOF的写入

Redis 将所有对数据库进行过写入的命令(及其参数)记录到 AOF 文件, 以此达到记录数据库状态的目的, 为了方便起见, 我们称呼这种记录过程为同步。

举个例子, 如果执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
redis> RPUSH list 1 2 3 4
(integer) 4

redis> LRANGE list 0 -1
1) "1"
2) "2"
3) "3"
4) "4"

redis> KEYS *
1) "list"

redis> RPOP list
"4"

redis> LPOP list
"1"

redis> LPUSH list 1
(integer) 3

redis> LRANGE list 0 -1
1) "1"
2) "2"
3) "3"

那么其中四条对数据库有修改的写入命令就会被同步到 AOF 文件中

除了 SELECT 命令是 AOF 程序自己加上去的之外, 其他命令都是之前我们在终端里执行的命令。

同步命令到 AOF 文件的整个过程可以分为三个阶段:

  1. 命令传播:Redis 将执行完的命令、命令的参数、命令的参数个数等信息发送到 AOF 程序中。
  2. 缓存追加:AOF 程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的 AOF 缓存中。
  3. 文件写入和保存:AOF 缓存中的内容被写入到 AOF 文件末尾,如果设定的 AOF 保存条件被满足的话, fsync 函数或者 fdatasync 函数会被调用,将写入的内容真正地保存到磁盘中。

以下几个小节将详细地介绍这三个步骤。

命令传播

当一个 Redis 客户端需要执行命令时, 它通过网络连接, 将协议文本发送给 Redis 服务器。比如说, 要执行命令 SET KEY VALUE , 客户端将向服务器发送文本 “*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n” 。

服务器在接到客户端的请求之后, 它会根据协议文本的内容, 选择适当的命令函数, 并将各个参数从字符串文本转换为 Redis 字符串对象(StringObject)。

比如说, 针对上面的 SET 命令例子, Redis 将客户端的命令指针指向实现 SET 命令的 setCommand 函数, 并创建三个 Redis 字符串对象, 分别保存 SET 、 KEY 和 VALUE 三个参数(命令也算作参数)。

每当命令函数成功执行之后, 命令参数都会被传播到 AOF 程序, 以及 REPLICATION 程序(本节不讨论这个,列在这里只是为了完整性的考虑)。

缓存追加

当命令被传播到 AOF 程序之后, 程序会根据命令以及命令的参数, 将命令从字符串对象转换回原来的协议文本。

比如说, 如果 AOF 程序接受到的三个参数分别保存着 SET 、 KEY 和 VALUE 三个字符串, 那么它将生成协议文本 “*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n” 。

协议文本生成之后, 它会被追加到 redis.h/redisServer 结构的 aof_buf 末尾。

redisServer 结构维持着 Redis 服务器的状态, aof_buf 域则保存着所有等待写入到 AOF 文件的协议文本:

文件写入和保存

每当服务器常规任务函数被执行、 或者事件处理器被执行时, aof.c/flushAppendOnlyFile 函数都会被调用, 这个函数执行以下两个工作:

WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。

SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。

两个步骤都需要根据一定的条件来执行, 而这些条件由 AOF 所使用的保存模式来决定, 以下小节就来介绍 AOF 所使用的三种保存模式, 以及在这些模式下, 步骤 WRITE 和 SAVE 的调用条件。

AOF 保存模式

Redis 目前支持三种 AOF 保存模式,它们分别是:

  • AOF_FSYNC_NO :不保存在这种模式下, 每次调用 flushAppendOnlyFile 函数, WRITE 都会被执行, 但 SAVE 会被略过。在这种模式下, SAVE 只会在以下任意一种情况中被执行:这三种情况下的 SAVE 操作都会引起 Redis 主进程阻塞。

    • Redis 被关闭
    • AOF 功能被关闭
    • 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)
  • AOF_FSYNC_EVERYSEC :每一秒钟保存一次。在这种模式中, SAVE 原则上每隔一秒钟就会执行一次, 因为 SAVE 操作是由后台子线程调用的, 所以它不会引起服务器主进程阻塞。注意, 在上一句的说明里面使用了词语“原则上”, 在实际运行中, 程序在这种模式下对 fsync 或 fdatasync 的调用并不是每秒一次, 它和调用 flushAppendOnlyFile 函数时 Redis 所处的状态有关。每当 flushAppendOnlyFile 函数被调用时, 可能会出现以下四种情况:根据以上说明可以知道, 在“每一秒钟保存一次”模式下, 如果在情况 1 中发生故障停机, 那么用户最多损失小于 2 秒内所产生的所有数据。如果在情况 2 中发生故障停机, 那么用户损失的数据是可以超过 2 秒的。Redis 官网上所说的, AOF 在“每一秒钟保存一次”时发生故障, 只丢失 1 秒钟数据的说法, 实际上并不准确。

    • 子线程正在执行 SAVE ,并且:
      1. 这个 SAVE 的执行时间未超过 2 秒,那么程序直接返回,并不执行 WRITE 或新的 SAVE 。
      2. 这个 SAVE 已经执行超过 2 秒,那么程序执行 WRITE ,但不执行新的 SAVE 。注意,因为这时 WRITE 的写入必须等待子线程先完成(旧的) SAVE ,因此这里 WRITE 会比平时阻塞更长时间。
    • 子线程没有在执行 SAVE ,并且:
      1. 上次成功执行 SAVE 距今不超过 1 秒,那么程序执行 WRITE ,但不执行 SAVE 。
      2. 上次成功执行 SAVE 距今已经超过 1 秒,那么程序执行 WRITE 和 SAVE 。
  • AOF_FSYNC_ALWAYS :每执行一个命令保存一次。在这种模式下,每次执行完一个命令之后, WRITE 和 SAVE 都会被执行。另外,因为 SAVE 是由 Redis 主进程执行的,所以在 SAVE 执行期间,主进程会被阻塞,不能接受命令请求。

总结:

模式 WRITE 是否阻塞? SAVE 是否阻塞? 停机时丢失的数据量
AOF_FSYNC_NO 阻塞 阻塞 操作系统最后一次对 AOF 文件触发 SAVE 操作之后的数据。
AOF_FSYNC_EVERYSEC 阻塞 不阻塞 一般情况下不超过 2 秒钟的数据。
AOF_FSYNC_ALWAYS 阻塞 阻塞 最多只丢失一个命令的数据。

5.3.3 AOF 文件的读取和数据还原

AOF 文件保存了 Redis 的数据库状态, 而文件里面包含的都是符合 Redis 通讯协议格式的命令文本。

这也就是说, 只要根据 AOF 文件里的协议, 重新执行一遍里面指示的所有命令, 就可以还原 Redis 的数据库状态了。

Redis 读取 AOF 文件并还原数据库的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端(fake client)。
  2. 读取 AOF 所保存的文本,并根据内容还原出命令、命令的参数以及命令的个数。
  3. 根据命令、命令的参数和命令的个数,使用伪客户端执行该命令。
  4. 执行 2 和 3 ,直到 AOF 文件中的所有命令执行完毕。

完成第 4 步之后, AOF 文件所保存的数据库就会被完整地还原出来。

注意, 因为 Redis 的命令只能在客户端的上下文中被执行, 而 AOF 还原时所使用的命令来自于 AOF 文件, 而不是网络, 所以程序使用了一个没有网络连接的伪客户端来执行命令。 伪客户端执行命令的效果, 和带网络连接的客户端执行命令的效果, 完全一样。

5.3.4 AOF 重写

AOF 文件通过同步 Redis 服务器所执行的命令, 从而实现了数据库状态的记录, 但是, 这种同步方式会造成一个问题: 随着运行时间的流逝, AOF 文件会变得越来越大。

举个例子, 如果服务器执行了以下命令:

1
2
3
4
5
6
7
RPUSH list 1 2 3 4      // [1, 2, 3, 4]

RPOP list // [1, 2, 3]

LPOP list // [2, 3]

LPUSH list 1 // [1, 2, 3]

那么光是记录 list 键的状态, AOF 文件就需要保存四条命令。而实质上,我们其实只要保存 list 最新状态的内存数据,就可以。

另一方面, 有些被频繁操作的键, 对它们所调用的命令可能有成百上千、甚至上万条, 如果这样被频繁操作的键有很多的话, AOF 文件的体积就会急速膨胀, 对 Redis 、甚至整个系统的造成影响。

为了解决以上的问题, Redis 需要对 AOF 文件进行重写(rewrite): 创建一个新的 AOF 文件来代替原有的 AOF 文件, 新 AOF 文件和原有 AOF 文件保存的数据库状态完全一样, 但新 AOF 文件的体积小于等于原有 AOF 文件的体积。

AOF 重写的实现

所谓的“重写”其实是一个有歧义的词语, 实际上, AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取, 它针对的是数据库中键的当前值。

如同上面对 list 进行的四个操作后,那么当前 列表键在 Redis里的值就为 [1,2,3]。如果我们要保存这个列表的当前状态, 并且尽量减少所使用的命令数, 那么最简单的方式不是去 AOF 文件上分析前面执行的四条命令, 而是直接读取 list 键在数据库的当前值, 然后用一条 RPUSH 1 2 3 命令来代替前面的四条命令。

除了列表和集合之外, 字符串、有序集、哈希表等键也可以用类似的方法来保存状态, 并且保存这些状态所使用的命令数量, 比起之前建立这些键的状态所使用命令的数量要大大减少。

AOF 后台重写

上一节展示的 AOF 重写程序可以很好地完成创建一个新 AOF 文件的任务, 但是, 在执行这个程序的时候, 调用者线程会被阻塞。

很明显, 作为一种辅佐性的维护手段, Redis 不希望 AOF 重写造成服务器无法处理请求, 所以 Redis 决定将 AOF 重写程序放到(后台)子进程里执行, 这样处理的最大好处是:

  1. 子进程进行 AOF 重写期间,主进程可以继续处理命令请求。
  2. 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。

不过, 使用子进程也有一个问题需要解决: 因为子进程在进行 AOF 重写期间, 主进程还需要继续处理命令, 而新的命令可能对现有的数据进行修改, 这会让当前数据库的数据和重写后的 AOF 文件中的数据不一致。

为了解决这个问题, Redis 增加了一个 AOF 重写缓存, 这个缓存在 fork 出子进程之后开始启用, Redis 主进程在接到新的写命令之后, 除了会将这个写命令的协议内容追加到现有的 AOF 文件之外, 还会追加到这个缓存中。

换言之, 当子进程在执行 AOF 重写时, 主进程需要执行以下三个工作:

  1. 处理命令请求。
  2. 将写命令追加到现有的 AOF 文件中。
  3. 将写命令追加到 AOF 重写缓存中。

这样一来可以保证:

  1. 现有的 AOF 功能会继续执行,即使在 AOF 重写期间发生停机,也不会有任何数据丢失。
  2. 所有对数据库进行修改的命令都会被记录到 AOF 重写缓存中。

当子进程完成 AOF 重写之后, 它会向父进程发送一个完成信号, 父进程在接到完成信号之后, 会调用一个信号处理函数, 并完成以下工作:

  1. 将 AOF 重写缓存中的内容全部写入到新 AOF 文件中。
  2. 对新的 AOF 文件进行改名,覆盖原有的 AOF 文件。

当步骤 1 执行完毕之后, 现有 AOF 文件、新 AOF 文件和数据库三者的状态就完全一致了。

当步骤 2 执行完毕之后, 程序就完成了新旧两个 AOF 文件的交替。

这个信号处理函数执行完毕之后, 主进程就可以继续像往常一样接受命令请求了。 在整个 AOF 后台重写过程中, 只有最后的写入缓存和改名操作会造成主进程阻塞, 在其他时候, AOF 后台重写都不会对主进程造成阻塞, 这将 AOF 重写对性能造成的影响降到了最低。

5.3.5 混合持久化

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

img

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

混合持久化优点:

混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;

兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

5.4 管道

Redis 管道并不是 Redis 服务器直接提供的技术,这个技术实际是由客户端提供的,跟服务器没有本质关系。

Redis 的消息交互:

当我们使用客户端对 Redis 进行一次操作时。客户端将请求传送给服务器,服务器处理完成后,再将响应回复给客户端、这就需要花费一个网络数据包来回的时间。

如果连续执行多条指令,那就会花费多个网络数据包来回的时间。从客户端层面上来看,客户端时经历了 发送请求1-接受响应1-发送请求2-接受响应2— 这样。那么我们实际上可以调整一下,多个指令请求的请求响应顺序。即 发送请求1-发送请求2-接受请求1-接受请求2。这这样两个连续的发送请求操作和两个连续的等待请求响应操作 总共只会花费一次网络来回。

这便是管道操作的本质,服务器根本没有区别对待,还是收到一条消息,执行一条消息,回复一条消息的正常流程。客户端通过对管道中指令列表改变操作顺序就可以大幅节省 IO 时间。管道中的指令越多,效果越好。

管道压力测试

Redis 自带了一个压力测试工具 redis-benchmark,使用这个工具就可以进行管道测试。首先我们对一个普通的 set 指令进行压测,QPS大约 2.5w/s。

1
2
root@b7ba9713c11c:/data# redis-benchmark -t set -q
SET: 24319.07 requests per second, p50=0.959 msec

加入管道选项 -P 参数,它表示单个管道内并行的请求数量,看下面 P=2时,QPS就可以达到 5w/s

1
2
3
4
5
6
root@b7ba9713c11c:/data# redis-benchmark -t set -q -P 2
SET: 50200.80 requests per second, p50=0.943 msec
root@b7ba9713c11c:/data# redis-benchmark -t set -q -P 40
SET: 175131.36 requests per second, p50=10.391 msec
root@b7ba9713c11c:/data# redis-benchmark -t set -q -P 100
SET: 182149.36 requests per second, p50=26.671 msec

发现到后面提高 P 参数,已经无法提高 QPS了,这一般都是因为 CPU 处理能力已经达到了瓶颈。

管道本质

下面就介绍一下一个请求的交互流程:

  1. 客户端进行调用 write 将消息写到 操作系统内核 为套接字分配的 发送缓冲 sendbuffer
  2. 客户端操作系统内核将 发送缓冲的内容 发送到网卡,网卡硬件将数据通过 网际路由 送到服务器网卡。
  3. 服务器操作系统内核将 网卡的数据 放到内核为套接字分配的接受缓冲 recv buffer。
  4. 服务器进程调用 read 从接受缓冲区中 取出消息进行处理。
  5. 服务器进程调用 write 将响应消息写到 内核为套接字分配的发送缓冲 send buffer。
  6. 服务器操作系统内核 将发送缓冲的内容 发送到网卡,网卡硬件将数据通过 网际路由 发送到客户端的网卡。
  7. 客户端操作系统内核 将网卡的数据 放到内核为套接字分配的 接受缓冲 recv buffer
  8. 客户端进程调用 read 从接收缓冲区中 取出消息 返回给上层业务逻辑 进行处理。

我们一开始可能以为 write 操作要等到对方收到消息才返回,但实际上不是这样的。write 操作只负责 将数据写到本地操作系统内核的 发送缓冲区然后就返回了。剩下的事 交给操作系统内核异步 将数据送到目标机器。但是如果发送缓冲区满了,那么就需要等待 缓冲区 空出,这个就是 写操作 IO 操作的真正耗时。

同理,read 操作并不是从目标机器拉取数据。read 操作只负责将 数据从本地操作系统内核的 接收缓冲区 取出来就了事。但是如果 缓冲区是空的,那么就需要等待数据到来,这个就是 读操作 IO 操作的真正耗时。

所以对于 客户端的 redis.get(key) 这样的命令来说,write 操作几乎没有耗时,直接写到 发送缓冲区就返回,而 read 操作比较耗时了,因为它要等待消息经过网络路由到目标机器处理后的响应消息,再发送到当前内核读缓冲 才可以返回。这才是一个网络来回的真正开销。

而对于管道来说,连续的 write 操作根本就没有耗时,之后第一个 read 操作会等待 一个网络的来回开销,然后响应信息到达 客户端系统内核的读缓冲了。因为 write 是连续发送,且几乎没有耗时,所以当 第一个read之后,后续所有read基本也同时随之到达 读缓冲。

5.5 事务

Redis 通过 MULTI、DISCARD 、EXEC 和 WATCH 四个命令来实现事务功能。事务提供了一种 “将多个命令打包,然后一次性、按顺序地执行”的机制,并且事务在执行的期间不会主动中断——服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的命令。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set name "kxr"
QUEUED
127.0.0.1:6379(TX)> get name
QUEUED
127.0.0.1:6379(TX)> sadd name-list "kxr" "jyl"
QUEUED
127.0.0.1:6379(TX)> smembers name-list
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) "kxr"
3) (integer) 2
4) 1) "jyl"
2) "kxr"

5.5.1 事务流程

一个事务从开始到执行会经历三个阶段:

  1. 开始事务
  2. 命令入队
  3. 执行事务
开始事务

MULTI 命令的执行 标记着事务的开始。这个命令唯一做的就是,将客户端的 REDIS_MULTI 选项打开,让客户端从非事务状态切换到事务状态。

命令入队

当客户端处于非事务状态下时,所有发送给服务端的命令都会立即被服务器执行。但是,当客户端进入事务状态之后,服务器在收到来自客户端的命令时,不会立即执行命令,而是将这些命令全部放进一个事务队列里,然后返回 QUEUED,表示命令已入队。

事务队列是一个数组,每个数组项是都包含三个属性:

  1. 要执行的命令(cmd)
  2. 命令的参数(argv)
  3. 参数的个数(argc)
执行事务

前面说到,当客户端进入事务状态之后,客户发送的命令就会被放进事务队列里。

但其实并不是所有的命令都会被放进事务队列,其中的例外就是 EXEC、DISCARD、MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时,它们会像客户端处于非事务状态一样,直接被服务器执行。

如果客户端正处于事务状态,那么当 EXEC 命令执行时,服务器根据客户端所保存的事务队列,以先进先出(FIFO)的方式执行事务队列中的命令: 最先入队的命令最先执行,而最后入队的命令最后执行。

当事务队列里的 所有命令被执行完之后,EXEC 命令会将回复队列作为自己的执行结果返回给客户端,客户端从事务状态返回到非事务状态,至此,事务执行完毕。

事务的整个执行过程的伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def execute_transaction():

# 创建空白的回复队列
reply_queue = []

# 取出事务队列里的所有命令、参数和参数数量
for cmd, argv, argc in client.transaction_queue:

# 执行命令,并取得命令的返回值
reply = execute_redis_command(cmd, argv, argc)

# 将返回值追加到回复队列末尾
reply_queue.append(reply)

# 清除客户端的事务状态
clear_transaction_state(client)

# 清空事务队列
clear_transaction_queue(client)

# 将事务的执行结果返回给客户端
send_reply_to_client(client, reply_queue)

优化:上面的 Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以通常 Redis 客户端在执行事务时都会结合 pipline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。

5.5.2 事务里的命令

无论是在事务状态下,还是非事务状态下,Redis 命令都是由同一个函数执行,所有它们共享很多服务器的一般设置,比如 AOF 配置、RDB 的配置,以及内存限制等等。

事务中的命令执行和普通命令执行主要是两天区别:

  1. 非事务状态下的命令以单个命令执行为单位,前一个命令和后一个命令不一定是同一个客户端。而事务状态则是以一个事务为单位,执行事务队列中的所有命令:除非当前事务执行完毕,否则服务器不会中断事务,也不会执行其他客户端的命令。
  2. 在非事务状态下,执行命令所得的结果会立即被返回给客户端。而事务则将所有命令所得返回结果集合到回复队列,再作为 EXEC 命令的结果返回给客户端。

5.5.3 DISCARD 、 MULTI 和 WATCH 命令

除了 EXEC 之外,服务器在客户端处于事务状态下,不加入到事务队列而执行的另外三个命令是:DISCARD 、 MULTI 和 WATCH

  • DISCARD:命令用于取消一个事务, 它清空客户端的整个事务队列, 然后将客户端从事务状态调整回非事务状态, 最后返回字符串 OK 给客户端, 说明事务已被取消。
  • MULTI:Redis 的事务是不可嵌套的, 当客户端已经处于事务状态, 而客户端又再向服务器发送 MULTI 时, 服务器只是简单地向客户端发送一个错误, 然后继续等待其他命令的入队。 MULTI 命令的发送不会造成整个事务失败, 也不会修改事务队列中已有的数据。
  • WATCH:只能在客户端进入事务状态之前执行, 在事务状态下发送 WATCH 命令会引发一个错误, 但它不会造成整个事务失败, 也不会修改事务队列中已有的数据(和前面处理 MULTI 的情况一样)

5.5.4 带 WATCH 的事务

WATCH 命令用于在事务开始之前监视任意数量的键: 当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。

以下示例展示了一个执行失败的事务例子:

第一个客户端执行:

1
2
3
4
5
6
7
8
127.0.0.1:6379> watch name
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set name t
QUEUED
127.0.0.1:6379(TX)> exec
(nil)

第二个客户端执行:

1
2
127.0.0.1:6379> set name tt
OK

在第一个客户端watch name 之后,事务执行之前,在第二个客户端中 修改 name 的值。这样当第一个客户端的执行事务时,Redis 会发现 name 整个被监视的键 已经被修改,因此客户端A的事务不会被执行,而是直接返回失败。

WATCH 命令的实现

在每个代表数据的 redis.h/redisDb 结构类型中,都保存了一个 watched_keys 字典,字典的键这个数据库被监视的键,而字典的值则是一个链表,链表中保存了所有监视这个键的客户端。

img

其中,键 key1 正在被 client2、client5 和 client1 三个客户端监视,其他一些键也分别被其他客户端监视着。

WATCH 命令的作用,就是将 当前客户端和要监视的键在 watched_keys 中进行关联。

举个例子, 如果当前客户端为 client10086 , 那么当客户端执行 WATCH key1 key2 时, 前面展示的 watched_keys 将被修改成这个样子:

img

通过 watched_keys 字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。

WATCH 的触发

在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如 FLUSHDB 、SET、DEL、LPUSH、SADD ,诸如此类), multi.c/touchWatchedKey 函数都会被调用 —— 它检查数据库的 watched_keys 字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 REDIS_DIRTY_CAS 选项打开:

img

当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:

  • 如果客户端的 REDIS_DIRTY_CAS 选项已经被打开,那么说明被客户端监视的键至少有一个已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。
  • 如果 REDIS_DIRTY_CAS 选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。

举个例子,假设数据库的 watched_keys 字典如下图所示:

img

如果某个客户端对 key1 进行了修改(比如执行 DEL key1 ), 那么所有监视 key1 的客户端, 包括 client2 、 client5 和 client1 的 REDIS_DIRTY_CAS 选项都会被打开, 当客户端 client2 、 client5 和 client1 执行 EXEC 的时候, 它们的事务都会以失败告终。

最后,当一个客户端结束它的事务时,无论事务是成功执行,还是失败, watched_keys 字典中和这个客户端相关的资料都会被清除。

5.5.6 事务的 ACID 性质

传统数据库,常常用 ACID 性质来检验 事务是否安全。Redis 事务保证了 一致性(C)、隔离性(I),但并不能保证 原子性(A)和 持久性(D)。

原子性(Atomicity)

单个 Redis 命令执行肯定是 原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所有 Redis 事务的执行并不是原子性的。

如果一个事务队列中的所有命令都被成功地执行,那么称这个事务执行成功。另一方面,如果 Redis 服务器进程在执行事务的过程中被停止 —— 比如接到 KILL 信号、宿主机器停机,等等,那么事务执行失败。当事务失败时,Redis 也不会进行任何的重试或者回滚动作。

简单总结:

  • 命令入队时就报错,会放弃事务执行,保证原子性。
  • 命令入队时没报错,实际执行时报错,不保证原子性。
  • EXEC 命令执行时实例故障,如果开启 AOF 日志,可以保证原子性。

其保证的是部分原子性,可以保证多个命令要么就一起执行,要么就一起不执行。但是不能保证 多个命令要么一起执行成功,要么都不执行成功。入队后,如果有命令执行失败,其之前命令执行操作并不会回退,其之后命令也照常执行。

一致性(Consistency)

一致性表示:事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后顺序都是合法数据状态。

  • 实体完整性(如行的主键存在且唯一);
  • 列完整性(如字段的类型、大小、长度要符合要求)
  • 外键约束;
  • 用户自定义完整性(如转账前后,两个账户余额的和应该不变)。

Redis 的一致性问题 可以分为三部分来讨论:入队错误、执行错误、Redis 进程被终结。

  1. 入队错误:在命令入队的过程中,如果客户端向服务器发送了错误的命令,比如命令的参数数量不对,等等, 那么服务器将向客户端返回一个出错信息, 并且将客户端的事务状态设为 REDIS_DIRTY_EXEC 。当客户端执行 EXEC 命令时, Redis 会拒绝执行状态为 REDIS_DIRTY_EXEC 的事务, 并返回失败信息。因此,带有不正确入队命令的事务不会被执行,也不会影响数据库的一致性。
  2. 执行错误:如果命令在事务执行的过程中发生错误,比如说,对一个不同类型的 key 执行了错误的操作, 那么 Redis 只会将错误包含在事务的结果中, 这不会引起事务中断或整个失败,不会影响已执行事务命令的结果,也不会影响后面要执行的事务命令, 所以它对事务的一致性也没有影响。
  3. Redis 进程被终结如果 Redis 服务器进程在执行事务的过程中被其他进程终结,或者被管理员强制杀死,那么根据 Redis 所使用的持久化模块,可能由以下情况出现:
    • 内存模块:如果 Redis 没有采取任何持久化机制,那么重启后的数据库总是空白的,所以数据总是一致的。
    • RDB 模块:在执行事务时,Redis 不会中断事务去执行保存 RDB 的工作,只有在事务执行之后,保存 RDB 的工作才可能开始。所以当RDB 模式下的 Redis 服务器进程在事务中途被杀死时,事务内执行的命令,不管成功了多少,都不会被保存到 RDB 文件里。恢复数据库需要使用现有的 RDB 文件,而这个 RDB 文件的数据保存的是最近一次的数据库快照(snapshot),所以它的数据可能不是最新的,但只要 RDB 文件本身没有因为其他问题而出错,那么还原后的数据库就是一致的。
    • AOF 模式:因为保存 AOF 文件的工作在后台线程进行,所以即使是在事务执行的中途,保存 AOF 文件的工作也可以继续进行,因此,根据事务语句是否被写入并保存到 AOF 文件,有以下两种情况发送:
      • 如果事务语句 未写入到 AOF 文件,或 AOF 未被 SYNC 调用保存到磁盘,那么当进程被杀死之后,Redis 可以根据 最近一次成功保存到 磁盘的 AOF 文件 来还原数据库,只要 AOF 文件本身没有因为其他问题而出错,那么还原后的数据库总是一致的,但其中的数据不一定是最新的。
      • 如果事务的部分语句 被写入到 AOF 文件中,并且 AOF 文件被成功保存,那么不完整的事务执行信息就会遗留在 AOF 文件里,当重启 Redis 时,程序会检测到 AOF 文件并不完整,Redis 会退出,并报告错误。需要使用 redis-check-aof 工具将部分成功的事务命令移除之后,才能再次启动服务器。还原之后的数据总是一致的,而且数据也是最新的(直到事务执行之前为止)。
隔离性(Isolation)

Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。

持久性(Durability)

因为事务不过是用队列包裹了一组 Redis 命令,并没有提供任何额外的持久性功能,所以事务的持久性由 Redis 所使用的持久化模块决定

  • 单纯的内存模式下,事务肯定是不持久的。
  • 在 RDB 模块下,服务器可能在事务执行之后、RDB 文件更新之前的这段时间失败,所以 RDB 模式下的 Redis 事务也不持久的。
  • 在 AOF 的 “总是SYNC” 模式下,事务的每条命令在执行成功之后,都会立即调用 fsync 或 fdatasync 将事务数据写入到 AOF文件。但是,这种保存是由后台线程进行的,主线程不会堵塞直到保存成功。所以命令执行成功到数据保存到硬盘之间,还是有一段非常小的间隔,所以这种模式下的事务也是不持久的。

其他 AOF 模式也和 “总是SYNC” 模式类似,所以它们都是不持久的。

5.5.7 小结

  • 事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。
  • 事务在执行过程中不会被中断,所有事务命令执行完之后,事务才能结束。
  • 多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。
  • 带 WATCH 命令的事务会将客户端和被监视的键在数据库的 watched_keys 字典中进行关联,当键被修改时,程序会将所有监视被修改键的客户端的 REDIS_DIRTY_CAS 选项打开。
  • 只有在客户端的 REDIS_DIRTY_CAS 选项未被打开时,才能执行事务,否则事务直接返回失败。
  • Redis 的事务保证了 ACID 中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)。

5.6 订阅与发布

Redis 通过 PUBLISH、SUBSCRIBE等命令实现了订阅与发布模式, 这个功能提供两种信息机制, 分别是订阅/发布到频道和订阅/发布到模式, 下文先讨论订阅/发布到频道的实现, 再讨论订阅/发布到模式的实现。

5.6.1 频道的订阅与信息发送

Redis 的 SUBSCRIBE 命令可以让客户端订阅任意数量的频道,每当有新消息发送到被订阅的频道时,信息就会被发送给所有订阅指定频道的客户端。

img

当有新消息通过 PUBLISH 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端:

img

订阅频道

每个 Redis 服务器进程都维持着一个表示服务器状态的 redis.h/redisServer 结构,结构的 pubsub_channels 属性是一个字典,这个字典就用于保存订阅频道的信息。

其中,字典的键为正在被订阅的频道,而字典的值则是一个链表,链表中保存了所有订阅这个频道的客户端。

比如说,在下图展示的这个 pubsub_channels 示例中, client2 、 client5 和 client1 就订阅了 channel1 , 而其他频道也分别被别的客户端所订阅:

img

当客户端调用 SUBSCRIBE 命令时, 程序就将客户端和要订阅的频道在 pubsub_channels 字典中关联起来。

举个例子,如果客户端 client10086 执行命令 SUBSCRIBE channel1 channel2 channel3 ,那么前面展示的 pubsub_channels 将变成下面这个样子:

img

通过 pubsub_channels 字典, 程序只要检查某个频道是否为字典的键, 就可以知道该频道是否正在被客户端订阅; 只要取出某个键的值, 就可以得到所有订阅该频道的客户端的信息。

发送信息到频道

了解了 pubsub_channels 字典的结构之后, 解释 PUBLISH 命令的实现就非常简单了: 当调用 PUBLISH channel message 命令, 程序首先根据 channel 定位到字典的键, 然后将信息发送给字典值链表中的所有客户端。

比如说,对于以下这个 pubsub_channels 实例, 如果某个客户端执行命令 PUBLISH channel1 “hello moto” ,那么 client2 、 client5 和 client1 三个客户端都将接收到 “hello moto” 信息:

img

退订频道

使用 UNSUBSCRIBE 命令可以退订指定的频道, 这个命令执行的是订阅的反操作: 它从 pubsub_channels 字典的给定频道(键)中, 删除关于当前客户端的信息, 这样被退订频道的信息就不会再发送给这个客户端。

5.6.2 模式的订阅与信息发送

当使用 PUBLISH 命令发送信息到某个频道时,不仅所有订阅该频道的客户端会收到信息,如果有 某个/某些 模式和 这个频道匹配的话,那么所有订阅 这个/这些 频道的客户端也同样会受到信息。

下图展示了一个带有频道和模式的例子, 其中 tweet.shop.* 模式匹配了 tweet.shop.kindle 频道和 tweet.shop.ipad 频道, 并且有不同的客户端分别订阅它们三个:

img

当有信息发送到 tweet.shop.kindle 频道时, 信息除了发送给 clientX 和 clientY 之外, 还会发送给订阅 tweet.shop.* 模式的 client123 和 client256 :

img

另一方面, 如果接收到信息的是频道 tweet.shop.ipad , 那么 client123 和 client256 同样会收到信息:

img

订阅模式

redisServer.pubsub_patterns 属性是一个链表,链表中保存着所有和模式相关的信息。

链表中的每个节点都包含一个 redis.h/pubsubPattern 结构:

client 属性保存着订阅模式的客户端,而 pattern 属性则保存着被订阅的模式。

每当调用 PSUBSCRIBE 命令订阅一个模式时, 程序就创建一个包含客户端信息和被订阅模式的 pubsubPattern 结构, 并将该结构添加到 redisServer.pubsub_patterns 链表中。

作为例子,下图展示了一个包含两个模式的 pubsub_patterns 链表, 其中 client123 和 client256 都正在订阅 tweet.shop.* 模式:

img

如果这时客户端 client10086 执行 PSUBSCRIBE broadcast.list.* , 那么 pubsub_patterns 链表将被更新成这样:

img

通过遍历整个 pubsub_patterns 链表,程序可以检查所有正在被订阅的模式,以及订阅这些模式的客户端。

发送信息到模式

发送信息到模式的工作也是由 PUBLISH 命令进行的。 PUBLISH 除了将 message 发送到所有订阅 channel 的客户端之外,它还会将 channel 和 pubsub_pattern 中的模式进行对比,如果 channel 和某个模式匹配的话,那么也将 message 发送到订阅那个模式的客户端。

举个例子,如果 Redis 服务器的 pubsub_patterns 状态如下:

img

那么当某个客户端发送信息 “Amazon Kindle, $69.” 到 tweet.shop.kindle 频道时, 除了所有订阅了 tweet.shop.kindle 频道的客户端会收到信息之外, 客户端 client123 和 client256 也同样会收到信息, 因为这两个客户端订阅的 tweet.shop.* 模式和 tweet.shop.kindle 频道匹配。

退订模式

使用 PUNSUBSCRIBE 命令可以退订指定的模式, 这个命令执行的是订阅模式的反操作: 程序会删除 redisServer.pubsub_patterns 链表中, 所有和被退订模式相关联的 pubsubPattern 结构, 这样客户端就不会再收到和模式相匹配的频道发来的信息。

5.6.3 小结

要点:

  • 订阅信息由服务器进程维持的 redisServer.pubsub_channels 字典保存,字典的键为被订阅的频道,字典的值为订阅频道的所有客户端。
  • 当有新消息发送到频道时,程序遍历频道(键)所对应的(值)所有客户端,然后将消息发送到所有订阅频道的客户端上。
  • 订阅模式的信息由服务器进程维持的 redisServer.pubsub_patterns 链表保存,链表的每个节点都保存着一个 pubsubPattern 结构,结构中保存着被订阅的模式,以及订阅该模式的客户端。程序通过遍历链表来查找某个频道是否和某个模式匹配。
  • 当有新消息发送到频道时,除了订阅频道的客户端会收到消息之外,所有订阅了匹配频道的模式的客户端,也同样会收到消息。
  • 退订频道和退订模式分别是订阅频道和订阅模式的反操作。

缺点:

PubSub 的生产者产地过来一个消息,Redis 会直接找到相应的消费者传递过去。如果一个消费者也没有,那么消息直接丢弃。如果开始有三个消费者,一个消费者突然挂掉了,生产者会继续发送消息,另外两个消费者可以持续受到消息。但是挂掉的消费者重新连上的时候,这断连期间生产者发送的消息,对于这个消费者来说就彻底消失了。

如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息直接丢弃。

正是因为 PubSub 有这些缺点,它几乎找不到合适的应用场景。所以 Redis 的作者单独开启了一个项目 Disque 专门做 多播消息队列。

github地址:https://github.com/antirez/disque-module。但是在 Redis5.0 新增了 Stream 数据结构,这个功能给 Redis 带来了持久化消息队列,从此 PubSub 可以消失了,Disqueue 估计也不会发出它的正式版了。

5.7 Redis集群模式—主从复制

https://pdai.tech/md/db/nosql-redis/db-redis-x-copy.html

我们知道要避免单点故障,即保证高可用,便需要冗余(副本)方式提供集群服务。而 Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离 的方式。

5.7.1 主从复制概述

主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。数据的复制是单向的,只能从 主节点 到 从节点。

主从复制的作用主要包括:

  • 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
  • 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
  • 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。

主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接收;
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库。

在 2.8 版本之前,只有全量复制,而2.8版本之后有全量和增量复制

  • 全量(同步)复制:比如第一次同步时
  • 增量(同步)复制:只会把主从库网络断连期间主库收到的命令,同步给从库。

5.7.2 全量复制

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步

  • 建立主从关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 这里我们创建了 两个 redis 实例
[root@VM-4-9-centos ~]# docker run -it -d --name redis2 -p 6379:6379 redis
9865ef807588457b05a4353a5c4a1699486343abab71d6682f98a6bc27497961
[root@VM-4-9-centos ~]# docker run -it -d --name redis1 -p 6380:6379 redis
f21ca2cacfea46f1b05baffffdafc9c46f76482cdea3d018ff7196469b75c6e9

# 查看所有容器的 ip地址
[root@VM-4-9-centos ~]# docker inspect -f '{{.Name}} - {{.NetworkSettings.IPAddress }}' $(docker ps -aq)
/redis2 - 172.17.0.5
/redis1 - 172.17.0.4

# 使用 redis1 容器的 redis命令行,存入 key=name,value=kongxr
[root@VM-4-9-centos ~]# docker exec -it redis1 /bin/bash
root@f21ca2cacfea:/data# redis-cli
127.0.0.1:6379> set name kongxr
OK

# 使用 reids2容器内的 redis命令行。查询key=name,未获取到值。
# 然后使用同步命令将redis2 作为从库,建立主从关系,并同步数据
[root@VM-4-9-centos ~]# docker exec -it redis2 /bin/bash
root@9865ef807588:/data# redis-cli
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> replicaof 172.17.0.4 6379
OK
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get name
"kongxr"

从上面的测试,可以看到 在建立主从关系后,从库会慢慢从主库中同步全量数据。

  • 全量复制的三个阶段

    img

    1. 第一阶段是主从库间建立连接、协商同步的过程,主要是为了全量复制做准备。在这一步,从库和主库建立连接,并告诉主库即将开始进行同步,主库确认回复后,主从库间就可以开始同步了。具体的来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包括了主库的 runID 和 复制进度 offset 两个参数。runID,是每个 Redis 实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为 “?” 。offset,此时设为 -1,表示第一次复制。主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上 两个参数:主库 runID 和主库目前的复制进度 offset ,返回给从库。从库收到响应后,会记录下这两个参数。这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
    2. 第二个阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成 RDB 文件。具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发送给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从库的过程中,主库不会被堵塞,仍然可以正常接受请求。但是,请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
    3. 第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样以来,主从库就实现同步了。

5.7.3 增量复制

在 Redis 2.8 版本引入了增量复制

  • 为什么会设计增量复制?如果主从库在命令传播时出现了网络闪断,那么从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8开始,网络断了之后,主从库会采用增量复制的方式继续同步。

  • 增量复制流程

    img

  • 先看两个概念: replication buffer 和 repl_backlog_bufferrepl_backlog_buffer:它是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量复制带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量复制,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量复制的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer。replication buffer:Redis和客户端通信也好,和从库通信也好,Redis都需要给分配一个 内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在增量同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer。对于这个问题来说,有两个关键点:

    • 如果在网络断开期间,repl_backlog_size环形缓冲区写满之后,从库是会丢失掉那部分被覆盖掉的数据,还是直接进行全量复制呢
    1. 一个从库如果和主库断连时间过长,造成它在主库repl_backlog_buffer的slave_repl_offset位置上的数据已经被覆盖掉了,此时从库和主库间将进行全量复制。
    2. 每个从库会记录自己的slave_repl_offset,每个从库的复制进度也不一定相同。在和主库重连进行恢复时,从库会通过psync命令把自己记录的slave_repl_offset发给主库,主库会根据从库各自的复制进度,来决定这个从库可以进行增量复制,还是全量复制。

5.7.4 更多理解

1.当主服务器不进行持久化时 复制的安全性

强烈建议主服务器开启持久化。如果真的不能开启持久化,那么一定要禁止Redis实例自动重启。

为什么不持久化的主服务器自动重启非常危险呢?为了更好的理解这个问题,看下面这个失败的例子,其中主服务器和从服务器中数据库都被删除了。

  • 我们设置节点A为主服务器,关闭持久化,节点B和C从节点A复制数据。
  • 这时出现了一个崩溃,但Redis具有自动重启系统,重启了进程,因为关闭了持久化,节点重启后只有一个空的数据集。
  • 节点B和C从节点A进行复制,现在节点A是空的,所以节点B和C上的复制数据也会被删除。
  • 当在高可用系统中使用Redis Sentinel,关闭了主服务器的持久化,并且允许自动重启,这种情况是很危险的。比如主服务器可能在很短的时间就完成了重启,以至于Sentinel都无法检测到这次失败,那么上面说的这种失败的情况就发生了。

如果数据比较重要,并且在使用主从复制时关闭了主服务器持久化功能的场景中,都应该禁止实例自动重启。

2.为什么主从全量复制使用 RDB 而不使用 AOF?
  • RDB 文件内容时经过压缩的 二进制数据(不同数据类型数据做了针对性优化),文件很小。而 AOF 文件记录的是 每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个 key 的多次冗余操作。在主从全量数据同步时,传输 RDB 文件可以尽量降低对主库机器网络带宽的消耗,从库在加载 RDB 文件时,一是文件小,读取整个文件的速度会很快,二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比 RDB 会慢得多,所以使用 RDB 进行主从全量复制的成本最低。
  • 假设要使用 AOF 做全量复制,意味着必须打开 AOF 功能,打开 AOF 功能就要选择文件的刷盘的策略,选择不当会严重影响 Redis 性能。而 RDB 只有在需要定时备份和主从全量复制数据时,才会触发生成一次快照。而在很多就是数据不敏感的业务场景,其实时不需要开启 AOF 的。
3.为什么有无磁盘复制模式?

Redis 默认时磁盘复制,但是如果使用比较低速的磁盘,这种操作会给主服务器带来比较大的压力。Redis从2.8.18版本开始尝试支持无磁盘的复制。使用这种设置时,子进程直接将RDB通过网络发送给从服务器,不使用磁盘作为中间存储。

无磁盘复制模式:master创建一个新进程直接dump RDB到slave的socket,不经过主进程,不经过硬盘。适用于disk较慢,并且网络较快的时候。

使用repl-diskless-sync配置参数来启动无磁盘复制。

使用repl-diskless-sync-delay 参数来配置传输开始的延迟时间;master等待一个repl-diskless-sync-delay的秒数,如果没slave来的话,就直接传,后来的得排队等了; 否则就可以一起传。

4.为什么还有 从库的从库的设计?

通过分析主从库间第一次数据同步的过程,你可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量复制。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。那么,有没有好的解决方法可以分担主库压力呢?

其实是有的,这就是“主 - 从 - 从”模式。

在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上

简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。

replicaof 所选从库的IP 6379

这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

img

级联的“主-从-从”模式好了,到这里,我们了解了主从库间通过全量复制实现数据同步的过程,以及通过“主 - 从 - 从”模式分担主库压力的方式。那么,一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

5.读写分离及其中的问题

在主从复制基础上实现的读写分离,可以实现 Redis 的读负载均衡:由主节点提供写服务,由一个或者多个从节点提供读服务(多个从节点既可以提供数据冗余程度,也可以最大化读负载能力);在读负载较大的应用场景下,可以大大提高 Redis 服务器的并发量。下面介绍在使用 Redis 读写分离时,需要注意的问题:

  • 延迟与不一致问题

前面已经讲到,由于主从复制的命令传播是异步的,延迟与数据的不一致不可避免。如果应用对数据不一致的接受程度程度较低,可能的优化措施包括:优化主从节点之间的网络环境(如在同机房部署);监控主从节点延迟(通过offset)判断,如果从节点延迟过大,通知应用不再通过该从节点读取数据;使用集群同时扩展写负载和读负载等。

在命令传播阶段以外的其他情况下,从节点的数据不一致可能更加严重,例如连接在数据同步阶段,或从节点失去与主节点的连接时等。从节点的slave-serve-stale-data参数便与此有关:它控制这种情况下从节点的表现;如果为yes(默认值),则从节点仍能够响应客户端的命令,如果为no,则从节点只能响应info、slaveof等少数命令。该参数的设置与应用对数据一致性的要求有关;如果对数据一致性要求很高,则应设置为no。

  • 数据过期问题

在单机版Redis中,存在两种删除策略:

  • 惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据时,服务器判断该数据是否过期,如果过期则删除。
  • 定期删除:服务器执行定时任务删除过期数据,但是考虑到内存和CPU的折中(删除会释放内存,但是频繁的删除操作对CPU不友好),该删除的频率和执行时间都受到了限制。

在主从复制场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。

Redis 3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端;将Redis升级到3.2可以解决数据过期问题。

  • 故障切换问题

在没有使用哨兵的读写分离场景下,应用针对读和写分别连接不同的Redis节点;当主节点或从节点出现问题而发生更改时,需要及时修改应用程序读写Redis数据的连接;连接的切换可以手动进行,或者自己写监控程序进行切换,但前者响应慢、容易出错,后者实现复杂,成本都不算低。

  • 总结

在使用读写分离之前,可以考虑其他方法增加Redis的读负载能力:如尽量优化主节点(减少慢查询、减少持久化等其他情况带来的阻塞等)提高负载能力;使用Redis集群同时提高读负载能力和写负载能力等。如果使用读写分离,可以使用哨兵,使主从节点的故障切换尽可能自动化,并减少对应用程序的侵入。

5.8 Redis集群模式— 哨兵机制(Redis Sentinel)

在上文主从复制的基础上,如果节点出现故障该怎么办?在 Redis 集群中,哨兵机制是实现主从库自动切换的关键机制,它有效的解决了主从复制模式下的故障转移的问题。其与Redis2.8版本开始引用。

https://xie.infoq.cn/article/f6a8c0c5218394d56f4ae329b

img

哨兵是一个独立的进程,作为进程,它会独立运行其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个 Redis 实例

哨兵实现了什么功能呢?下面是 Redis 官方文档的描述:

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;而配置提供者和通知功能,则需要在与客户端的交互中才能体现。

5.8.1 哨兵集群的搭建

上图中哨兵集群式如何组建起来的?哨兵实例之间相互发现,要归功于 Redis 提供的 pub/sub 机制,即 发布/订阅机制

在主从集群中,主库上由一个名为 sentinel:hello 的频道,不同哨兵就是通过它来相互发现,实现互相通信的。在下图,哨兵1把自己的 IP(172.16.19.3)和端口(26579)发布到__sentinel__:hello频道上,哨兵2和3订阅了该频道。那么此时,哨兵2和3就可以从这个频道直接获取哨兵1的 IP 地址和端口号。然后,哨兵2、3可以和哨兵1建立网络连接。

img

通过这个方式,哨兵2、3也可以建立网络连接,这样一来,哨兵集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。

5.8.2 哨兵监控 Redis 库

哨兵监控什么?并且如何完成监控的?

这是由哨兵向主库发送 INFO命令完成的。如下图,哨兵2给主库发送 INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续的对从库进行监控。哨兵1和3 可以通过相同的方法和从库建立连接。

img

哨兵的工作内容:

  • 每个 Sentinel 以每秒钟一次的频率向它所知的 Master,Slave 以及其他 Sentinel 实例发送一个 PING 命令。(心跳机制)
  • 如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线
  • 如果一个 Master 被标记为主观下线,则正在监视这个 Master 的所有 Sentinel 要以每秒一次的频率确认 Master 的确进入了主观下线状态。(确认投票下线
  • 当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认 Master 的确进入了主观下线状态, 则 Master 会被标记为客观下线
  • 在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率向它已知的所有 Master,Slave 发送 INFO 命令。(同步数据)
  • 当 Master 被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次
  • 若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的客观下线状态就会被移除
  • 若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除

5.8.3 主库下线的判定

哨兵如何判断主库已经下线了?

首先要区别两个概念:

  • 主观下线:任何一个哨兵都是可以监控探测,并作出 Redis 下线的判断
  • 客观下线:有哨兵集群共同决定 Redis 节点是否下线

当某个哨兵 判断主库 “主观下线”后,就会给其他哨兵发送 is-master-down-by-addr命令。接着,其他哨兵会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y相当于赞成票,N相当于反对票。如果赞成票数是大于等于 哨兵配置文件中的 quorum 配置项(比如这里如果 quorum = 2),则就可以判定 主库客观下线了。

5.8.4 哨兵集群的选举

判断完主库下线后,由哪个哨兵节点来执行主从切换呢?这里就需要哨兵集群的选举机制了

  • 为什么必然会出现 选举/共识 机制?为了避免哨兵的单点情况发生,所以需要一个哨兵的分布式集群。作为分布式集群,必然涉及到共识问题(即选举问题)
  • 哨兵的选举机制是什么样的?
    1. 发现主库客观下线的哨兵节点(这里称为 A)向每个哨兵节点发送命令要求对方选举自己为领头哨兵(leader)
    2. 如果目标哨兵没有选举过其他人,则同意将 A 选举为领头哨兵
    3. 如果 A 发现有超过半数且超过 quorum 参数值的哨兵节点同意选自己成为领头哨兵,则 A 哨兵成功选举为领头哨兵。【sentinel 集群执行故障转移时需要选举 leader,此时涉及到 majority,majority 代表 sentinel 集群中大部分 sentinel 节点的个数,只有大于等于 max(quorum, majority) 个节点给某个 sentinel 节点投票,才能确定该 sentinel 节点为 leader,majority 的计算方式为:num(sentinels) / 2 + 1
    4. 当有多个哨兵节点同时参与领头哨兵选举时,出现没有任何节点当选可能,此时每个参选节点等待一个随机时间进行下一轮选举,直到选出领头哨兵
  • 任何一个想要 执行 主从切换操作的 哨兵,要满足两个条件:

    • 第一,拿到半数以上的赞成票;
    • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

以3个哨兵为例,假设此时的 quorum 设置为2,那么,任何一个想成为 Leader 的哨兵只要拿到 2张赞成票,就可以了。

更进一步理解

这里很多人会搞混 判定客观下线 和 是否能够主从切换(用到选举机制) 两个概念,我们再看一个例子。

Redis 1主4从,5个哨兵,哨兵配置quorum为2,如果3个哨兵故障,当主库宕机时,哨兵能否判断主库“客观下线”?能否自动切换

经过实际测试:

1、哨兵集群可以判定主库“主观下线”。由于quorum=2,所以当一个哨兵判断主库“主观下线”后,询问另外一个哨兵后也会得到同样的结果,2个哨兵都判定“主观下线”,达到了quorum的值,因此,哨兵集群可以判定主库为“客观下线”

2、但哨兵不能完成主从切换。哨兵标记主库“客观下线后”,在选举“哨兵领导者”时,一个哨兵必须拿到超过多数的选票(5/2+1=3票)。但目前只有2个哨兵活着,无论怎么投票,一个哨兵最多只能拿到2票,永远无法达到N/2+1选票的结果。

5.8.5 新主库的选出、故障转移

主库既然判定客观下线了,并且选举出了领头哨兵,那么如何从剩余的 slave节点(从库)中选择一个新的主库呢?

  • 过滤掉不健康的(下线或断线),没有回复过哨兵 ping 响应的从节点
  • 选择 salve-priority从节点优先级最高的(redis.conf)
  • 选择复制偏移量最大(即复制主节点最完整的从节点)

新的主库选择出来了,就可以开始进行故障的转移了

假设根据我们一开始的图:(我们假设:判断主库客观下线了,同时选出sentinel 3是哨兵leader)

img

故障转移流程如下

img

将slave-1脱离原从节点(PS: 5.0 中应该是replicaof no one),升级主节点,

将从节点slave-2指向新的主节点

通知客户端主节点已更换

将原主节点(oldMaster)变成从节点,指向新的主节点

5.9 Redis集群模式-Redis Cluster(高可用集群)

前面两节,主从复制和哨兵机制保障了高可用,就读写分离而言虽然 slave 节点扩展了主从的读并发能力,但是写能力和存储能力是没有得到扩展。如果面对海量数据写入,就必须构建 master(主节点分片)之间的集群,同时必然需要吸收高可用(主从复制和哨兵机制)能力,即每个 master 分片节点还需要由 slave 节点。这是分布式系统中典型的纵向扩展(集群的分片技术)

Redis Cluster是一种服务器Sharding技术(分片和路由都是在服务端实现),采用多主多从,每一个分区都是由一个Redis主机和多个从机组成,片区和片区之间是相互平行的。Redis Cluster集群采用了P2P的模式,完全去中心化。

img

如上图,官方推荐,集群部署至少要 3 台以上的master节点,好使用 3 主 3 从六个节点的模式。Redis Cluster集群具有如下几个特点:

  • 集群完全去中心化,采用多主多从;所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。
  • 客户端与 Redis 节点直连,不需要中间代理层。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
  • 每一个分区都是由一个Redis主机和多个从机组成,分片和分片之间是相互平行的。
  • 每一个master节点负责维护一部分槽,以及槽所映射的键值数据;集群中每个节点都有全量的槽信息,通过槽每个node都知道具体数据存储到哪个node上。

5.9.1 哈希槽

Redis-cluster 没有使用一致性 hash,而是引入了 哈希槽的概念。 Redis-cluster 中有 16384(2的14次方)个哈希槽,每个 key 通过 CRC16校验后对 16383取模 来决定放置在哪个槽。Cluster 中的每个节点负责一部分槽(hash slot)【一致性hash在算法章节说】

比如集群中存在三个节点,则可能存在下面类似分配:

  • 节点 A 包含0到5500号 哈希槽
  • 节点 B 包含 5501到11000号 哈希槽
  • 节点 C 包含 11001到16384 哈希槽

哈希槽=CRC16(key) % 16384,为什么不直接 哈希槽=CRC16(key)?这样就可以有 2^16个值。

这是因为redis节点发送心跳包时,需要将所有的槽放到这个心跳包。如果slots=2^16,需占用空间 = 2^16 / 8 / 1024 = 8KB。而 slots=16384 只占用 2KB。并且一般情况下 Redis Cluster 集群主节点数量基本不可能超过1000个,超过1000个一般会导致网络堵塞。。如果slots更少,虽然能进一步降低心跳包大小,但是 会更容易出现碰撞概率(命中失效)。所以 slots = 16384 比较合理

5.9.2 Key Hash Tags

因为 key 分布在不同节点,所以 Multi-Key 操作就会受限。实际场景比如:

  • SUNION、mset、mget,这类命令会操作多个key
  • 事务,在一个事务中会操作多个key
  • LUA脚本,在LUA脚本中也会操作多个key

Hash Tags 提供了一种途径,用来将多个(key)分配到相同的 hash slot 中。这时 Redis Cluster中实现 multi-key 操作的基础。

  • key包含一个{字符
  • 并且 如果在这个{的右面有一个}字符
  • 并且 如果在{和}之间存在至少一个字符

例如:

  • {user1000}.following和{user1000}.followers这两个key会被hash到相同的hash slot中,因为只有user1000会被用来计算hash slot值。
  • foo{}{bar}这个key不会启用hash tag因为第一个{和}之间没有字符。
  • foozap这个key中全部内容会被用来计算hash slot
  • foo{bar}{zap}这个key中的bar会被用来计算计算hash slot,而zap不会

5.9.3 请求重定向

Redis cluster 采用去中心化的架构,集群的主节点各自负责一部分槽,客户端如何确定 key 到底会映射到 哪个节点上呢?这就涉及到请求重定向

在 Cluster 模式下,节点对请求的处理过程如下:

  1. 检查当前 key 是否存在于 当前 node
    • 通过key有效部分使用 CRC16函数计算散列值,再对16384 取余,计算出 slot 的编号。
    • Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内通过消息交换每个节点都会知道所有节点的槽信息。从而得到负责该槽的 节点指针。
  1. 若 slot 不是由自身负责,则返回 MOVED 重定向。
  2. 若 slot 由自身负责,且 key 在 slot 中,则返回该 key 对应结果。
  3. 若 key 不存在此 slot中,检查该 slot 是否正在迁出(MIGRATING)?
  4. slot 正在迁出,返回 ASK错误重定向客户端到 迁移的目的服务器上
  5. 若 slot 未迁出,检查 slot 是否在导入中 ?
  6. 若 slot 导入中且由 ASKING 标记,则直接操作
  7. 否则返回 MOVED 重定向

img

请求处理过程中,可能涉及到两个重定向,分别时 MOVED重定向、ASK重定向

MOVED 重定向

通过计算 key 和 本地 slot 缓存,得到负责 slot 的节点。一般就去请求了,但是可能有两种情况:

  • 槽命中:直接返回结果
  • 槽不命中:即当前键命令所请求的键 不在当前请求的节点中,则当前节点会向客户端发送一个 MOVED 重定向。客户端根据 MOVED重定向所包含的内容找到目标节点,再一次发送命令。redis-cli会帮你自动重定向(如果没有集群方式启动,即没加参数 -c,redis-cli不会自动重定向)

由于本地会缓存映射的存在,所以绝大部分时候都不会触发 MOVED,而MOVED是用来协助客户端更新 slot-node 映射。

img

ASK 重定向

集群伸缩时,集群伸缩会导致槽迁移。槽迁移过程中,一个槽内的key 会分为多个批次,依次迁移。所以存在,一部分数据在源节点,一般部分数据在迁移的目标节点。ASK重定向由此诞生

出现上述情况,客户端的命令执行流程如下:

  1. 客户端根据本地 slot 缓存发送命令到源节点,如果存在 键对象 则直接执行并返回结果给客户端。
  2. 如果键对象不存在,则可能存在于目标节点。这时源节点会回复 ASK 重定向异常。格式如下:(error)ASK{slot}{targetIP}:{targetPort}
  3. 客户端从 ASK 重定向异常中 提取目标节点信息,发送 asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执,不存在则返回不存在信息。
两者的区别

ASK与MOVED虽然都是对客户端的重定向控制,但是有着本质区别。ASK 重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存

5.9.4 故障转移

Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。

Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。

主观下线流程:

img

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表中。

通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。

故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:

  • 从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。
  • 当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

img

  • 发起选举。Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作。

img

img

预估故障转移时间

failover-time(毫秒) ≤ cluster-node-timeout + cluster-node-timeout / 2 + 1000

  • 主观下线识别时间:cluster-node-timeout
  • 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
  • 从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功。

故障转移时间跟 cluster-node-timeout 参数息息相关,默认15秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好。

5.9.5 脑裂问题

什么是脑裂?

在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。

这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了

脑裂可能会导致数据丢失?

然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题

总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

解决方案

当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。

这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。

即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了

等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

再来举个例子

假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。

同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。

这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。

5.9.6 状态检测及维护

Redis Cluster 中节点状态如何维护呢?这些就涉及 有哪些状态、底层协议Gossip及具体的通讯机制

Cluster 中 每个节点都维护一份在自己看来当前整个集群的状态,主要包括:

  • 当前集群的状态
  • 集群中各节点所负责的 slots 信息及其 migrate 状态
  • 集群中各节点的 master-slave 状态
  • 集群中各节点的存活状态及不可达投票

当集群状态发生变化,如:如新节点加入、slot迁移、节点宕机、slave提升为新Master,我们希望这些变化尽快的被发现,传播到整个集群的所有节点并达成一致。节点之间相互的心跳(PING,PONG,MEET)及其携带的数据是集群状态传播最主要的途径。

Gossip协议

Redis Cluster 通讯底层是 Gossip 协议,所以需要对 Gossip 协议有一定了解

gossip 协议(gossip protocol)又称 epidemic 协议(epidemic protocol),是基于流行病传播方式的节点或者进程之间信息交换的协议。 在分布式系统中被广泛使用,比如我们可以使用 gossip 协议来确保网络中所有节点的数据一样。

Gossip协议已经是P2P网络中比较成熟的协议了。Gossip协议的最大的好处是,即使集群节点的数量增加,每个节点的负载也不会增加很多,几乎是恒定的。这就允许Consul管理的集群规模能横向扩展到数千个节点

Gossip算法又被称为反熵(Anti-Entropy),熵是物理学上的一个概念,代表杂乱无章,而反熵就是在杂乱无章中寻求一致,这充分说明了Gossip的特点:在一个有界网络中,每个节点都随机地与其他节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其他节点,也可能仅知道几个邻居节点,只要这些节可以通过网络连通,最终他们的状态都是一致的,当然这也是疫情传播的特点。https://www.backendcloud.cn/2017/11/12/raft-gossip/

上面的描述都比较学术,其实Gossip协议对于我们吃瓜群众来说一点也不陌生,Gossip协议也成为流言协议,说白了就是八卦协议,这种传播规模和传播速度都是非常快的,你可以体会一下。所以计算机中的很多算法都是源自生活,而又高于生活的

Gossip协议的使用

Redis 集群是去中心化的,彼此之间状态同步考 gossip 协议通讯,集群的消息有以下几种类型:

  • Meet 通过 cluster meet ip port命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
  • Ping 节点每秒会向集群中其他节点发送 ping 消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等
  • Pong 节点收到 ping 消息后会回复 pong 消息,消息中同样带有自己已知的两个节点信息
  • Fail 节点 ping 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。
基于Gossip 协议的故障检测

集群中每个节点都会定期地向集群中其他节点发送 PING 消息,以此交换各个节点状态信息,检测各个节点状态:在线状态、疑似下线状态、PFAIL、已下线状态FAIL

自己保存信息:当主节点A通过消息得知主节点B认为主节点D进入了疑似下线(PFAIL)状态时,主节点A会在自己的clusterState.nodes字典中找到主节点D所对应的clusterNode结构,并将主节点B的下线报告添加到clusterNode结构的fail_reports链表中,并后续关于结点D疑似下线的状态通过Gossip协议通知其他节点。

一起裁定:如果集群里面,半数以上的主节点都将主节点D报告为疑似下线,那么主节点D将被标记为已下线(FAIL)状态,将主节点D标记为已下线的节点会向集群广播主节点D的FAIL消息,所有收到FAIL消息的节点都会立即更新nodes里面主节点D状态标记为已下线。

最终裁定:将 node 标记为 FAIL 需要满足以下两个条件:

  • 有半数以上的主节点将 node 标记为 PFAIL 状态。
  • 当前节点也将 node 标记为 PFAIL 状态。

5.9.7 通讯状态和维护

我们理解了Gossip协议基础后,就可以进一步理解Redis节点之间相互的通讯心跳(PING,PONG,MEET)实现和维护了

  1. 什么时候进行心跳?Redis 节点会记录其向每个节点上次发出 ping 和收到 pong 的时间,心跳发送时机与这两个值有关。通过下面的方式既能保证及时更新集群状态,又不至于使心跳数过多:
    • 每次Cron向所有未建立链接的节点发送ping或meet
    • 每1秒从所有已知节点中随机选取5个,向其中上次收到pong最久远的一个发送ping
    • 每次Cron向收到pong超过timeout/2的节点发送ping
    • 收到ping或meet,立即回复pong
  1. 发送那些心跳数据?
    • Header,发送者自己的信息:所负责的 slots 的信息;主从信息;ip port 信息;状态信息
    • Gossip,发送者所了解的部分其他节点的信息:ping_sent、pong_received;ip port信息;状态信息(比如发送者认为该节点已经不可到达,会在状态信息中标记其为 PFAIL或FAIL)
  1. 如何处理心跳
    • 新节点加入
      1. 发送meet包加入集群
      2. 从pong包中的 gossip 得到未知的其他节点
      3. 循环上述过程,直到最终加入集群
    • Slots 信息
      1. 判断发送者声明的 slots 信息,跟本地记录的是否不同
      2. 如果不同,且发送者 epoch较大,更新本地记录
      3. 如果不同,且发送者 epoch较小,发送 Update 信息通知发送者
    • Master slave信息发现发送者的master、slave信息变化,更新本地状态
    • 节点Fail探测(故障发现)Gossip的存在使得集群状态的改变可以更快的达到整个集群。每个心跳包中会包含多个Gossip包,那么多少个才是合适的呢,redis的选择是N/10,其中N是节点数,这样可以保证在PFAIL投票的过期时间内,节点可以收到80%机器关于失败节点的gossip,从而使其顺利进入FAIL状态。
      1. 超过超时时间仍然没有收到 pong 包的节点会被当前节点标记为 PFAIL
      2. PFAIL 标记会随着 gossip 传播
      3. 每次收到心跳包会检测其中对其他节点的 PFAIL 标记,当做对该节点的FAIL的投票维护在本机
      4. 对某个节点的 PFAIL标记达到大多数时,将其变为 FAIL 标记并广播 FAIL消息
  1. 只能通过 gossip + 心跳 传递信息?当需要发布一些非常重要需要立即发送的信息时,上述 心跳+Gossip的方式就显得捉襟见肘了。这时就需要向所有集群内机器广播信息,使用广播发的场景:

    • 节点的 Fail 信息:当发现某一个节点不可达时,探测节点会将其标记为 PFAIL状态,并通过心跳传播出去。当某一个节点发现这个节点的 PFAIL 超过半数时修改其为 FAIL 并发起广播。
    • Failover Request 信息:slave 尝试发起 FailOver时 广播其要求投票的信息
    • 新 Master 信息:FailOver成功的节点向整个集群广播自己的信息

5.9.8 扩容、缩容

当集群出现容量限制或者其他一些原因需要扩容时,redis cluster提供了比较优雅的集群扩容方案。

  1. 首先将新节点加入到集群中,可以通过在集群中任何一个客户端执行cluster meet 新节点ip:端口,或者通过redis-trib add node添加,新添加的节点默认在集群中都是主节点。
  2. 迁移数据 迁移数据的大致流程是,首先需要确定哪些槽需要被迁移到目标节点,然后获取槽中key,将槽中的key全部迁移到目标节点,然后向集群所有主节点广播槽(数据)全部迁移到了目标节点。直接通过redis-trib工具做数据迁移很方便。 现在假设将节点A的槽10迁移到B节点,过程如下:
1
2
B:cluster setslot 10 importing A.nodeId
A:cluster setslot 10 migrating B.nodeId

循环获取槽中key,将key迁移到B节点

1
2
A:cluster getkeysinslot 10 100
A:migrate B.ip B.port "" 0 5000 keys key1[ key2....]

向集群广播槽已经迁移到B节点

cluster setslot 10 node B.nodeId

缩容的大致过程与扩容一致,需要判断下线的节点是否是主节点,以及主节点上是否有槽,若主节点上有槽,需要将槽迁移到集群中其他主节点,槽迁移完成之后,需要向其他节点广播该节点准备下线(cluster forget nodeId)。最后需要将该下线主节点的从节点指向其他主节点,当然最好是先将从节点下线

5.9.9 Write Safety 分析

https://segmentfault.com/a/1190000039226390

Redis Cluster 是 Redis 的分布式实现,就如同官方文档里强调的,其设计优先考虑的是 高性能和线性扩展能力,尽量保证 write safety。这里所说的 write 丢失是指,回复 客户端响应后,后续请求中出现未做变更或者丢失的情况。导致该问题,主要在 主从切换、实例重启、脑裂三种情况下。

  • 主从切换

    • 被动 failover情景:master c 为主节点,负责 slot 1-100,其对应的从节点是 slave c。当master c挂掉后,slave c 在 最多2倍 cluster_node_timeout 的时间 内把 master c 标记成 FALL,进而触发 failover 逻辑。在 slave c 成功切换为 master前,slot 1-100 仍然由 master c 负责,访问也会报错。当 slave c 切换为 master 后,gossip 广播路由变更,在这个过程中,client 访问 slave c,仍然可以得到正常回应,而访问其他持有老路由的 node,请求会被 moved 到挂掉的 master c,访问报错。问题:如果写到 master 上的数据还没来得及同步到 slave 就挂掉了,那么这部分数据就会丢失(重启后不存在 merge操作)。即写入的数据丢失。master 回复 client ack 于 同步 slave 几乎同时进行的,这种情况很少发生(时间窗口小),但是这存在这个风险
    • 主动 failover主动 failover 通过 sysadmin 在 slave node 上执行 CLUSTER FAILOVER [FORCE|TAKEOVER] 命令触发。完整的 manual failover 可以概括为以下步骤:该命令的三个选项分别由不同的行为:
      1. slave 发起请求,gossip 消息携带 CLUSTERMSG_TYPE_MFSTART 标识。
      2. master 阻塞 client,停服时间为 2 倍 CLUSTER_MF_TIMEOUT,目前版本为 10s。
      3. slave 追赶主从复制 offset 数据。
      4. slave 开始发起选举,并最终当选。
      5. slave 切换自身 role,接管 slots,并广播新的路由信息。
      6. 其他节点更改路由,cluster 路由打平。
      • 默认选项:执行完整的 mf 流程,master 由停服行为,因此不存在write丢失问题。
      • FORCE选项:从第四步开始执行。在 slave c 统计选票阶段,master c 仍然可以正常接收用户请求,且主从异步复制,这些都可能导致 write 丢失。mf 将在未来的某个时间点开始执行,timeout 时间为 CLUSTER_MF_TIMEOUT(现版本为 5s),每次 clusterCron 都会检查。
      • TAKEOVER选项:从第五步开始执行。slave 直接增加自己的 configEpoch(无需其他node同意),接管 slots。从 slave c切换为 master 到 原 master c 更新路由 这段期间,发送到 原master 从的请求,都可能存在 write 丢失的可能。一般在一个 ping 的时间内完成,时间窗口很小。master c 和 slave c 以外节点更新路由滞后只会带来多一次的 moved 错误,不会导致 write 丢失。
  • master 重启clusterState 结构体中有一个 state 成员变量,表示 cluster 的全局状态,控制着当前 cluster 是否可以提供服务,有以下两种取值:

    • cluster 状态初始化
1
2
#define CLUSTER_OK 0 /* Everything looks ok */
#define CLUSTER_FAIL 1 /* The cluster can't work */

server 重启后,state 被初始化为 CLUSTER_FAIL,此状态下的 cluster 是拒绝访问的。这对保证 write safety 是非常必要的!可以想象,如果 master A 挂掉后,对应的 slave A’ 通过选举成功当选为新 master。此时,A 重启,且恰好有一些 client 看到的路由没有更新,它们仍然会往 A 上写数据,如果接受这些 write,就会丢数据!A’ 才是这个 sharding 大家公认的 master。所以,A’ 重启后需要先禁用服务,直到路由变更完成。所以如果 CLUSTER_WRITABLE_DELAY 内,未能更新路由,可能就导致 write 丢失。

    • cluster 状态变更什么时候 cluster 才会出现 CLUSTER_FAIL -> CLUSTER_OK 的状态变更呢。从 clusterCron 定时任务中,可以知道 clusterCron状态变更要延迟 CLUSTER_WRITABLE_DELAY 毫秒,当前版本是2s。访问延迟就是为等待 路由变更,那么什么时候触发路由变更呢?一个新 server 刚启动,它与其他 node 进行 gossip 通信的 link 都是 null,在 clusterCron 里检查出来后会依次连接,并发送 ping。作为一个路由过期的老节点,收到其他节点发来的 update 消息,更改自身路由。CLUSTER_WRITABLE_DELAY 毫秒后,A 节点恢复访问,我们认为 CLUSTER_WRITABLE_DELAY 的时间窗口足够更新路由。
  • 网络分区

    • 网络分区发生由于网络的不可靠,网络分区时一个必须要考虑的问题。当网络分区发生后,cluster 被割裂成 majority 和 minority 两部分,这里以分区中的 master 节点来区分。
      1. 对于 minority 部分,slave 会发起选举,但是不能收到大多数 master 的选票,也就无法完成正常的 failover 流程。同时在 clusterCron 里的大部分节点会被标记为 CLUSTER_NODE_PFAIL 状态,进而触发集群状态更新。在 minority 中,cluster 状态在一段时间后,会被更改为 CLUSTER_FAIL。但,对于一个划分到 minority 的 master 节点,在状态更改前是一直可以访问的,这就有一个时间窗口,会导致 write 丢失。在 clusterCron 函数中可以计算出这个时间窗口大小:从 partition 时间开始算起,cluster_node_timeout 时间后才会有 node 标记为 PFAIL,加上 gossip 消息传播会偏向于携带 PFAIL 的节点,master节点 不必等到 cluster_node_timeout/2 把 cluster nodes ping 遍,就可以把 cluster 标记为 CLUSTER_FAIL可以推算出,时间窗口大约为 cluster_node_timeout。另外,会记录下禁用服务的时间,即 among_minority_time
      2. 对于 majority 部分,slave 会发起选举,切换为新的master并提供服务。如果partition 时间小于 cluster_node_timeout,以至于没有 PFAIL 标识出现,就不会有 write 丢失。
    • 网络分区恢复当网络分区恢复后,minority 中 老的master 重新加进 cluster,master 要想提供服务,就必须先将 cluster 状态从 CLUSTER_FAIL 修改为 CLUSTER_OK,那么,应该什么时候改呢?我们知道 老master中应该是旧路由,此时它应该变更为 slave,所以,还是需要等待一段时间做路由变更,否则有可能出现 write 丢失的问题。从 clusterUpdateState 函数的逻辑里,可以看出时间窗口为 cluster_node_timeout

总结:

failover 可能因为选举和主从异步复制数据偏差带来 write 丢失。master 重启通过 CLUSTER_WRITABLE_DELAY 延迟,等 cluster 状态变更为 CLUSTER_OK,可以重新访问,不存在 write 丢失。partition 中的 minority 部分,在 cluster 状态变更为 CLUSTER_FAIL 之前,可能存在 write 丢失。partition 恢复后,通过 rejoin_delay 延迟,等 cluster 状态变更为 CLUSTER_OK,可以重新访问,不存在 write 丢失。

5.9.10 availability 分析

https://segmentfault.com/a/1190000039234661?utm_source=sf-similar-article

主要在三种情况下,出现不可用:

  • 网络故障Redis Cluster 在发生 网络分区后,minority 部分是不可用的。假设 majority 部分有 过半数 master 和 所有不在majority的master其下的一个slave。那么,经过 NODE_TIMEOUT 时间加额外几秒钟(给slave进行failover),cluster 恢复可用状态。
  • sharding 缺失故障默认情况下,当检测到有 slot 没有绑定,Redis Cluster 就会停止接受请求。在这种配置下(三主三从),如果 cluster 部分节点挂掉(一个主节点和其对应的从节点都挂了),也就是说一个范围内的 slot 不再有节点负责,最终整个 cluster 会变的不能提供服务。有时候,服务部分可用比整个不可用更有意义,因此,即使一部分 sharding 可用,也要让 cluster 提供服务。redis 将这种选择权交到了用户手中,conf 里提供 cluster-require-full-coverage 参数。如果该参数为false,那么有 slot 未绑定或者 sharding确实,server 也是可以接受请求的。
  • 当集群节点宕机,出现集群Master节点个数小于3个的时候,或者集群可用节点个数为偶数的时候,基于 failover 这种选举机制的自动主从切换过程可能会不能正常工作。标记 fail、以及选举新master的过程,都可能异常。
replicas migration 功能

举个例子,如果一个包含N个 master 的集群,每个Master 有唯一 slave。单个 node 出现故障,cluster必定仍然可用;第二个 node 再出现再出现故障。如果第二个节点正好是上面已经故障的master节点的slave,则此时集群不可用。如果第二个节点是其他节点,则集群仍然可用。所以集群不可用的概率是 1/(N*2-1)

Redis Cluster 为了提高可用性,这个是用于在每次故障之后,重新布局集群的slave,给没有slave的master配备上slave,以此来更好应对下次故障。

具体实现:

这种负责 部分slot但是没有健康slave的 master,就称为 orphaned master。当slave检测到自己的 master 拥有不少于2个健康slave,且 cluster 中恰好有 orphan master 时,触发 clusterHandleSlaveMigration 函数逻辑,尝试进行 slave 漂移,slave步骤有如下四步

  1. CLUSTER_FAIL 集群漂移 if (server.cluster->state != CLUSTER_OK) return;非 CLUSTER_OK 集群本来旧无法正常接收请求,所以也不需要漂移。
  2. 检查 cluster-migration-barrier 参数redis conf 提供了cluster-migration-barrier 参数,用来决定 slave 数量达到多少个才会把冗余 slave 漂移出去。只有 master 健康 slave 的个数超过 cluster-migration-barrier 配置的数量时,才会漂移。
  3. 选出要漂移的 slave,以及漂移给谁。选择 node name 最小的slave,漂移给遍历到的第一个 orphaned master
  4. 执行漂移在 failover 期间,master 有一段时间是没有 slave,为了防止误漂,漂移必须有一定的延迟。时间为 CLUSTER_SLAVE_MIGRATION _DELAY 现版本为 5s。

六、Redisson

6.1 分布式锁

分布式锁,是控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁。

6.1.1 常见redis 分布式锁

一般Redis分布式锁有如下几种实现方案:

  • 命令 setnx + expire 分开写
1
2
3
4
5
6
7
8
9
10
if(jedis.setnx(key,lock_value) == 1){ // 加锁
expire(key,100); // 设置过期时间
try{
do something // 业务处理
}catch(){

}finally{
jedis.del(key); // 释放锁
}
}

如果执行完 setnx 加锁,正要执行 expire 设置过期时间,进行crash或者重启维护,那么这个锁就一直被锁住了,别的线程永远获取不到锁了,所以分布式不能这种实现。

  • setnx + value 值过期时间为了解决方案一,发生异常锁得不到释放的场景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
long expires = System.currentTimeMillis() + expireTime; // 系统时间 + 设置的过期时间
if(jedis.setnx(key,expires) == 1){
return true;
}
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, String.vaue(expires)) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);

if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}

//其他情况,均返回加锁失败
return false;
}

这一方案巧妙移除了 expire 单独设置过期时间的操作,把过期时间放到了 setnx 的 value 值中。解决了 发生异常锁得不到释放的问题。但是此方案也有自己的缺点:

    • 过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
    • 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖
    • 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
  • set 的扩展命令(set ex px nx)Redis 的 set 扩展参数(SET key value[EX seconds][PX milliseconds][NX|XX])是原子性的。EX seconds:设定key的过期时间,时间单位是秒。PX milliseconds:设定key的过期时间,单位是毫秒NX:表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获取锁,而其他客户端请求只能等起释放锁,才能获取。XX:仅当key存在时设置值

1
2
3
4
5
6
7
8
9
if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
  }
  finally {
jedis.del(key_resource_id); //释放锁
}
}

但是呢,这个方案还是可能存在问题:问题一:锁过期释放了,业务还没执行完。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。问题二:锁被别的线程误删。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。

  • set ex px nx + 校验唯一随机值 再删除既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
  }
  finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}

在这里,判断是不是当前线程加的锁释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。因为 finally 部分执行时不能保证原子性,一般也是用 lua脚本代替。

6.1.2 Redisson 的解决方案

1. 单机方案

其实上面的方案还是会存在 锁过期释放,业务没有执行完的问题。所以其实我们可以开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁过期时间延长,防止锁过期提前释放。

只要线程1加锁成功,就会启动一个 watch dog,它是一个后台线程,会每隔10秒检查一下锁。如果线程1还持有锁,那么就会不断的延长锁key的过期时间。因此 Redission 解决了 业务还没执行完 锁就过期释放的 问题。

2. 基于故障转移的RedLock算法

上面的所有的方案都是基于单机版的,然而实际上生产环境redis都是集群部署。

直接在 redis 主从集群中使用上面的方案,会有如下问题:

客户端在 Redis 的 master 节点上拿到了 锁,但是这个锁还没有同步到 slave 节点上,master节点就发生了故障。然后进行了故障转移,slave节点升级为 master节点。因此 客户端 加的锁丢失了。

因此Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock

Redlock架构图

应用前提:在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

实现步骤:

  1. 获取当前的时间戳
  2. 依次尝试向5个实例,使用相同的 key 和 具有唯一性的value(例如UUID)获取锁。客户端请求各实例获取锁时,应有设置响应超时时间。并且这个响应超时时间尽量远小于锁的失效时间。如此设计的原因,是因为我们不能在已经挂掉的master上花费太多时间。如果花费太多时间,会造成还没向全部master请求完,锁的失效时间就已经到了。因此 如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  3. 客户端使用当前时间减去开始获取锁的时间(步骤1记录的时间),就可以得到 获取锁 所用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且整个过程使用的时间小于锁失效时间时,锁才算获取成功
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

七、Redis应用问题

7.1 Redis与MySQL双写一致性如何保证?

一旦出现数据更新,redis与数据库之间的数据一致性问题就会出现。

一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。

  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

是选择更新缓存还是删除缓存?

如果 线程A先更新数据库,之后线程B也向数据库中更新同一值,但是B请求快,先写入了缓存,A后写入了缓存。那么实际上 缓存中还是旧值,而数据库中是B更改后的新值。导致数据最终不一致。

但是你选择的是删除缓存。那么在最后一次删除缓存后,请求再来时会查询数据库最新数据。那么就避免了这个问题。所以 我们选择 删除缓存。

不管是先删除缓存再更新数据库,还是先更新数据库再删除缓存,都有可能存在数据不一致的情况。

  1. 先删除缓存再更新数据库:在删除缓存后,更新数据库前。就可能会有个请求获取缓存,此时缓存没有,它就去查数据库了,就得到了脏数据。并将脏数据塞入了缓存中。这就导致了 缓存与数据库 最终不一致。
  2. 先更新数据库再删除缓存:在删除缓存之前,去读到的都是 脏数据。在并发写不高、redis删除失败概率不大时,可以一定程度实现 最终一致性。但是在并发写较高,就会出现下面的情况:

img

此时 再有线程进来读取缓存,就会读取到就是a=2,但是实际 数据库中 a=3。这就导致了数据最终不一致。

此方案可以考虑在写并发极低的情况下使用。

但是综合来看,上面两个方案即使在不考虑 删除key 可能失败的情况,也不能保证 缓存和数据库 数据最终一致。

3.延迟双删:

延迟双删再上面方案1 的基础上,增加了一步 延迟一定时间后 再删除缓存。从而避免方案1,造成的脏数据存在缓存中。达到下面左图到效果

img

img

这样就可以实现 数据最终一致性。但是我们也可以清楚的发现,如果延迟时间不够,很有可能会出现 Thread-2 的写入缓存操作 在Thread-1第二次删除缓存 之后发生。那么此时,数据又会出现不一致的情况。

所以我们应当设置一个合理的 延迟时间,但是即使合理,也不能说 一定能保证在任何情况下 Thread-2 写入操作都在 Thread-1 第二次删缓存 之后。

4.异步更新缓存(基于CDC的同步机制)

通过CDC(数据变更跟踪)将缓存与数据库的一致性同步从业务中独立出来统一处理,保证数据一致性。

整体思路:

  1. 更新、写 数据库后,会产生数据变更记录。(MySQL中有binlog日志,SQLServer中有CDC变更表)
  2. 通过数据变更记录来更新 Redis中数据

这里可以使用:1. FlinkCDC 来实现 对数据库变更数据的追踪、处理;2. 数据变更记录 存入 消息队列,消费者有序实现 Redis 更新。

上面的所有方案中,都没有考虑 删除缓存失败 的可能,如果考虑删除缓存失败,可能所有方案都保证不了 数据最终一致性。所以在 删除缓存 这一操作,可以考虑 失败重试 或者 将需要删除的key存入消息队列中,依次保证 删除缓存的成功。

从整个大局来看,我们会发现 如果缓存不设置过期时间,是比较容易造成 redis与数据库 最终一致性难以保证的。最简单的方法就是 设置过期时间,这样即使脏数据在缓存中,也不会存在很久。

个人看法:小团队或者小项目可以考虑 使用方案2+设置key过期时间,较大项目可以考虑 使用方案4

7.2 Redis 的大key如何处理?

什么是 Redis 大key ?

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。

一般而言,下面这两种情况被称为大 key:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000个;

大key会造成什么问题?

大 key 会带来以下四种影响:

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

如何找到大key?

  1. redis-cli –bigkeys 查找大key

可以通过 redis-cli –bigkeys 命令查找大 key:

使用的时候注意事项:

    • 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
    • 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。

该方式的不足之处:

    • 这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;
    • 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;
  1. 使用 SCAN 命令查找大 key

使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。

对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。

对于集合类型来说,有两种方法可以获得它占用的内存大小:

    • 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令;
    • 如果不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。
  1. 使用 RdbTools 工具查找大 key

使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。

比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。

1
rdb dump.rdb -c memory --bytes 10240 -f redis.csv

如何优化大key?

  • 对大key进行拆分和压缩

例如将含有数万成员的一个HASH Key拆分为多个HASH Key,使用multiGet方法获得值,并确保每个Key的成员数量在合理范围。这样的拆分主要是为了减少单台操作的压力,而是将压力平摊到集群各个实例中,降低单台机器的IO操作。

  • 对大key可以进行清理

将不适用Redis能力的数据存至其它存储,并在Redis中删除此类数据。

  • 在Redis集群架构中对热Key进行复制

在Redis集群架构中,由于热Key的迁移粒度问题,无法将请求分散至其他数据分片,导致单个数据分片的压力无法下降。此时,可以将对应热Key进行复制并迁移至其他数据分片,例如将热Key foo复制出3个内容完全一样的Key并名为foo2、foo3、foo4,将这三个Key迁移到其他数据分片来解决单个数据分片的热Key压力。

  • 使用读写分离架构

如果热Key的产生来自于读请求,您可以将实例改造成读写分离架构来降低每个数据分片的读请求压力,甚至可以不断地增加从节点。但是读写分离架构在增加业务代码复杂度的同时,也会增加Redis集群架构复杂度。

7.3 如何选择持久化策略?

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

AOF 优点是丢失数据少,但是数据恢复不快。

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

混合持久化优点:

  • 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

八、Redis 涉及的算法

1.一致性Hash

img

1.1 问题的由来

大多数应用,背后肯定不只有一台服务器提供服务。因为高可用或并发量的需要,都会使用多台服务器组成集群对外提供服务。那么问题来了,这么多服务器,要如何分配客户端请求呢?其实这个问题,就是 负载均衡问题了。解决负载均衡问题的算法很多,不同的负载均衡算法,适用于不同的应用场景和需求。一般,最简单的方式,就是引入一个中间的负载均衡层,让它将外界的请求 “轮流” 转发给内部的集群。比如集群有三个节点,并收到了3个请求,那么每个节点都会处理一个请求。

考虑到每个节点的硬件配置有区别,一般引用权重值。按不同节点的权重值,来分配请求,让处理能力更抢的节点,分担更多请求。

但是这种加权轮询使用场景是建立前提——每个节点存储的数据都是相同的。这样,访问任意一个节点都可以获取相同的结果。但是,这就无法应对 分布式系统。因为分布式系统,每个节点存储的数据是不同的。

比如:分布式存储系统,一般为了提高系统的容量,就会把数据水平切分到不同的节点来存储。比如 Redis,某个key应该到哪个或者那些节点上获的,应该是确定的。而不是任意访问一个节点都可以获取 key 对应的 value。

1.2 直接使用哈希算法?

很容易就会想到 hash算法,其可以通过一个 key 进行 哈希计算,每次都可以得到相同的值。这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。

哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。如果客户端要获取指定 key 的数据,通过上面的公式定位节点。

但是这有一个很致命的问题:如果节点数据发生了变化,也就是在对系统做扩容或者缩容时,可能造成大部分映射关系改变。并且必须迁移改变了映射关系的数据,否则会查询不到数据的问题。假设总数据条数为 M,哈希算法在面对节点数量变化时,最坏情况下所有数据都需要迁移,所以它的数据迁移规模时 O(M),这样数据迁移成本太高。

1.3 使用一致性哈希算法有什么问题?

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。一致哈希算法也用了取模运算,但于哈希算法不同的是,哈希算法是对节点数量进行取模,而一致哈希算法是对 2^32 进行取模运算=

们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环。这个圆想可以想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:

img

一致性哈希要进行两步哈希:

  • 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
  • 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上

问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?

答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:

img

接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。

比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。

img

所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗?

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:

img

你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。

假设节点数量从 3 减少到了 2,比如将节点 A 移除:

img

你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。

但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:

img

这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。

另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。

比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。

所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

1.3 通过虚拟节点提高均衡度

要想解决节点能在 哈希环上 分配不均匀的问题,就是要有大量的节点,节点越多,哈希环上的节点分布就越均匀。但问题是,实际上我们没有那么多节点,所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。

具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点。所以这里有 两层 映射关系。

比如对每个节点分别设置 3 个虚拟节点:

  • 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
  • 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
  • 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。

img

你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。

上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有160 个虚拟节点。

另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。比如,当某个节点被移除时,对应 该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了 节点被移除 导致的压力。

而且有虚拟节点的概念也方便了,对不同节点进行权重区分。硬件配置更好的节点,增加更多虚拟节点。

1.4 总结

轮训这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。

为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。


摘录文章:

Redis 设计与实现(第一版):https://redisbook.readthedocs.io/en/latest/index.html

美团二面:Redis与MySQL双写一致性如何保证?