Redis简介与基础原理

 Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。从2010年3月15日起,Redis的开发工作由VMware主持。从2013年5月开始,Redis的开发由Pivotal赞助。

1:简单动态字符串(SDS)

Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。

1)常数复杂度获取字符串长度。
2)杜绝缓冲区溢出。
3)减少修改字符串长度时所需的内存重分配次数。
4)二进制安全。
5)兼容部分C字符串函数。  

2:redis的数据结构

redis的数据结构有:简单动态字符串(SDS)、双端链表、字典、跳跃表、压缩列表、整数集合等等。

Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,

这个系统包含字符串对象(String)、列表对象(List)、哈希对象(Hash)、集合对象(Set)和有序集合对象(Zset)这五种类型的对象。

Redis的对象系统还实现了基于引用计数技术的内存回收机制。

3:Redis持久化

3.1:RDB持久化:

RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。 

命令:

SAVE:会阻塞redis进程,直至RDB文件创建完毕。

BGSAVE:会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。

RDB文件的载入工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令。

另外值得一提的是,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

1:如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。

2:只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

3.2:AOF持久化:

AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的。

1:当appendfsync的值为always时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,

所以always的效率是appendfsync选项三个值当中最慢的一个,但从安全性来说,always也是最安全的,因为即使出现故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据。

2:当appendfsync的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且每隔一秒就要在子线程中对AOF文件进行一次同步。

从效率上来讲,everysec模式足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据。 

3:当appendfsync的值为no时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。

因为处于no模式下的flushAppendOnlyFile调用无须执行同步操作,所以该模式下的AOF文件写入速度总是最快的,

不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以该模式的单次同步时长通常是三种模式中时间最长的。

从平摊操作的角度来看,no模式和everysec模式的效率类似,当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据。 

3.3:AOF重写

aof重写功能读取当前服务器的状态来实现,重写功能基于后台子线程执行,在子线程重写期间服务器继续处理命令请求,可能产生数据不一致,

为了解决这个问题,redis设置了重写缓冲区,此时所有的写命令也会追加到aof重写缓冲区中,待重写工作完成后,会将重写缓冲区中的内容写入到新的aof文件中,

对新的aof文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

4:过期键删除策略

1:定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。

对内存友好,可以及时释放内存,但是对CPU非常不友好,在CPU时间紧张的状态下,会对服务器的响应时间和吞吐量造成影响。

要让服务器创建大量的定时器,从而实现定时删除策略,在现阶段来说并不现实。

2:惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。

与定时删除相反,对内存十分不友好,如果大量的过期建一直没有被访问,那么它们就永远不会被删除,造成内存泄漏。

3:定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

定期删除策略是前两种策略的一种整合和折中,它的难点是确定删除操作执行的时长和频率,如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,

如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

Redis服务器实际使用的是惰性删除定期删除两种策略。

5:缓存淘汰策略

http://www.redis.cn/topics/lru-cache.html 

Redis3.0版本支持的淘汰策略有6种:

1. volatile-lru:从设置过期时间的数据集(server.db[i].expires)中挑选出最近最少使用的数据淘汰。没有设置过期时间的key不会被淘汰,

这样就可以在增加内存空间的同时保证需要持久化的数据不会丢失。

2. volatile-ttl:除了淘汰机制采用LRU,策略基本上与volatile-lru相似,从设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰,ttl值越大越优先被淘汰。

3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。当内存达到限制无法写入非过期时间的数据集时,

可以通过该淘汰策略在主键空间中随机移除某个key。

4. allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰,该策略要淘汰的key面向的是全体key集合,而非过期的key集合。

5. allkeys-random:从数据集(server.db[i].dict)中选择任意数据淘汰。

6. no-enviction:禁止驱逐数据,也就是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失,

这也是系统默认的一种淘汰策略

Redis4.0版本后新增两种

7. volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。

8. allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

6:Redis集群的三种方式

6.1:主从复制

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,

我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。

同步过程:

1)从服务器向主服务器发送SYNC命令。

2)收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。

3)当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,

将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态。

4)主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。

命令传播过程:

在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,

主服务器的数据库就有可能会被修改,并导致主从服务器状态不再一致。主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,

发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。

这种复制过程有一个缺点就是对于断线后的主从不一致情况,要想恢复一致,效率非常低。

Redis2.8版本后使用PSYNC命令代替SYNC命令来执行复制时的同步操作。

PSYNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

1:其中完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,它们都是通过让主服务器创建并发送RDB文件,

以及向从服务器发送保存在缓冲区里面的写命令来进行同步。

2:而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,

从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

其中部分重同步的实现是基于复制偏移量已经复制积压缓冲区。

 6.2:哨兵模式(Sentinel)

Sentinel是Redis的高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,

并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

哨兵的工作方式:

1:每个Sentinel(哨兵)进程以每秒钟一次的频率向整个集群中的Master主服务器,Slave从服务器以及其他Sentinel(哨兵)进程发送一个 PING 命令。

2:如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel(哨兵)进程标记为主观下线(SDOWN)。

3:如果一个Master主服务器被标记为主观下线(SDOWN),则正在监视这个Master主服务器的所有 Sentinel(哨兵)进程要以每秒一次的频率确认Master主服务器的确进入了主观下线状态。

4:当有足够数量的 Sentinel(哨兵)进程(大于等于配置文件指定的值)在指定的时间范围内确认Master主服务器进入了主观下线状态(SDOWN), 则Master主服务器会被标记为客观下线(ODOWN)。

5:在一般情况下, 每个 Sentinel(哨兵)进程会以每 10 秒一次的频率向集群中的所有Master主服务器、Slave从服务器发送 INFO 命令。

6:当Master主服务器被 Sentinel(哨兵)进程标记为客观下线(ODOWN)时,Sentinel(哨兵)进程向下线的 Master主服务器的所有 Slave从服务器发送 INFO 命令的频率会从 10 秒一次改为每秒一次。

7:若没有足够数量的 Sentinel(哨兵)进程同意 Master主服务器下线, Master主服务器的客观下线状态就会被移除。若 Master主服务器重新向 Sentinel(哨兵)进程发送 PING 命令返回有效回

复,Master主服务器的主观下线状态就会被移除。 

故障转移过程:

故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,

将这个从服务器转换为主服务器。

1:删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。

2:删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。

3:删除所有与已下线主服务器连接断开超过down-after-milliseconds*10毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,

而删除断开时长超过down-after-milliseconds*10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,

列表中剩余的从服务器保存的数据都是比较新的。

4:领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。如果有多个具有相同最高优先级的从服务器,

那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,

并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。

最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的从服务器。

6.3:Redis-Cluster集群 

redis的哨兵模式基本已经可以实现高可用,读写分离 ,但是在这种模式下每台redis服务器都存储相同的数据,很浪费内存,

所以在redis3.0上加入了cluster模式,实现的redis的分布式存储,也就是说每台redis节点上存储不同的内容,集群通过分片(sharding)来进行数据共享,

并提供复制和故障转移功能。集群的整个数据库被分为16384个槽(slot),当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),

如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。

def slot_number(key):
        return CRC16(key) & 16383

其中CRC16(key)语句用于计算键key的CRC-16校验和,而&16383语句则用于计算出一个介于0至16383之间的整数作为键key的槽号。 

为了保证高可用,Redis-Cluster也引入了主从模式,其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

选取新主节点的方法是基于Raft算法。  

7:Redis分布式锁

7.1:单个redis实例

SET resource_name my_random_value NX PX 30000

该命令仅在密钥不存在(NX选项)且到期时间为30000毫秒(PX选项)时设置密钥。密钥设置为my_random_value。此值在所有客户端和所有锁定请求中必须唯一。 

1:必须设置过期时间是因为如果有个客户端获取锁成功后,它崩溃了或者网络原因无法与redis节点通信,那么它就会一直持有这个锁,导致其他客户端永远无法获取锁。

而且这个过期时间就是客户端持有锁的有效时间。

2:my_random_value保证随机性,唯一性,因为释放锁的Lua脚本就是根据期望值决定的,如果my_random_value是一个固定值,那么可能导致客户端A将客户端B的锁释放掉。

使用随机值是为了以安全的方式释放锁,并且脚本会告诉Redis:仅当密钥存在且存储在密钥上的值恰好是我期望的值时,才删除该密钥。这是通过以下Lua脚本完成的:

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

为了避免删除另一个客户端创建的锁,这一点很重要。例如,一个客户端可能获取了该锁,在某些操作中被阻塞的时间超过了该锁的有效时间(密钥将过期的时间),

然后又删除了某个其他客户端已经获取的锁。仅使用DEL是不安全的,因为一个客户端可能会删除另一个客户端的锁。

使用上述脚本时,每个锁都由一个随机字符串“签名”,因此仅当该锁仍是客户端尝试将其删除的设置时,该锁才会被删除。 

单实例的redis实现分布式锁虽然实现简单但是服务的可用性较低,那为了提高可用性,使用redis主从两个实例,是否可行呢?

答案是不可以,因为当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(SLAVEOF命令是一个异步命令)是异步的,

这可能导致在failover过程中丧失锁的安全性。比如下面的流程:

1:客户端1从Master获取了锁。
2:Master宕机了,存储锁的key还没有来得及同步到Slave上。
3:Slave升级为Master。
4:客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

7.2:Redlock算法

在分布式版本中,我们假设我们有N(通常设置为5)个Redis masters。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。

我们已经描述了如何在单个实例中安全地获取和释放锁。我们认为该算法将使用此方法在单个实例中获取和释放锁,这是理所当然的。

我们将N = 5设置为一个合理的值,因此我们需要在不同的计算机或虚拟机上运行5个Redis主服务器,以确保它们将以大多数独立的方式发生故障。

为了获取锁,客户端执行以下操作:

1.它以毫秒为单位获取当前时间。

2.它尝试在所有N个实例中顺序使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁定时,客户端使用的超时时间小于总锁定自动释放时间,

以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点通信时保持阻塞:

如果一个实例不可用,我们应该尝试与下一个实例尽快通信。

3.客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时,

并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。

4.如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。

5.如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负)而未能获得该锁,则它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。

释放锁只需在所有实例中释放锁,无论客户端是否认为它能够成功锁定给定实例,删除所有实例的锁,释放锁使用Lua脚本,保证原子性。

 RedLock存在的问题:

Redlock对系统时钟准确性要求较高,clock skew is real (时钟偏移在现实中是存在的)。

长时间的GC pause。如果完成1~4步后,发生GC,那么可能导致当前客户端在GC期间因为超时,锁失效(redis键过期),GC后仍认为自己持有锁。

长时间的网络延迟。客户端与资源服务器之间的延迟,对所有的分布式锁的实现都会带来影响。

延伸基于ZooKeeper的分布式锁

http://zookeeper.apache.org/doc/r3.4.9/recipes.html#sc_recipes_Locks(避免“herd effect”(羊群效应)) 

  • 在正常情况下,客户端可以持有锁任意长的时间,这可以确保它做完所有需要的资源访问操作之后再释放锁。这避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问 题。实际上,基于ZooKeeper的锁是依靠Session(心跳)来维持锁的持有状态的,而Redis不支持Sesion。
  • 基于ZooKeeper的锁支持在获取锁失败之后等待锁重新释放的事件。这让客户端对锁的使用更加灵活。

8:参考文献

1:https://redis.io/topics/distlock

2:http://zhangtielei.com/posts/blog-redlock-reasoning.html

3:http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html

4:https://www.cnblogs.com/51life/p/10233340.html

5:Redis设计与实现

原文地址:https://www.cnblogs.com/yxy-ngu/p/12579146.html