Redis实现及原理分析

Redis 微秒级操作

redis 保存在内存的好处是读写很快,毕竟内存的访问速度一般都在百 ns 级别。但是,潜在的风险是一旦掉电,所有的数据都会丢失。保存在外存,虽然可以避免数据丢失,但是受限于磁盘的慢速读写(通常在几 ms 级别),键值数据库的整体性能会被拉低。缓存场景下的数据需要能快速访问但允许丢失,Redis 属于内存键值数据库。

通过网络框架以 Socket 通信的形式对外提供键值对操作,这种形式可以提供广泛的键值存储服务,当客户端发送PUT hello world命令后,该命令回封装在网络包中发给键值数据库,键值数据库网络框架接收到网络包,并按照相应的协议进行解析之后,就可以知道,客户端想写入一个键值对,并开始实际的写入流程。

如果一个线程既要处理网络连接、解析请求,又要完成数据存取,一旦某一步操作发生阻塞,整个线程就会阻塞住,这就降低了系统响应速度。如果我们采用不同线程处理不同操作,那么,某个线程被阻塞时,其他线程还能正常运行。但是,不同线程间如果需要访问共享资源,那又会产生线程竞争,也会影响系统效率,这又该怎么办呢?此时,需要查找所要操作的键值对是否存在,这依赖于键值数据库的索引模块。

Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性。

redis接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,这要归功于它的数据结构。键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作。

Redis数据类型

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。

Redis采用哈希表作为索引,很大一部分原因在于,其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表 O(1) 的操作复杂度相匹配。我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。这个查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说,不管哈希表里有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。

当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。哈希桶的个数通常要少于 key 的数量,这也就是说,难免会有一些 key 的哈希值对应到了同一个哈希桶中。
Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

在这里插入图片描述
这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
  3. 释放哈希表 1 的空间。留作下一次 rehash 扩容备用。

这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash。简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
在这里插入图片描述
对于 String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。

集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。其中,哈希表的操作特点我们刚刚已经学过了;整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是 O(N),操作效率比较低。

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
在这里插入图片描述
跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。
在这里插入图片描述
在这里插入图片描述

Redis单线程

Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
对redis的数据结构来说,即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。为了避免这些问题,Redis 直接采用了单线程模式。Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

以 Get 请求为例,redis 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。
在这里插入图片描述
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,FD 就是刚才所说的多个套接字。Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,内核调用Redis 单线程对该事件队列不断进行处理,会调用相应的回调函数处理。
在这里插入图片描述

Redis持久化

Redis持久化有两种方式:AOF日志和快照

Redis AOF日志

Redis把后端数据库中的数据存储在内存中,然后直接从内存中读取数据,响应速度会非常快。一旦服务器宕机,内存中的数据将全部丢失。

etcd使用写前日志(WAL),在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志。因为AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的,但不会先去对这些命令进行语法检查,不过,AOF 也有两个潜在的风险:
1、如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
2、AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

三种写回策略

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

解决日志太大AOF重写

在读取一条命令时,将当前读取到的值写入一个新的AOF文件,因为旧的AOF文件是追加的方式写入的,若一个键被修改多次,旧的AOF文件会记录每一个修改的命令,而新的AOF文件会记录最新的一条命令,写入新AOF文件是子进程完成不会阻塞主进程。
在这里插入图片描述
在这里插入图片描述

redis 宕机后的快速恢复

当使用 AOF 进行故障恢复时,我们仍然需要把所有的操作记录都运行一遍。再加上 Redis 的单线程设计,这些命令操作只能一条一条按顺序执行,这个“重放”的过程就会很慢了。另一种持久化方法:内存快照。

内存快照,就是把某一时刻的状态以文件的形式写到磁盘上,这个快照文件就称为 RDB 文件,Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中,在主线程志从的时候创建一个子进程专门用于写入RDB文件,避免了主线程的阻塞,但是仍有一个问题,子进程在做快照的时候(需要20s)这是主线程收到对数据修改的操作怎么办?我们希望数据等20s后再修改显然不现实,所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
在这里插入图片描述
此时的问题是多久做一次快照,如果频繁地执行全量快照,也会带来两方面的开销。一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽;另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
在这里插入图片描述

主从库数据同步

主从库模式一旦采用了读写分离,所有数据的修改只会在主库上进行,不用协调三个实例。主库有了最新的数据后,会同步给从库,这样,主从库的数据就是一致的。
在这里插入图片描述
在这里插入图片描述
Redis 的主从库同步有三种模式:全量复制、基于长连接的命令传播,以及增量复制。

全量复制

全量复制虽然耗时,但是对于从库来说,如果是第一次同步,全量复制是无法避免的,所以建议一个 Redis 实例的数据库不要太大,大小在几 GB 级别比较合适,避免给主库过大的同步压力。

长连接复制

长连接复制是主从库正常运行后的常规同步阶段。一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

增量复制

如果网络断连,主从库之间就无法进行命令传播了,主从库会采用增量复制的方式继续同步,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。
在这里插入图片描述

主库挂掉,服务不中断的哨兵机制

哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

监控

监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。
哨兵机制通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。当有大于一半数量的哨兵认为主库下线才将主库下线,引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

选主

主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。使用配置项 down-after-milliseconds * 10来判断从库的网络状态,值越大越不适合做主库。也可以通过手动设置 slave-priority 配置项,给不同的从库设置不同优先级;从库的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

通知

在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

哨兵之间相互发现

在配置哨兵的信息时,只设置主库的 IP 和端口,并没有配置其他哨兵的连接信息。哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。
在这里插入图片描述

哨兵知道从库IP

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

Redis并发控制

redis初始化

import (
   "github.com/go-redis/redis"
)

var Client *redis.Client

func NewRedisClient(address, password string) *redis.Client {
   client := redis.NewClient(&redis.Options{
      Network:  "tcp",
      Addr:     address,
      Password: password,
   })
   if _, err := client.Ping().Result(); err != nil {
      panic(err)
   }
   return client
}

为了保证并发访问的正确性,Redis 提供了两种方法,分别是加锁和原子操作。

原子操作

1、Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。
2、而Redis 的 Lua 脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。

Redis 属于分布式系统,当有多个客户端需要争抢锁时,我们必须要保证,这把锁不能是某个客户端本地的锁。否则的话,其它客户端是无法访问这把锁的。当有多个客户端需要获取锁时,我们需要分布式锁保证其可以被多个客户端共享访问和获取。

单机上的锁

对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。变量值为 0 时,表示没有线程获取锁;变量值为 1 时,表示已经有线程获取到锁了。一个线程调用加锁操作,其实就是检查锁变量值是否为 0。如果是 0,就把锁的变量值设置为 1,表示获取到锁,如果不是 0,就返回错误信息,表示加锁失败,已经有别的线程获取到锁了。而一个线程调用释放锁操作,其实就是将锁变量的值置为 0,以便其它线程可以来获取锁。

分布式锁

分布式锁同样可以用一个变量来实现。客户端加锁和释放锁的操作逻辑,也和单机上的加锁和释放锁操作逻辑一致:加锁时同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为 0,表明客户端不再持有锁。注意,加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),而这三个操作在执行时需要保证原子性。SETNX 命令,它用于设置键值对的值并加锁。DEL 命令删除健值对并释放锁。
在这里插入图片描述
即使这样仍存在风险。如果客户端 A 执行了 SETNX 命令加锁后,假设客户端 B 执行了 DEL 命令释放锁,此时,客户端 A 的锁就被误释放了。如果客户端 C 正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端 A 和 C 同时在对共享数据进行操作,数据就会被修改错误,面对这种情况redis对每个客户端设置的锁使用了唯一的标识,所以在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识。

虽然加锁保证了互斥性,但是加锁也会导致系统并发性能降低。
锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放。

import 
(
    "time"
    "github.com/go-redis/redis"
)

var Client *redis.Client

// *******************   加锁  *******************   
success, err := Client.SetNX("ip", "10.1.255.1", 5 * time.Second).Result()
if err != nil {
    // 执行出错
   }
if !success {
   // 获取锁失败
} else {
   // 获取锁成功,赋值成功
}

// *******************   释放锁  *******************   
client.Del("ip")

当有时有多个操作需获得锁但又不确定多久释放锁,需要使用redislock

import (
   "github.com/bsm/redislock"
)

func ObtainLock(key string) (*redislock.Lock, error) {
   option := redislock.Options{
      RetryCount:   1,
      RetryBackoff: 10 * time.Millisecond,
      Metadata:     "",
      Context:      nil,
   }
   return client.Obtain(key, 10 * time.Millisecond, &option)
}

locker, err := ObtainMutexLock("ip")
// 多个查询写入mysql操作
defer locker.Release()
基于多个redis的分布式锁Redlock

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值