ZhouXiangの博客 后端工程师&机器学习爱好者

Java学习系列之缓存

2019-10-19

本文系记录对Java中缓存的学习资料,如有异议,欢迎联系我讨论修改。PS:图侵删!如果看不清图请访问: 传送门

缓存基本概念及框架

Redis缓存

Redis是一个Key-Value类型的内存数据库,整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据复试到硬盘上进行保存。Redis的性能非常出色,每秒可以处理超过10万次读写操作。此外,Redis支持多种数据结构,单个value的最大限制是1GB。Redis可以用来实现很多有用的功能。例如使用List来做FIFO双向链表,实现一个轻量级的高性能消息队列服务;使用Set可以做高性能的tag系统等。Redis的主要缺点是数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。

Memcached缓存

Memcached是一种基于内存的key-value存储,用来存储小块的任意数据(字符串、对象)。Memcached简洁而强大。它的简洁设计便于快速开发,减轻开发难度,解决了大数据量缓存的很多问题。它的API兼容大部分流行的开发语言。一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态Web应用的速度、提高可扩展性。

Redis和Memcached的区别

  • Memcached不支持持久化,而Redis支持
  • Memcached数据结构简单(全是简单字符串),Redis相对复杂且丰富
  • Redis的速度比Memcached快很多

Redis的优点

  • 速度快:数据在距离CPU近的位置,存取速度快
  • 支持丰富的数据结构,例如String,list,set,hash,sort map
  • 支持事务ACID
  • 具有丰富的特性,例如:缓存、设置过期时间

Redis数据结构与对象

Redis有丰富的数据结构,例如:简单字符串、链表、字典、跳跃表、整数集合、压缩列表等,但是数据库的实现并没有并不是直接使用这五种数据结构,而是基于这些数据结构创建了一个对象系统,这个系统包括字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种类型的对象,每种对象至少用到了一种数据结构。Redis中使用对象表示键和值,当新建一个键值对时,Redis至少创建2个对象,一个是键对象,另一个是值对象。

Redis中简单字符串数据结构的认识(用于实现默认字符串)

在Redis数据库中,包含字符串值的键值对在底层都是由SDS实现。SDS具有以下特征:

  • 常数复杂度获取字符串长度(只需要访问len属性)
  • 杜绝缓冲区溢出(自动检查&扩容)
  • 减少修改字符串时带来的内存重分配次数(利用空间预分配技术和惰性释放策略)
  • 保证二进制安全
  • 兼容C系字符串风格

Redis中链表数据结构的认识(用于实现列表的键)

链表提供了高效的节点重排能力以及顺序访问方式,可通过增删节点灵活地调整链表的长度。链表在Redis中应用广泛,例如列表键的底层实现之一。链表具有以下特征:

  • 双向性,保留前驱和后续的指针
  • 无环性
  • 有头指针和尾指针
  • 具有多态性,可以保存不同类型的值

Redis中字典数据结构的认识(用于实现数据库)

字典又称为符号表、映射,是一种用于保存键值对的抽象数据结构。字典在Redis中是数据库的底层实现,对数据库的CURD就是构建在对字典的操作上。当一个新的键值对添加到字典里时,程序需要首先根据键值对的值计算出哈希值,再通过哈希值与掩码进行二次哈希得到索引值,最后将哈希表节点放到哈希表数组的指定索引上。扩展和收缩的工作可以通过调用rehash实现,实现过程类似HashMap。在rehash时,如果已有元素很多,可以采用渐进式rehash,即分多次、渐进式地将键值对迁移。

Redis中跳跃表数据结构的认识(用于实现有序集合)

跳跃表是一种有序数据结构,它通过在每个节点维持多个指向其他节点的指针来达到快速访问节点的目的。Redis使用跳跃表作为有序集合的底层实现之一,如果一个有序集合包含的元素数量较多,或者有序集合元素是比较长的字符串,Redis就会使用跳跃表作为有序集合的底层实现。跳跃表是一种支持平均O(logN),最坏O(N)的节点查找。

Redis中整数集合数据结构的认识(用于实现集合的键)

整数集合是集合键的底层实现之一,当一个集合只包含整数元素时,并且每个集合的元素数量不多时,Redis就会使用整数集合作为集合建的底层实现。其中,contents数组用于存储整数,数组中的值按照值的大小从小到大有序排列,并且不会包含重复项。

Redis中压缩列表数据结构的认识(用于实现列表的键)

压缩列表是列表键和哈希表键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项是小整数或者短的字符串,那么会使用压缩列表作为列表键的底层实现。

Redis不同对象与编码的关系

Redis字符串对象

字符串对象可以是int、raw或者embstr。如果一个字符串时整数,并且可用long型表示,那么该字符串对象编码就是int。如果字符串长度大于39字节,那么将使用一个简单动态字符串(sds)保存,并将对象编码设置为raw。如果字符串长度小于等于39字节,则字符串以编码方式embstr来保存该字符串值。embstr字符串在执行修改命令后会变成一个raw字符串。

Redis列表对象

列表对象的编码可以是ziplist或者linkedlist。ziplist使用功能压缩列表作为底层实现,每个压缩列表节点保存一个列表元素。

Redis哈希对象

哈希对象的编码可以是ziplist和hashtable。ziplist编码的哈希对象使用压缩列表作为底层实现,当有新的键值对要加入哈希对象时,会先将保存了键的压缩列表节点推入到压缩列表表尾,再将保存了值的压缩列表节点推入到列表表尾。这样的话,一对键值对总是相邻的,并且键节点在前值节点在后。只有当键值对均为字符串对象(长度必须小于64字节)且键值对数量小于512个时才能使用ziplist,否则必须使用hashtable编码。

Redis集合对象

集合对象的编码可以是intset和hashtable。intset编码的集合对象使用整数集合作为底层实现,所有元素都保存在整数集合中。另一方面,使用hashtable的集合对象使用字典作为底层实现,字典中每个键都是一个字符串对象,即一个集合元素,而字典的值都是NULL的 集合对象所有的元素都是整数值并且集合对象数量不超过512个时使用intset实现,否则使用hashtable实现。

Redis有序集合对象

有序集合对象的编码可以是ziplist和skiplist。ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨着的压缩列表节点保存,第一个保存集合元素,第二个保存集合元素对应的分值。压缩列表内集合元素按照分值大小进行排序,分值较小的在前,分值大的在后。skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。zset中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点保存一个集合元素,跳跃表节点的object属性保存元素的成员,score属性保存元素的分值。通过该跳跃表,可以对有序集合进行范围型操作,比如zrank、zrange命令就是基于跳跃表实现的。zset中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素,字典的键保存集合元素的成员,字典的值保存集合成员的分值。通过该字典,可以O(1)复杂度查找到特定成员的分值,zscore命令就是根据这一特性来实现的。通过字典+skiplist作为底层实现,各取所长为我所用。当有序集合保存的元素数量小于128个且所有元素长度均小于64字节时使用ziplist编码,否则必须使用skiplist+hash编码

为什么有序集合同时采用跳表和字典来实现

为了同时具有下述情况的最优性能:

  • 以O(1)复杂度查找元素
  • 尽可能快地执行范围型操作

Redis对象中的一些其他特性

  • 空转时长:是记录对象最后一次访问的时间
  • 内存回收:通过引用计数功能,达到内存回收目的
  • 对象共享:当一个对象(仅包含整数值的字符串对象)被另外一个地方使用时,可以直接在该对象引用计数上++就行

持久化

RDB

Redis提供了RDB持久化功能,这个功能将Redis在内存中的数据库状态保存到一个RDB文件中。这个RDB文件是一个经过压缩的二进制文件,可以通过该文件的加载还原数据库状态。有两个命令可以实现RDB持久化,分别是SAVE与BGSAVE。SAVE命令会阻塞当前服务器进程,直到RDB文件创建完毕,在阻塞期间服务器不能处理任何命令请求。而BGSAVE命令会派生出一个子进程,由子进程负责创建RDB文件,不影响服务器进程继续处理命令请求。Redis没有专门用于载入RDB文件的命令,当服务器启动时就会检测是否存在RDB文件,然后自动载入。如果开启AOF持久化功能,那么服务器会优先使用AOF还原服务器状态。

RDB触发机制:

  • 手动触发:save和bgsave命令
  • 自动触发:使用save自动保存的相关配置;从节点执行全量复制操作;执行shutdown命令时,如果没有开启AOF持久化功能则会自动执行bgsave

RDB优缺点:

  • RDB是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的数据快照,适合备份,全量复制等场景
  • 加载RDB恢复数据远远快于AOF
  • 没办法做到实时持久化/秒级持久化,因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,频繁执行成本过高

AOF

AOF持久化通过保存Redis服务器锁执行的写命令来记录数据库状态,以文本形式保存。AOF持久化的实现可以分为命令追加、文件写入、文件同步三个步骤。

在执行命令追加时,首先将所有的写命令追加到aof_buf缓冲区中;之后,AOF会根据对应的刷盘策略向磁盘做同步操作。一个特殊的刷盘策略是,将缓冲区中所有的内容写入到AOF文件中,如果上次写入距离现在超过一秒,那么再次对AOF文件进行同步。现在计算机系统中,为了提高文件写入效率,往往在缓冲区填满后或满足一定时限后一次性写入磁盘。这种情况下,一旦遇到停机,则内存缓冲区内的数据就会丢失。为此,可以选择fsync函数,强制刷盘。

AOF载入时会创建一个不带网络连接的伪客户端进行读取。伪客户端从AOF文件中分析并读取出一条写命令并执行,直到没有命令。

随着时间的流式,AOF文件的大小逐渐扩大,还原时所需的时间也越来越多。为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能。重写后新的AOF文件与旧的AOF文件具有一致的状态,几乎不包括冗余命令。AOF重写的实现原理为:从数据库读取键现在的值,然后用一条命令去记录键值对从而代替原来记录这个这个键值对的多条命令。此外,在重写AOF期间,服务器将无法接受客户端的请求。因此,AOF重写有后台模式,即重写的逻辑放到子进程中执行。为了解决重写时,服务端处理新请求而导致的AOF文件不一致性,又引入了AOF重写缓冲区。完整的AOF重写流程如下:

  • 执行AOF重写请求。如果当前进程正在执行bgsave操作,重写命令会等待bgsave执行完后再执行。
  • 父进程执行fork创建子进程。
  • fork操作完成后,主进程会继续响应其它命令。所有修改命令依然会写入到aof_buf中,并根据appendfsync策略持久化到AOF文件中。
  • 因fork操作运用的是写时复制技术,所以子进程只能共享fork操作时的内存数据,对于fork操作后,生成的数据,主进程会单独开辟一块aof_rewrite_buf保存。
  • 子进程根据内存快照,按照命令合并规则写入到新的AOF文件中。每次批量写入磁盘的数据量由aof-rewrite-incremental-fsync参数控制,默认为32M,避免单次刷盘数据过多造成硬盘阻塞。
  • 新AOF文件写入完成后,子进程发送信号给父进程,父进程更新统计信息。
  • 父进程将aof_rewrite_buf(AOF重写缓冲区)的数据写入到新的AOF文件中。
  • 使用新AOF文件替换老文件,完成AOF重写。

AOF重写触发条件如下:

  • 手动触发:客户端执行bgrewriteaof命令
  • 自动触发:只有当AOF文件大小大于auto-aof-rewrite-min-size时候才可能重写,默认为64mb;当前AOF文件大小和最后一次重写后的大小之间的比率等于或者等于指定的增长百分比

混合持久化

混合持久化就是同时结合RDB持久化以及AOF持久化混合写入AOF文件。这样做的好处是可以结合 rdb 和 aof 的优点, 快速加载同时避免丢失过多的数据,缺点是 aof 里面的 rdb 部分就是压缩格式不再是 aof 格式,可读性差。

RDB与AOF的对比

  • RDB在恢复大数据集时的速度比AOF的恢复速度要快
  • 数据文件体积较大,即使有重写机制,但是在相同的数据集情况下,AOF文件通常比RDB文件大
  • 由于频繁地将命令同步到文件中,AOF持久化对性能的影响相对RDB较大,但是对于我们来说是可以接受的
  • RDB文件是特定的格式,阅读性差,由于格式固定,可能存在不兼容情况
  • AOF数据更完整,秒级数据丢失;RDB是一个快照过程,无法完整的保存所以数据,尤其在数据量比较大时候,一旦出现故障丢失的数据将更多
  • 优先使用AOF还原数据

Redis多线程

Redis单线程

所谓的单线程适用于处理网络连接,在进行RDB、AOF重写时一定会涉及多进程多线程。Redis在处理网络连接的读写时,采用多路复用模型(Select,Poll,Epoll)实现一个线程处理多个网络连接,且内存操作速度极快导致Redis的QPS/吞吐量极高。采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。虽然单线程效率高,但是遇到耗时的命令会导致并发性降低,尤其是写并发。此时多核的特性没有利用。为了解决这一问题,可以启动多个实例,组成master-master或者master-slave的形式,耗时的读命令可以完全在slave进行。

Redis绝对线程安全么?

Redis采用了线程封闭的方式,把任务封闭在一个线程,自然避免了线程安全问题,不过对于需要依赖多个redis操作的复合操作来说,依然需要锁,而且有可能是分布式锁。

Redis的并发竞争问题如何解决

并发竞争多发生在并发写竞争,解决办法如下:

  • 使用乐观锁的方式进行解决;(watch机制配合事务锁)
  • 排队的机制进行。将所有需要对同一个key的请求进行入队操作,然后用一个消费者线程从队头依次读出请求,并对相应的key进行操作

复制

在Redis中,用户通过执行slaveof指令,让一个服务器(从)去复制另一个服务器(主)。Redis的复制功能分为同步、命令传播两个操作。同步操作用于将从服务器状态更新至主服务器当前所处的数据库状态。命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重回一致状态。

简述旧版复制的理解

从服务器对主服务器进行复制,需要执行sync命令。首先从服务器向主服务器发送sync命令;收到sync命令的主服务器执行bgsave命令,在后台生成一个rdb文件,并使用一个缓冲区记录从现在开始执行的所有写命令;当主服务器的bgsve命令执行完毕时,主服务器将rdb文件发给从服务器,从服务器加载该rdb文件;主服务器将所有缓冲区内的写命令发给从服务器,从服务器执行这些命令,使得自己的服务器状态与主服务器状态一致。

复制一般分为两种情况:

  • 初次复制:从服务器没有复制过任何主服务器或与上次复制的主服务器不同
  • 断线后重新复制:中间存在断网后又继续连接的情况 在旧版复制中,断网时间内导致数据库状态不一致,解决办法是再次调用sync命令,然而sync涉及生成RDB、网络传输、RDB载入这几个过程,具有相当大的性能开销与时间开销,如果丢失的仅是一小部分数据,这种全量复制的解决办法效率不高。

简述新版复制的理解

为了解决旧版复制在断线后复制时的低效问题,新版本复制的命令改为psync,并具有完整重同步可部分重同步两种模式。其中,完整重同步用于初次复制,与sync基本一致;部分重同步用于处理断线后复制的情况,用于将断线后执行的写命令发送给从服务器。部分重同步的实现依赖于如下三个部分:

  • 主从复制偏移量
  • 主服务器的复制积压缓冲区
  • 服务器运行ID

主从复制偏移量表示分别记录已传输/接收的字节数,一旦值不同意味着发生了不一致性状态。复制积压缓冲区是由主服务器维护的一个固定长度FIFO队列,默认大小为1MB。主服务器的复制积压缓冲区会保存一部分最近传播的写命令并记录对应的复制偏移量。如果从服务器的复制偏移量小于复制积压缓冲区内的任意一个偏移量,则执行全量复制;否则执行部分重同步操作。服务器运行ID则用于表示服务器,可判断从节点是否是新机器。

简述大致的步骤

  • 设置主服务器的地址和端口
  • 建立套接字连接
  • 发送ping命令
  • 身份验证
  • 发送端口信息
  • 同步
  • 命令传播

哨兵

Sentinel是什么

哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。哨兵有两个作用:

  • 通过发送命令,让Redis服务器返回监控其运行状态,包括主服务器和从服务器
  • 当哨兵监测到master宕机,会自动将slave切换成master,然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换主机 Sentinel是Redis高可用的实现方案,可以实现对Redis的监控、通知、自动故障转移。当它发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它还会和其他Sentinel节点进行“协商”,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。整个过程完全是自动的,不需要人工来介入,所以这套方案很有效地解决了Redis的高可用问题。

Sentinel检测下线状态的处理过程

在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送ping命令,并通过实例返回的ping命令恢复来判断实例是否在线。如果在指定的时间段内,目标连续向Sentinel返回无效回复,那么Sentinel会修改目标对应实例结构,标记其已主观下线。当Sentinel将一个主服务器判断为主观下线后,为了确认这个主服务器是否真的下线,它会向同样监视这一主服务器的其他Sentinel进行询问,交换意见。当Sentinel从其他Sentinel得到足够的下线判断之后,Sentinel就会将服务器判定为客观下线。随后执行故障转移操作。

选举领头Sentinel的过程

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线的主服务器进行故障转移操作。选举过程如下:

  • 首先,监视同一个主服务器的多个在线Sentinel具有称为领头Sentinel的资格
  • 每次进行Sentinel选举后,Sentinel的配置纪元都会自增1,配置纪元实际上是一个计数器
  • Sentinel向其他Sentinel发送命令,要求其他Sentinel将自己设置为局部领头Sentinel,采用的是先到先得机制,后面的要求一律禁止
  • 如果某个Sentinel发现其他Sentinel将其设置为局部领头Sentinel,且数量大于等于len/2+1,那么选举过程结束
  • 如果在给定时间内,没有选出领头Sentinel,则会在一段时间后再次发起选举,直到选出Sentinel

故障转移的过程

在选出领头Sentinel后,将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:

  • 在已下线主服务器属下的所有从服务器中,挑选出一个从服务器,并转换为主服务器
  • 让已下线主服务器属下的所有从服务器改为复制新的主服务器
  • 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它将称为新的主服务器的从服务器

选出新的主服务器的过程如下:

  • 领头Sentinel会将已下线主服务器的所有从服务器信息提取到一个列表中用于过滤
  • 首先删除列表中处于下线和断线的从服务器
  • 随后删除列表中所有最近5秒内没有回复过领头Sentinel命令的从服务器
  • 随后删除列表中所有与已下线服务器连接断开超过down-after-milliseconds*10毫秒的从服务器,保证从服务器的数据较新
  • 之后对从服务器按照优先级排序(降序)
  • 如果优先级一致,按照复制偏移量排序(降序)
  • 如果偏移量一致,则选择运行ID最小的从服务器

哨兵可能存在的问题

  • 异步复制导致的数据丢失:因为master -> slave的复制是异步的,所以可能有部分数据还没复制到slave,master就宕机了,此时这些部分数据就丢失了
  • 脑裂导致的数据丢失:脑裂,也就是说,某个master所在机器突然脱离了正常的网络,跟其他slave机器不能连接,但是实际上master还运行着。此时哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master,这个时候,集群里就会有两个master,也就是所谓的脑裂

集群

Redis Cluster是一种服务器Sharding技术,3.0版本开始正式提供。RedisCluster中,Sharding采用slot(槽)的概念,一共分成16384个槽,这有点儿类pre-sharding思路。对于每个进入Redis的键值对,根据key进行散列,分配到这16384个slot中的某一个中。使用的hash算法也比较简单,就是CRC16后16384取模。Redis集群中的每个node(节点)负责分摊这16384个slot中的一部分,也就是说,每个slot都对应一个node负责处理。当动态添加或减少node节点时,需要将16384个槽做个再分配,槽中的键值也要迁移。Redis集群,要保证16384个槽对应的node都正常工作,如果某个node发生故障,那它负责的slots也就失效,整个集群将不能工作。为了增加集群的可访问性,官方推荐的方案是将node配置成主从结构,即一个master主节点,挂n个slave从节点。这时,如果主节点失效,RedisCluster会根据选举算法从slave节点中选择一个上升为主节点,整个集群继续对外提供服务,RedisCluster本身提供了故障转移容错的能力。RedisCluster的新节点识别能力、故障判断及故障转移能力是通过集群中的每个node都在和其它nodes进行通信,这被称为集群总线(cluster bus)。它们使用特殊的端口号,即对外服务端口号加10000。例如如果某个node的端口号是6379,那么它与其它nodes通信的端口号是16379。nodes之间的通信采用特殊的二进制协议。对客户端来说,整个cluster被看做是一个整体,客户端可以连接任意一个node进行操作,就像操作单一Redis实例一样,当客户端操作的key没有分配到该node上时,Redis会返回转向指令,指向正确的node。

集群分片的实现原理

Redis集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片可以在线进行,并且集群不需要下线,源节点和目标节点可以继续处理命令请求。重新分片操作是由Redis集群管理软件redis-trib负责执行。步骤如下:

  • redis-trib对目标节点发送命令,让目标节点准备好从源节点导入属于槽slot的键值对
  • redis-trib对源节点发送命令,让源节点准备好将属于槽slot的键值对迁向目标节点
  • redis-trib向源节点发送命令,获得最多count个属于槽slot的键值对的键名
  • 对于上一步获得的每个键名,redis-trib都向源节点发送一个迁移命令,将被选中的键原子地迁移到目标节点
  • 重复上两步,直到所有键值对被迁移成功
  • redis-trib向集群中的任意一个节点发送命令,将槽指派给目标节点,这一指派消息会通过消息发送至整个集群,最终集群中的所有节点都会直到槽slot已经指派给了目标节点

当在迁移过程中,如果被访问的slot,可能会有部分key存在在源节点,有部分在目标节点中。
当客户端发送请求到源节点的时候,源节点会查看对应的key是否还在本节点,如果存在,则直接执行命令返回给客户。如果不存在,则会给客户端返回一个ASK错误,指引客户端往正在导入的目标slot去请求对应的key。客户端可以通过返回的ASK错误中的目标节点进行对应KEY的请求。

当客户端发送请求到目标节点时。如果客户端请求时,带上ASKING标识,由目标节点会执行对应KEY的查询。正常情况下,如果是通过查询源slot,获取ASK错误之后,再到目标节点进行查询的时候,需要带上ASKING标识。如果客户端请求时,未带上ASKING标识,原由上,对应的slot还属于源节点,则目标节点会拒绝执行KEY查询,会返回一个MOVED错误给客户端,告诉客户端对应的KEY的slot属于源节点。正常情况下,如果第一次请求KEY到了正在迁移的目标节点,则会收到MOVED错误。

集群故障转移的过程

当一个节点发现自己复制的主节点进入下线状态时,从节点将开始对下线主节点进行故障转移,执行步骤如下:

  • 下线主节点的所有从节点中,将有一个从节点被选中
  • 被选中的从节点称为新的主节点
  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  • 新的主节点向集群广播PONG消息,通知其上位
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成

选举新的主节点的方式

类似于选举Sentinel的方式,采用投票机制,每人一票,先到先得,大于等于len/2+1即胜出。

发布与订阅

Redis中的发布订阅

Pub/Sub功能(means Publish,Subscribe)即发布及订阅功能。基于事件的系统中,Pub/Sub是目前广泛使用的通信模型,它采用事件作为基本的通信机制,提供大规模系统所要求的松散耦合的交互模式:订阅者(如客户端)以事件订阅的方式表达出它有兴趣接收的一个事件或一类事件;发布者(如服务器)可将订阅者感兴趣的事件随时通知相关订阅者。Redis的pub/sub是一种消息通信模式,主要的目的是解除消息发布者和消息订阅者之间的耦合,Redis作为一个pub/sub的server,在订阅者和发布者之间起到了消息路由的功能。

客户端可以订阅频道或模式,从而成为订阅者,每当其他客户端向被订阅的频道发消息时,该频道的所有订阅者都会接收到这条消息。

事务

Redis事务的认识

Redis事务提供了一种,将多个命令打包然后一次性、按顺序地执行”的机制,并且事务在执行的期间不会主动中断。服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。一个事务从开始到结束会经历以下三个阶段:

  • 事务开始:multi命令表示事务开始
  • 命令入队:当一个客户端切换到事务状态时,服务器会根据这个客户端发来的不同命令执行不同的操作,如果是exec/discard/watch/multi这几个命令那么立即执行;否则将会放入一个事务队列中,然后返回一个queued回复
  • 事务执行:当一个处于事务状态的客户端向服务器发送exec命令时,立即执行。服务器遍历客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端

Redis事务和传统的关系型事务不同,其不支持事务回滚,即使事务队列中的某个命令在执行期间出了错误,整个事务也会继续执行下去,直到事务队列中的所有命令执行完毕。

Redis中的Watch命

Watch命令是一个乐观锁,它在Exec命令执行之前,监视任意数量的键,在执行exec命令时,检查被监视的键是否至少有一个已经被修改过,如果是就拒绝事务,并行客户后端返回代表事务执行失败的空回复。WATCH命令可用于提供CAS(check-and-set)功能。

缓存相关名词

缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。解决办法:

  • 对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。还有最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
  • 采用一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟

缓存雪崩

如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。解决办法:

  • 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待
  • 可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存
  • 不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件
  • 做二级缓存,或者双缓存策略。A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期

缓存穿透

缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key

缓存预热

缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据。解决方案为:

  • 直接写个缓存刷新页面,上线时手工操作下
  • 数据量不大,可以在项目启动的时候自动进行加载
  • 定时刷新缓存

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

其他

Redis的高可用是怎么实现的

  • 持久化:持久化是最简单的高可用方法。它的主要作用是数据备份,即将数据存储在硬盘,保证数据不会因进程退出而丢失
  • 复制:复制是高可用Redis的基础,哨兵和集群都是在复制基础上实现高可用的。复制主要实现了数据的多机备份以及对于读操作的负载均衡和简单的故障恢复。缺陷是故障恢复无法自动化、写操作无法负载均衡、存储能力受到单机的限制
  • 哨兵:在复制的基础上,哨兵实现了自动化的故障恢复。缺陷是写操作无法负载均衡,存储能力受到单机的限制
  • 集群:通过集群,Redis解决了写操作无法负载均衡以及存储能力受到单机限制 的问题,实现了较为完善的高可用方案

为什么Redis的速度快

  • 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速
  • 数据结构简单,对数据操作也简单
  • 处理网络请求采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗
  • 使用多路I/O复用模型,非阻塞IO

Redis通讯协议

RESP 是redis客户端和服务端之前使用的一种通讯协议,RESP的特点:实现简单、快速解析、可读性好。

  • 简单字符串Simple Strings, 以 “+”加号 开头
  • 错误Errors, 以”-“减号 开头
  • 整数型Integer, 以 “:” 冒号开头
  • 大字符串类型Bulk Strings, 以 “$”美元符号开头,长度限制512M
  • 数组类型 Arrays,以 “*“星号开头

Redis的键淘汰策略

  • volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略
  • volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略
  • volatile-random:随机删除过期键,直到腾出足够空间为止
  • volatile-lfu:根据LFU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
  • allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止
  • allkeys-lfu:根据LFU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止
  • allkeys-random:随机删除所有键,直到腾出足够空间为止
  • noeviction:不会删除任何数据,拒绝所有写入操作并返回客户端错误信息,此 时Redis只响应读操作

Redis的键生存时间和过期时间

通过Expire命令或PExpire命令,客户端可以以秒或毫秒级精度为数据库中的某个键设置生存时间,在经过指定的秒数或毫秒数之后,服务器就会自动删除生存时间为0的键。过期时间是一个Unix时间戳。TTL或PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间。

如何判定一个键是否过期

redisDb结构的expires字典保存了数据库中所有键的过期时间,称之为过期字典。判定一个键是否过期时,首先检查给定键是否在过去字典,如果在那么取得键的过期时间。然后检查当前Unix时间戳是否大于键的过期时间。

过期键的删除策略

  • 定时删除:定时器在键过期时,执行对键的删除操作。对CPU不友好
  • 惰性删除:每次从键空间获取键时,都检查取得的键是否过期,如果过期的话就删除该键。对内存不友好
  • 定期删除:每隔一段时间对全库检查,删除里面过期的键。需要合理地设置执行频率

如何保证Redis中的数据都是热点数据

Redis内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。

Redis作为消息队列和其他消息队列应用的区别

可靠消费:

  • Redis没有相应的机制保证消息的消费,当消费者消费失败的时候,消息体丢失,需要手动处理
  • RabbitMQ:具有消息消费确认,即使消费者消费失败,也会自动使消息体返回原队列,同时可全程持久化,保证消息体被正确消费

可靠发布:

  • Resis不提供,需要自行实现
  • RabbitMQ具有发布确认功能,保证消息被发布到服务器

高可用:

  • Redis:采用主从模式,读写分离,但是故障转移还没有非常完善的官方解决方案
  • RabbitMQ:集群采用磁盘、内存节点,任意单点故障都不会影响整个队列的操作

持久化:

  • Redis:AOF、RDB
  • RabbitMQ:队列,消息,都可以选择是否持久化

消费者负载均衡:

  • Redis:不提供,需自行实现
  • RabbitMQ:根据消费者情况,进行消息的均衡分发

队列监控:

  • Redis:不提供,需自行实现
  • RabbitMQ:后台可以监控某个队列的所有信息,(内存,磁盘,消费者,生产者,速率等)

流量控制:

  • Redis:不提供,需自行实现
  • RabbitMQ:服务器过载的情况,对生产者速率会进行限制,保证服务可靠性

Redis分布式锁

在Redis中,可以利用一些API方法实现分布式锁:

  • 利用setnx()和expire()实现。调用setnx(key,value)方法时,如果key不存在,设置当前key成功,返回1;否则表明key存在设置失败。调用expire(time)用于设置超时时间。最后执行完业务代码后,通过delete命令删除key。这两个函数组合调用,可以解决分布式加锁的需求,但是一旦setncx()调用后宕机,将发生死锁
  • 利用getset(key, newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。过程如下:

1.setnx(lockkey, 当前时间+过期超时时间),如果返回1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
2.get(lockkey) 获取值oldExpireTime,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
3.计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
4.判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
5.在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

Redis实现消息队列为什么可靠性低,怎么改进?

Redis消息队列在使用时,如果消息处理失败消息将丢失;如果掉电消息队列挂掉同样会导致消息丢失;消费者下线也会导致消息丢失。一般,使用Redis的List作为消息队列,BLPOP或BRPOP命令分别实行push和pop操作,而且这两个操作时即时的。如果list中没有任务的时候,该连接将会被阻塞;接的阻塞有一个超时时间,当超时时间设置为0时,即可无限等待,直到弹出消息。为了解决可靠性低的问题,可以参考RabbitMQ的ACK——消息确认机制:

  • worker处理失败后,要回滚消息到原始pending队列
  • 假如worker挂掉,也要回滚消息到原始pending队列 具体实现方案如下:
  • 维护两个队列:pending队列和doing表(hash表)
  • workers定义为ThreadPool
  • 由pending队列出队后,workers分配一个线程(单个worker)去处理消息——给目标消息append一个当前时间戳和当前线程名称,将其写入doing表,然后该worker去消费消息,完成后自行在doing表擦除信息
  • 启用一个定时任务,每隔一段时间去扫描doing队列,检查每隔元素的时间戳,如果超时,则由worker的ThreadPoolExecutor去检查线程是否存在,如果存在则取消当前任务执行,并把事务rollback。最后把该任务从doing队列中pop出,再重新push进pending队列
  • 在worker的某线程中,如果处理业务失败,则主动回滚,并把任务从doing队列中移除,重新push进pending队列

Redis最适合的几种常见场景

  • 会话缓存(购物车信息)
  • 全页缓存
  • 队列
  • 排行榜/计数器
  • 发布/订阅

Redis如何方式宕机

  • 搭集群: 分n组,每组有两个机器,主机和备机
  • 心跳检测:每隔一段时间备机会ping一下主机,主机回一个pong
  • 容灾:主机数据同步给备机
  • 扩容:redis中槽范围0-16383,一共是16384个槽,将这些槽分给对应组机器
  • 负载均衡:redis会将key使用crc16索法进行计算.会得出一个纯数字的值余数落到那个solt槽范围内就将数据分配到这个机器上

管道在Redis中的作用

Pipeline在某些场景下非常有用,比如有多个Command需要被“及时的”提交,而且他们对相应结果没有互相依赖,对结果响应也无需立即获得,那么pipeline就可以充当这种“批处理”的工具;而且在一定程度上,可以较大的提升性能,性能提升的原因主要是TCP连接中减少了“交互往返”的时间。管道(pipeline)可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline通过减少客户端与redis的通信次数来实现降低往返延时时间,而且Pipeline实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。Pipeline的默认的同步的个数为53个,也就是说arges中累加到53条数据时会把数据提交。其过程如下图所示:client可以将三个命令放到一个tcp报文一起发送,Server则可以将三条命令的处理结果放到一个TCP报文返回。

Redis如何优化内存

  • RedisObject对象
    高并发写入场景中,在条件允许的情况下建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数从而提高性能。
  • 缩减键值对象
    降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。在设计键时,在完整描述业务情况下,键值越短越好。值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。以JAVA为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如: protostuff,kryo等。
  • 共享对象池
    对象共享池指Redis内部维护[0-9999]的整数对象池。创建大量的整数类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象池,用于节约内存。除了整数值对象,其他类型如list,hash,set,zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
  • 字符串优化
    深刻理解Redis字符串对于内存优化非常有帮助。
  • 编码优化
    Redis对外提供了string,list,hash,set,zet等类型,但是Redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。编码不同将直接影响数据的内存占用和读写效率。
  • 控制key的数量
    当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消耗大量内存。Redis本质是一个数据结构服务器,它为我们提供多种数据结构,如hash,list,set,zset等结构。使用Redis时不要进入一个误区,大量使用get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。

缓存算法

https://www.cnblogs.com/hongdada/p/10406902.html

一致性哈希

https://www.cnblogs.com/lpfuture/p/5796398.html

一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0-2^32-1。下一步将各个服务器使用Hash进行一个哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置。一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。另外,一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器ip或主机名的后面增加编号来实现。

Redis锁

Redis中锁的几种实现

第一种锁命令INCR:
这种加锁的思路是,key不存在,那么key的值会先被初始化为0,然后再执行INCR操作进行加一。然后其它用户在执行INCR操作进行加一时,如果返回的数大于1,说明这个锁正在被使用当中。

1 客户端A请求服务器获取key的值为1表示获取了锁
2 客户端B也去请求服务器获取key的值为2表示获取锁失败
3 客户端A执行代码完成,删除锁
4 客户端B在等待一段时间后在去请求的时候获取key的值为1表示获取锁成功
5 客户端B执行代码完成,删除锁

$redis->incr($key);
$redis->expire($key, $ttl); //设置生成时间为1秒

第二种锁SETNX:

这种加锁的思路是,如果key不存在,将key设置为value如果key已存在,则SETNX不做任何动作。

1 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功
2 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败
3 客户端A执行代码完成,删除锁
4 客户端B在等待一段时间后在去请求设置key的值,设置成功
5 客户端B执行代码完成,删除锁

$redis->setNX($key, $value);
$redis->expire($key, $ttl);

第三种锁SET:

上面两种方法都有一个问题,会发现,都需要设置key过期。那么为什么要设置key过期呢?如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测。但是借助Expire来设置就不是原子性操作了。所以还可以通过事务来确保原子性,但是还是有些问题,所以官方就引用了另外一个,使用 SET 命令本身已经从版本 2.6.12 开始包含了设置过期时间的功能。

1 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功
2 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败
3 客户端A执行代码完成,删除锁
4 客户端B在等待一段时间后在去请求设置key的值,设置成功
5 客户端B执行代码完成,删除锁
    
$redis->set($key, $value, array('nx', 'ex' => $ttl));  //ex表示秒

Redlock算法

在Redlock之前,很多人对于分布式锁的实现都是基于单个Redis节点的。而Redlock是基于多个Redis节点(都是Master)的一种实现。

基于单Redis节点的分布式锁

首先,Redis客户端为了获取锁,向Redis节点发送如下命令:

SET resource_name my_random_value NX PX 30000

上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。这里面有好几个问题需要重点分析一下:

  • 首先第一个问题,这个锁必须要设置一个过期时间。否则的话,当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了
  • 第二个问题,基于setnx+expire获得这个锁的命令的执行并非原子,如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁
  • 第三个问题,如果获得一个锁后,其执行时间超出了超时时间(网络延迟、业务处理时间长、GC Pause),随后释放锁让其他客户端获得,此时会同时有多个客户端拥有对资源写的能力,不符合规定
  • 第四个问题,failover

分布式锁算法Redlock

分布式锁的算法Redlock,它基于N个完全独立的Redis节点(通常情况下N可以设置成5)。运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

  • 获取当前时间(毫秒数)
  • 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)
  • 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败
  • 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间
  • 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。我们前面讨论的单Redis节点的分布式锁在failover的时候锁失效的问题,在Redlock中不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟Redis对数据的持久化程度有关。假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

1. 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
2. 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
3. 节点C重启后,客户端2锁住了C, D, E,获取锁成功。

这样,客户端1和客户端2同时获得了锁(针对同一资源)。在默认情况下,Redis的AOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现)。所以,上面分析的由于节点重启引发的锁失效问题,总是有可能出现的。为了应对这一问题,antirez又提出了延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。

在释放锁的时候也应该额外注意一些问题。客户端应该向所有Redis节点发起释放锁的操作。也就是说,即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。这是为什么呢?设想这样一种情况,客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。

对Redlock的质疑


在上面的时序图中,假设锁服务本身是没有问题的,它总是能保证任一时刻最多只有一个客户端获得锁。上图中出现的lease这个词可以暂且认为就等同于一个带有自动过期功能的锁。客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,而客户端2获得了锁。当客户端1从GC pause中恢复过来的时候,它不知道自己持有的锁已经过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,而这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作用失效了)。对于GC而言,GC的时机发生在任意的时刻;对于导致进程pause的原因也有许多,比如虚存造成的缺页故障(page fault),再比如CPU资源的竞争,即使不考虑进程pause的情况,网络延迟也仍然会造成类似的结果。总结起来就是说,即使锁服务本身是没有问题的,而仅仅是客户端有长时间的pause或网络延迟,仍然会造成两个客户端同时访问共享资源的冲突情况发生。

解决办法如下,Martin给出了一种方法,称为fencing token。fencing token是一个单调递增的数字,当客户端成功获取锁的时候它随同锁一起返回给客户端。而客户端访问共享资源的时候带着这个fencing token,这样提供共享资源的服务就能根据它进行检查,拒绝掉延迟到来的访问请求(避免了冲突)。如下图:

在上图中,客户端1先获取到的锁,因此有一个较小的fencing token,等于33,而客户端2后获取到的锁,有一个较大的fencing token,等于34。客户端1从GC pause中恢复过来之后,依然是向存储服务发送访问请求,但是带了fencing token = 33。存储服务发现它之前已经处理过34的请求,所以会拒绝掉这次33的请求。这样就避免了冲突。


Similar Posts

Comments